Compare commits

...
120 Commits
Author SHA1 Message Date
Glavo 7d41f7bdd3 Add support for Minecraft 26.2-snapshot-1+ to unofficial supported platforms (#5928) 2026-04-08 01:48:50 +08:00
辞庐 8e6ac947ed fix: 安装整合包部分任务未正确归类 (#5922) 2026-04-06 20:38:07 +08:00
辞庐 20e321f502 优化原理图管理&世界管理输入校验 (#5923) 2026-04-06 20:36:49 +08:00
辞庐 00b323e507 优化 Java 下载弹窗 Spinner (#5924) 2026-04-06 20:35:43 +08:00
辞庐 333b593c8a 优化添加认证服务器 http 警告颜色 (#5925) 2026-04-06 20:31:31 +08:00
Glavo 665f6df142 Add IDEA copyright configuration files (#5919) 2026-04-06 13:12:07 +08:00
辞庐 9e16130537 fix: Alex 模型皮肤 3D 预览手臂背后有一列侧面的异常像素 (#5688) 2026-04-05 23:16:40 +08:00
Glavo 5c98d6734d 修复多次打开微软登录弹窗后报错的问题 (#5911) 2026-04-05 23:13:25 +08:00
辞庐 c3801a3445 优化下载页面依赖项展示 (#5872) 2026-04-05 23:10:53 +08:00
辞庐andGlavo 1d137a3a88 优化 ComboBox 展开图标 (#5917)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-04-05 22:57:37 +08:00
辞庐 2e8b9ae3ca fix: 长按左侧关闭返回按钮可以拖动窗口 (#5915) 2026-04-05 22:57:00 +08:00
Calboot fb750e8717 [Bugfix] 修复导入整合包界面箭头颜色错误的问题 (#5914)
Fixes #5913
2026-04-05 22:15:11 +08:00
辞庐 f9ae0902d8 fix: 安装模组未进行文件名存在检查 (#5879) 2026-04-05 20:33:08 +08:00
GlavoandBurning_TNT 8d69cbc02f Fix #5907: 修复无法为 MC 26.1.1 从官方源安装 NeoForge 的问题 (#5910)
Co-authored-by: Burning_TNT <pangyl08@163.com>
2026-04-04 22:03:54 +08:00
辞庐 90c2372c92 fix: JFXRippler 可能无法覆盖整个控件 (#5609) 2026-04-04 21:44:21 +08:00
Glavo 84c4943de5 搜索 Java 时缓存 Java 信息 (#4716) 2026-04-04 21:41:20 +08:00
3gf8jv4dv 057943b6c0 fix(i18n): clarify terms for specific world detail pages (#5486) 2026-04-04 21:22:41 +08:00
Calboot e943cb0aec [Bugfix] 修复 HMCL 在 1.20.1 neoforge 环境下将部分 forge 模组识别为 MixinExtras 的问题 (#5888)
Fixes #5329
2026-04-04 20:57:09 +08:00
Wulian233 70290e205b 优化 JFXComboBox 样式 添加 hover 背景与手型指针 (#5905) 2026-04-04 20:15:53 +08:00
Glavo a73b8c7172 Add Gemini Code Assist configuration file (#5903) 2026-04-03 23:19:08 +08:00
3gf8jv4dv a5942c8e91 chore: add LWJGL Unsafe Agent to Settings > About page (#5896) 2026-04-03 21:28:42 +08:00
Glavo dc6a97520b fix: ensure main class is not null for game instance (#5902) 2026-04-03 21:27:54 +08:00
8be3f04cc8 feat: 拖拽版本 json 安装实例 (#5730)
resolves #5459

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-03 21:23:14 +08:00
Calboot 8d85b1537d [Bugfix] 修复 HMCL 下载游戏内容时出现空游戏版本条目的问题 (#5899)
Fixes #5894
2026-04-03 20:58:24 +08:00
3gf8jv4dv b16f2a6ce6 chore: update HelloNBT's link on Settings > About page (#5895) 2026-04-03 20:45:39 +08:00
Glavo e19f175e29 优化 Java 下载 (#5859)
1. 清理代码;
2. 先将文件下载至临时文件夹,下载完毕后再移动至目标目录。
2026-04-03 20:44:10 +08:00
Glavo f3a5b2a99d 支持 26w14a (#5892) 2026-04-02 00:02:08 +08:00
neveler eac2740ea8 优化关于页组件间距使整体风格更加统一协调 (#5861) 2026-03-30 21:17:27 +08:00
a06da53ec9 修复清理游戏资源 / 库操作在 FXThread 运行的问题 (#5301)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 21:04:08 +08:00
Xirren 765ea24d22 优化手动更新URL (#5880) 2026-03-30 20:40:50 +08:00
Glavo bd24ab37f5 Bump XZ for Java to 1.12 (#5883) 2026-03-30 20:40:32 +08:00
Glavo c689272c42 Bump lwjgl-unsafe-agent to 2.0 (#5882) 2026-03-30 20:27:11 +08:00
952cc5dd37 [Feature] 允许设置游戏内容默认下载源 (#5781)
Resolves #5594

Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-03-29 22:07:20 +08:00
辞庐andGlavo 356df5d630 更新 amdgpu.ids 文件 (#5316)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-29 21:58:43 +08:00
Damon Lu 0e433c5f4b 启动时检测 Wine 环境并进行警告 (#5518) 2026-03-29 21:56:44 +08:00
Glavo a28509de85 支持加载 SVG 图片 (#5484) 2026-03-29 21:29:25 +08:00
3gf8jv4dv e45601ecda chore: unify version type and release channel in english locale (#5548) 2026-03-29 21:02:54 +08:00
辞庐andGlavo 5bbf5cbfc8 启动时对会丢失游戏数据的情况添加 5 秒确认 (#5738)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-29 21:00:44 +08:00
afb98c20ce 优化 update.note.dev 英语换行 (#5876)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-29 20:52:31 +08:00
辞庐 6b2bc4606b fix: JFXDialog 弹窗过程中模糊 (#5877)
resolves #5073
2026-03-29 20:43:58 +08:00
mineDiamondandGlavo 6b57cec576 feat: 为自定义颜色选择对话框添加显式的确认和取消按钮 (#5775)
close #5767

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-29 20:43:26 +08:00
Glavo 83111b83f2 更新 launch.advice.lwjgl_3_4_1 文言文译文 (#5874) 2026-03-29 02:44:29 +08:00
Glavo 420e337c8c Update LWJGL advice message (#5873) 2026-03-29 02:18:20 +08:00
Glavoand3gf8jv4dv 028e43c959 通过 Java Agent 优化 LWJGL 3.4.1 性能 (#5870)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-03-29 01:35:07 +08:00
ef01acca6d feat: 下载页面折叠快照 Minecraft 版本 (#5601)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-27 22:58:49 +08:00
110d5f5630 更新更新渠道选择选项 (#5703)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-27 21:41:54 +08:00
neveler 94497a073d 种子地图、要塞地图、下届要塞地图和末地城地图支持 26.1 (#5860) 2026-03-27 21:06:36 +08:00
mineDiamond ff540a95ee refactor: 将 Datapack 重命名为 DataPack (#5785) 2026-03-26 21:26:18 +08:00
辞庐 ff6a32cdc3 fix: SettingsMap 缺少 toString 方法 (#5857) 2026-03-26 21:19:06 +08:00
Glavo 969644635a Bump HelloNBT to 0.3.0 (#5855) 2026-03-25 18:12:40 +08:00
Glavo a053736e95 Bump twelvemonkeys to 3.13.1 (#5854) 2026-03-25 18:07:58 +08:00
Glavo 879f82c462 Bump kala-compress to 1.27.1-3 (#5851) 2026-03-25 17:45:09 +08:00
Glavo e2d8d9f93f 更新文言文翻译 (#5845) 2026-03-24 21:39:53 +08:00
Glavo da4ed04697 Update default Java version to JAVA_25 (#5841) 2026-03-24 20:35:02 +08:00
Glavo 5d44df6b40 Fix #5822: 修复 JFXTextField 导致 GPU 占用率异常高的问题 (#5839) 2026-03-24 00:11:20 +08:00
辞庐andGlavo 114503e0bf 为游戏崩溃界面导出和启动器日志导出添加加载指示器 (#5694)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-23 22:03:16 +08:00
Calboot 8bd88aa8d8 Inject candidates for CF pack icon download (#5837) 2026-03-23 21:55:51 +08:00
Glavo cc7d1e1aae Add Minecraft 26.1 snapshot/pre/rc versions (#5833) 2026-03-23 21:54:14 +08:00
Damon Lu 56d4effda0 将“不适用于当前系统的选项”的系统支持信息移到 subtitle (#5813) 2026-03-23 21:46:13 +08:00
neveler 7c2dbe3dae 简化 JFXDialog 圆角 (#5836) 2026-03-23 21:45:23 +08:00
Calboot 99469077d2 [Enhancement] CurseForge整合包也下载图标 & 防止图标重复 (#5786) 2026-03-23 21:44:09 +08:00
neveler ec930843da JFXCustomColorPickerDialog 忽略 Alpha 通道 (#5830)
resolve #5824
2026-03-23 21:18:07 +08:00
neveler 969d314802 为 dialog 添加圆角 (#5832) 2026-03-23 21:10:54 +08:00
Glavo 3b11541b0a 更新内存状态栏 (#5829) 2026-03-23 20:33:57 +08:00
Glavo c58d5a3a81 Extract clamp methods to MathUtils (#5826) 2026-03-22 21:27:56 +08:00
Glavo cf9b4aacb2 优化 checkstyle 在缺失版权声明时的报错 (#5825) 2026-03-22 21:22:55 +08:00
Glavo e430db0cd6 使用 HelloNBT 替代 OpenNBT (#5667) 2026-03-21 22:57:52 +08:00
辞庐andGlavo 1b31e96629 feat(LogWindow): 在游戏进程结束后禁用“结束游戏进程”与“导出游戏运行栈”按钮 (#5660)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-21 21:56:51 +08:00
辞庐 5c19239ddd 优化 tag 展示逻辑 (#5702)
resolves #5677
2026-03-21 21:38:34 +08:00
Glavo c181c7825b 当 CurseForge API Key 缺失时隐藏下载源切换选项 (#5802) 2026-03-21 21:31:11 +08:00
辞庐andGlavo 80d2fefa1f 使用 checkstyle 检查文件版权声明 (#5687)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-21 21:14:05 +08:00
Glavo 8701030934 Bump version root to 3.13 (#5811) 2026-03-20 22:18:57 +08:00
mineDiamond ae4c152a8b feat: 添加新发布候选版本版本号格式支持 (#5810) 2026-03-20 21:16:06 +08:00
Glavo e436163be7 支持愚人节模式 (#5791) 2026-03-20 21:15:11 +08:00
辞庐andGlavo 9b8e65449b feat: 更新时继承所有 JVM 参数 (#5631)
Close #5612
Close #5049

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-20 02:25:59 +08:00
Glavo fbb52f4e8a 平滑进度条 (#5805) 2026-03-19 04:07:34 +08:00
Glavo 704eb32048 优化 JFXSpinner 动画生成逻辑 (#5804) 2026-03-18 15:48:09 +08:00
Glavo 730d9b4ce8 更新 JFXSpinner 默认样式 (#5803) 2026-03-18 13:13:07 +08:00
辞庐andGlavo c7b316ecba 更新 Cleanroom Java 版本选择逻辑 (#5782)
resoloves #5780

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-17 21:41:35 +08:00
Zkitefly 6df5a02c3f Add issue template configuration for Gitee (#5787) 2026-03-17 20:53:27 +08:00
Glavo 1b9fde3d60 使用 MCIM 作为回退下载源 (#5798) 2026-03-17 20:52:47 +08:00
Glavo f435cda6a3 更新进度条样式 (#5800) 2026-03-17 20:43:49 +08:00
Glavo 4a69fde6e6 更新 GitHub Actions (#5801) 2026-03-17 17:03:19 +08:00
Damon LuandGlavo 294a0cd7da 修复 macOS 平台上空闲内存识别问题 (#4876)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-17 04:45:51 +08:00
辞庐 e057e20b3c Fix #5295 : 实现 Curseforge 光影下载 (#5313) 2026-03-16 21:47:34 +08:00
辞庐 8c0ca7c731 调整导出游戏运行栈加载指示器大小 (#5663) 2026-03-13 22:13:58 +08:00
辞庐andGlavo 0d591a5429 优化删除垃圾桶图标 (#5664)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-13 22:12:10 +08:00
辞庐 900d7595d5 fix: 125% 缩放 Popup 模糊 (#5772) 2026-03-13 22:07:11 +08:00
辞庐andGlavo 5e18c0aad4 若背景自定义路径是文件夹 则随机在文件夹选取图片展示 (#5769)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-13 22:05:43 +08:00
Glavo f51f982f80 更新模组数据 (2026-03-12) (#5773) 2026-03-13 21:35:10 +08:00
Damon Lu 7c5430424f 修复 JFXColorPicker 到 JFXCustomColorPickerDialog 颜色信息传递问题 (#5776)
Resolve #5766
2026-03-13 21:21:36 +08:00
5909c73c48 feat: 添加"关闭启动时的更新弹窗"功能 (#4706)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-13 21:11:32 +08:00
3gf8jv4dv 3166c1d405 chore: update license on about page (#5620) 2026-03-11 21:39:20 +08:00
mineDiamond 27c73b4434 fix: 修复 26.1-pre-1 版本号无法归一化的问题 (#5765) 2026-03-11 20:35:00 +08:00
Glavo 705f978c43 删除 ModpackSelectionPage.onChooseRemoteFile() 中的冗余 handler.resolve() (#5728) 2026-03-09 09:40:58 +08:00
Glavo 67432bf614 Add cooldown to launch button action (#5759) 2026-03-08 22:14:40 +08:00
辞庐 378c06656d fix: 无法导入部分 Modrinth 整合包 (#5669) 2026-03-08 22:09:45 +08:00
辞庐 e2d6904269 fix: 游戏进程是 Java 11 以下时无法导出游戏运行栈 (#5662) 2026-03-08 22:09:29 +08:00
辞庐 8ef2f8be1b 修正运行路径非法字符 = 提示 (#5737) 2026-03-08 22:06:48 +08:00
辞庐 584f0f39bc 优化安装实例时自动补全模组加载器名称 (#5698) 2026-03-08 22:05:23 +08:00
辞庐 6529a3bb03 fix: 导出游戏运行栈不兼容 Tooltip 未正确展示 (#5659) 2026-03-08 22:05:01 +08:00
辞庐 0e6f43ccba feat: 支持键盘↑↓选择 LineSelectButton (#5636)
resolves #5452
2026-03-08 22:04:01 +08:00
Glavo 94b10a8b29 将 -jfx-toggle-line-color 调整为 -monet-secondary-container (#5760) 2026-03-08 21:48:03 +08:00
辞庐 dc60af6d96 移除下载页面超链接多余 tooltip (#5739) 2026-03-08 21:47:49 +08:00
辞庐andGlavo 272c5d1b57 转换 Curseforge 整合包元数据为 Record (#5625)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-08 21:35:17 +08:00
辞庐 5bfe945fd0 Bump Gradle to 9.4.0 (#5731) 2026-03-08 20:56:19 +08:00
mineDiamond 3e8a295cf2 feat: 添加新版本存档数据获取支持 (#5675)
在[26.1-snapshot-6](https://zh.minecraft.wiki/w/Java%E7%89%8826.1-snapshot-6)中,mojang对整个[存档格式](https://zh.minecraft.wiki/w/Java%E7%89%88%E5%AD%98%E6%A1%A3%E6%A0%BC%E5%BC%8F)和[存档基础数据存储格式](https://zh.minecraft.wiki/w/%E5%AD%98%E6%A1%A3%E5%9F%BA%E7%A1%80%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8%E6%A0%BC%E5%BC%8F)进行了非常深的破坏性变更,这导致HMCL几乎无法正确读取新版本的世界数据,本PR将添加对新版本的新格式支持

此PR与 #5419 实现的功能一致,但是使用OpenNBT。

close https://github.com/HMCL-dev/HMCL/issues/5436
2026-03-08 12:49:36 +00:00
辞庐 0d717b84c4 清理 WizardNavigationResult (#5665) 2026-03-07 21:27:17 +08:00
辞庐 13672a57ff fix: #5682 安装整合包界面文件名重复的提示显示不全 (#5691) 2026-03-07 21:25:54 +08:00
MovTery b19151a811 fix: 导出 Modrinth 整合包时,不要求填写作者 (#5710) 2026-03-07 21:18:02 +08:00
辞庐 c7d9bc696a 优化 LineSelectButton Popup Label 配色 (#5707) 2026-03-07 21:15:31 +08:00
辞庐 f426ea48fa 整合包安装页面 文件名 -> 游戏实例名 (#5692) 2026-03-07 20:52:04 +08:00
辞庐 8b9eeb68f7 修正 BAT 脚本限制为 32767 (#5697)
Close #4044
2026-03-07 20:51:09 +08:00
辞庐 413069d334 fix: LineComponent 副标题样式未正确应用 (#5706) 2026-03-07 20:46:57 +08:00
Calboot bab81da466 [Enhancement] ScrollBar 圆角 (#5716) 2026-03-07 20:43:26 +08:00
Hommam-Prom 1138d063d2 Arabic Translition Update (#5714)
15 Ramadan 1447 - 5 March 2026
By Hommam-Prom
2026-03-07 20:39:43 +08:00
Glavo 041eedb765 Bump jetbrains-annotations to 26.1.0 (#5717) 2026-03-06 18:38:52 +08:00
辞庐 48046cd416 fix: SpinnerPane 裁切元素 (#5693) 2026-03-03 21:46:44 +08:00
辞庐 9372c0903c 优化 FileSelector 按钮样式 (#5690) 2026-03-03 21:42:12 +08:00
辞庐 ee79c50dea fix: 手动取消模组更新检查会弹窗操作失败 (#5705) 2026-03-03 21:37:47 +08:00
204 changed files with 8836 additions and 4087 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
code_review:
pull_request_opened:
code_review: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: QQ 群
url: https://docs.hmcl.net/groups.html
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
+11 -14
View File
@@ -12,9 +12,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
@@ -31,23 +31,20 @@ jobs:
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload JAR
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-jar
path: |
HMCL/build/libs/HMCL-*.jar
HMCL/build/libs/HMCL-*.jar.sha256
path: HMCL/build/libs/HMCL-*.jar
archive: false
- name: Upload EXE
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: |
HMCL/build/libs/HMCL-*.exe
HMCL/build/libs/HMCL-*.exe.sha256
path: HMCL/build/libs/HMCL-*.exe
archive: false
- name: Upload SH
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
path: HMCL/build/libs/HMCL-*.sh
archive: false
+6
View File
@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Hello Minecraft! Launcher&#10;Copyright (C) &amp;#36;today.year huangyuhui &lt;huanghongxun2008@126.com&gt; and contributors&#10;&#10;This program is free software: you can redistribute it and/or modify&#10;it under the terms of the GNU General Public License as published by&#10;the Free Software Foundation, either version 3 of the License, or&#10;(at your option) any later version.&#10;&#10;This program is distributed in the hope that it will be useful,&#10;but WITHOUT ANY WARRANTY; without even the implied warranty of&#10;MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10;GNU General Public License for more details.&#10;&#10;You should have received a copy of the GNU General Public License&#10;along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;." />
<option name="myName" value="HMCL" />
</copyright>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="CopyrightManager">
<settings default="HMCL">
<LanguageOptions name="JAVA">
<option name="fileTypeOverride" value="3" />
<option name="addBlankAfter" value="false" />
</LanguageOptions>
</settings>
</component>
+3
View File
@@ -57,6 +57,7 @@ dependencies {
implementation(project(":HMCLBoot"))
implementation("libs:JFoenix")
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.fxsvgimage)
implementation(libs.java.info)
implementation(libs.monet.fx)
implementation(libs.nayuki.qrcodegen)
@@ -66,6 +67,7 @@ dependencies {
}
embedResources(libs.authlib.injector)
embedResources(libs.lwjgl.unsafe.agent)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -155,6 +157,7 @@ val hmclProperties = buildList {
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
add("hmcl.lwjgl-unsafe-agent.version" to libs.lwjgl.unsafe.agent.get().version!!)
}
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
@@ -23,21 +23,13 @@ import com.jfoenix.controls.events.JFXDialogEvent;
import com.jfoenix.converters.DialogTransitionConverter;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.animation.*;
import javafx.beans.DefaultProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableObjectProperty;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.css.StyleableProperty;
import javafx.css.*;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
@@ -49,7 +41,6 @@ import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
@@ -155,7 +146,6 @@ public class JFXDialog extends StackPane {
});
contentHolder = new StackPane();
contentHolder.setBackground(new Background(new BackgroundFill(Color.WHITE, new CornerRadii(2), null)));
JFXDepthManager.setDepth(contentHolder, 4);
contentHolder.setPickOnBounds(false);
// ensure stackpane is never resized beyond it's preferred size
@@ -381,6 +371,14 @@ public class JFXDialog extends StackPane {
setCycleDuration(Duration.seconds(0.4));
setDelay(Duration.ZERO);
}
@Override
protected void starting() {
}
@Override
protected void stopping() {
}
}
/***************************************************************************
@@ -20,8 +20,6 @@
package com.jfoenix.controls;
import com.jfoenix.skins.JFXProgressBarSkin;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.Skin;
@@ -51,26 +49,18 @@ public class JFXProgressBar extends ProgressBar {
return new JFXProgressBarSkin(this);
}
private boolean smoothProgress = true;
public boolean isSmoothProgress() {
return smoothProgress;
}
public void setSmoothProgress(boolean smoothProgress) {
this.smoothProgress = smoothProgress;
}
private void initialize() {
setPrefWidth(200);
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
private DoubleProperty secondaryProgress;
public DoubleProperty secondaryProgressProperty() {
if (secondaryProgress == null) {
secondaryProgress = new SimpleDoubleProperty(this, "secondaryProgress", INDETERMINATE_PROGRESS);
}
return secondaryProgress;
}
public double getSecondaryProgress() {
return secondaryProgress == null ? INDETERMINATE_PROGRESS : secondaryProgress.get();
}
public void setSecondaryProgress(double secondaryProgress) {
secondaryProgressProperty().set(secondaryProgress);
}
}
@@ -231,10 +231,11 @@ public class JFXRippler extends StackPane {
*
* @return the ripple radius size
*/
protected double computeRippleRadius() {
double width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth();
double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight();
return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5;
protected double computeRippleRadius(double centerX, double centerY) {
double dx = Math.max(centerX, control.getLayoutBounds().getWidth() - centerX);
double dy = Math.max(centerY, control.getLayoutBounds().getHeight() - centerY);
return (Math.sqrt(dx * dx + dy * dy) / 0.9) + 2;
}
protected void setOverLayBounds(Rectangle overlay) {
@@ -473,7 +474,7 @@ public class JFXRippler extends StackPane {
super(centerX,
centerY,
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius() : getRipplerRadius(), null);
computeRippleRadius(centerX, centerY) : getRipplerRadius(), null);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
@@ -518,7 +519,7 @@ public class JFXRippler extends StackPane {
new KeyValue(opacityProperty(),
1,
RIPPLE_INTERPOLATOR)
), new KeyFrame(Duration.millis(900), inKeyValues));
), new KeyFrame(Duration.millis(600), inKeyValues));
setScaleX(0);
setScaleY(0);
@@ -31,6 +31,7 @@ import javafx.scene.control.Skin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/// JFXSpinner is the material design implementation of a loading spinner.
///
@@ -71,10 +72,10 @@ public class JFXSpinner extends ProgressIndicator {
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-spinner";
private static final double DEFAULT_RADIUS = 12.0;
private static final double DEFAULT_RADIUS = 16.0;
/**
* specifies the radius of the spinner node, by default it's set to `12.0`
* specifies the radius of the spinner node, by default it's set to `16.0`
*/
private StyleableDoubleProperty radius;
@@ -96,25 +97,8 @@ public class JFXSpinner extends ProgressIndicator {
this.radiusProperty().set(radius);
}
/// specifies from which angle the spinner should start spinning
private StyleableDoubleProperty startingAngle;
public final StyleableDoubleProperty startingAngleProperty() {
if (this.startingAngle == null) {
startingAngle = new SimpleStyleableDoubleProperty(StyleableProperties.STARTING_ANGLE,
JFXSpinner.this,
"startingAngle",
0.0);
}
return this.startingAngle;
}
public final double getStartingAngle() {
return startingAngle != null ? startingAngle.get() : 0.0;
}
public final void setStartingAngle(final double startingAngle) {
this.startingAngleProperty().set(startingAngle);
public double getStartingAngle() {
return 360 - ThreadLocalRandom.current().nextDouble() * 720;
}
private static final class StyleableProperties {
@@ -132,26 +116,12 @@ public class JFXSpinner extends ProgressIndicator {
}
};
private static final CssMetaData<JFXSpinner, Number> STARTING_ANGLE =
new CssMetaData<>("-jfx-starting-angle",
SizeConverter.getInstance(), 0.0) {
@Override
public boolean isSettable(JFXSpinner control) {
return control.startingAngle == null || !control.startingAngle.isBound();
}
@Override
public StyleableDoubleProperty getStyleableProperty(JFXSpinner control) {
return control.startingAngleProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ProgressIndicator.getClassCssMetaData());
Collections.addAll(styleables, RADIUS, STARTING_ANGLE);
Collections.addAll(styleables, RADIUS);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
@@ -88,6 +88,14 @@ public class JFXTextField extends TextField {
useJFXContextMenu(this);
}
// https://github.com/HMCL-dev/HMCL/issues/5822
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
@Override
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
}
/***************************************************************************
* *
* Properties *
@@ -42,11 +42,11 @@ public final class JFXDepthManager {
private static final DropShadow[] depth = new DropShadow[] {
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 1),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 6, 0, 0, 2),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 10, 0, 0, 3),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 14, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 6)
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 0),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 8, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 12, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 16, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 8)
};
/**
@@ -40,12 +40,16 @@ import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.util.Duration;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Shadi Shaheen
*/
@@ -78,7 +82,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
// create JFX Decorator
pickerDecorator = new JFXDecorator(dialog, this, false, false, false);
pickerDecorator.setOnCloseButtonAction(this::updateColor);
pickerDecorator.setOnCloseButtonAction(this::close);
pickerDecorator.setPickOnBounds(false);
customScene = new Scene(pickerDecorator, Color.TRANSPARENT);
StyleSheets.init(customScene);
@@ -153,6 +157,22 @@ public class JFXCustomColorPickerDialog extends StackPane {
paraTransition.play();
});
container.getChildren().add(tabs);
HBox actionsHBox = new HBox();
actionsHBox.getStyleClass().add("jfx-color-dialog-actions");
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
acceptButton.setOnAction(event -> updateColor());
acceptButton.getStyleClass().add("jfx-color-dialog-accept");
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.setOnAction(event -> close());
cancelButton.getStyleClass().add("jfx-color-dialog-cancel");
actionsHBox.getChildren().addAll(acceptButton, cancelButton);
container.getChildren().add(actionsHBox);
initRun = () -> {
// change tabs labels font color according to the selected color
pane.backgroundProperty().addListener((o, oldVal, newVal) -> {
@@ -179,7 +199,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
String rgb = String.format("rgba(%d, %d, %d, 1)",
String rgb = String.format("rgb(%d, %d, %d)",
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
@@ -232,7 +252,13 @@ public class JFXCustomColorPickerDialog extends StackPane {
hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
acceptButton.textFillProperty().bind(Bindings.createObjectBinding(() -> {
Color fill = (Color) pane.getBackground().getFills().get(0).getFill();
return ColorScheme.newBuilder(Themes.getColorScheme())
.setPrimaryColorSeed(fill)
.build()
.getPrimary();
}, pane.backgroundProperty()));
((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty()
.bind(Bindings.createObjectBinding(() -> {
@@ -262,9 +288,6 @@ public class JFXCustomColorPickerDialog extends StackPane {
});
};
container.getChildren().add(tabs);
this.getChildren().add(container);
this.setPadding(new Insets(0));
@@ -294,7 +317,11 @@ public class JFXCustomColorPickerDialog extends StackPane {
if (!systemChange) {
userChange = true;
try {
curvedColorPicker.setColor(Color.valueOf(colorWebString));
Color color = Color.valueOf(colorWebString);
if (color.getOpacity() != 1.0) {
color = Color.color(color.getRed(), color.getGreen(), color.getBlue());
}
curvedColorPicker.setColor(color);
} catch (IllegalArgumentException ignored) {
// if color is not valid then do nothing
}
@@ -309,6 +336,9 @@ public class JFXCustomColorPickerDialog extends StackPane {
public void setCurrentColor(Color currentColor) {
this.currentColorProperty.set(currentColor);
if (curvedColorPicker != null && currentColor != null) {
curvedColorPicker.setColor(currentColor);
}
}
Color getCurrentColor() {
@@ -20,61 +20,38 @@
package com.jfoenix.skins;
import com.jfoenix.controls.JFXProgressBar;
import com.jfoenix.utils.JFXNodeUtils;
import com.jfoenix.utils.TreeShowingProperty;
import javafx.animation.*;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.skin.ProgressIndicatorSkin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
/// # Material Design ProgressBar Skin
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-10-06
public class JFXProgressBarSkin extends ProgressIndicatorSkin {
public class JFXProgressBarSkin extends SkinBase<JFXProgressBar> {
private StackPane track;
private StackPane secondaryBar;
private StackPane bar;
private double barWidth = 0;
private double secondaryBarWidth = 0;
private Timeline indeterminateTransition;
private Region clip;
private static final double DEFAULT_HEIGHT = 4;
private final StackPane track;
private final StackPane bar;
private final Rectangle clip;
private Animation transition;
private final TreeShowingProperty treeShowingProperty;
private double fullWidth;
public JFXProgressBarSkin(JFXProgressBar bar) {
super(bar);
public JFXProgressBarSkin(JFXProgressBar control) {
super(control);
this.treeShowingProperty = new TreeShowingProperty(bar);
this.treeShowingProperty = new TreeShowingProperty(control);
bar.widthProperty().addListener(observable -> {
updateProgress();
updateSecondaryProgress();
});
registerChangeListener(bar.progressProperty(), (obs) -> updateProgress());
registerChangeListener(bar.secondaryProgressProperty(), obs -> updateSecondaryProgress());
registerChangeListener(bar.visibleProperty(), obs -> updateAnimation());
registerChangeListener(bar.parentProperty(), obs -> updateAnimation());
registerChangeListener(bar.sceneProperty(), obs -> updateAnimation());
unregisterChangeListeners(treeShowingProperty);
unregisterChangeListeners(bar.indeterminateProperty());
registerChangeListener(treeShowingProperty, obs -> this.updateAnimation());
registerChangeListener(bar.indeterminateProperty(), obs -> initialize());
initialize();
getSkinnable().requestLayout();
}
protected void initialize() {
registerChangeListener(treeShowingProperty, obs -> updateProgress(false));
registerChangeListener(control.progressProperty(), obs -> updateProgress(true));
track = new StackPane();
track.getStyleClass().setAll("track");
@@ -82,14 +59,14 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
bar = new StackPane();
bar.getStyleClass().setAll("bar");
secondaryBar = new StackPane();
secondaryBar.getStyleClass().setAll("secondary-bar");
clip = new Rectangle();
clip.setArcWidth(DEFAULT_HEIGHT);
clip.setArcHeight(DEFAULT_HEIGHT);
bar.setClip(clip);
clip = new Region();
clip.setBackground(new Background(new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY)));
bar.backgroundProperty().addListener(observable -> JFXNodeUtils.updateBackground(bar.getBackground(), clip));
getChildren().setAll(track, bar);
getChildren().setAll(track, secondaryBar, bar);
getSkinnable().requestLayout();
}
@Override
@@ -104,7 +81,7 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
return topInset + bar.prefHeight(width) + bottomInset;
return topInset + DEFAULT_HEIGHT + bottomInset;
}
@Override
@@ -120,105 +97,129 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
@Override
protected void layoutChildren(double x, double y, double w, double h) {
track.resizeRelocate(x, y, w, h);
secondaryBar.resizeRelocate(x, y, secondaryBarWidth, h);
bar.resizeRelocate(x, y, getSkinnable().isIndeterminate() ? w : barWidth, h);
clip.resizeRelocate(0, 0, w, h);
bar.resizeRelocate(x, y, w, h);
if (getSkinnable().isIndeterminate()) {
createIndeterminateTimeline();
if (JFXNodeUtils.isTreeShowing(getSkinnable())) {
indeterminateTransition.play();
}
// apply clip
bar.setClip(clip);
} else if (indeterminateTransition != null) {
clip.relocate(0, 0);
clip.setWidth(0);
clip.setHeight(h);
clip.setTranslateX(0);
fullWidth = w;
clearAnimation();
updateProgress(false);
}
private boolean wasIndeterminate = false;
private void updateProgress(boolean playProgressAnimation) {
double progress = Math.min(getSkinnable().getProgress(), 1.0);
boolean isIndeterminate = progress < 0.0;
boolean isTreeShowing = treeShowingProperty.get();
if (isIndeterminate != wasIndeterminate) {
wasIndeterminate = isIndeterminate;
clearAnimation();
// remove clip
bar.setClip(null);
clip.setTranslateX(0);
}
}
protected void updateSecondaryProgress() {
final JFXProgressBar control = (JFXProgressBar) getSkinnable();
secondaryBarWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2
* Math.min(1, Math.max(0, control.getSecondaryProgress()))) / 2.0F;
control.requestLayout();
}
boolean wasIndeterminate = false;
protected void pauseTimeline(boolean pause) {
if (getSkinnable().isIndeterminate()) {
if (indeterminateTransition == null) {
createIndeterminateTimeline();
if (isIndeterminate) { // indeterminate
if (isTreeShowing) {
if (transition == null) {
transition = createIndeterminateTransition();
transition.playFromStart();
} else {
transition.play();
}
} else if (transition != null) {
transition.pause();
}
if (pause) {
indeterminateTransition.pause();
} else { // determinate
clearAnimation();
if (isTreeShowing && playProgressAnimation
&& AnimationUtils.isAnimationEnabled()
&& getSkinnable().isSmoothProgress()) {
transition = createDeterminateTransition(progress);
transition.playFromStart();
} else {
indeterminateTransition.play();
clip.setWidth(computeBarWidth(progress));
}
}
}
private void updateAnimation() {
final boolean isTreeShowing = treeShowingProperty.get();
if (indeterminateTransition != null) {
pauseTimeline(!isTreeShowing);
} else if (isTreeShowing) {
createIndeterminateTimeline();
}
private static final Duration INDETERMINATE_DURATION = Duration.seconds(1);
private Transition createIndeterminateTransition() {
double minWidth = 0;
double maxWidth = fullWidth * 0.4;
Transition transition = new Transition() {
{
setInterpolator(Interpolator.LINEAR);
setCycleDuration(INDETERMINATE_DURATION);
}
@Override
protected void interpolate(double frac) {
double currentWidth;
if (frac <= 0.5) {
currentWidth = Interpolator.EASE_IN.interpolate(minWidth, maxWidth, frac / 0.5);
} else {
currentWidth = Interpolator.EASE_OUT.interpolate(maxWidth, minWidth, (frac - 0.5) / 0.5);
}
double targetCenter;
if (frac <= 0.1) {
targetCenter = 0.0;
} else if (frac >= 0.9) {
targetCenter = fullWidth;
} else {
targetCenter = ((frac - 0.1) / 0.8) * fullWidth;
}
clip.setWidth(currentWidth);
clip.setTranslateX(targetCenter - currentWidth / 2.0);
}
};
transition.setCycleCount(Timeline.INDEFINITE);
return transition;
}
private void updateProgress() {
final ProgressIndicator control = getSkinnable();
final boolean isIndeterminate = control.isIndeterminate();
if (!(isIndeterminate && wasIndeterminate)) {
barWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2
* Math.min(1, Math.max(0, control.getProgress()))) / 2.0F;
control.requestLayout();
}
wasIndeterminate = isIndeterminate;
}
private static final Duration DETERMINATE_DURATION = Duration.seconds(0.2);
private void createIndeterminateTimeline() {
if (indeterminateTransition != null) {
clearAnimation();
}
double dur = 1;
ProgressIndicator control = getSkinnable();
final double w = control.getWidth() - (snappedLeftInset() + snappedRightInset());
indeterminateTransition = new Timeline(new KeyFrame(
Duration.ZERO,
new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_IN),
new KeyValue(clip.translateXProperty(), -w / 2, Interpolator.LINEAR)
),
new KeyFrame(
Duration.seconds(0.5 * dur),
new KeyValue(clip.scaleXProperty(), 0.4, Interpolator.LINEAR)
),
new KeyFrame(
Duration.seconds(0.9 * dur),
new KeyValue(clip.translateXProperty(), w / 2, Interpolator.LINEAR)
),
new KeyFrame(
Duration.seconds(1 * dur),
new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_OUT)
));
indeterminateTransition.setCycleCount(Timeline.INDEFINITE);
private Timeline createDeterminateTransition(double targetProgress) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(clip.widthProperty(), clip.getWidth(), Interpolator.EASE_OUT)),
new KeyFrame(DETERMINATE_DURATION,
new KeyValue(clip.widthProperty(), computeBarWidth(targetProgress), Interpolator.EASE_OUT))
);
timeline.setOnFinished(e -> {
if (transition == timeline) {
transition = null;
}
});
return timeline;
}
private void clearAnimation() {
indeterminateTransition.stop();
indeterminateTransition.getKeyFrames().clear();
indeterminateTransition = null;
if (transition != null) {
transition.stop();
transition = null;
}
}
@Override
public void dispose() {
super.dispose();
treeShowingProperty.dispose();
if (indeterminateTransition != null) {
clearAnimation();
}
clearAnimation();
}
private double computeBarWidth(double progress) {
assert progress >= 0 && progress <= 1;
double barWidth = ((int) fullWidth * 2 * progress) / 2.0;
return progress > 0 ? Math.max(barWidth, DEFAULT_HEIGHT) : barWidth;
}
}
@@ -20,25 +20,24 @@
package com.jfoenix.skins;
import com.jfoenix.controls.JFXSpinner;
import com.jfoenix.utils.JFXNodeUtils;
import com.jfoenix.utils.TreeShowingProperty;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.scene.Group;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Arc;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.StrokeLineCap;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import java.util.ArrayList;
import java.util.List;
/// JFXSpinner material design skin
///
@@ -47,10 +46,7 @@ import javafx.util.Duration;
/// @since 2017-09-25
public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
private static final Color GREEN_COLOR = Color.valueOf("#0F9D58");
private static final Color RED_COLOR = Color.valueOf("#db4437");
private static final Color YELLOW_COLOR = Color.valueOf("#f4b400");
private static final Color BLUE_COLOR = Color.valueOf("#4285f4");
private static final double DEFAULT_STROKE_WIDTH = 4;
private JFXSpinner control;
private final TreeShowingProperty treeShowingProperty;
@@ -61,110 +57,62 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
private Arc track;
private final StackPane arcPane;
private final Rectangle fillRect;
private double arcLength = -1;
private final Text text;
private final double startingAngle;
public JFXSpinnerSkin(JFXSpinner control) {
super(control);
this.control = control;
this.treeShowingProperty = new TreeShowingProperty(control);
this.startingAngle = control.getStartingAngle();
arc = new Arc();
arc.setManaged(false);
arc.setStartAngle(0);
arc.setLength(180);
arc.getStyleClass().setAll("arc");
arc.setFill(Color.TRANSPARENT);
arc.setStrokeWidth(3);
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
arc.setStrokeLineCap(StrokeLineCap.ROUND);
track = new Arc();
track.setManaged(false);
track.setStartAngle(0);
track.setLength(360);
track.setStrokeWidth(3);
track.setStrokeWidth(DEFAULT_STROKE_WIDTH);
track.getStyleClass().setAll("track");
track.setFill(Color.TRANSPARENT);
fillRect = new Rectangle();
fillRect.setFill(Color.TRANSPARENT);
text = new Text();
text.setStyle("-fx-font-size:null");
text.getStyleClass().setAll("text", "percentage");
final Group group = new Group(fillRect, track, arc, text);
final Group group = new Group(fillRect, track, arc);
group.setManaged(false);
arcPane = new StackPane(group);
arcPane.setPrefSize(50, 50);
getChildren().setAll(arcPane);
// register listeners
registerChangeListener(control.indeterminateProperty(), obs -> initialize());
registerChangeListener(control.progressProperty(), obs -> updateProgress());
registerChangeListener(treeShowingProperty, obs -> updateAnimation());
registerChangeListener(control.sceneProperty(), obs -> updateAnimation());
registerChangeListener(treeShowingProperty, obs -> updateProgress());
}
private void initialize() {
if (getSkinnable().isIndeterminate()) {
if (timeline == null) {
createTransition();
if (JFXNodeUtils.isTreeShowing(getSkinnable())) {
private void updateProgress() {
double progress = Double.min(getSkinnable().getProgress(), 1.0);
if (progress < 0) { // indeterminate
boolean treeShowing = treeShowingProperty.get();
if (treeShowing) {
if (timeline == null) {
timeline = createTransition();
timeline.playFromStart();
} else {
timeline.play();
}
} else if (timeline != null) {
timeline.pause();
}
} else {
} else { // determinate
clearAnimation();
arc.setStartAngle(90);
updateProgress();
}
}
private KeyFrame[] getKeyFrames(double angle, double duration, Paint color) {
KeyFrame[] frames = new KeyFrame[4];
frames[0] = new KeyFrame(Duration.seconds(duration),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 45 + control.getStartingAngle(),
Interpolator.LINEAR));
frames[1] = new KeyFrame(Duration.seconds(duration + 0.4),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 90 + control.getStartingAngle(),
Interpolator.LINEAR));
frames[2] = new KeyFrame(Duration.seconds(duration + 0.7),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 135 + control.getStartingAngle(),
Interpolator.LINEAR));
frames[3] = new KeyFrame(Duration.seconds(duration + 1.1),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 435 + control.getStartingAngle(),
Interpolator.LINEAR),
new KeyValue(arc.strokeProperty(), color, Interpolator.EASE_BOTH));
return frames;
}
private void pauseTimeline(boolean pause) {
if (getSkinnable().isIndeterminate()) {
if (timeline == null) {
createTransition();
}
if (pause) {
timeline.pause();
} else {
timeline.play();
}
}
}
private void updateAnimation() {
final boolean isTreeShowing = treeShowingProperty.get();
if (timeline != null) {
pauseTimeline(!isTreeShowing);
} else if (isTreeShowing) {
createTransition();
arc.setLength(-360 * progress);
}
}
@@ -224,21 +172,9 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
fillRect.setHeight(arcSize);
if (!isValid) {
initialize();
updateProgress();
isValid = true;
}
if (!getSkinnable().isIndeterminate()) {
arc.setLength(arcLength);
if (text.isVisible()) {
final double progress = control.getProgress();
int intProgress = (int) Math.round(progress * 100.0);
Font font = text.getFont();
text.setFont(Font.font(font.getFamily(), radius / 1.7));
text.setText((progress > 1 ? 100 : intProgress) + "%");
text.relocate((arcSize - text.getLayoutBounds().getWidth()) / 2, (arcSize - text.getLayoutBounds().getHeight()) / 2);
}
}
}
private void updateArcLayout(double radius, double arcSize) {
@@ -254,66 +190,64 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
track.setStrokeWidth(arc.getStrokeWidth());
}
boolean wasIndeterminate = false;
protected void updateProgress() {
final ProgressIndicator control = getSkinnable();
final boolean isIndeterminate = control.isIndeterminate();
if (!(isIndeterminate && wasIndeterminate)) {
arcLength = -360 * control.getProgress();
control.requestLayout();
}
wasIndeterminate = isIndeterminate;
}
private void createTransition() {
if (!getSkinnable().isIndeterminate()) return;
final Paint initialColor = arc.getStroke();
if (initialColor == null) {
arc.setStroke(BLUE_COLOR);
}
KeyFrame[] blueFrame = getKeyFrames(0, 0, initialColor == null ? BLUE_COLOR : initialColor);
KeyFrame[] redFrame = getKeyFrames(450, 1.4, initialColor == null ? RED_COLOR : initialColor);
KeyFrame[] yellowFrame = getKeyFrames(900, 2.8, initialColor == null ? YELLOW_COLOR : initialColor);
KeyFrame[] greenFrame = getKeyFrames(1350, 4.2, initialColor == null ? GREEN_COLOR : initialColor);
KeyFrame endingFrame = new KeyFrame(Duration.seconds(5.6),
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
frames.add(new KeyFrame(Duration.seconds(duration),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
1845 + control.getStartingAngle(),
Interpolator.LINEAR));
angle + 45 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 90 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 135 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 435 + startingAngle,
Interpolator.LINEAR)));
}
if (timeline != null) {
timeline.stop();
timeline.getKeyFrames().clear();
private Timeline createTransition() {
Timeline timeline;
if (AnimationUtils.isAnimationEnabled()) {
var keyFrames = new ArrayList<KeyFrame>(17);
addKeyFrames(keyFrames, 0, 0);
addKeyFrames(keyFrames, 450, 1.4);
addKeyFrames(keyFrames, 900, 2.8);
addKeyFrames(keyFrames, 1350, 4.2);
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
1845 + startingAngle,
Interpolator.LINEAR)));
timeline = new Timeline();
timeline.getKeyFrames().setAll(keyFrames);
} else {
final double arcLength = 250;
timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
new KeyFrame(Duration.seconds(1.2),
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
);
}
timeline = new Timeline(blueFrame[0],
blueFrame[1],
blueFrame[2],
blueFrame[3],
redFrame[0],
redFrame[1],
redFrame[2],
redFrame[3],
yellowFrame[0],
yellowFrame[1],
yellowFrame[2],
yellowFrame[3],
greenFrame[0],
greenFrame[1],
greenFrame[2],
greenFrame[3],
endingFrame);
timeline.setCycleCount(Timeline.INDEFINITE);
timeline.setDelay(Duration.ZERO);
timeline.playFromStart();
return timeline;
}
private void clearAnimation() {
if (timeline != null) {
timeline.stop();
timeline.getKeyFrames().clear();
timeline = null;
}
}
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.swing.JOptionPane;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
@@ -49,6 +50,8 @@ public final class EntryPoint {
createHMCLDirectories();
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
checkWine();
setupJavaFXVMOptions();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
@@ -220,6 +223,20 @@ public final class EntryPoint {
}
}
private static void checkWine() {
if (OperatingSystem.isRunningUnderWine()) {
SwingUtils.initLookAndFeel();
LOG.warning("HMCL is running under Wine or its distributions!");
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
}
}
private static void addEnableNativeAccess() {
if (JavaRuntime.CURRENT_VERSION > 21) {
try {
@@ -17,35 +17,36 @@
*/
package org.jackhuang.hmcl;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ObservableBooleanValue;
import javafx.geometry.Rectangle2D;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Duration;
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;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.CrashReporter;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.NativeUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.*;
import java.io.File;
import java.io.IOException;
@@ -65,8 +66,8 @@ import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class Launcher extends Application {
public static final CookieManager COOKIE_MANAGER = new CookieManager();
@@ -111,7 +112,7 @@ public final class Launcher extends Application {
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
&& ConfigHolder.isNewlyCreated()
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
if (showAlert(AlertType.WARNING, i18n("fatal.mac_app_translocation"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
return;
} else {
checkConfigInTempDir();
@@ -182,6 +183,27 @@ public final class Launcher extends Application {
return new Alert(alertType, contentText, buttons).showAndWait().orElse(null);
}
private static boolean confirmWithCountdown(Alert.AlertType alertType, String contentText, int seconds) {
Alert alert = new Alert(alertType, contentText, ButtonType.YES, ButtonType.NO);
Button okButton = (Button) alert.getDialogPane().lookupButton(ButtonType.YES);
okButton.setDisable(true);
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
for (int i = 0; i < seconds; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(okButton.textProperty(), i18n("button.ok.countdown", seconds - i)));
}
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
new KeyValue(okButton.textProperty(), i18n("button.ok")),
new KeyValue(okButton.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
alert.setOnShown(e -> timeline.play());
alert.setOnCloseRequest(e -> timeline.stop());
return alert.showAndWait().orElse(null) == ButtonType.YES;
}
private static boolean isConfigInTempDir() {
String configPath = ConfigHolder.configLocation().toString();
@@ -221,7 +243,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) {
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
EntryPoint.exit(0);
}
}
@@ -49,6 +49,7 @@ public final class Metadata {
public static final String ABOUT_URL = PUBLISH_URL + "/about";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
public static final String DOCS_URL = "https://docs.hmcl.net";
public static final String CONTACT_URL = DOCS_URL + "/help.html";
@@ -1,3 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.countly;
import org.jackhuang.hmcl.Metadata;
@@ -21,12 +21,16 @@ 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.NativePatcher;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
@@ -150,4 +154,60 @@ public final class HMCLGameLauncher extends DefaultLauncher {
generateOptionsTxt();
super.makeLaunchScript(scriptFile);
}
@Override
protected void appendJvmArgs(CommandBuilder result) {
super.appendJvmArgs(result);
if (config().getAllowAutoAgent()
&& !options.isNoGeneratedJVMArgs()
&& !options.isNoGeneratedOptimizingJVMArgs()
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
try {
result.add("-javaagent:" + extractLwjglUnsafeAgent());
} catch (Exception e) {
LOG.warning("Failed to extract lwjgl-unsafe-agent", e);
}
}
}
private Path extractLwjglUnsafeAgent() throws IOException {
String agentVersion = JarUtils.getAttribute("hmcl.lwjgl-unsafe-agent.version", null);
if (agentVersion == null) {
throw new IOException("Missing hmcl.lwjgl-unsafe-agent.version attribute");
}
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.getArtifact().getFileName();
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
byte[] bytes;
try (InputStream input = DefaultLauncher.class.getResourceAsStream("/assets/" + fileName)) {
if (input == null) {
throw new IOException("/assets/" + fileName + " not found");
}
bytes = input.readAllBytes();
}
if (Files.isRegularFile(agentPath)) {
try {
if (Files.size(agentPath) == bytes.length) {
return agentPath;
}
} catch (IOException e) {
LOG.warning("Failed to check size of " + agentPath, e);
}
}
Files.createDirectories(agentPath.getParent());
FileUtils.saveSafely(agentPath, output -> output.write(bytes));
return agentPath;
}
}
@@ -310,7 +310,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
Optional<Path> iconFile = getVersionIconFile(id);
if (iconFile.isPresent()) {
try {
return FXUtils.loadImage(iconFile.get());
return FXUtils.loadImage(iconFile.get(), 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load version icon of " + id, e);
}
@@ -65,6 +65,7 @@ import java.util.stream.Collectors;
import static javafx.application.Platform.runLater;
import static javafx.application.Platform.setImplicitExit;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.resolveException;
@@ -75,6 +76,8 @@ import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
public final class LauncherHelper {
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
private final Profile profile;
private Account account;
private final String selectedVersion;
@@ -197,6 +200,28 @@ public final class LauncherHelper {
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> {
if (config().getAllowAutoAgent()
|| setting.isNoJVMArgs()
|| setting.isNoOptimizingJVMArgs()
|| Boolean.TRUE.equals(config().getShownTips().get(LWJGL_3_4_1_TIP))
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
return Task.completed(null);
} else {
CompletableFuture<Void> future = new CompletableFuture<>();
runInFX(() -> {
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
config().setAllowAutoAgent(true);
future.complete(null);
}, () -> {
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
future.complete(null);
});
});
return Task.fromCompletableFuture(future);
}
})
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
@@ -387,7 +412,16 @@ public final class LauncherHelper {
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
try {
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
GameJavaVersion minimumJavaVersion = null;
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
}
}
if (minimumJavaVersion == null)
minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) {
Controllers.dialog(
@@ -402,8 +436,17 @@ public final class LauncherHelper {
targetJavaVersion = GameJavaVersion.get(targetJavaVersionMajor);
} catch (NumberFormatException ignored) {
}
} else
targetJavaVersion = version.getJavaVersion();
} else {
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
}
}
if (targetJavaVersion == null)
targetJavaVersion = version.getJavaVersion();
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
downloadJava(targetJavaVersion, profile)
@@ -433,7 +476,7 @@ public final class LauncherHelper {
if (java != null) {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.isMandatory()) {
violatedMandatoryConstraints.add(constraint);
} else {
@@ -468,9 +511,14 @@ 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.CLEANROOM)) {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
.orElse("");
gameJavaVersion = !cleanroomVersion.isEmpty()
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
@@ -560,9 +608,14 @@ 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"));
case CLEANROOM: {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
if (!cleanroomVersion.isEmpty())
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
else
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
break;
}
case VANILLA_JAVA_8_51:
suggestions.add(i18n("launch.advice.java8_51_1_13"));
break;
@@ -115,8 +115,8 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
}
@Override
public RemoteMod getModById(String id) throws IOException {
return getBackedRemoteModRepository().getModById(id);
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
}
@Override
@@ -125,7 +125,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(id);
public Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
}
}
@@ -51,7 +51,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.List;
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;
@@ -167,7 +170,7 @@ public final class ModpackHelper {
return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static boolean isExternalGameNameConflicts(String name) {
@@ -211,16 +214,16 @@ public final class ModpackHelper {
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
@@ -228,7 +231,7 @@ public final class ModpackHelper {
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
.thenComposeAsync(profile.getRepository().refreshVersionsAsync())
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
default:
throw new UnsupportedModpackException();
}
@@ -163,6 +163,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html);
}
@Override
public void close() {
if (!future.isDone())
future.completeExceptionally(new AuthenticationException("OAuth server is closing"));
stop();
}
public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
@@ -99,7 +99,7 @@ public final class HMCLJavaRepository implements JavaRepository {
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
}
private static void getAllJava(List<JavaRuntime> list, Platform platform, Path platformRoot, boolean isManaged) {
private static void getAllJava(List<Path> list, Platform platform, Path platformRoot) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(platformRoot)) {
for (Path file : stream) {
try {
@@ -117,8 +117,7 @@ public final class HMCLJavaRepository implements JavaRepository {
}
if (Files.isDirectory(javaDir)) {
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
list.add(executable);
}
}
} catch (Throwable e) {
@@ -131,28 +130,23 @@ public final class HMCLJavaRepository implements JavaRepository {
}
@Override
public Collection<JavaRuntime> getAllJava(Platform platform) {
public Collection<Path> getAllJava(Platform platform) {
Path platformRoot = getPlatformRoot(platform);
if (!Files.isDirectory(platformRoot))
return Collections.emptyList();
ArrayList<JavaRuntime> list = new ArrayList<>();
getAllJava(list, platform, platformRoot, true);
if (platform.getOperatingSystem() == OperatingSystem.MACOS) {
platformRoot = root.resolve(platform.getOperatingSystem().getMojangName() + "-" + platform.getArchitecture().getCheckedName());
if (Files.isDirectory(platformRoot))
getAllJava(list, platform, platformRoot, false);
}
ArrayList<Path> list = new ArrayList<>();
getAllJava(list, platform, platformRoot);
return list;
}
@Override
public Task<JavaRuntime> getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) {
Path javaDir = getJavaDir(platform, gameJavaVersion);
Path tempDir = getPlatformRoot(platform).resolve(".tmp").resolve(javaDir.getFileName());
return new MojangJavaDownloadTask(downloadProvider, javaDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
return new MojangJavaDownloadTask(downloadProvider, javaDir, tempDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
Path executable;
try {
executable = JavaManager.getExecutable(javaDir).toRealPath();
@@ -165,16 +159,16 @@ public final class HMCLJavaRepository implements JavaRepository {
JavaInfo info;
if (JavaManager.isCompatible(platform))
info = JavaInfoUtils.fromExecutable(executable, false);
info = JavaInfoUtils.fromExecutable(executable);
else
info = new JavaInfo(platform, result.download.getVersion().getName(), null);
info = new JavaInfo(platform, result.download().version().name(), null);
Map<String, Object> update = new LinkedHashMap<>();
update.put("provider", "mojang");
update.put("component", gameJavaVersion.component());
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
result.remoteFiles.getFiles().forEach((path, file) -> {
result.remoteFiles().getFiles().forEach((path, file) -> {
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
if (downloadInfo != null) {
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.java;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -27,7 +28,6 @@ import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
@@ -39,35 +39,10 @@ public final class JavaInfoUtils {
private JavaInfoUtils() {
}
private static Path tryFindReleaseFile(Path executable) {
Path parent = executable.getParent();
if (parent != null && parent.getFileName() != null && parent.getFileName().toString().equals("bin")) {
Path javaHome = parent.getParent();
if (javaHome != null && javaHome.getFileName() != null) {
Path releaseFile = javaHome.resolve("release");
String javaHomeName = javaHome.getFileName().toString();
if ((javaHomeName.contains("jre") || javaHomeName.contains("jdk") || javaHomeName.contains("openj9"))
&& Files.isRegularFile(releaseFile)) {
return releaseFile;
}
}
}
return null;
}
public static @NotNull JavaInfo fromExecutable(Path executable, boolean tryFindReleaseFile) throws IOException {
public static @NotNull JavaInfo fromExecutable(Path executable) throws IOException {
assert executable.isAbsolute();
Path releaseFile;
if (tryFindReleaseFile && (releaseFile = tryFindReleaseFile(executable)) != null) {
try {
return JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException ignored) {
}
}
Path thisPath = JarUtils.thisJarPath();
if (thisPath == null) {
throw new IOException("Failed to find current HMCL location");
}
@@ -94,7 +69,6 @@ public final class JavaInfoUtils {
? architecture
: Architecture.SYSTEM_ARCH);
return new JavaInfo(platform, result.javaVersion, result.javaVendor);
} catch (IOException e) {
throw e;
@@ -103,14 +77,9 @@ public final class JavaInfoUtils {
}
}
private static final class Result {
@SerializedName("os.name")
public String osName;
@SerializedName("os.arch")
public String osArch;
@SerializedName("java.version")
public String javaVersion;
@SerializedName("java.vendor")
public String javaVendor;
@JsonSerializable
private record Result(@SerializedName("os.name") String osName, @SerializedName("os.arch") String osArch,
@SerializedName("java.version") String javaVersion,
@SerializedName("java.vendor") String javaVendor) {
}
}
@@ -17,6 +17,8 @@
*/
package org.jackhuang.hmcl.java;
import com.google.gson.*;
import com.google.gson.stream.JsonWriter;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.Metadata;
@@ -30,7 +32,9 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
@@ -40,9 +44,14 @@ import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -55,6 +64,16 @@ public final class JavaManager {
private JavaManager() {
}
private static final String[] KNOWN_VENDOR_DIRECTORIES = {
"Java",
"BellSoft",
"AdoptOpenJDK",
"Zulu",
"Microsoft",
"Eclipse Foundation",
"Semeru"
};
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
@@ -160,12 +179,12 @@ public final class JavaManager {
return javaRuntime;
}
JavaInfo info = JavaInfoUtils.fromExecutable(executable, true);
JavaInfo info = JavaInfoUtils.fromExecutable(executable);
return JavaRuntime.of(executable, info, false);
}
public static void refresh() {
Task.supplyAsync(JavaManager::searchPotentialJavaExecutables).whenComplete(Schedulers.javafx(), (result, exception) -> {
Task.supplyAsync(() -> searchPotentialJavaExecutables(false)).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (result != null) {
LATCH.await();
allJava = result;
@@ -304,7 +323,7 @@ public final class JavaManager {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.isMandatory()) {
violationMandatory = true;
} else {
@@ -326,7 +345,7 @@ public final class JavaManager {
}
public static void initialize() {
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables();
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables(true);
JavaManager.allJava = allJava;
LATCH.countDown();
FXUtils.runInFX(() -> updateAllJavaProperty(allJava));
@@ -334,56 +353,56 @@ public final class JavaManager {
// search java
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables() {
Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
searchAllJavaInRepository(javaRuntimes, Platform.SYSTEM_PLATFORM);
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables(boolean useCache) {
Searcher searcher = new Searcher(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("javaCache.json"));
if (useCache)
searcher.loadCache();
searcher.searchAllJavaInRepository(Platform.SYSTEM_PLATFORM);
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86_64);
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
}
break;
case MACOS:
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
searchAllJavaInRepository(javaRuntimes, Platform.MACOS_X86_64);
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
break;
}
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles", "C:\\Program Files");
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(x86)", "C:\\Program Files (x86)");
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(ARM)", "C:\\Program Files (ARM)");
}
searcher.searchJavaInProgramFiles("ProgramFiles", "C:\\Program Files");
searcher.searchJavaInProgramFiles("ProgramFiles(x86)", "C:\\Program Files (x86)");
break;
case LINUX:
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/java")); // Oracle RPMs
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib/jvm")); // General locations
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib32/jvm")); // General locations
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib64/jvm")); // General locations
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
searcher.searchAllJavaInDirectory(Path.of("/usr/java")); // Oracle RPMs
searcher.searchAllJavaInDirectory(Path.of("/usr/lib/jvm")); // General locations
searcher.searchAllJavaInDirectory(Path.of("/usr/lib32/jvm")); // General locations
searcher.searchAllJavaInDirectory(Path.of("/usr/lib64/jvm")); // General locations
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
break;
case MACOS:
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get("/Library/Java/JavaVirtualMachines"));
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
tryAddJavaExecutable(javaRuntimes, Paths.get("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
tryAddJavaExecutable(javaRuntimes, Paths.get("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
searcher.searchJavaInMacJavaVirtualMachines(Path.of("/Library/Java/JavaVirtualMachines"));
searcher.searchJavaInMacJavaVirtualMachines(Path.of(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
searcher.tryAddJavaExecutable(Path.of("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
searcher.tryAddJavaExecutable(Path.of("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
// Homebrew
tryAddJavaExecutable(javaRuntimes, Paths.get("/opt/homebrew/opt/java/bin/java"));
searchAllJavaInDirectory(javaRuntimes, Paths.get("/opt/homebrew/Cellar/openjdk"));
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Paths.get("/opt/homebrew/Cellar"), "openjdk@*")) {
searcher.tryAddJavaExecutable(Path.of("/opt/homebrew/opt/java/bin/java"));
searcher.searchAllJavaInDirectory(Path.of("/opt/homebrew/Cellar/openjdk"));
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Path.of("/opt/homebrew/Cellar"), "openjdk@*")) {
for (Path dir : dirs) {
searchAllJavaInDirectory(javaRuntimes, dir);
searcher.searchAllJavaInDirectory(dir);
}
} catch (IOException e) {
LOG.warning("Failed to get subdirectories of /opt/homebrew/Cellar");
@@ -397,16 +416,16 @@ public final class JavaManager {
// Search Minecraft bundled runtimes
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH.isX86()) {
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false));
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false));
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), ".minecraft/runtime"), false);
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
}
searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
@@ -420,7 +439,7 @@ public final class JavaManager {
}
try {
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
searcher.tryAddJavaExecutable(Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
} catch (InvalidPathException ignored) {
}
}
@@ -430,16 +449,17 @@ public final class JavaManager {
String[] paths = System.getenv("HMCL_JRES").split(File.pathSeparator);
for (String path : paths) {
try {
tryAddJavaHome(javaRuntimes, Paths.get(path));
searcher.tryAddJavaHome(Path.of(path));
} catch (InvalidPathException ignored) {
}
}
}
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), ".jdks"));
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
for (String javaPath : ConfigHolder.globalConfig().getUserJava()) {
try {
tryAddJavaExecutable(javaRuntimes, Paths.get(javaPath));
searcher.tryAddJavaExecutable(Path.of(javaPath));
} catch (InvalidPathException e) {
LOG.warning("Invalid Java path: " + javaPath);
}
@@ -447,233 +467,404 @@ public final class JavaManager {
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
if (currentJava != null
&& !javaRuntimes.containsKey(currentJava.getBinary())
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
javaRuntimes.put(currentJava.getBinary(), currentJava);
searcher.addResult(currentJava.getBinary(), currentJava);
}
LOG.trace(javaRuntimes.values().stream().sorted()
searcher.saveCache();
LOG.trace(searcher.javaRuntimes.values().stream().sorted()
.map(it -> String.format(" - %s %s (%s, %s): %s",
it.isJDK() ? "JDK" : "JRE",
it.getVersion(),
it.getPlatform().getArchitecture().getDisplayName(),
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
it.getBinary()))
.collect(Collectors.joining("\n", "Finished Java lookup, found " + javaRuntimes.size() + "\n", "")));
return javaRuntimes;
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
return searcher.javaRuntimes;
}
private static void tryAddJavaHome(Map<Path, JavaRuntime> javaRuntimes, Path javaHome) {
Path executable = getExecutable(javaHome);
if (!Files.isRegularFile(executable)) {
return;
private static final class Searcher {
private final Path cacheFile;
final Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
private final LinkedHashMap<Path, JavaInfoCache> caches = new LinkedHashMap<>();
private final Set<Path> failed = new HashSet<>();
private boolean needRefreshCache = false;
Searcher(Path cacheFile) {
this.cacheFile = cacheFile;
}
try {
executable = executable.toRealPath();
} catch (IOException e) {
LOG.warning("Failed to resolve path " + executable, e);
return;
private static final Pattern CACHE_VERSION_PATTERN = Pattern.compile("(?<major>\\d+)(?:\\.(?<minor>\\d+))?");
private static final int CACHE_MAJOR_VERSION = 0;
private static final int CACHE_MINOR_VERSION = 0;
private record JavaInfoCache(String key, JavaInfo info) {
}
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
return;
}
void loadCache() {
if (Files.notExists(cacheFile))
return;
JavaInfo info = null;
Path releaseFile = javaHome.resolve("release");
if (Files.exists(releaseFile)) {
try {
info = JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException e) {
LOG.warning("Failed to read release file " + releaseFile, e);
JsonObject jsonFile = JsonUtils.fromJsonFile(cacheFile, JsonObject.class);
JsonElement fileVersion = jsonFile.get("version");
Matcher matcher;
if (fileVersion instanceof JsonPrimitive version
&& (matcher = CACHE_VERSION_PATTERN.matcher(version.getAsString())).matches()) {
int major = Integer.parseInt(matcher.group("major"));
String minorString = matcher.group("minor");
int minor = minorString != null ? Integer.parseInt(minorString) : 0;
if (major != CACHE_MAJOR_VERSION || minor < CACHE_MINOR_VERSION)
throw new IOException("Unsupported cache file, version: %s".formatted(version.getAsString()));
} else
throw new IOException("Invalid version JSON: " + fileVersion);
JsonArray cachesArray = jsonFile.getAsJsonArray("caches");
for (JsonElement element : cachesArray) {
try {
var obj = (JsonObject) element;
Path realPath = Path.of(obj.getAsJsonPrimitive("path").getAsString()).toRealPath();
String key = obj.getAsJsonPrimitive("key").getAsString();
OperatingSystem osName = OperatingSystem.parseOSName(obj.getAsJsonPrimitive("os.name").getAsString());
Architecture osArch = Architecture.parseArchName(obj.getAsJsonPrimitive("os.arch").getAsString());
String javaVersion = obj.getAsJsonPrimitive("java.version").getAsString();
JavaInfo.Builder infoBuilder = JavaInfo.newBuilder(Platform.getPlatform(osName, osArch), javaVersion);
if (obj.get("java.vendor") instanceof JsonPrimitive vendor)
infoBuilder.setVendor(vendor.getAsString());
caches.put(realPath, new JavaInfoCache(key, infoBuilder.build()));
} catch (Exception e) {
LOG.warning("Invalid cache: " + element);
needRefreshCache = true;
}
}
} catch (Exception ex) {
LOG.warning("Failed to load cache file: " + cacheFile);
needRefreshCache = true;
}
}
if (info == null) {
void saveCache() {
if (!needRefreshCache)
return;
needRefreshCache = false;
try {
info = JavaInfoUtils.fromExecutable(executable, false);
FileUtils.saveSafely(cacheFile, output -> {
try (var writer = new JsonWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))) {
writer.beginObject();
writer.name("version").value("%d.%d".formatted(CACHE_MAJOR_VERSION, CACHE_MINOR_VERSION));
writer.name("caches");
writer.beginArray();
for (Map.Entry<Path, JavaInfoCache> entry : caches.entrySet()) {
Path path = entry.getKey();
JavaInfoCache cache = entry.getValue();
JavaInfo info = cache.info();
writer.beginObject();
writer.name("path").value(path.toString());
writer.name("key").value(cache.key());
writer.name("os.name").value(info.getPlatform().os().getCheckedName());
writer.name("os.arch").value(info.getPlatform().arch().getCheckedName());
writer.name("java.version").value(info.getVersion());
if (info.getVendor() != null)
writer.name("java.vendor").value(info.getVendor());
writer.endObject();
}
writer.endArray();
writer.endObject();
}
});
} catch (Exception e) {
LOG.warning("Failed to save cache file: " + cacheFile);
}
}
private static @Nullable String createCacheKey(Path realPath) {
Path binDir = realPath.getParent();
if (binDir == null || !FileUtils.getName(binDir).equals("bin"))
return null;
if (Files.isRegularFile(realPath.resolveSibling("ikvm.properties")))
return null;
Path javaHome = binDir.getParent();
if (javaHome == null)
return null;
Path libDir = javaHome.resolve("lib");
if (!Files.isDirectory(libDir))
return null;
BasicFileAttributes launcherAttributes;
String releaseHash = null;
BasicFileAttributes coreLibsAttributes = null;
try {
launcherAttributes = Files.readAttributes(realPath, BasicFileAttributes.class);
Path releaseFile = javaHome.resolve("release");
if (Files.exists(releaseFile)) {
releaseHash = DigestUtils.digestToString("SHA-1", releaseFile);
} else {
Path coreLibsFile = libDir.resolve("rt.jar");
if (!Files.isRegularFile(coreLibsFile)) {
coreLibsFile = javaHome.resolve("jre/lib/rt.jar");
if (!Files.isRegularFile(coreLibsFile))
return null;
coreLibsAttributes = Files.readAttributes(coreLibsFile, BasicFileAttributes.class);
}
}
} catch (Exception e) {
LOG.warning("Failed to create cache key for " + realPath, e);
return null;
}
StringJoiner joiner = new StringJoiner(",");
joiner.add("sz:" + launcherAttributes.size());
joiner.add("lm:" + launcherAttributes.lastModifiedTime().toMillis());
if (releaseHash != null)
joiner.add(releaseHash);
if (coreLibsAttributes != null) {
joiner.add("rsz:" + coreLibsAttributes.size());
joiner.add("rlm:" + coreLibsAttributes.lastModifiedTime().toMillis());
}
return joiner.toString();
}
void addResult(Path realPath, JavaRuntime javaRuntime) {
javaRuntimes.put(realPath, javaRuntime);
}
void tryAddJavaHome(Path javaHome) {
tryAddJavaExecutable(getExecutable(javaHome));
}
void tryAddJavaExecutable(Path executable) {
tryAddJavaExecutable(executable, false);
}
void tryAddJavaExecutable(Path executable, boolean isManaged) {
try {
executable = executable.toRealPath();
} catch (IOException e) {
return;
}
if (javaRuntimes.containsKey(executable)
|| failed.contains(executable)
|| ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
return;
}
String cacheKey = createCacheKey(executable);
if (cacheKey != null) {
JavaInfoCache cache = caches.get(executable);
if (cache != null) {
if (isCompatible(cache.info().getPlatform()) && cacheKey.equals(cache.key())) {
javaRuntimes.put(executable, JavaRuntime.of(executable, cache.info(), isManaged));
return;
} else {
caches.remove(executable);
needRefreshCache = true;
}
}
} else if (caches.remove(executable) != null) {
needRefreshCache = true;
}
JavaInfo info;
try {
info = JavaInfoUtils.fromExecutable(executable);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
failed.add(executable);
return;
}
if (cacheKey != null) {
caches.put(executable, new JavaInfoCache(cacheKey, info));
needRefreshCache = true;
}
javaRuntimes.put(executable, JavaRuntime.of(executable, info, isManaged));
}
void tryAddJavaInComponentDir(String platform, Path component, boolean verify) {
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
if (!Files.isRegularFile(sha1File))
return;
Path dir = component.resolve(platform).resolve(component.getFileName());
if (verify) {
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) continue;
int idx = line.indexOf(" /#//");
if (idx <= 0)
throw new IOException("Illegal line: " + line);
Path file = dir.resolve(line.substring(0, idx));
// Should we check the sha1 of files? This will take a lot of time.
if (Files.notExists(file))
throw new NoSuchFileException(file.toAbsolutePath().toString());
}
} catch (IOException e) {
LOG.warning("Failed to verify Java in " + component, e);
return;
}
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Path macPath = dir.resolve("jre.bundle/Contents/Home");
if (Files.exists(macPath)) {
tryAddJavaHome(macPath);
return;
} else
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
}
tryAddJavaHome(dir);
}
void searchAllJavaInRepository(Platform platform) {
for (Path java : REPOSITORY.getAllJava(platform)) {
tryAddJavaExecutable(java, true);
}
for (Path java : LOCAL_REPOSITORY.getAllJava(platform)) {
tryAddJavaExecutable(java, true);
}
if (platform.os() == OperatingSystem.MACOS) {
// In the past, we used 'osx' as the checked name for macOS
Path platformRoot = REPOSITORY.getPlatformRoot(platform).resolveSibling("osx-" + platform.getArchitecture().getCheckedName());
searchAllJavaInDirectory(platformRoot);
}
}
if (info != null && isCompatible(info.getPlatform()))
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
}
void searchAllOfficialJava(Path directory, boolean verify) {
if (!Files.isDirectory(directory))
return;
// Examples:
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
private static void tryAddJavaExecutable(Map<Path, JavaRuntime> javaRuntimes, Path executable) {
try {
executable = executable.toRealPath();
} catch (IOException e) {
return;
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
if (javaPlatform != null) {
searchAllOfficialJava(directory, javaPlatform, verify);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
}
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
}
}
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
return;
}
JavaInfo info = null;
try {
info = JavaInfoUtils.fromExecutable(executable, true);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
}
if (info != null && isCompatible(info.getPlatform())) {
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
}
}
private static void tryAddJavaInComponentDir(Map<Path, JavaRuntime> javaRuntimes, String platform, Path component, boolean verify) {
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
if (!Files.isRegularFile(sha1File))
return;
Path dir = component.resolve(platform).resolve(component.getFileName());
if (verify) {
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) continue;
int idx = line.indexOf(" /#//");
if (idx <= 0)
throw new IOException("Illegal line: " + line);
Path file = dir.resolve(line.substring(0, idx));
// Should we check the sha1 of files? This will take a lot of time.
if (Files.notExists(file))
throw new NoSuchFileException(file.toAbsolutePath().toString());
void searchAllOfficialJava(Path directory, String platform, boolean verify) {
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
for (Path component : dir) {
tryAddJavaInComponentDir(platform, component, verify);
}
} catch (IOException e) {
LOG.warning("Failed to verify Java in " + component, e);
return;
LOG.warning("Failed to list java-runtime directory " + directory, e);
}
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Path macPath = dir.resolve("jre.bundle/Contents/Home");
if (Files.exists(macPath)) {
tryAddJavaHome(javaRuntimes, macPath);
void searchAllJavaInDirectory(Path directory) {
if (!Files.isDirectory(directory)) {
return;
} else
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
}
}
tryAddJavaHome(javaRuntimes, dir);
}
private static void searchAllJavaInRepository(Map<Path, JavaRuntime> javaRuntimes, Platform platform) {
for (JavaRuntime java : REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
}
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, boolean verify) {
if (!Files.isDirectory(directory))
return;
// Examples:
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
if (javaPlatform != null) {
searchAllOfficialJava(javaRuntimes, directory, javaPlatform, verify);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(subDir);
}
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
}
}
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, String platform, boolean verify) {
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
for (Path component : dir) {
tryAddJavaInComponentDir(javaRuntimes, platform, component, verify);
void searchJavaInProgramFiles(String env, String defaultValue) {
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
Path path;
try {
path = Path.of(programFiles);
} catch (InvalidPathException ignored) {
return;
}
} catch (IOException e) {
LOG.warning("Failed to list java-runtime directory " + directory, e);
}
}
private static void searchAllJavaInDirectory(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
if (!Files.isDirectory(directory)) {
return;
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(javaRuntimes, subDir);
for (String vendor : KNOWN_VENDOR_DIRECTORIES) {
searchAllJavaInDirectory(path.resolve(vendor));
}
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
}
}
private static void searchJavaInProgramFiles(Map<Path, JavaRuntime> javaRuntimes, String env, String defaultValue) {
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
Path path;
try {
path = Paths.get(programFiles);
} catch (InvalidPathException ignored) {
return;
}
for (String vendor : new String[]{"Java", "BellSoft", "AdoptOpenJDK", "Zulu", "Microsoft", "Eclipse Foundation", "Semeru"}) {
searchAllJavaInDirectory(javaRuntimes, path.resolve(vendor));
}
}
private static void searchJavaInMacJavaVirtualMachines(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
if (!Files.isDirectory(directory)) {
return;
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(javaRuntimes, subDir.resolve("Contents/Home"));
void searchJavaInMacJavaVirtualMachines(Path directory) {
if (!Files.isDirectory(directory)) {
return;
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(subDir.resolve("Contents/Home"));
}
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
}
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
}
}
// ==== Windows Registry Support ====
private static void queryJavaInRegistryKey(Map<Path, JavaRuntime> javaRuntimes, WinReg.HKEY hkey, String location) {
WinReg reg = WinReg.INSTANCE;
if (reg == null)
return;
// ==== Windows Registry Support ====
void queryJavaInRegistryKey(WinReg.HKEY hkey, String location) {
WinReg reg = WinReg.INSTANCE;
if (reg == null)
return;
for (String java : reg.querySubKeys(hkey, location)) {
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
continue;
Object home = reg.queryValue(hkey, java, "JavaHome");
if (home instanceof String) {
try {
tryAddJavaHome(javaRuntimes, Paths.get((String) home));
} catch (InvalidPathException e) {
LOG.warning("Invalid Java path in system registry: " + home);
for (String java : reg.querySubKeys(hkey, location)) {
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
continue;
if (reg.queryValue(hkey, java, "JavaHome") instanceof String home) {
try {
tryAddJavaHome(Path.of(home));
} catch (InvalidPathException e) {
LOG.warning("Invalid Java path in system registry: " + home);
}
}
}
}
}
}
@@ -222,6 +222,36 @@ public final class Config extends ObservableSetting {
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
}
@SerializedName("disableAutoShowUpdateDialog")
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
public BooleanProperty disableAutoShowUpdateDialogProperty() {
return disableAutoShowUpdateDialog;
}
public boolean isDisableAutoShowUpdateDialog() {
return disableAutoShowUpdateDialog.get();
}
public void setDisableAutoShowUpdateDialog(boolean disableAutoShowUpdateDialog) {
this.disableAutoShowUpdateDialog.set(disableAutoShowUpdateDialog);
}
@SerializedName("disableAprilFools")
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
public BooleanProperty disableAprilFoolsProperty() {
return disableAprilFools;
}
public boolean isDisableAprilFools() {
return disableAprilFools.get();
}
public void setDisableAprilFools(boolean disableAprilFools) {
this.disableAprilFools.set(disableAprilFools);
}
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
@@ -537,6 +567,21 @@ public final class Config extends ObservableSetting {
this.versionListSource.set(versionListSource);
}
@SerializedName("defaultAddonSource")
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
public StringProperty defaultAddonSourceProperty() {
return defaultAddonSource;
}
public String getDefaultAddonSource() {
return defaultAddonSource.get();
}
public void setDefaultAddonSource(String defaultAddonSource) {
this.defaultAddonSource.set(defaultAddonSource);
}
@SerializedName("hasProxy")
private final BooleanProperty hasProxy = new SimpleBooleanProperty();
@@ -659,6 +704,21 @@ public final class Config extends ObservableSetting {
this.disableAutoGameOptions.set(disableAutoGameOptions);
}
@SerializedName("allowAutoAgent")
private final BooleanProperty allowAutoAgent = new SimpleBooleanProperty(false);
public BooleanProperty allowAutoAgentProperty() {
return allowAutoAgent;
}
public boolean getAllowAutoAgent() {
return allowAutoAgent.get();
}
public void setAllowAutoAgent(boolean allowAutoAgent) {
this.allowAutoAgent.set(allowAutoAgent);
}
// Accounts
@SerializedName("authlibInjectorServers")
@@ -1,3 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.setting;
public final class SambaException extends RuntimeException {
@@ -23,6 +23,7 @@ import com.jfoenix.validation.base.ValidatorBase;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.DoubleProperty;
@@ -64,12 +65,16 @@ import org.jackhuang.hmcl.ui.versions.GameListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -82,6 +87,7 @@ 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 String APRIL_FOOLS = "aprilFools";
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
@@ -99,9 +105,14 @@ public final class Controllers {
GameListPage gameListPage = new GameListPage();
gameListPage.selectedProfileProperty().bindBidirectional(Profiles.selectedProfileProperty());
gameListPage.profilesProperty().bindContent(Profiles.profilesProperty());
FXUtils.applyDragListener(gameListPage, ModpackHelper::isFileModpackByExtension, modpacks -> {
Path modpack = modpacks.get(0);
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), modpack), i18n("install.modpack"));
FXUtils.applyDragListener(gameListPage, file -> ModpackHelper.isFileModpackByExtension(file) || "json".equalsIgnoreCase(FileUtils.getNameWithoutExtension(file)), files -> {
Path file = files.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), file), i18n("install.modpack"));
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Versions.installFromJson(Profiles.getSelectedProfile(), file);
}
});
return gameListPage;
});
@@ -225,6 +236,8 @@ public final class Controllers {
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
LOG.info("April Fools: " + AprilFools.isEnabled());
if (System.getProperty("prism.lcdtext") == null) {
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
@@ -432,6 +445,53 @@ public final class Controllers {
agreementPane.setActions(agreementLink, yesButton, noButton);
Controllers.dialog(agreementPane);
}
aprilFools:
if (AprilFools.isEnabled()) {
int currentYear = LocalDate.now().getYear();
if (config().getShownTips().get(APRIL_FOOLS) instanceof Number year && year.intValue() >= currentYear)
break aprilFools;
if (!I18n.getLocale().getLocale().getLanguage().equals("zh"))
break aprilFools;
SupportedLocale lzh = SupportedLocale.getSupportedLocales().stream()
.filter(locale -> "lzh".equals(locale.getName()))
.findFirst().orElse(null);
if (lzh == null) {
LOG.warning("No supported locale found for lzh");
break aprilFools;
}
Runnable updateShowTips = () -> config().getShownTips().put(APRIL_FOOLS, currentYear);
Controllers.confirmWithCountdown(i18n("launcher.april_fools.switch_lzh"), null, 10,
MessageType.QUESTION, () -> {
Controllers.confirm(i18n("launcher.april_fools.switch_lzh.confirm"), null, MessageType.QUESTION, () -> {
LOG.info("Switching locale to " + lzh);
updateShowTips.run();
config().setLocalization(lzh);
Controllers.onApplicationStop();
try {
FileSaver.waitForAllSaves();
} catch (InterruptedException ignored) {
// Ignore
}
try {
Restarter.restartSelf();
} catch (IOException e) {
LOG.warning("Failed to restart self", e);
}
Platform.exit();
}, updateShowTips);
}, updateShowTips);
}
}
public static void dialog(Region content) {
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.*;
import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
@@ -66,6 +67,7 @@ import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
@@ -233,8 +235,8 @@ public final class FXUtils {
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"
public static final List<String> IMAGE_EXTENSIONS = List.of(
"png", "jpg", "jpeg", "bmp", "gif", "webp", "svg", "apng"
);
private static final Map<String, Image> builtinImageCache = new ConcurrentHashMap<>();
@@ -1239,8 +1241,8 @@ public final class FXUtils {
.setSignificance(Task.TaskSignificance.MINOR);
}
public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uri)
public static Task<Image> getRemoteImageTask(List<URI> uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uris)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
@@ -1288,6 +1290,23 @@ public final class FXUtils {
return button;
}
public static void setOnActionWithCooldown(ButtonBase button, Runnable action) {
setOnActionWithCooldown(button, action, Motion.SHORT4);
}
public static void setOnActionWithCooldown(ButtonBase button, Runnable action, Duration cooldown) {
button.setOnAction(e -> {
button.setDisable(true);
var pause = new PauseTransition(cooldown);
pause.setOnFinished(event -> button.setDisable(false));
pause.play();
action.run();
e.consume();
});
}
public static Label newSafeTruncatedLabel() {
Label label = new Label();
label.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS);
@@ -42,6 +42,7 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
@@ -273,10 +274,10 @@ public class GameCrashWindow extends Stage {
logWindow.show();
}
private void exportGameCrashInfo() {
private CompletableFuture<Path> exportGameCrashInfo() {
Path logFile = Paths.get("minecraft-exported-crash-info-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".zip").toAbsolutePath();
CompletableFuture.supplyAsync(() ->
return CompletableFuture.supplyAsync(() ->
logs.stream().map(Log::getLog).collect(Collectors.joining("\n")))
.thenComposeAsync(logs -> {
long processStartTime = managedProcess.getProcess().info()
@@ -301,20 +302,7 @@ public class GameCrashWindow extends Stage {
return false;
}
});
})
.handleAsync((result, exception) -> {
if (exception == null) {
FXUtils.showFileInExplorer(logFile);
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build();
DialogUtils.show(stackPane, dialog);
} else {
LOG.warning("Failed to export game crash info", exception);
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build();
DialogUtils.show(stackPane, dialog);
}
return null;
}, Schedulers.javafx());
}).thenApply(ignored -> logFile);
}
private final class View extends VBox {
@@ -444,8 +432,35 @@ public class GameCrashWindow extends Stage {
HBox toolBar = new HBox();
VBox.setMargin(toolBar, new Insets(0, 0, 4, 0));
{
JFXButton exportGameCrashInfoButton = FXUtils.newRaisedButton(i18n("logwindow.export_game_crash_logs"));
exportGameCrashInfoButton.setOnAction(e -> exportGameCrashInfo());
SpinnerPane exportButtonPane = new SpinnerPane();
exportButtonPane.getStyleClass().add("small-spinner-pane");
JFXButton exportButton = FXUtils.newRaisedButton(i18n("logwindow.export_game_crash_logs"));
exportButtonPane.setContent(exportButton);
exportButton.setOnAction(e -> {
exportButtonPane.showSpinner();
exportGameCrashInfo().whenCompleteAsync((result, exception) -> {
exportButtonPane.hideSpinner();
if (exception == null) {
FXUtils.showFileInExplorer(result);
var dialog = new MessageDialogPane.Builder(
i18n("settings.launcher.launcher_log.export.success", result),
i18n("message.success"),
MessageDialogPane.MessageType.SUCCESS
).ok(null).build();
DialogUtils.show(stackPane, dialog);
} else {
LOG.warning("Failed to export game crash info", exception);
var dialog = new MessageDialogPane.Builder(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR
).ok(null).build();
DialogUtils.show(stackPane, dialog);
}
}, Schedulers.javafx());
});
JFXButton logButton = FXUtils.newRaisedButton(i18n("logwindow.title"));
logButton.setOnAction(e -> showLogWindow());
@@ -457,7 +472,7 @@ public class GameCrashWindow extends Stage {
toolBar.setPadding(new Insets(8));
toolBar.setSpacing(8);
toolBar.getStyleClass().add("jfx-tool-bar");
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, helpButton);
toolBar.getChildren().setAll(exportButtonPane, logButton, helpButton);
}
getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar);
@@ -36,6 +36,7 @@ import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
@@ -241,6 +242,10 @@ public final class LogWindow extends Stage {
});
}
private ManagedProcess getGameProcess() {
return gameProcess;
}
@Override
protected Skin<?> createDefaultSkin() {
return new LogWindowSkin(this);
@@ -411,11 +416,12 @@ public final class LogWindow extends Stage {
terminateButton.setOnAction(e -> getSkinnable().onTerminateGame());
SpinnerPane exportDumpPane = new SpinnerPane();
exportDumpPane.getStyleClass().add("small-spinner-pane");
JFXButton exportDumpButton = new JFXButton(i18n("logwindow.export_dump"));
if (SystemUtils.supportJVMAttachment()) {
exportDumpButton.setOnAction(e -> getSkinnable().onExportDump(exportDumpPane));
} else {
exportDumpButton.setTooltip(new Tooltip(i18n("logwindow.export_dump.no_dependency")));
FXUtils.installFastTooltip(exportDumpPane, i18n("logwindow.export_dump.no_dependency"));
exportDumpButton.setDisable(true);
}
exportDumpPane.setContent(exportDumpButton);
@@ -424,6 +430,13 @@ public final class LogWindow extends Stage {
clearButton.setOnAction(e -> getSkinnable().onClear());
hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
control.getGameProcess().getProcess()
.onExit()
.thenRunAsync(() -> {
terminateButton.setDisable(true);
exportDumpButton.setDisable(true);
}, Schedulers.javafx());
vbox.getChildren().add(bottom);
}
}
@@ -0,0 +1,228 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.application.Platform;
import javafx.beans.property.*;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Rectangle;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.MathUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus;
import java.lang.ref.WeakReference;
/// @author Glavo
public final class MemoryStatusBar extends Control {
private static final String DEFAULT_STYLE_CLASS = "memory-status-bar";
public MemoryStatusBar() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
setPrefWidth(200);
}
private final ReadOnlyObjectProperty<PhysicalMemoryStatus> memoryStatus = UpdateMemoryStatus.memoryStatusProperty();
/// The memory status of the system.
///
/// The property will be automatically updated.
///
/// The property has no bean, because it may share between multiple MemoryStatusBar instances.
public ReadOnlyObjectProperty<PhysicalMemoryStatus> memoryStatusProperty() {
return memoryStatus;
}
public PhysicalMemoryStatus getMemoryStatus() {
return memoryStatus.get();
}
private final DoubleProperty memoryAllocated = new SimpleDoubleProperty();
public DoubleProperty memoryAllocatedProperty() {
return memoryAllocated;
}
public double getMemoryAllocated() {
return memoryAllocated.get();
}
public void setMemoryAllocated(double memoryAllocated) {
this.memoryAllocated.set(memoryAllocated);
}
@Override
protected SkinBase<MemoryStatusBar> createDefaultSkin() {
return new Skin(this);
}
private static final class Skin extends SkinBase<MemoryStatusBar> {
private static final int HEIGHT = 4;
private final StackPane track;
private final Rectangle memoryUsed;
private final Rectangle memoryAllocate;
private double contentWidth = -1;
Skin(MemoryStatusBar control) {
super(control);
track = new StackPane();
track.getStyleClass().add("track");
track.setPadding(new Insets(HEIGHT, 0, 0, 0));
memoryUsed = new Rectangle();
memoryUsed.setManaged(false);
memoryUsed.setArcWidth(HEIGHT);
memoryUsed.setArcHeight(HEIGHT);
memoryUsed.getStyleClass().add("memory-used");
memoryAllocate = new Rectangle();
memoryAllocate.setManaged(false);
memoryAllocate.setArcWidth(HEIGHT);
memoryAllocate.setArcHeight(HEIGHT);
memoryAllocate.getStyleClass().add("memory-allocate");
this.getChildren().setAll(track, memoryAllocate, memoryUsed);
registerInvalidationListener(control.memoryStatusProperty(), it -> updateBar());
registerInvalidationListener(control.memoryAllocatedProperty(), it -> updateBar());
}
private void updateBar() {
if (contentWidth <= 0) {
// not yet layout
return;
}
PhysicalMemoryStatus status = getSkinnable().getMemoryStatus();
double total = status.getTotal();
double used = status.getUsed();
double allocated = getSkinnable().getMemoryAllocated();
if (total <= 0) {
memoryUsed.setWidth(contentWidth);
memoryAllocate.setWidth(contentWidth);
return;
}
double usedWidth = MathUtils.clamp(contentWidth * (used / total), 0, contentWidth);
double allocatedWidth = MathUtils.clamp(contentWidth * (allocated / total) + usedWidth, 0, contentWidth);
memoryUsed.setWidth(usedWidth);
memoryAllocate.setWidth(allocatedWidth);
}
@Override
protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) {
track.resizeRelocate(contentX, contentY, contentWidth, contentHeight);
memoryUsed.relocate(contentX, contentY);
memoryAllocate.relocate(contentX, contentY);
this.contentWidth = contentWidth;
memoryUsed.setHeight(contentHeight);
memoryAllocate.setHeight(contentHeight);
updateBar();
}
@Override
public double computeBaselineOffset(double topInset, double rightInset, double bottomInset, double leftInset) {
return Node.BASELINE_OFFSET_SAME_AS_HEIGHT;
}
@Override
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return leftInset + track.prefWidth(height) + rightInset;
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
return topInset + HEIGHT + bottomInset;
}
@Override
protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
return computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
}
}
private static final class UpdateMemoryStatus extends Thread {
private static final int UPDATE_INTERVAL = 3000;
@FXThread
private static WeakReference<ObjectProperty<PhysicalMemoryStatus>> memoryStatusPropertyCache;
@FXThread
static ReadOnlyObjectProperty<PhysicalMemoryStatus> memoryStatusProperty() {
if (memoryStatusPropertyCache != null) {
var property = memoryStatusPropertyCache.get();
if (property != null) {
return property;
}
}
ObjectProperty<PhysicalMemoryStatus> property = new SimpleObjectProperty<>(PhysicalMemoryStatus.INVALID);
memoryStatusPropertyCache = new WeakReference<>(property);
new UpdateMemoryStatus(memoryStatusPropertyCache).start();
return property;
}
private final WeakReference<ObjectProperty<PhysicalMemoryStatus>> memoryStatusPropertyRef;
UpdateMemoryStatus(WeakReference<ObjectProperty<PhysicalMemoryStatus>> memoryStatusPropertyRef) {
this.memoryStatusPropertyRef = memoryStatusPropertyRef;
setName("UpdateMemoryStatus");
setDaemon(true);
setPriority(Thread.MIN_PRIORITY);
}
@Override
public void run() {
while (true) {
PhysicalMemoryStatus status = SystemInfo.getPhysicalMemoryStatus();
var memoryStatusProperty = memoryStatusPropertyRef.get();
if (memoryStatusProperty == null)
return;
if (Controllers.isStopped())
return;
Platform.runLater(() -> memoryStatusProperty.set(status));
try {
//noinspection BusyWait
Thread.sleep(UPDATE_INTERVAL);
} catch (InterruptedException e) {
return;
}
}
}
}
}
@@ -173,7 +173,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
spinnerCopyUUID.setContent(btnCopyUUID);
right.getChildren().add(spinnerCopyUUID);
JFXButton btnRemove = FXUtils.newToggleButton4(SVG.DELETE);
JFXButton btnRemove = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
btnRemove.setOnAction(e -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::remove, null));
BorderPane.setAlignment(btnRemove, Pos.CENTER);
FXUtils.installFastTooltip(btnRemove, i18n("button.delete"));
@@ -115,7 +115,7 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
GridPane.setRowIndex(lblServerName, 1);
lblServerWarning = new Label(i18n("account.injector.http"));
lblServerWarning.setStyle("-fx-text-fill: red;");
lblServerWarning.setStyle("-fx-text-fill: -monet-error;");
GridPane.setColumnIndex(lblServerWarning, 0);
GridPane.setRowIndex(lblServerWarning, 2);
lblServerWarning.managedProperty().bind(lblServerWarning.visibleProperty());
@@ -80,8 +80,7 @@ public class FileSelector extends HBox {
JFXTextField customField = new JFXTextField();
FXUtils.bindString(customField, valueProperty());
JFXButton selectButton = new JFXButton();
selectButton.setGraphic(SVG.FOLDER_OPEN.createIcon(15));
JFXButton selectButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN, 15);
selectButton.setOnAction(e -> {
if (directory) {
DirectoryChooser chooser = new DirectoryChooser();
@@ -26,7 +26,7 @@ import javafx.scene.control.ScrollBar;
import javafx.scene.control.Skin;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.MathUtils;
// Referenced in root.css
@SuppressWarnings("unused")
@@ -75,7 +75,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
*/
if (getSkinnable().getMax() > getSkinnable().getMin()) {
dragStart = thumb.localToParent(me.getX(), me.getY());
double clampedValue = Lang.clamp(getSkinnable().getMin(), getSkinnable().getValue(), getSkinnable().getMax());
double clampedValue = MathUtils.clamp(getSkinnable().getValue(), getSkinnable().getMin(), getSkinnable().getMax());
preDragThumbPos = (clampedValue - getSkinnable().getMin()) / (getSkinnable().getMax() - getSkinnable().getMin());
me.consume();
}
@@ -107,7 +107,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
getSkinnable().requestFocus();
double newValue = (position * (getSkinnable().getMax() - getSkinnable().getMin())) + getSkinnable().getMin();
if (!Double.isNaN(newValue)) {
getSkinnable().setValue(Lang.clamp(getSkinnable().getMin(), newValue, getSkinnable().getMax()));
getSkinnable().setValue(MathUtils.clamp(newValue, getSkinnable().getMin(), getSkinnable().getMax()));
}
}
@@ -129,12 +129,12 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
track.heightProperty().unbind();
if (scrollBar.getOrientation() == Orientation.HORIZONTAL) {
track.relocate(0, -5);
track.relocate(0, -8);
track.widthProperty().bind(scrollBar.widthProperty());
track.setHeight(5);
track.setHeight(8);
} else {
track.relocate(-5, 0);
track.setWidth(5);
track.relocate(-8, 0);
track.setWidth(8);
track.heightProperty().bind(scrollBar.heightProperty());
}
@@ -144,13 +144,13 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
thumb.heightProperty().unbind();
if (scrollBar.getOrientation() == Orientation.HORIZONTAL) {
thumb.relocate(0, -5);
thumb.relocate(0, -8);
thumb.widthProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.widthProperty())));
thumb.setHeight(5);
thumb.setHeight(8);
thumb.xProperty().bind(Bindings.subtract(scrollBar.widthProperty(), thumb.widthProperty()).multiply(position));
} else {
thumb.relocate(-5, 0);
thumb.setWidth(5);
thumb.relocate(-8, 0);
thumb.setWidth(8);
thumb.heightProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.heightProperty())));
thumb.yProperty().bind(Bindings.subtract(scrollBar.heightProperty(), thumb.heightProperty()).multiply(position));
}
@@ -162,7 +162,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
return Double.MAX_VALUE;
}
return 5;
return 8;
}
@Override
@@ -171,7 +171,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
return Double.MAX_VALUE;
}
return 5;
return 8;
}
};
}
@@ -30,9 +30,6 @@ import org.jackhuang.hmcl.ui.SVG;
public class LineButton extends LineButtonBase {
private static final String DEFAULT_STYLE_CLASS = "line-button";
private static final int IDX_TRAILING_TEXT = IDX_TRAILING;
private static final int IDX_TRAILING_ICON = IDX_TRAILING + 1;
public static LineButton createNavigationButton() {
var button = new LineButton();
button.setTrailingIcon(SVG.ARROW_FORWARD);
@@ -50,7 +47,14 @@ public class LineButton extends LineButtonBase {
public LineButton() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
container.setMouseTransparent(true);
}
protected int getTrailingTextIndex() {
return IDX_TRAILING;
}
protected int getTrailingIconIndex() {
return getTrailingTextIndex() + 1;
}
private ObjectProperty<EventHandler<ActionEvent>> onAction;
@@ -110,12 +114,13 @@ public class LineButton extends LineButtonBase {
trailingTextLabel = new Label();
trailingTextLabel.getStyleClass().add("trailing-label");
trailingTextLabel.setTextOverrun(OverrunStyle.CENTER_ELLIPSIS);
trailingTextLabel.setMouseTransparent(true);
}
trailingTextLabel.setText(message);
setNode(IDX_TRAILING_TEXT, trailingTextLabel);
setNode(getTrailingTextIndex(), trailingTextLabel);
} else if (trailingTextLabel != null) {
trailingTextLabel.setText("");
setNode(IDX_TRAILING_TEXT, null);
setNode(getTrailingTextIndex(), null);
}
}
};
@@ -149,7 +154,7 @@ public class LineButton extends LineButtonBase {
@Override
protected void invalidated() {
setNode(IDX_TRAILING_ICON, get());
setNode(getTrailingIconIndex(), get());
}
};
@@ -171,6 +176,7 @@ public class LineButton extends LineButtonBase {
public void setTrailingIcon(SVG rightIcon, double size) {
Node rightIconNode = rightIcon.createIcon(size);
rightIconNode.getStyleClass().add("trailing-icon");
rightIconNode.setMouseTransparent(true);
setTrailingIcon(rightIconNode);
}
}
@@ -64,6 +64,7 @@ public abstract class LineComponent extends StackPane implements NoPaddingCompon
this.container = new HBox(SPACING);
container.getStyleClass().add("line-component-container");
container.setAlignment(Pos.CENTER_LEFT);
container.setPickOnBounds(true);
this.titleLabel = new Label();
titleLabel.getStyleClass().add("title-label");
@@ -20,16 +20,18 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.*;
import javafx.scene.input.*;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
@@ -41,7 +43,7 @@ import java.util.function.Function;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
/// @author Glavo
public final class LineSelectButton<T> extends LineButton {
public class LineSelectButton<T> extends LineButton {
private static final String DEFAULT_STYLE_CLASS = "line-select-button";
private static final PseudoClass SELECTED_PSEUDO_CLASS = PseudoClass.getPseudoClass("selected");
@@ -81,6 +83,8 @@ public final class LineSelectButton<T> extends LineButton {
PopupMenu popupMenu = new PopupMenu();
this.popup = new JFXPopup(popupMenu);
popupMenu.addEventHandler(KeyEvent.KEY_PRESSED, this::handleKeyEvent);
ripplerContainer.addEventFilter(ScrollEvent.ANY, ignored -> popup.hide());
Bindings.bindContent(popupMenu.getContent(), MappedObservableList.create(itemsProperty(), item -> {
@@ -213,4 +217,32 @@ public final class LineSelectButton<T> extends LineButton {
return items.get();
}
private void handleKeyEvent(KeyEvent event) {
ObservableList<T> list = getItems();
if (list == null || list.isEmpty()) return;
int index = list.indexOf(getValue());
var code = event.getCode();
if (code == KeyCode.UP) {
if (index > 0) {
setValue(list.get(index - 1));
} else {
setValue(list.get(list.size() - 1));
}
event.consume();
} else if (code == KeyCode.DOWN) {
if (index < list.size() - 1) {
setValue(list.get(index + 1));
} else {
setValue(list.get(0));
}
event.consume();
} else if (code == KeyCode.ENTER || code == KeyCode.ESCAPE) {
if (popup != null && popup.isShowing()) {
popup.hide();
event.consume();
}
}
}
}
@@ -189,6 +189,7 @@ public class SpinnerPane extends Control {
Skin(SpinnerPane control) {
super(control);
root.setClip(null);
updateContent();
this.getChildren().setAll(root);
@@ -83,8 +83,8 @@ 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 static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 4, 0);
private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 4, 26);
private TaskExecutor executor;
private final JFXListView<Node> listView = new JFXListView<>();
@@ -279,6 +279,7 @@ public final class TaskListPane extends StackPane {
private final class Cell extends ListCell<Node> {
private static final double STATUS_ICON_SIZE = 14;
private static final Insets PROGRESS_BAR_MARGIN = new Insets(2, 0, 0, 0);
private final BorderPane pane = new BorderPane();
private final StackPane left = new StackPane();
@@ -310,6 +311,7 @@ public final class TaskListPane extends StackPane {
bar.minWidthProperty().bind(barWidth);
bar.prefWidthProperty().bind(barWidth);
bar.maxWidthProperty().bind(barWidth);
BorderPane.setMargin(bar, PROGRESS_BAR_MARGIN);
setGraphic(pane);
}
@@ -325,8 +327,9 @@ public final class TaskListPane extends StackPane {
pane.paddingProperty().unbind();
title.textProperty().unbind();
message.textProperty().unbind();
bar.progressProperty().unbind();
bar.setSmoothProgress(false);
bar.progressProperty().unbind();
StageNode prevStageNode;
if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null)
prevStageNode.status.removeListener(statusChangeListener);
@@ -364,6 +367,8 @@ public final class TaskListPane extends StackPane {
pane.setRight(null);
pane.setBottom(null);
}
bar.setSmoothProgress(true);
}
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.StringProperty;
import javafx.beans.property.StringPropertyBase;
@@ -168,11 +169,13 @@ public class TwoLineListItem extends VBox {
var scrollPane = new ScrollPane(tagsBox);
HBox.setHgrow(scrollPane, Priority.ALWAYS);
lblTitle.setMinWidth(Label.USE_PREF_SIZE);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.onChangeAndOperate(tagsBox.heightProperty(), height -> FXUtils.setLimitHeight(scrollPane, height.doubleValue()));
scrollPane.setPrefWidth(50);
firstLine.getChildren().setAll(lblTitle, scrollPane);
tags.addListener((InvalidationListener) ignored -> scrollPane.requestLayout());
}
return tags;
}
@@ -50,20 +50,19 @@ import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.construct.Navigator;
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.MathUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.ArrayList;
import java.util.Locale;
import java.util.Random;
import java.util.stream.Collectors;
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;
@@ -194,7 +193,10 @@ public class DecoratorController {
String backgroundImage = config().getBackgroundImage();
if (backgroundImage != null)
try {
image = tryLoadImage(Paths.get(backgroundImage));
Path path = Path.of(backgroundImage);
image = Files.isDirectory(path)
? randomImageIn(path)
: tryLoadImage(path);
} catch (Exception e) {
LOG.warning("Couldn't load background image", e);
}
@@ -216,7 +218,7 @@ public class DecoratorController {
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.;
double opacity = MathUtils.clamp(config().getBackgroundImageOpacity(), 0, 100) / 100.;
if (paint instanceof Color || paint == null) {
Color color = (Color) paint;
if (color == null)
@@ -300,14 +302,14 @@ public class DecoratorController {
return null;
}
List<Path> candidates;
ArrayList<Path> candidates;
try (Stream<Path> stream = Files.list(imageDir)) {
candidates = stream
.filter(it -> FXUtils.IMAGE_EXTENSIONS.contains(getExtension(it).toLowerCase(Locale.ROOT)))
.filter(Files::isReadable)
.collect(toList());
.collect(Collectors.toCollection(ArrayList::new));
} catch (IOException e) {
LOG.warning("Failed to list files in ./bg", e);
LOG.warning("Failed to list files in " + imageDir, e);
return null;
}
@@ -274,6 +274,7 @@ public class DecoratorSkin extends SkinBase<Decorator> {
if (canBack) {
JFXButton backNavButton = new JFXButton();
skinnable.forbidDraggingWindow(backNavButton);
backNavButton.setFocusTraversable(false);
backNavButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty()));
backNavButton.getStyleClass().add("jfx-decorator-button");
@@ -285,6 +286,7 @@ public class DecoratorSkin extends SkinBase<Decorator> {
if (canClose) {
JFXButton closeNavButton = new JFXButton();
skinnable.forbidDraggingWindow(closeNavButton);
closeNavButton.setFocusTraversable(false);
closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeNavButton.getStyleClass().add("jfx-decorator-button");
@@ -349,6 +351,7 @@ public class DecoratorSkin extends SkinBase<Decorator> {
refreshNavButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty()));
refreshNavButton.getStyleClass().add("jfx-decorator-button");
refreshNavButton.onActionProperty().bind(skinnable.onRefreshNavButtonActionProperty());
skinnable.forbidDraggingWindow(refreshNavButton);
Rectangle separator = new Rectangle();
separator.visibleProperty().bind(refreshNavButton.visibleProperty());
@@ -54,13 +54,18 @@ import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<DecoratorPage.State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1));
@@ -84,8 +89,8 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
"game", versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((profile, __, mod, file) -> {
Versions.downloadModpackImpl(profile, uploadVersion, mod, file);
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, profile, __, mod, file) -> {
Versions.downloadModpackImpl(downloadProvider, profile, uploadVersion, mod, file);
}, false);
JFXButton installLocalModpackButton = FXUtils.newRaisedButton(i18n("install.modpack"));
@@ -94,9 +99,9 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
page.getActions().add(installLocalModpackButton);
return page;
}));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((profile, version, mod, file) -> download(profile, version, file, "mods"), true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((profile, version, mod, file) -> download(profile, version, file, "resourcepacks"), true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((profile, version, mod, file) -> download(profile, version, file, "shaderpacks"), true)));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "mods"), true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "resourcepacks"), true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "shaderpacks"), true)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteModRepository.WORLDS)));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
@@ -129,16 +134,29 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
};
}
public static void download(Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) {
public static void download(DownloadProvider downloadProvider, 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();
Set<String> existingFiles;
try (var list = Files.list(runDirectory.resolve(subdirectoryName))) {
existingFiles = list.map(Path::getFileName)
.map(Path::toString)
.collect(Collectors.toSet());
} catch (IOException e) {
LOG.warning("Failed to list files in " + runDirectory.resolve(subdirectoryName), e);
existingFiles = Set.of();
}
Set<String> finalExistingFiles = existingFiles;
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(file.getFile().getUrl(), dest);
var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.getFile().getUrl()), dest);
task.setName(file.getName());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
@@ -153,7 +171,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
handler.resolve();
}, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid));
}, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("profile.already_exists"), (it) -> !finalExistingFiles.contains(it)));
}
@@ -124,14 +124,14 @@ public class InstallersPage extends AbstractInstallersPage {
if (libraryType != null) {
String loaderName = switch (libraryType) {
case FORGE -> "forge";
case NEO_FORGE -> "neoforge";
case CLEANROOM -> "cleanroom";
case LEGACY_FABRIC -> "legacy_fabric";
case FABRIC -> "fabric";
case LITELOADER -> "liteloader";
case QUILT -> "quilt";
case OPTIFINE -> "optifine";
case FORGE -> "Forge";
case NEO_FORGE -> "NeoForge";
case CLEANROOM -> "Cleanroom";
case LEGACY_FABRIC -> "LegacyFabric";
case FABRIC -> "Fabric";
case LITELOADER -> "LiteLoader";
case QUILT -> "Quilt";
case OPTIFINE -> "OptiFine";
default -> null;
};
@@ -1,3 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.download;
import com.jfoenix.controls.JFXButton;
@@ -42,11 +59,11 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
{
var archiveNamePane = new LinePane();
{
archiveNamePane.setTitle(i18n("archive.file.name"));
archiveNamePane.setTitle(i18n("version.name"));
txtModpackName = new JFXTextField();
txtModpackName.setPrefWidth(300);
// FIXME: Validator are not shown properly
FXUtils.setLimitHeight(archiveNamePane, 75);
// BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32));
BorderPane.setAlignment(txtModpackName, Pos.CENTER_RIGHT);
archiveNamePane.setRight(txtModpackName);
@@ -97,8 +97,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
graphic.setMouseTransparent(true);
graphic.setLeft(new TwoLineListItem(i18n("modpack.choose." + type), i18n("modpack.choose." + type + ".detail")));
SVGPath arrow = new SVGPath();
arrow.setContent(SVG.ARROW_FORWARD.getPath());
SVGPath arrow = SVG.ARROW_FORWARD.createIcon();
BorderPane.setAlignment(arrow, Pos.CENTER);
graphic.setRight(arrow);
@@ -144,8 +143,6 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
// otherwise we still consider the file as modpack zip file
// since casually the url may not ends with ".zip"
Path modpack = Files.createTempFile("modpack", ".zip");
handler.resolve();
Controllers.taskDialog(
new FileDownloadTask(url, modpack)
.whenComplete(Schedulers.javafx(), e -> {
@@ -221,10 +221,15 @@ public final class ModpackInfoPage extends Control implements WizardPage {
list.getContent().addAll(
createTextFieldLinePane(i18n("modpack.name"), skinnable.name, new RequiredValidator()),
createTextFieldLinePane(i18n("archive.author"), skinnable.author, new RequiredValidator()),
createTextFieldLinePane(i18n("archive.version"), skinnable.version, new RequiredValidator())
);
if (skinnable.options.isRequireAuthor()) {
list.getContent().add(
createTextFieldLinePane(i18n("archive.author"), skinnable.author, new RequiredValidator())
);
}
if (skinnable.options.isRequireFileApi()) {
list.getContent().add(createTextFieldLinePane(
i18n("modpack.file_api"), skinnable.fileApi,
@@ -19,9 +19,17 @@ package org.jackhuang.hmcl.ui.image;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
import org.girod.javafx.svgimage.LoaderParameters;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.girod.javafx.svgimage.ScaleQuality;
import org.jackhuang.hmcl.task.Schedulers;
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;
@@ -39,7 +47,9 @@ import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -73,6 +83,46 @@ public final class ImageUtils {
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
};
public static final ImageLoader SVG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
String content = new String(input.readAllBytes(), StandardCharsets.UTF_8);
LoaderParameters parameters = new LoaderParameters();
parameters.autoStartAnimations = false;
SVGImage image;
if (Platform.isFxApplicationThread()) {
image = SVGLoader.load(content, parameters);
} else {
// TODO: Currently, SVGLoader.load(...) requires the javafx.swing module if it operates on a non-JavaFX thread.
image = CompletableFuture.supplyAsync(
() -> SVGLoader.load(content, parameters),
Schedulers.javafx()
).get();
}
if (image == null)
throw new IOException("Failed to load SVG image");
var snapshotParameters = new SnapshotParameters();
snapshotParameters.setFill(Color.TRANSPARENT);
if (requestedWidth <= 0. || requestedHeight <= 0.) {
return image.toImage(snapshotParameters);
}
double scaleX = requestedWidth / image.getScaledWidth();
double scaleY = requestedHeight / image.getScaledHeight();
if (preserveRatio || scaleX == scaleY) {
double scale = Math.min(scaleX, scaleY);
return image.scale(scale).toImage(snapshotParameters);
} else {
// FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS
return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY);
}
};
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);
@@ -136,11 +186,13 @@ public final class ImageUtils {
public static final Map<String, ImageLoader> EXT_TO_LOADER = Map.of(
"webp", WEBP,
"svg", SVG,
"apng", APNG
);
public static final Map<String, ImageLoader> CONTENT_TYPE_TO_LOADER = Map.of(
"image/webp", WEBP,
"image/svg+xml", SVG,
"image/apng", APNG
);
@@ -165,6 +217,14 @@ public final class ImageUtils {
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
}
private static final byte[] SVG_HEADER = "<svg".getBytes(StandardCharsets.US_ASCII);
// This is currently a simple check, more complex checks can be considered in the future
public static boolean isSVG(byte[] headerBuffer) {
return headerBuffer.length > SVG_HEADER.length
&& Arrays.equals(headerBuffer, 0, SVG_HEADER.length, SVG_HEADER, 0, SVG_HEADER.length);
}
private static final byte[] PNG_HEADER = {
(byte) 0x89, (byte) 0x50, (byte) 0x4e, (byte) 0x47,
(byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a,
@@ -232,6 +292,8 @@ public final class ImageUtils {
return WEBP;
if (isApng(headerBuffer))
return APNG;
if (isSVG(headerBuffer))
return SVG;
return null;
}
@@ -18,10 +18,8 @@
package org.jackhuang.hmcl.ui.main;
import com.google.gson.*;
import javafx.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.Image;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
@@ -30,6 +28,7 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
@@ -38,11 +37,18 @@ import java.io.InputStream;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class AboutPage extends StackPane {
public final class AboutPage extends SpinnerPane {
private final WeakListenerHolder holder = new WeakListenerHolder();
public AboutPage() {
VBox content = new VBox();
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
ComponentList about = new ComponentList();
{
var launcher = LineButton.createExternalLinkButton(Metadata.PUBLISH_URL);
@@ -84,27 +90,16 @@ public final class AboutPage extends StackPane {
legal.getContent().setAll(copyright, claim, openSource);
}
VBox content = new VBox(16);
content.setPadding(new Insets(10));
content.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("about")),
about,
ComponentList.createComponentListTitle(i18n("about.thanks_to")),
thanks,
ComponentList.createComponentListTitle(i18n("about.dependency")),
deps,
ComponentList.createComponentListTitle(i18n("about.legal")),
legal
);
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
getChildren().setAll(scrollPane);
}
private static Image loadImage(String url) {
@@ -85,7 +85,13 @@ public class DownloadSettingsPage extends StackPane {
downloadSourcePane.setItems(DownloadProviders.DIRECT_PROVIDERS.keySet());
downloadSourcePane.valueProperty().bindBidirectional(config().downloadTypeProperty());
downloadSource.getContent().setAll(autoChooseDownloadSource, versionListSourcePane, downloadSourcePane);
var defaultAddonSourcePane = new LineSelectButton<String>();
defaultAddonSourcePane.setTitle(i18n("settings.launcher.default_addon_source"));
defaultAddonSourcePane.setConverter(key -> I18n.i18n("mods." + key));
defaultAddonSourcePane.setItems("modrinth", "curseforge");
defaultAddonSourcePane.valueProperty().bindBidirectional(config().defaultAddonSourceProperty());
downloadSource.getContent().setAll(autoChooseDownloadSource, versionListSourcePane, downloadSourcePane, defaultAddonSourcePane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.download_source")), downloadSource);
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.ui.main;
import javafx.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.theme.Themes;
@@ -37,9 +36,7 @@ public class FeedbackPage extends SpinnerPane {
public FeedbackPage() {
VBox content = new VBox();
content.setPadding(new Insets(10));
content.setSpacing(10);
content.setFillWidth(true);
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
@@ -84,8 +81,5 @@ public class FeedbackPage extends SpinnerPane {
ComponentList.createComponentListTitle(i18n("contact.feedback")),
feedback
);
this.setContent(content);
}
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui.main;
import com.google.gson.annotations.SerializedName;
import javafx.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
@@ -42,9 +41,7 @@ public class HelpPage extends SpinnerPane {
public HelpPage() {
content = new VBox();
content.setPadding(new Insets(10));
content.setSpacing(10);
content.setFillWidth(true);
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
@@ -47,6 +47,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.DialogPane;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Result;
@@ -233,7 +234,6 @@ public final class JavaDownloadDialog extends StackPane {
downloadButton.setOnAction(e -> onDownload());
downloadButton.getStyleClass().add("dialog-accept");
downloadButton.disableProperty().bind(Bindings.isNull(remoteVersionBox.getSelectionModel().selectedItemProperty()));
downloadButtonPane.getChildren().setAll(downloadButton);
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
@@ -300,6 +300,11 @@ public final class JavaDownloadDialog extends StackPane {
remoteVersionBox.getSelectionModel().selectFirst();
});
SpinnerPane spinnerPane = new SpinnerPane();
spinnerPane.getStyleClass().add("small-spinner-pane");
spinnerPane.setContent(downloadButton);
downloadButtonPane.getChildren().setAll(spinnerPane);
Consumer<DiscoJavaVersionList> updateListStatus = list -> {
remoteVersionBox.setItems(null);
packageTypeBox.getItems().clear();
@@ -308,11 +313,11 @@ public final class JavaDownloadDialog extends StackPane {
warningLabel.setText(null);
if (list == null || (list.versions != null && list.versions.isEmpty()))
downloadButtonPane.getChildren().setAll(downloadButton);
spinnerPane.hideSpinner();
else if (list.status == DiscoJavaVersionList.Status.LOADING)
downloadButtonPane.getChildren().setAll(new JFXSpinner());
spinnerPane.showSpinner();
else {
downloadButtonPane.getChildren().setAll(downloadButton);
spinnerPane.hideSpinner();
if (list.status == DiscoJavaVersionList.Status.SUCCESS) {
packageTypeBox.getItems().setAll(list.versions.keySet());
@@ -164,7 +164,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
if (skinnable.realPath == null) {
revealButton.setDisable(true);
JFXButton removeButton = FXUtils.newToggleButton4(SVG.DELETE);
JFXButton removeButton = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
removeButton.setOnAction(e -> skinnable.onRemove());
FXUtils.installFastTooltip(removeButton, i18n("java.disabled.management.remove"));
@@ -227,7 +227,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchLabel.setText(i18n("version.launch.empty"));
currentLabel.setText(null);
graphic.getChildren().setAll(launchLabel);
launchButton.setOnAction(e -> MainPage.this.launchNoGame());
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launchNoGame);
if (tooltip == null)
tooltip = new Tooltip(i18n("version.launch.empty.tooltip"));
FXUtils.installFastTooltip(launchButton, tooltip);
@@ -235,7 +235,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchLabel.setText(i18n("version.launch"));
currentLabel.setText(currentGame);
graphic.getChildren().setAll(launchLabel, currentLabel);
launchButton.setOnAction(e -> MainPage.this.launch());
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch);
if (tooltip != null)
Tooltip.uninstall(launchButton, tooltip);
}
@@ -277,7 +277,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
private void showUpdate(boolean show) {
doAnimation(show);
if (show && getLatestVersion() != null && !Objects.equals(config().getPromptedVersion(), getLatestVersion().getVersion())) {
if (show && !config().isDisableAutoShowUpdateDialog()
&& getLatestVersion() != null
&& !Objects.equals(config().getPromptedVersion(), getLatestVersion().getVersion())) {
Controllers.dialog(new MessageDialogPane.Builder("", i18n("update.bubble.title", getLatestVersion().getVersion()), MessageDialogPane.MessageType.INFO)
.addAction(i18n("button.view"), () -> {
config().setPromptedVersion(getLatestVersion().getVersion());
@@ -54,6 +54,7 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
@@ -98,7 +99,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
if (mainPage == null) {
MainPage mainPage = new MainPage();
FXUtils.applyDragListener(mainPage,
file -> ModpackHelper.isFileModpackByExtension(file) || NBTFileType.isNBTFileByExtension(file),
file -> ModpackHelper.isFileModpackByExtension(file) || NBTFileType.isNBTFileByExtension(file) || "json".equalsIgnoreCase(FileUtils.getExtension(file)),
modpacks -> {
Path file = modpacks.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
@@ -113,6 +114,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Versions.installFromJson(Profiles.getSelectedProfile(), file);
}
});
@@ -18,24 +18,24 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRadioButton;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.*;
import javafx.scene.text.TextAlignment;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.setting.Settings;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -45,6 +45,8 @@ import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.AprilFools;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
@@ -60,20 +62,16 @@ import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class SettingsPage extends ScrollPane {
@SuppressWarnings("FieldCanBeLocal")
private final ToggleGroup updateChannelGroup;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener updateListener;
@@ -120,82 +118,59 @@ public final class SettingsPage extends ScrollPane {
settingsPane.getContent().add(sponsorPane);
}
ObjectProperty<UpdateChannel> updateChannel;
{
ComponentSublist updatePane = new ComponentSublist();
JFXButton updateButton = new JFXButton();
updateButton.setGraphic(SVG.UPDATE.createIcon(20));
updateButton.setOnAction(e -> onUpdate());
updateButton.setPadding(Insets.EMPTY);
FXUtils.installFastTooltip(updateButton, i18n("update.tooltip"));
var updatePane = new LineSelectButton<UpdateChannel>() {
{
getStyleClass().add("update-pane");
setNode(IDX_TRAILING, updateButton);
}
@Override
protected int getTrailingTextIndex() {
return LineComponent.IDX_TRAILING + 1;
}
};
updateChannel = updatePane.valueProperty();
updatePane.setTitle(i18n("update"));
updatePane.setHasSubtitle(true);
updatePane.setValue(UpdateChannel.getChannel());
final Label lblUpdate;
final Label lblUpdateSub;
{
VBox headerLeft = new VBox();
updatePane.setConverter(channel -> i18n("update.channel." + channel.channelName));
updatePane.setItems(List.of(UpdateChannel.STABLE, UpdateChannel.DEVELOPMENT));
updatePane.setDescriptionConverter(channel -> i18n("update.note." + channel.channelName));
lblUpdate = new Label(i18n("update"));
lblUpdate.getStyleClass().add("title-label");
lblUpdateSub = new Label();
lblUpdateSub.getStyleClass().add("subtitle-label");
headerLeft.getChildren().setAll(lblUpdate, lblUpdateSub);
updatePane.setHeaderLeft(headerLeft);
}
final StringProperty lblUpdateSubProperty = updatePane.subtitleProperty();
{
JFXButton btnUpdate = FXUtils.newToggleButton4(SVG.UPDATE, 20);
btnUpdate.setOnAction(e -> onUpdate());
FXUtils.installFastTooltip(btnUpdate, i18n("update.tooltip"));
updateListener = any -> {
btnUpdate.setVisible(UpdateChecker.isOutdated());
boolean outdated = UpdateChecker.isOutdated();
updateButton.setVisible(outdated);
updateButton.setManaged(outdated);
updatePane.pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), outdated);
if (UpdateChecker.isOutdated()) {
lblUpdateSub.setText(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion()));
lblUpdateSub.getStyleClass().setAll("update-label");
lblUpdate.setText(i18n("update.found"));
lblUpdate.getStyleClass().setAll("update-label");
lblUpdateSubProperty.set(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion()));
} else if (UpdateChecker.isCheckingUpdate()) {
lblUpdateSub.setText(i18n("update.checking"));
lblUpdateSub.getStyleClass().setAll("subtitle-label");
lblUpdate.setText(i18n("update"));
lblUpdate.getStyleClass().setAll("title-label");
lblUpdateSubProperty.set(i18n("update.checking"));
} else {
lblUpdateSub.setText(i18n("update.latest"));
lblUpdateSub.getStyleClass().setAll("subtitle-label");
lblUpdate.setText(i18n("update"));
lblUpdate.getStyleClass().setAll("title-label");
lblUpdateSubProperty.set(i18n("update.latest"));
}
};
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
updateListener.invalidated(null);
updatePane.setHeaderRight(btnUpdate);
}
{
VBox content = new VBox(12);
content.setPadding(new Insets(8, 0, 0, 0));
updateChannelGroup = new ToggleGroup();
JFXRadioButton chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable"));
chkUpdateStable.setUserData(UpdateChannel.STABLE);
chkUpdateStable.setToggleGroup(updateChannelGroup);
JFXRadioButton chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev"));
chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT);
chkUpdateDev.setToggleGroup(updateChannelGroup);
Label noteWrapper = new Label(i18n("update.note"));
VBox.setMargin(noteWrapper, new Insets(8, 0, 0, 0));
content.getChildren().setAll(chkUpdateStable, chkUpdateDev, noteWrapper);
updatePane.getContent().add(content);
}
settingsPane.getContent().add(updatePane);
}
@@ -205,8 +180,6 @@ public final class SettingsPage extends ScrollPane {
previewPane.setSubtitle(i18n("update.preview.subtitle"));
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
ObjectProperty<UpdateChannel> updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class);
updateChannel.set(UpdateChannel.getChannel());
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
@@ -216,6 +189,22 @@ public final class SettingsPage extends ScrollPane {
settingsPane.getContent().add(previewPane);
}
{
LineToggleButton disableAutoShowUpdateDialogPane = new LineToggleButton();
disableAutoShowUpdateDialogPane.setTitle(i18n("update.disable_auto_show_update_dialog"));
disableAutoShowUpdateDialogPane.setSubtitle(i18n("update.disable_auto_show_update_dialog.subtitle"));
disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(config().disableAutoShowUpdateDialogProperty());
settingsPane.getContent().add(disableAutoShowUpdateDialogPane);
}
if (AprilFools.isShowAprilFoolsSettings()) {
LineToggleButton disableAprilFools = new LineToggleButton();
disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools"));
disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart"));
disableAprilFools.selectedProperty().bindBidirectional(config().disableAprilFoolsProperty());
settingsPane.getContent().add(disableAprilFools);
}
{
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
fileCommonLocation.loadChildren(Arrays.asList(
@@ -271,6 +260,15 @@ public final class SettingsPage extends ScrollPane {
settingsPane.getContent().add(disableAutoGameOptionsPane);
}
{
LineToggleButton allowAutoAgentPane = new LineToggleButton();
allowAutoAgentPane.setTitle(i18n("settings.launcher.allow_auto_agent"));
allowAutoAgentPane.setSubtitle(i18n("settings.launcher.allow_auto_agent.subtitle"));
allowAutoAgentPane.selectedProperty().bindBidirectional(config().allowAutoAgentProperty());
settingsPane.getContent().add(allowAutoAgentPane);
}
{
BorderPane debugPane = new BorderPane();
@@ -284,12 +282,31 @@ public final class SettingsPage extends ScrollPane {
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
SpinnerPane exportLogPane = new SpinnerPane();
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
logButton.setOnAction(e -> onExportLogs());
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, logButton);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
@@ -361,86 +378,79 @@ public final class SettingsPage extends ScrollPane {
}
}
private void onExportLogs() {
thread(() -> {
private CompletableFuture<Path> onExportLogs() {
return CompletableFuture.supplyAsync(Lang.wrap(() -> {
String nameBase = "hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"));
List<Path> recentLogFiles = LOG.findRecentLogFiles(5);
Path outputFile;
try {
if (recentLogFiles.isEmpty()) {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".log");
if (recentLogFiles.isEmpty()) {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".log");
LOG.info("Exporting latest logs to " + outputFile);
try (OutputStream output = Files.newOutputStream(outputFile)) {
LOG.exportLogs(output);
}
} else {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".zip");
LOG.info("Exporting latest logs to " + outputFile);
try (OutputStream output = Files.newOutputStream(outputFile)) {
LOG.exportLogs(output);
}
} else {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".zip");
LOG.info("Exporting latest logs to " + outputFile);
LOG.info("Exporting latest logs to " + outputFile);
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
try (var os = Files.newOutputStream(outputFile);
var zos = new ZipOutputStream(os)) {
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
try (var os = Files.newOutputStream(outputFile);
var zos = new ZipOutputStream(os)) {
Set<String> entryNames = new HashSet<>();
Set<String> entryNames = new HashSet<>();
for (Path path : recentLogFiles) {
String fileName = FileUtils.getName(path);
String extension = StringUtils.substringAfterLast(fileName, '.');
for (Path path : recentLogFiles) {
String fileName = FileUtils.getName(path);
String extension = StringUtils.substringAfterLast(fileName, '.');
if ("gz".equals(extension) || "xz".equals(extension)) {
// If an exception occurs while decompressing the input file, we should
// ensure the input file and the current zip entry are closed,
// then copy the compressed file content as-is into a new entry in the zip file.
InputStream input = null;
try {
input = Files.newInputStream(path);
input = "gz".equals(extension)
? new GZIPInputStream(input)
: new XZInputStream(input);
} catch (Throwable ex) {
LOG.warning("Failed to open log file " + path, ex);
IOUtils.closeQuietly(input, ex);
input = null;
}
String entryName = getEntryName(entryNames, StringUtils.substringBeforeLast(fileName, "."));
if (input != null && exportLogFile(zos, path, entryName, input, buffer))
continue;
}
// Copy the log file content as-is into a new entry in the zip file.
if ("gz".equals(extension) || "xz".equals(extension)) {
// If an exception occurs while decompressing the input file, we should
// ensure the input file and the current zip entry are closed.
// ensure the input file and the current zip entry are closed,
// then copy the compressed file content as-is into a new entry in the zip file.
InputStream input;
InputStream input = null;
try {
input = Files.newInputStream(path);
input = "gz".equals(extension)
? new GZIPInputStream(input)
: new XZInputStream(input);
} catch (Throwable ex) {
LOG.warning("Failed to open log file " + path, ex);
continue;
IOUtils.closeQuietly(input, ex);
input = null;
}
exportLogFile(zos, path, getEntryName(entryNames, fileName), input, buffer);
String entryName = getEntryName(entryNames, StringUtils.substringBeforeLast(fileName, "."));
if (input != null && exportLogFile(zos, path, entryName, input, buffer))
continue;
}
zos.putNextEntry(new ZipEntry(getEntryName(entryNames, "hmcl-latest.log")));
LOG.exportLogs(zos);
zos.closeEntry();
// Copy the log file content as-is into a new entry in the zip file.
// If an exception occurs while decompressing the input file, we should
// ensure the input file and the current zip entry are closed.
InputStream input;
try {
input = Files.newInputStream(path);
} catch (Throwable ex) {
LOG.warning("Failed to open log file " + path, ex);
continue;
}
exportLogFile(zos, path, getEntryName(entryNames, fileName), input, buffer);
}
zos.putNextEntry(new ZipEntry(getEntryName(entryNames, "hmcl-latest.log")));
LOG.exportLogs(zos);
zos.closeEntry();
}
} catch (IOException e) {
LOG.warning("Failed to export logs", e);
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(e), null, MessageType.ERROR));
return;
}
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", outputFile)));
FXUtils.showFileInExplorer(outputFile);
});
return outputFile;
}), Schedulers.io());
}
private void onSponsor() {
@@ -22,8 +22,14 @@ import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Skin;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.control.skin.TreeViewSkin;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -33,6 +39,7 @@ import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Path;
@@ -56,8 +63,9 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
this.state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("nbt.title", file.toString())));
this.file = file;
this.type = NBTFileType.ofFile(file);
//noinspection DataFlowIssue
this.type = NBTFileType.ofFile(file);
if (type == null) {
throw new IOException("Unknown type of file " + file);
}
@@ -85,14 +93,33 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
actions.getChildren().setAll(saveButton, cancelButton);
Task.supplyAsync(() -> type.readAsTree(file))
Task.supplyAsync(() -> type.read(file))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
setLoading(false);
NBTTreeView view = new NBTTreeView(result);
NBTTreeItem root = new NBTTreeItem(result, FileUtils.getName(file));
var view = new TreeView<>(root) {
@Override
protected Skin<?> createDefaultSkin() {
return new TreeViewSkin<>(this) {
{
FXUtils.smoothScrolling(getVirtualFlow());
}
};
}
};
view.setCellFactory(ignored -> new NBTTreeCell());
view.addEventHandler(TreeItem.<NBTElement>branchExpandedEvent(), event -> {
TreeItem<NBTElement> item = event.getTreeItem();
if (item.getValue() instanceof Tag && item.getChildren().size() == 1)
item.getChildren().get(0).setExpanded(true);
});
root.setExpanded(true);
BorderPane.setMargin(view, new Insets(10));
onEscPressed(view, cancelButton::fire);
root.setCenter(view);
this.root.setCenter(view);
} else {
LOG.warning("Fail to open nbt file", exception);
Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(exception), null, MessageDialogPane.MessageType.WARNING, cancelButton::fire);
@@ -17,148 +17,34 @@
*/
package org.jackhuang.hmcl.ui.nbt;
import com.github.steveice10.opennbt.NBTIO;
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
import com.github.steveice10.opennbt.tag.builtin.IntTag;
import com.github.steveice10.opennbt.tag.builtin.ListTag;
import com.github.steveice10.opennbt.tag.builtin.Tag;
import kala.compress.utils.BoundedInputStream;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.chunk.ChunkRegion;
import org.glavo.nbt.io.NBTCodec;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.zip.GZIPInputStream;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
/**
* @author Glavo
*/
/// @author Glavo
public enum NBTFileType {
COMPRESSED("dat", "dat_old") {
@Override
public Tag read(Path file) throws IOException {
try (BufferedInputStream fileInputStream = new BufferedInputStream(Files.newInputStream(file))) {
fileInputStream.mark(3);
byte[] header = new byte[3];
if (fileInputStream.read(header) < 3) {
throw new IOException("File is too small");
}
fileInputStream.reset();
InputStream input;
if (Arrays.equals(header, new byte[]{0x1f, (byte) 0x8b, 0x08})) {
input = new GZIPInputStream(fileInputStream);
} else {
input = fileInputStream;
}
Tag tag = NBTIO.readTag(input);
if (!(tag instanceof CompoundTag))
throw new IOException("Unexpected tag: " + tag);
return tag;
}
return NBTCodec.of().readTag(file);
}
},
ANVIL("mca") {
@Override
public Tag read(Path file) throws IOException {
return REGION.read(file);
}
@Override
public NBTTreeView.Item readAsTree(Path file) throws IOException {
return REGION.readAsTree(file);
public NBTElement read(Path file) throws IOException {
return NBTCodec.of().readRegion(file);
}
},
REGION("mcr") {
@Override
public Tag read(Path file) throws IOException {
try (RandomAccessFile r = new RandomAccessFile(file.toFile(), "r")) {
ListTag tag = new ListTag(file.getFileName().toString(), CompoundTag.class);
if (r.length() == 0) {
return tag;
}
byte[] header = new byte[4096];
byte[] buffer = new byte[1 * 1024 * 1024]; // The maximum size of each chunk is 1MiB
Inflater inflater = new Inflater();
r.readFully(header);
for (int i = 0; i < 4096; i += 4) {
int offset = ((header[i] & 0xff) << 16) + ((header[i + 1] & 0xff) << 8) + (header[i + 2] & 0xff);
int length = header[i + 3] & 0xff;
if (offset == 0 || length == 0) {
continue;
}
r.seek(offset * 4096L);
r.readFully(buffer, 0, length * 4096);
int chunkLength = ((buffer[0] & 0xff) << 24) + ((buffer[1] & 0xff) << 16) + ((buffer[2] & 0xff) << 8) + (buffer[3] & 0xff);
InputStream input = new ByteArrayInputStream(buffer);
input.skip(5);
input = new BoundedInputStream(input, chunkLength - 1);
switch (buffer[4]) {
case 0x01:
// GZip
input = new GZIPInputStream(input);
break;
case 0x02:
// Zlib
inflater.reset();
input = new InflaterInputStream(input, inflater);
break;
case 0x03:
// Uncompressed
break;
default:
throw new IOException("Unsupported compression method: " + Integer.toHexString(buffer[4] & 0xff));
}
try (InputStream in = input) {
Tag chunk = NBTIO.readTag(in);
if (!(chunk instanceof CompoundTag))
throw new IOException("Unexpected tag: " + chunk);
tag.add(chunk);
}
}
return tag;
}
}
@Override
public NBTTreeView.Item readAsTree(Path file) throws IOException {
NBTTreeView.Item item = new NBTTreeView.Item(read(file));
for (Tag tag : ((ListTag) item.getValue())) {
CompoundTag chunk = (CompoundTag) tag;
NBTTreeView.Item tree = NBTTreeView.buildTree(chunk);
Tag xPos = chunk.get("xPos");
Tag zPos = chunk.get("zPos");
if (xPos instanceof IntTag && zPos instanceof IntTag) {
tree.setText(String.format("Chunk: %d %d", xPos.getValue(), zPos.getValue()));
} else {
tree.setText("Chunk: Unknown");
}
item.getChildren().add(tree);
}
return item;
public ChunkRegion read(Path file) throws IOException {
return NBTCodec.of().readRegion(file);
}
};
@@ -168,7 +54,7 @@ public enum NBTFileType {
return NBTFileType.ofFile(file) != null;
}
public static NBTFileType ofFile(Path file) {
public static @Nullable NBTFileType ofFile(Path file) {
String ext = FileUtils.getExtension(file);
for (NBTFileType type : types) {
for (String extension : type.extensions) {
@@ -186,11 +72,6 @@ public enum NBTFileType {
this.extensions = extensions;
}
public abstract Tag read(Path file) throws IOException;
public abstract NBTElement read(Path file) throws IOException;
public NBTTreeView.Item readAsTree(Path file) throws IOException {
NBTTreeView.Item root = NBTTreeView.buildTree(read(file));
root.setName(file.getFileName().toString());
return root;
}
}
@@ -1,79 +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.nbt;
import com.github.steveice10.opennbt.tag.builtin.Tag;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/**
* @author Glavo
*/
public enum NBTTagType {
BYTE, SHORT, INT, LONG, FLOAT, DOUBLE,
BYTE_ARRAY, INT_ARRAY, LONG_ARRAY,
STRING,
LIST, COMPOUND;
private static final Map<String, NBTTagType> lookupTable = new HashMap<>();
static {
for (NBTTagType type : values()) {
lookupTable.put(type.getTagClassName(), type);
}
}
public static NBTTagType typeOf(Tag tag) {
NBTTagType type = lookupTable.get(tag.getClass().getSimpleName());
if (type == null) {
throw new IllegalArgumentException("Unknown tag: " + type);
}
return type;
}
private final String iconUrl;
private final String tagClassName;
NBTTagType() {
String tagName;
String className;
int idx = name().indexOf('_');
if (idx < 0) {
tagName = name().charAt(0) + name().substring(1).toLowerCase(Locale.ROOT);
className = tagName + "Tag";
} else {
tagName = name().charAt(0) + name().substring(1, idx + 1).toLowerCase(Locale.ROOT)
+ name().charAt(idx + 1) + name().substring(idx + 2).toLowerCase(Locale.ROOT);
className = tagName.substring(0, idx) + tagName.substring(idx + 1) + "Tag";
}
this.iconUrl = "/assets/img/nbt/TAG_" + tagName + ".png";
this.tagClassName = className;
}
public String getIconUrl() {
return iconUrl;
}
public String getTagClassName() {
return tagClassName;
}
}
@@ -0,0 +1,129 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.nbt;
import javafx.scene.control.TreeCell;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.chunk.Chunk;
import org.glavo.nbt.chunk.ChunkRegion;
import org.glavo.nbt.tag.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class NBTTreeCell extends TreeCell<@Nullable NBTElement> {
private static @Nullable Image getIcon(NBTElement element) {
if (element instanceof Tag tag) {
TagType<?> type = tag.getType();
if (type == TagType.BYTE) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Byte.png");
} else if (type == TagType.SHORT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Short.png");
} else if (type == TagType.INT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Int.png");
} else if (type == TagType.LONG) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Long.png");
} else if (type == TagType.FLOAT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Float.png");
} else if (type == TagType.DOUBLE) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Double.png");
} else if (type == TagType.STRING) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_String.png");
} else if (type == TagType.BYTE_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Byte_Array.png");
} else if (type == TagType.INT_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Int_Array.png");
} else if (type == TagType.LONG_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Long_Array.png");
} else if (type == TagType.LIST) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_List.png");
} else if (type == TagType.COMPOUND) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Compound.png");
} else {
return null;
}
} else if (element instanceof ChunkRegion)
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_List.png");
else if (element instanceof Chunk)
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Compound.png");
else
return null;
}
private final ImageView imageView;
public NBTTreeCell() {
imageView = new ImageView();
imageView.setFitHeight(16);
imageView.setFitWidth(16);
setGraphic(imageView);
}
private NBTTreeItem getNBTTreeItem() {
return (NBTTreeItem) getTreeItem();
}
@Override
public void updateItem(@Nullable NBTElement item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
imageView.setImage(null);
setText(null);
return;
}
imageView.setImage(getIcon(item));
String name = getNBTTreeItem().getOverrideName();
if (name == null) {
NBTElement value = getNBTTreeItem().getValue();
if (value instanceof Tag tag) {
if (tag.getParent() instanceof ListTag<?>) {
name = Integer.toString(tag.getIndex());
} else {
name = tag.getName();
}
} else if (value instanceof Chunk chunk) {
name = "Chunk (" + chunk.getLocalX() + ", " + chunk.getLocalZ() + ")";
} else {
name = "";
}
}
String text;
if (item instanceof ParentTag<?> parentTag) {
text = i18n("nbt.entries", parentTag.size());
} else if (item instanceof ValueTag<?> valueTag) {
text = valueTag.getAsString();
} else {
text = null;
}
if (text == null) {
setText(name);
} else {
setText(name + ": " + text);
}
}
}
@@ -0,0 +1,68 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.nbt;
import javafx.collections.ObservableList;
import javafx.scene.control.TreeItem;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.NBTParent;
import org.glavo.nbt.chunk.Chunk;
import org.glavo.nbt.tag.*;
import org.jetbrains.annotations.Nullable;
/// @author Glavo
public final class NBTTreeItem extends TreeItem<NBTElement> {
private final @Nullable String overrideName;
public NBTTreeItem(NBTElement value) {
this(value, null);
}
public NBTTreeItem(NBTElement value, @Nullable String overrideName) {
super(value);
this.overrideName = overrideName;
}
@Override
public boolean isLeaf() {
return !(getValue() instanceof NBTParent<?> parent) || parent.isEmpty();
}
private boolean needInitChildren = true;
@Override
public ObservableList<TreeItem<NBTElement>> getChildren() {
ObservableList<TreeItem<NBTElement>> children = super.getChildren();
if (needInitChildren) {
needInitChildren = false;
if (getValue() instanceof Chunk chunk) {
if (chunk.getRootTag() != null) {
children.setAll(chunk.getRootTag().stream().map(NBTTreeItem::new).toList());
}
} else if (getValue() instanceof NBTParent<?> parent) {
children.setAll(parent.stream().map(NBTTreeItem::new).toList());
}
}
return children;
}
public @Nullable String getOverrideName() {
return overrideName;
}
}
@@ -1,184 +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.nbt;
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
import com.github.steveice10.opennbt.tag.builtin.ListTag;
import com.github.steveice10.opennbt.tag.builtin.Tag;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.control.skin.TreeViewSkin;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.util.Callback;
import org.jackhuang.hmcl.ui.FXUtils;
import java.lang.reflect.Array;
import java.util.EnumMap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Glavo
*/
public final class NBTTreeView extends TreeView<Tag> {
public NBTTreeView(NBTTreeView.Item tree) {
this.setRoot(tree);
if (tree != null) tree.setExpanded(true);
this.setCellFactory(cellFactory());
}
@Override
protected javafx.scene.control.Skin<?> createDefaultSkin() {
return new TreeViewSkin<Tag>(this) {
{
FXUtils.smoothScrolling(getVirtualFlow());
}
};
}
private static Callback<TreeView<Tag>, TreeCell<Tag>> cellFactory() {
EnumMap<NBTTagType, Image> icons = new EnumMap<>(NBTTagType.class);
return view -> new TreeCell<>() {
private void setTagText(String text) {
String name = ((Item) getTreeItem()).getName();
if (name == null) {
setText(text);
} else if (text == null) {
setText(name);
} else {
setText(name + ": " + text);
}
}
private void setTagText(int nEntries) {
setTagText(i18n("nbt.entries", nEntries));
}
@Override
public void updateItem(Tag item, boolean empty) {
super.updateItem(item, empty);
ImageView imageView = (ImageView) this.getGraphic();
if (imageView == null) {
imageView = new ImageView();
this.setGraphic(imageView);
}
if (item == null) {
imageView.setImage(null);
setText(null);
return;
}
NBTTagType tagType = NBTTagType.typeOf(item);
imageView.setImage(icons.computeIfAbsent(tagType, type -> new Image(type.getIconUrl())));
imageView.setFitHeight(16);
imageView.setFitWidth(16);
if (((Item) getTreeItem()).getText() != null) {
setText(((Item) getTreeItem()).getText());
} else {
switch (tagType) {
case BYTE:
case SHORT:
case INT:
case LONG:
case FLOAT:
case DOUBLE:
case STRING:
setTagText(item.getValue().toString());
break;
case BYTE_ARRAY:
case INT_ARRAY:
case LONG_ARRAY:
setTagText(Array.getLength(item.getValue()));
break;
case LIST:
setTagText(((ListTag) item).size());
break;
case COMPOUND:
setTagText(((CompoundTag) item).size());
break;
default:
setTagText(null);
}
}
}
};
}
public static Item buildTree(Tag tag) {
Item item = new Item(tag);
if (tag instanceof CompoundTag) {
for (Tag subTag : ((CompoundTag) tag)) {
item.getChildren().add(buildTree(subTag));
}
} else if (tag instanceof ListTag) {
int idx = 0;
for (Tag subTag : ((ListTag) tag)) {
Item subTree = buildTree(subTag);
subTree.setName(String.valueOf(idx++));
item.getChildren().add(subTree);
}
}
FXUtils.onChangeAndOperate(item.expandedProperty(), expanded -> {
if (expanded && item.getChildren().size() == 1) item.getChildren().get(0).setExpanded(true);
});
return item;
}
public CompoundTag getRootTag() {
return ((CompoundTag) getRoot().getValue());
}
public static class Item extends TreeItem<Tag> {
private String text;
private String name;
public Item() {
}
public Item(Tag value) {
super(value);
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return name == null ? getValue().getName() : name;
}
}
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -15,21 +15,8 @@
* 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.wizard;
public enum WizardNavigationResult {
PROCEED {
@Override
public boolean getDeferredComputation() {
return true;
}
},
DENY {
@Override
public boolean getDeferredComputation() {
return false;
}
};
@NotNullByDefault
package org.jackhuang.hmcl.ui.nbt;
public abstract boolean getDeferredComputation();
}
import org.jetbrains.annotations.NotNullByDefault;
@@ -33,7 +33,7 @@ public class SkinCube extends MeshView {
public static float[] createTexCoords(float width, float height, float depth, float scaleX, float scaleY,
float startX, float startY, boolean isSlim) {
float x = (width + depth) * 2, y = height + depth, half_width = width / x * scaleX, half_depth = depth / x * scaleX,
top_x = depth / x * scaleX + startX, top_y = startY, arm4 = isSlim ? half_depth : half_width,
top_x = depth / x * scaleX + startX, top_y = startY, arm4 = half_width,
bottom_x = startX, middle_y = depth / y * scaleY + top_y, bottom_y = scaleY + top_y;
return new float[]{
top_x, top_y, // T0 ---
@@ -1,3 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.versions;
import com.jfoenix.controls.JFXTextField;
@@ -195,9 +212,11 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
useNativeGLFWPane = new LineToggleButton();
useNativeGLFWPane.setTitle(i18n("settings.advanced.use_native_glfw"));
useNativeGLFWPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only"));
useNativeOpenALPane = new LineToggleButton();
useNativeOpenALPane.setTitle(i18n("settings.advanced.use_native_openal"));
useNativeOpenALPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only"));
workaroundPane.getContent().setAll(
nativesDirSublist, rendererPane, noJVMArgsPane, noOptimizingJVMArgsPane, noGameCheckPane,
@@ -22,7 +22,7 @@ import javafx.collections.ObservableList;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.mod.Datapack;
import org.jackhuang.hmcl.mod.DataPack;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -44,45 +44,45 @@ import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.DatapackInfoObject> implements WorldManagePage.WorldRefreshable {
public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.DataPackInfoObject> implements WorldManagePage.WorldRefreshable {
private final World world;
private final Datapack datapack;
private final DataPack dataPack;
final BooleanProperty readOnly;
public DatapackListPage(WorldManagePage worldManagePage) {
public DataPackListPage(WorldManagePage worldManagePage) {
world = worldManagePage.getWorld();
datapack = new Datapack(world.getFile().resolve("datapacks"));
setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new));
dataPack = new DataPack(world.getFile().resolve("datapacks"));
setItems(MappedObservableList.create(dataPack.getPacks(), DataPackListPageSkin.DataPackInfoObject::new));
readOnly = worldManagePage.readOnlyProperty();
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
this::installMultiDatapack, this::refresh);
this::installMultiDataPack, this::refresh);
refresh();
}
private void installMultiDatapack(List<Path> datapackPath) {
datapackPath.forEach(this::installSingleDatapack);
private void installMultiDataPack(List<Path> dataPackPath) {
dataPackPath.forEach(this::installSingleDataPack);
if (readOnly.get()) {
Controllers.showToast(i18n("datapack.reload.toast"));
}
}
private void installSingleDatapack(Path datapack) {
private void installSingleDataPack(Path dataPack) {
try {
this.datapack.installPack(datapack, world.getGameVersion());
this.dataPack.installPack(dataPack, world.getGameVersion());
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse datapack file " + datapack, e);
LOG.warning("Unable to parse datapack file " + dataPack, e);
}
}
@Override
protected Skin<?> createDefaultSkin() {
return new DatapackListPageSkin(this);
return new DataPackListPageSkin(this);
}
public void refresh() {
setLoading(true);
Task.runAsync(datapack::loadFromDir)
Task.runAsync(dataPack::loadFromDir)
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
.start();
}
@@ -94,18 +94,18 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
if (res != null) {
installMultiDatapack(res);
installMultiDataPack(res);
}
datapack.loadFromDir();
dataPack.loadFromDir();
}
void removeSelected(ObservableList<DatapackListPageSkin.DatapackInfoObject> selectedItems) {
void removeSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
selectedItems.stream()
.map(DatapackListPageSkin.DatapackInfoObject::getPackInfo)
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> {
try {
datapack.deletePack(pack);
dataPack.deletePack(pack);
} catch (IOException e) {
// Fail to remove mods if the game is running or the datapack is absent.
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
@@ -113,23 +113,23 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
});
}
void enableSelected(ObservableList<DatapackListPageSkin.DatapackInfoObject> selectedItems) {
void enableSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
selectedItems.stream()
.map(DatapackListPageSkin.DatapackInfoObject::getPackInfo)
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> pack.setActive(true));
}
void disableSelected(ObservableList<DatapackListPageSkin.DatapackInfoObject> selectedItems) {
void disableSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
selectedItems.stream()
.map(DatapackListPageSkin.DatapackInfoObject::getPackInfo)
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> pack.setActive(false));
}
void openDataPackFolder() {
FXUtils.openFolder(datapack.getPath());
FXUtils.openFolder(dataPack.getPath());
}
@NotNull Predicate<DatapackListPageSkin.DatapackInfoObject> updateSearchPredicate(String queryString) {
@NotNull Predicate<DataPackListPageSkin.DataPackInfoObject> updateSearchPredicate(String queryString) {
if (queryString.isBlank()) {
return dataPack -> true;
}
@@ -42,7 +42,7 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.mod.Datapack;
import org.jackhuang.hmcl.mod.DataPack;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -67,7 +67,7 @@ import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
@@ -75,8 +75,8 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
private final HBox selectingToolbar;
InvalidationListener updateBarByStateWeakListener;
private final JFXListView<DatapackInfoObject> listView;
private final FilteredList<DatapackInfoObject> filteredList;
private final JFXListView<DataPackInfoObject> listView;
private final FilteredList<DataPackInfoObject> filteredList;
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final BooleanProperty isSelecting = new SimpleBooleanProperty(false);
@@ -85,7 +85,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
private static final AtomicInteger lastShiftClickIndex = new AtomicInteger(-1);
final Consumer<Integer> toggleSelect;
DatapackListPageSkin(DatapackListPage skinnable) {
DataPackListPageSkin(DataPackListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
@@ -110,7 +110,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> isSearching.set(true))
);
JFXButton removeButton = createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> {
JFXButton removeButton = createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
@@ -181,7 +181,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new DatapackInfoListCell(listView, getSkinnable().readOnly));
listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnly));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
this.listView.setItems(filteredList);
@@ -216,13 +216,13 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
}
}
static class DatapackInfoObject extends RecursiveTreeObject<DatapackInfoObject> {
static class DataPackInfoObject extends RecursiveTreeObject<DataPackInfoObject> {
private final BooleanProperty activeProperty;
private final Datapack.Pack packInfo;
private final DataPack.Pack packInfo;
private SoftReference<CompletableFuture<Image>> iconCache;
DatapackInfoObject(Datapack.Pack packInfo) {
DataPackInfoObject(DataPack.Pack packInfo) {
this.packInfo = packInfo;
this.activeProperty = packInfo.activeProperty();
}
@@ -235,7 +235,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
return packInfo.getDescription().toString();
}
Datapack.Pack getPackInfo() {
DataPack.Pack getPackInfo() {
return packInfo;
}
@@ -270,7 +270,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
}
}
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<DatapackInfoObject>> current) {
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<DataPackInfoObject>> current) {
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
CompletableFuture<Image> imageFuture;
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
@@ -286,7 +286,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
imageFuture.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<DatapackInfoObject> infoObjectProperty = current.get();
ObjectProperty<DataPackInfoObject> infoObjectProperty = current.get();
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
// The current ListCell has already switched to another object
return;
@@ -298,13 +298,13 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
}
}
private final class DatapackInfoListCell extends MDListCell<DatapackInfoObject> {
private final class DataPackInfoListCell extends MDListCell<DataPackInfoObject> {
final JFXCheckBox checkBox = new JFXCheckBox();
ImageContainer imageContainer = new ImageContainer(32);
final TwoLineListItem content = new TwoLineListItem();
BooleanProperty booleanProperty;
DatapackInfoListCell(JFXListView<DatapackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
DataPackInfoListCell(JFXListView<DataPackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
super(listView);
HBox container = new HBox(8);
@@ -326,7 +326,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
}
@Override
protected void updateControl(DatapackInfoObject dataItem, boolean empty) {
protected void updateControl(DataPackInfoObject dataItem, boolean empty) {
if (empty) return;
content.setTitle(dataItem.getTitle());
content.setSubtitle(dataItem.getSubtitle());
@@ -338,7 +338,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
}
}
public void handleSelect(DatapackInfoListCell cell, MouseEvent mouseEvent) {
public void handleSelect(DataPackInfoListCell cell, MouseEvent mouseEvent) {
if (cell.isEmpty()) {
mouseEvent.consume();
return;
@@ -101,6 +101,10 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
this.downloadProvider = DownloadProviders.getDownloadProvider();
}
public DownloadProvider getDownloadProvider() {
return downloadProvider;
}
public ObservableList<Node> getActions() {
return actions;
}
@@ -233,18 +237,20 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteMod> listView = new JFXListView<>();
private final RemoteImageLoader iconLoader = new RemoteImageLoader() {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull URI uri) {
return FXUtils.getRemoteImageTask(uri, 80, 80, true, true);
}
};
private final RemoteImageLoader iconLoader;
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
listView.getStyleClass().add("no-horizontal-scrollbar");
iconLoader = new RemoteImageLoader(control.downloadProvider) {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris) {
return FXUtils.getRemoteImageTask(uris, 80, 80, true, true);
}
};
BorderPane pane = new BorderPane();
GridPane searchPane = new GridPane();
@@ -27,17 +27,10 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
@@ -53,11 +46,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.SimpleMultimap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
@@ -65,14 +54,7 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -112,7 +94,7 @@ public class DownloadPage extends Control implements DecoratorPage {
setFailed(false);
Task.supplyAsync(() -> {
Stream<RemoteMod.Version> versions = addon.getData().loadVersions(repository);
Stream<RemoteMod.Version> versions = addon.getData().loadVersions(repository, page.getDownloadProvider());
return sortVersions(versions);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
@@ -179,7 +161,7 @@ public class DownloadPage extends Control implements DecoratorPage {
if (this.callback == null) {
saveAs(mod, file);
} else {
this.callback.download(version.getProfile(), version.getVersion(), mod, file);
this.callback.download(page.getDownloadProvider(), version.getProfile(), version.getVersion(), mod, file);
}
}
@@ -252,14 +234,12 @@ public class DownloadPage extends Control implements DecoratorPage {
openMcmodButton.setExternalLink(getSkinnable().translations.getMcmodUrl(getSkinnable().mod));
descriptionPane.getChildren().add(openMcmodButton);
openMcmodButton.setMinWidth(Region.USE_PREF_SIZE);
FXUtils.installFastTooltip(openMcmodButton, i18n("mods.mcmod"));
}
JFXHyperlink openUrlButton = new JFXHyperlink(control.page.getLocalizedOfficialPage());
openUrlButton.setExternalLink(getSkinnable().addon.getPageUrl());
descriptionPane.getChildren().add(openUrlButton);
openUrlButton.setMinWidth(Region.USE_PREF_SIZE);
FXUtils.installFastTooltip(openUrlButton, control.page.getLocalizedOfficialPage());
}
SpinnerPane spinnerPane = new SpinnerPane();
@@ -293,7 +273,7 @@ public class DownloadPage extends Control implements DecoratorPage {
Version game = repository.getResolvedPreservingPatchesVersion(control.version.getVersion());
String gameVersion = repository.getGameVersion(game).orElse(null);
if (gameVersion != null) {
if (gameVersion != null && control.versions.containsKey(gameVersion)) {
List<RemoteMod.Version> modVersions = control.versions.get(gameVersion);
if (modVersions != null && !modVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = LibraryAnalyzer.analyze(game, gameVersion).getModLoaders();
@@ -314,25 +294,55 @@ public class DownloadPage extends Control implements DecoratorPage {
}
}
for (String gameVersion : control.versions.keys().stream()
.sorted(Collections.reverseOrder(GameVersionNumber::compare))
.toList()) {
List<RemoteMod.Version> versions = control.versions.get(gameVersion);
if (versions == null || versions.isEmpty()) {
continue;
final class Versions {
static ComponentSublist createSublist(DownloadPage control, String title, List<String> target) {
var sublist = new ComponentSublist(() -> {
ArrayList<ModItem> items = new ArrayList<>();
for (String gv : target) {
List<RemoteMod.Version> versions = control.versions.get(gv);
if (versions != null) {
for (RemoteMod.Version v : versions) {
items.add(new ModItem(control.addon, v, control));
}
}
}
return items;
});
sublist.setTitle(title);
sublist.getStyleClass().add("no-padding");
return sublist;
}
var sublist = new ComponentSublist(() -> {
ArrayList<ModItem> items = new ArrayList<>(versions.size());
for (RemoteMod.Version v : versions) {
items.add(new ModItem(control.addon, v, control));
}
return items;
});
sublist.getStyleClass().add("no-padding");
sublist.setTitle("Minecraft " + gameVersion);
final List<String> releases = new ArrayList<>();
final List<String> snapshots = new ArrayList<>();
}
list.getContent().add(sublist);
TreeMap<GameVersionNumber, Versions> map = new TreeMap<>(Collections.reverseOrder());
for (String version : control.versions.keys()) {
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(version);
GameVersionNumber releaseOfSnapshot = GameVersionNumber.getReleaseOfSnapshot(gameVersion);
GameVersionNumber releaseVersion = Objects.requireNonNullElse(releaseOfSnapshot, gameVersion);
Versions lists = map.computeIfAbsent(releaseVersion, v -> new Versions());
if (releaseOfSnapshot == null) {
lists.releases.add(version);
} else {
lists.snapshots.add(version);
}
}
for (Map.Entry<GameVersionNumber, Versions> entry : map.entrySet()) {
GameVersionNumber gameVersion = entry.getKey();
Versions versions = entry.getValue();
if (!versions.releases.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.release", gameVersion), versions.releases));
if (!versions.snapshots.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.snapshot", gameVersion), versions.snapshots));
}
});
}
@@ -341,7 +351,7 @@ public class DownloadPage extends Control implements DecoratorPage {
}
}
private static final class DependencyModItem extends StackPane {
private static final class DependencyModItem extends LineButton {
public static final EnumMap<RemoteMod.DependencyType, String> I18N_KEY = new EnumMap<>(Lang.mapOf(
Pair.pair(RemoteMod.DependencyType.EMBEDDED, "mods.dependency.embedded"),
Pair.pair(RemoteMod.DependencyType.OPTIONAL, "mods.dependency.optional"),
@@ -357,16 +367,16 @@ public class DownloadPage extends Control implements DecoratorPage {
pane.setPadding(new Insets(0, 8, 0, 8));
pane.setAlignment(Pos.CENTER_LEFT);
TwoLineListItem content = new TwoLineListItem();
pane.setMouseTransparent(true);
HBox.setHgrow(content, Priority.ALWAYS);
var imageView = new ImageContainer(40);
pane.getChildren().setAll(imageView, content);
RipplerContainer container = new RipplerContainer(pane);
FXUtils.onClicked(container, () -> {
FXUtils.setLimitHeight(this, 60);
setOnAction((e) -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(page, addon, version, callback));
});
getChildren().setAll(container);
setNode(IDX_LEADING, pane);
if (addon != RemoteMod.BROKEN) {
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(page.repository.getType()).getModByCurseForgeId(addon.getSlug());
@@ -483,6 +493,7 @@ public class DownloadPage extends Control implements DecoratorPage {
loadDependencies(version, selfPage, spinnerPane, dependenciesList);
spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList));
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setContent(dependenciesList);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
@@ -550,7 +561,7 @@ public class DownloadPage extends Control implements DecoratorPage {
dependencies.put(dependency.getType(), list);
}
queue.add(Task.supplyAsync(Schedulers.io(), dependency::load)
queue.add(Task.supplyAsync(Schedulers.io(), () -> dependency.load(selfPage.page.getDownloadProvider()))
.setSignificance(Task.TaskSignificance.MINOR)
.thenAcceptAsync(Schedulers.javafx(), dep -> {
if (dep == RemoteMod.BROKEN) {
@@ -578,7 +589,8 @@ public class DownloadPage extends Control implements DecoratorPage {
}
}
@FunctionalInterface
public interface DownloadCallback {
void download(Profile profile, @Nullable String version, RemoteMod mod, RemoteMod.Version file);
void download(DownloadProvider downloadProvider, Profile profile, @Nullable String version, RemoteMod mod, RemoteMod.Version file);
}
}
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import java.util.MissingResourceException;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -50,7 +51,7 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.SHADER_PACK, null, ModrinthRemoteModRepository.SHADER_PACKS);
var page = new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.SHADER_PACK, CurseForgeRemoteModRepository.SHADERS, ModrinthRemoteModRepository.SHADER_PACKS);
page.supportChinese.set(false);
return page;
}
@@ -61,13 +62,30 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
repository = new Repository(type, curseForge, modrinth);
supportChinese.set(true);
downloadSources.get().setAll("mods.modrinth", "mods.curseforge");
if (modrinth != null) {
downloadSource.set("mods.modrinth");
} else if (curseForge != null) {
downloadSource.set("mods.curseforge");
boolean supportedCurseForge = CurseForgeRemoteModRepository.isAvailable() && curseForge != null;
downloadSources.setAll("mods.modrinth");
if (supportedCurseForge) {
downloadSources.add("mods.curseforge");
}
if ("curseforge".equalsIgnoreCase(config().getDefaultAddonSource())) {
if (supportedCurseForge) {
downloadSource.set("mods.curseforge");
} else if (modrinth != null) {
downloadSource.set("mods.modrinth");
} else {
throw new AssertionError("Should not be here.");
}
} else {
throw new AssertionError("Should not be here.");
if (modrinth != null) {
downloadSource.set("mods.modrinth");
} else if (supportedCurseForge) {
downloadSource.set("mods.curseforge");
} else {
throw new AssertionError("Should not be here.");
}
}
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.ui.versions;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.task.Schedulers;
@@ -30,16 +31,18 @@ import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class ModCheckUpdatesTask extends Task<List<LocalModFile.ModUpdate>> {
private final DownloadProvider downloadProvider;
private final List<Task<LocalModFile.ModUpdate>> dependents;
public ModCheckUpdatesTask(String gameVersion, Collection<LocalModFile> mods) {
public ModCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<LocalModFile> mods) {
this.downloadProvider = downloadProvider;
dependents = mods.stream().map(mod ->
Task.supplyAsync(Schedulers.io(), () -> {
LocalModFile.ModUpdate candidate = null;
for (RemoteMod.Type type : RemoteMod.Type.values()) {
LocalModFile.ModUpdate update = null;
try {
update = mod.checkUpdates(gameVersion, type.getRemoteModRepository());
update = mod.checkUpdates(downloadProvider, gameVersion, type.getRemoteModRepository());
} catch (IOException e) {
LOG.warning(String.format("Cannot check update for mod %s.", mod.getFileName()), e);
}
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -43,6 +44,7 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
@@ -239,11 +241,12 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
.composeAsync(() -> {
Optional<String> gameVersion = profile.getRepository().getGameVersion(instanceId);
if (gameVersion.isPresent()) {
return new ModCheckUpdatesTask(gameVersion.get(), mods);
return new ModCheckUpdatesTask(DownloadProviders.getDownloadProvider(), gameVersion.get(), mods);
}
return null;
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception instanceof CancellationException) return;
if (exception != null || result == null) {
Controllers.dialog(i18n("mods.check_updates.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
} else if (result.isEmpty()) {
@@ -43,6 +43,7 @@ import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.task.Schedulers;
@@ -154,7 +155,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
// Toolbar Selecting
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> {
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
@@ -465,7 +466,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
Task.runAsync(() -> {
Optional<RemoteMod.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo(), modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteMod remoteMod = repository.getModById(versionOptional.get().getModid());
RemoteMod remoteMod = repository.getModById(DownloadProviders.getDownloadProvider(), versionOptional.get().getModid());
FXUtils.runInFX(() -> {
for (ModLoaderType modLoaderType : versionOptional.get().getLoaders()) {
String loaderName = switch (modLoaderType) {
@@ -493,7 +494,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
repository instanceof CurseForgeRemoteModRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false),
remoteMod,
new Profile.ProfileVersion(ModListPageSkin.this.getSkinnable().getProfile(), ModListPageSkin.this.getSkinnable().getInstanceId()),
(profile, version, mod, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(profile, version, file, "mods")
(downloadProvider, profile, version, mod, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(downloadProvider, profile, version, file, "mods")
));
});
button.setDisable(false);
@@ -1,3 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.versions;
import com.jfoenix.controls.JFXButton;
@@ -23,7 +23,10 @@ import com.jfoenix.controls.JFXListView;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
@@ -157,20 +160,9 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
return;
Path parent = currentDirectory.path;
Controllers.dialog(new InputDialogPane(
Controllers.prompt(
i18n("schematics.create_directory.prompt"),
"",
(result, handler) -> {
if (StringUtils.isBlank(result)) {
handler.reject(i18n("schematics.create_directory.failed.empty_name"));
return;
}
if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) {
handler.reject(i18n("schematics.create_directory.failed.invalid_name"));
return;
}
Path targetDir = parent.resolve(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("schematics.create_directory.failed.already_exists"));
@@ -185,7 +177,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
LOG.warning("Failed to create directory: " + targetDir, e);
handler.reject(i18n("schematics.create_directory.failed", targetDir));
}
}));
}, "", new RequiredValidator(), new Validator(i18n("schematics.create_directory.failed.invalid_name"), FileUtils::isNameValid));
}
private DirItem loadAll(Path dir, @Nullable DirItem parent) {
@@ -30,8 +30,10 @@ import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.EventPriority;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.game.GameRepository;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.Profile;
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.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
@@ -39,8 +41,10 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Supplier;
@@ -186,15 +190,34 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
}
private void clearLibraries() {
FileUtils.deleteDirectoryQuietly(getProfile().getRepository().getBaseDirectory().resolve("libraries"));
var libraries = getProfile().getRepository().getBaseDirectory().resolve("libraries");
Task.runAsync(Schedulers.io(), () -> {
FileUtils.deleteDirectoryQuietly(libraries);
}).whenComplete(Schedulers.javafx(), (exception) -> {
if (exception != null) {
Controllers.dialog(i18n("message.failed") + "\n" + StringUtils.getStackTrace(exception), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}).start();
}
private void clearAssets() {
HMCLGameRepository baseDirectory = getProfile().getRepository();
FileUtils.deleteDirectoryQuietly(baseDirectory.getBaseDirectory().resolve("assets"));
if (version.get() != null) {
FileUtils.deleteDirectoryQuietly(baseDirectory.getRunDirectory(version.get().getVersion()).resolve("resources"));
}
Path assetsDir = getProfile().getRepository().getBaseDirectory().resolve("assets");
Profile.ProfileVersion currentVersion = version.get();
Path resourcesDir = currentVersion != null
? getProfile().getRepository().getRunDirectory(currentVersion.getVersion()).resolve("resources")
: null;
Task.runAsync(Schedulers.io(), () -> {
FileUtils.deleteDirectoryQuietly(assetsDir);
if (resourcesDir != null) {
FileUtils.deleteDirectoryQuietly(resourcesDir);
}
}).whenComplete(Schedulers.javafx(), (exception) -> {
if (exception != null) {
Controllers.dialog(i18n("message.failed") + "\n" + StringUtils.getStackTrace(exception), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}).start();
}
private void clearJunkFiles() {
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.*;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
@@ -43,6 +42,7 @@ import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.MemoryStatusBar;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
@@ -57,7 +57,6 @@ import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.lang.ref.WeakReference;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -115,8 +114,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
private final IntegerProperty maxMemory = new SimpleIntegerProperty();
private final BooleanProperty modpack = new SimpleBooleanProperty();
private final ReadOnlyObjectProperty<PhysicalMemoryStatus> memoryStatus = UpdateMemoryStatus.memoryStatusProperty();
public VersionSettingsPage(boolean globalSetting) {
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
@@ -296,30 +293,14 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
lowerBoundPane.getChildren().setAll(label, slider, txtMaxMemory, new Label(i18n("settings.memory.unit.mib")));
}
StackPane progressBarPane = new StackPane();
progressBarPane.setAlignment(Pos.CENTER_LEFT);
VBox.setMargin(progressBarPane, new Insets(8, 0, 0, 16));
{
progressBarPane.setMinHeight(4);
progressBarPane.getStyleClass().add("memory-total");
StackPane usedMemory = new StackPane();
usedMemory.getStyleClass().add("memory-used");
usedMemory.maxWidthProperty().bind(Bindings.createDoubleBinding(() ->
progressBarPane.getWidth() *
(memoryStatus.get().getUsed() * 1.0 / memoryStatus.get().getTotal()), progressBarPane.widthProperty(),
memoryStatus));
StackPane allocateMemory = new StackPane();
allocateMemory.getStyleClass().add("memory-allocate");
allocateMemory.maxWidthProperty().bind(Bindings.createDoubleBinding(() ->
progressBarPane.getWidth() *
Math.min(1.0,
(double) (HMCLGameRepository.getAllocatedMemory(maxMemory.get() * 1024L * 1024L, memoryStatus.get().getAvailable(), chkAutoAllocate.isSelected())
+ memoryStatus.get().getUsed()) / memoryStatus.get().getTotal()), progressBarPane.widthProperty(),
maxMemory, memoryStatus, chkAutoAllocate.selectedProperty()));
progressBarPane.getChildren().setAll(allocateMemory, usedMemory);
}
MemoryStatusBar memoryStatusBar = new MemoryStatusBar();
VBox.setMargin(memoryStatusBar, new Insets(8, 0, 0, 16));
memoryStatusBar.memoryAllocatedProperty().bind(Bindings.createDoubleBinding(() ->
(double) HMCLGameRepository.getAllocatedMemory(
maxMemory.get() * 1024L * 1024L,
memoryStatusBar.getMemoryStatus().getAvailable(),
chkAutoAllocate.isSelected()),
memoryStatusBar.memoryStatusProperty(), maxMemory, chkAutoAllocate.selectedProperty()));
BorderPane digitalPane = new BorderPane();
VBox.setMargin(digitalPane, new Insets(0, 0, 0, 16));
@@ -327,26 +308,29 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
Label lblPhysicalMemory = new Label();
lblPhysicalMemory.getStyleClass().add("memory-label");
digitalPane.setLeft(lblPhysicalMemory);
lblPhysicalMemory.textProperty().bind(Bindings.createStringBinding(() ->
i18n("settings.memory.used_per_total",
GIGABYTES.convertFromBytes(memoryStatus.get().getUsed()),
GIGABYTES.convertFromBytes(memoryStatus.get().getTotal())), memoryStatus));
lblPhysicalMemory.textProperty().bind(Bindings.createStringBinding(() -> {
PhysicalMemoryStatus memoryStatus = memoryStatusBar.getMemoryStatus();
return i18n("settings.memory.used_per_total",
GIGABYTES.convertFromBytes(memoryStatus.getUsed()),
GIGABYTES.convertFromBytes(memoryStatus.getTotal()));
}, memoryStatusBar.memoryStatusProperty()));
Label lblAllocateMemory = new Label();
lblAllocateMemory.textProperty().bind(Bindings.createStringBinding(() -> {
PhysicalMemoryStatus memoryStatus = memoryStatusBar.getMemoryStatus();
long maxMemory = Lang.parseInt(this.maxMemory.get(), 0) * 1024L * 1024L;
return i18n(memoryStatus.get().hasAvailable() && maxMemory > memoryStatus.get().getAvailable()
return i18n(memoryStatus.hasAvailable() && maxMemory > memoryStatus.getAvailable()
? (chkAutoAllocate.isSelected() ? "settings.memory.allocate.auto.exceeded" : "settings.memory.allocate.manual.exceeded")
: (chkAutoAllocate.isSelected() ? "settings.memory.allocate.auto" : "settings.memory.allocate.manual"),
GIGABYTES.convertFromBytes(maxMemory),
GIGABYTES.convertFromBytes(HMCLGameRepository.getAllocatedMemory(maxMemory, memoryStatus.get().getAvailable(), chkAutoAllocate.isSelected())),
GIGABYTES.convertFromBytes(memoryStatus.get().getAvailable()));
}, memoryStatus, maxMemory, chkAutoAllocate.selectedProperty()));
GIGABYTES.convertFromBytes(HMCLGameRepository.getAllocatedMemory(maxMemory, memoryStatus.getAvailable(), chkAutoAllocate.isSelected())),
GIGABYTES.convertFromBytes(memoryStatus.getAvailable()));
}, memoryStatusBar.memoryStatusProperty(), maxMemory, chkAutoAllocate.selectedProperty()));
lblAllocateMemory.getStyleClass().add("memory-label");
digitalPane.setRight(lblAllocateMemory);
}
maxMemoryPane.getChildren().setAll(title, chkAutoAllocate, lowerBoundPane, progressBarPane, digitalPane);
maxMemoryPane.getChildren().setAll(title, chkAutoAllocate, lowerBoundPane, memoryStatusBar, digitalPane);
}
launcherVisibilityPane = new LineSelectButton<>();
@@ -472,7 +456,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
componentList.disableProperty().bind(enableSpecificSettings.not());
}
private void fireWorkingDirChanged() {
FXUtils.runInFX(() -> fireEvent(new VersionPage.WorkingDirChangedEvent()));
}
@@ -776,57 +760,4 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
return state.getReadOnlyProperty();
}
private static final class UpdateMemoryStatus extends Thread {
@FXThread
private static WeakReference<ObjectProperty<PhysicalMemoryStatus>> memoryStatusPropertyCache;
@FXThread
static ReadOnlyObjectProperty<PhysicalMemoryStatus> memoryStatusProperty() {
if (memoryStatusPropertyCache != null) {
var property = memoryStatusPropertyCache.get();
if (property != null) {
return property;
}
}
ObjectProperty<PhysicalMemoryStatus> property = new SimpleObjectProperty<>(PhysicalMemoryStatus.INVALID);
memoryStatusPropertyCache = new WeakReference<>(property);
new UpdateMemoryStatus(memoryStatusPropertyCache).start();
return property;
}
private final WeakReference<ObjectProperty<PhysicalMemoryStatus>> memoryStatusPropertyRef;
UpdateMemoryStatus(WeakReference<ObjectProperty<PhysicalMemoryStatus>> memoryStatusPropertyRef) {
this.memoryStatusPropertyRef = memoryStatusPropertyRef;
setName("UpdateMemoryStatus");
setDaemon(true);
setPriority(Thread.MIN_PRIORITY);
}
@Override
public void run() {
while (true) {
PhysicalMemoryStatus status = SystemInfo.getPhysicalMemoryStatus();
var memoryStatusProperty = memoryStatusPropertyRef.get();
if (memoryStatusProperty == null)
return;
if (Controllers.isStopped())
return;
Platform.runLater(() -> memoryStatusProperty.set(status));
try {
//noinspection BusyWait
Thread.sleep(1000);
} catch (InterruptedException e) {
return;
}
}
}
}
}
@@ -22,7 +22,11 @@ import javafx.application.Platform;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.setting.*;
@@ -42,13 +46,13 @@ import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
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 java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -72,11 +76,11 @@ public final class Versions {
}
}
public static void downloadModpackImpl(Profile profile, String version, RemoteMod mod, RemoteMod.Version file) {
public static void downloadModpackImpl(DownloadProvider downloadProvider, Profile profile, String version, RemoteMod mod, RemoteMod.Version file) {
Path modpack;
URI downloadURL;
List<URI> downloadURLs;
try {
downloadURL = NetworkUtils.toURI(file.getFile().getUrl());
downloadURLs = downloadProvider.injectURLWithCandidates(file.getFile().getUrl());
modpack = Files.createTempFile("modpack", ".zip");
} catch (IOException | IllegalArgumentException e) {
Controllers.dialog(
@@ -85,7 +89,7 @@ public final class Versions {
return;
}
Controllers.taskDialog(
new FileDownloadTask(downloadURL, modpack)
new FileDownloadTask(downloadURLs, modpack)
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
ModpackInstallWizardProvider installWizardProvider;
@@ -145,7 +149,8 @@ public final class Versions {
} else {
handler.reject(i18n("version.manage.rename.fail"));
}
}, version, new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId),
}, version,
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId),
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName) || newVersionName.equals(version)));
}
@@ -157,6 +162,43 @@ public final class Versions {
FXUtils.openFolder(profile.getRepository().getRunDirectory(version));
}
public static void installFromJson(Profile profile, Path file) {
Version version;
try {
version = profile.getRepository().readVersionJson(file);
} catch (Exception e) {
Controllers.dialog(i18n("install.new_game.malformed_json"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
return;
}
Controllers.prompt(i18n("version.manage.duplicate.prompt"), (result, handler) -> {
handler.resolve();
DefaultDependencyManager dependencyManager = profile.getDependency();
HMCLGameRepository repository = profile.getRepository();
Version newVersion = version.setId(result).setJar(result);
Controllers.taskDialog(
Task.allOf(new GameDownloadTask(dependencyManager, null, newVersion),
Task.allOf(
new GameAssetDownloadTask(dependencyManager, newVersion, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true),
new GameLibrariesTask(dependencyManager, newVersion, true)
).withRunAsync(() -> {
// ignore failure
}))
.thenComposeAsync(repository.saveAsync(newVersion))
.thenRunAsync(repository::refreshVersions)
.whenComplete(Schedulers.javafx(), (exception) -> {
if (exception == null) {
profile.setSelectedVersion(result);
} else {
Controllers.dialog(
DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
}
}), i18n("install.new_game"), TaskCancellationAction.NORMAL);
}, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName)));
}
public static void duplicateVersion(Profile profile, String version) {
Controllers.prompt(
new PromptDialogPane.Builder(i18n("version.manage.duplicate.prompt"), (res, handler) -> {
@@ -259,7 +259,7 @@ public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.Backup
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> skinnable.onReveal());
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE);
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
right.getChildren().add(btnDelete);
FXUtils.installFastTooltip(btnDelete, i18n("world.backup.delete"));
btnDelete.setOnAction(event -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::onDelete, null));
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.ui.versions;
import com.github.steveice10.opennbt.tag.builtin.*;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.SimpleBooleanProperty;
@@ -34,9 +33,8 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.glavo.nbt.tag.*;
import org.jackhuang.hmcl.game.World;
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.SVG;
@@ -66,7 +64,8 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
private final WorldManagePage worldManagePage;
private boolean isReadOnly;
private final World world;
private CompoundTag levelDat;
private CompoundTag levelData;
private CompoundTag playerData;
private final ImageContainer iconImageView = new ImageContainer(32);
@@ -76,15 +75,9 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
refresh();
}
private CompoundTag loadWorldInfo() throws IOException {
if (!Files.isDirectory(world.getFile()))
throw new IOException("Not a valid world directory");
return world.getLevelData();
}
private void updateControls() {
CompoundTag dataTag = levelDat.get("Data");
CompoundTag dataTag = (CompoundTag) levelData.get("Data");
CompoundTag playerTag = playerData;
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
@@ -107,7 +100,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
setRightTextField(worldNamePane, worldNameField, 200);
if (dataTag.get("LevelName") instanceof StringTag worldNameTag) {
var worldName = new SimpleStringProperty(worldNameTag.getValue());
var worldName = new SimpleStringProperty(worldNameTag.get());
FXUtils.bindString(worldNameField, worldName);
worldNameField.getProperties().put(WorldInfoPage.class.getName() + ".worldNameProperty", worldName);
worldName.addListener((observable, oldValue, newValue) -> {
@@ -205,12 +198,14 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
worldSpawnPoint.setTitle(i18n("world.info.spawn"));
String value;
// Valid after 1.21.9-pre1
if (dataTag.get("spawn") instanceof CompoundTag spawnTag && spawnTag.get("pos") instanceof IntArrayTag posTag) {
value = Dimension.of(spawnTag.get("dimension") instanceof StringTag dimensionTag
? dimensionTag
: new StringTag("SpawnDimension", "minecraft:overworld"))
.formatPosition(posTag);
} else if (dataTag.get("SpawnX") instanceof IntTag intX
value = (spawnTag.get("dimension") instanceof StringTag dimensionTag)
? Dimension.of(dimensionTag).formatPosition(posTag)
: Dimension.OVERWORLD.formatPosition(posTag);
}
// Valid before 1.21.9-pre1
else if (dataTag.get("SpawnX") instanceof IntTag intX
&& dataTag.get("SpawnY") instanceof IntTag intY
&& dataTag.get("SpawnZ") instanceof IntTag intZ) {
value = Dimension.OVERWORLD.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue());
@@ -248,34 +243,51 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
{
generateFeaturesButton.setTitle(i18n("world.info.generate_features"));
generateFeaturesButton.setDisable(isReadOnly);
// generate_features was valid after 20w20a and MapFeatures was before that
if (dataTag.get("WorldGenSettings") instanceof CompoundTag worldGenSettings) {
bindTagAndToggleButton(worldGenSettings.get("generate_features"), generateFeaturesButton);
// Valid before (1.16)20w20a
if (dataTag.get("MapFeatures") instanceof ByteTag generateFeaturesTag) {
bindTagAndToggleButton(generateFeaturesTag, generateFeaturesButton);
}
// Valid after (1.16)20w20a
else if (world.getNormalizedWorldGenSettingsData() != null) {
CompoundTag unifiedWorldGenSettingsDataTag = world.getNormalizedWorldGenSettingsData();
Tag tag = unifiedWorldGenSettingsDataTag.get("generate_features"); // Valid between (1.16)20w20a and 26.1-snapshot-6
if (tag == null)
tag = unifiedWorldGenSettingsDataTag.get("generate_structures"); // Valid after 26.1-snapshot-6
bindTagAndToggleButton(tag, generateFeaturesButton);
} else {
bindTagAndToggleButton(dataTag.get("MapFeatures"), generateFeaturesButton);
generateFeaturesButton.setDisable(true);
}
}
var difficultyButton = new LineSelectButton<Difficulty>();
{
difficultyButton.setTitle(i18n("world.info.difficulty"));
difficultyButton.setDisable(worldManagePage.isReadOnly());
difficultyButton.setDisable(isReadOnly);
difficultyButton.setItems(Difficulty.items);
if (dataTag.get("Difficulty") instanceof ByteTag difficultyTag) {
Difficulty difficulty = Difficulty.of(difficultyTag.getValue());
if (difficulty != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue((byte) newValue.ordinal());
saveLevelDat();
}
});
} else {
difficultyButton.setDisable(true);
}
Difficulty difficulty;
// Valid before 26.1-snapshot-6
if (dataTag.get("Difficulty") instanceof ByteTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue((byte) newValue.ordinal());
saveWorldData();
}
});
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("difficulty") instanceof StringTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue(newValue.getTagStringValue());
saveWorldData();
}
});
} else {
difficultyButton.setDisable(true);
}
@@ -285,8 +297,17 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
{
difficultyLockPane.setTitle(i18n("world.info.difficulty_lock"));
difficultyLockPane.setDisable(isReadOnly);
bindTagAndToggleButton(dataTag.get("DifficultyLocked"), difficultyLockPane);
// Valid before 26.1-snapshot-6
if (dataTag.get("DifficultyLocked") instanceof ByteTag difficultyLockedTag) {
bindTagAndToggleButton(difficultyLockedTag, difficultyLockPane);
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("locked") instanceof ByteTag lockedTag) {
bindTagAndToggleButton(lockedTag, difficultyLockPane);
} else {
difficultyLockPane.setDisable(true);
}
}
worldInfo.getContent().setAll(
@@ -296,14 +317,14 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info")), worldInfo);
}
if (dataTag.get("Player") instanceof CompoundTag playerTag) {
if (playerTag != null) {
ComponentList playerInfo = new ComponentList();
var locationPane = new LineTextPane();
{
locationPane.setTitle(i18n("world.info.player.location"));
Dimension dimension = Dimension.of(playerTag.get("Dimension"));
if (dimension != null && playerTag.get("Pos") instanceof ListTag posTag) {
if (dimension != null && playerTag.get("Pos") instanceof ListTag<?> posTag) {
locationPane.setText(dimension.formatPosition(posTag));
}
}
@@ -326,7 +347,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
if (playerTag.get("respawn") instanceof CompoundTag respawnTag
&& respawnTag.get("dimension") instanceof StringTag dimensionTag
&& respawnTag.get("pos") instanceof IntArrayTag intArrayTag
&& intArrayTag.length() >= 3) { // Valid after 25w07a
&& intArrayTag.size() >= 3) { // Valid after 25w07a
spawnPane.setText(Dimension.of(dimensionTag).formatPosition(intArrayTag));
} else if (playerTag.get("SpawnX") instanceof IntTag intX
&& playerTag.get("SpawnY") instanceof IntTag intY
@@ -343,8 +364,14 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
playerGameTypePane.setDisable(worldManagePage.isReadOnly());
playerGameTypePane.setItems(GameType.items);
if (playerTag.get("playerGameType") instanceof IntTag playerGameTypeTag
&& dataTag.get("hardcore") instanceof ByteTag hardcoreTag) {
// Valid before 26.1-snapshot-6
Tag rawHardcoreTag = dataTag.get("hardcore");
// Valid after 26.1-snapshot-6
if (rawHardcoreTag == null && dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingsTag) {
rawHardcoreTag = difficultySettingsTag.get("hardcore");
}
if (playerTag.get("playerGameType") instanceof IntTag playerGameTypeTag && rawHardcoreTag instanceof ByteTag hardcoreTag) {
boolean isHardcore = hardcoreTag.getValue() == 1;
GameType gameType = GameType.of(playerGameTypeTag.getValue(), isHardcore);
if (gameType != null) {
@@ -358,7 +385,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
playerGameTypeTag.setValue(newValue.ordinal());
hardcoreTag.setValue((byte) 0);
}
saveLevelDat();
saveWorldData();
}
});
} else {
@@ -428,10 +455,10 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
toggleButton.selectedProperty().addListener((o, oldValue, newValue) -> {
try {
byteTag.setValue((byte) (newValue ? 1 : 0));
saveLevelDat();
saveWorldData();
} catch (Exception e) {
toggleButton.setSelected(oldValue);
LOG.warning("Exception happened when saving level.dat", e);
LOG.warning("Exception happened when saving world info", e);
}
});
} else {
@@ -451,11 +478,11 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
Integer integer = Lang.toIntOrNull(newValue);
if (integer != null) {
intTag.setValue(integer);
saveLevelDat();
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving level.dat", e);
LOG.warning("Exception happened when saving world data", e);
}
}
});
@@ -472,11 +499,11 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
Float floatValue = Lang.toFloatOrNull(newValue);
if (floatValue != null) {
floatTag.setValue(floatValue);
saveLevelDat();
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving level.dat", e);
LOG.warning("Exception happened when saving world data", e);
}
}
});
@@ -484,30 +511,27 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true));
}
private void saveLevelDat() {
LOG.info("Saving level.dat of world " + world.getWorldName());
private void saveWorldData() {
LOG.info("Saving data of world " + world.getWorldName());
try {
this.world.writeLevelDat(levelDat);
this.world.writeWorldData();
} catch (IOException e) {
LOG.warning("Failed to save level.dat of world " + world.getWorldName(), e);
LOG.warning("Failed to save world data", e);
}
}
@Override
public void refresh() {
this.isReadOnly = worldManagePage.isReadOnly();
this.setLoading(true);
Task.supplyAsync(this::loadWorldInfo)
.whenComplete(Schedulers.javafx(), ((result, exception) -> {
if (exception == null) {
this.levelDat = result;
updateControls();
setLoading(false);
} else {
LOG.warning("Failed to load level.dat", exception);
setFailedReason(i18n("world.info.failed"));
}
})).start();
setFailedReason(null);
try {
this.isReadOnly = worldManagePage.isReadOnly();
this.levelData = world.getLevelData();
this.playerData = world.getPlayerData();
updateControls();
} catch (Exception e) {
LOG.warning("Failed to refresh world info", e);
setFailedReason(i18n("world.info.failed"));
}
}
private record Dimension(String name) {
@@ -517,7 +541,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
static Dimension of(Tag tag) {
if (tag instanceof IntTag intTag) {
return switch (intTag.getValue()) {
return switch (intTag.get()) {
case 0 -> OVERWORLD;
case -1 -> THE_NETHER;
case 1 -> THE_END;
@@ -537,30 +561,23 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
}
String formatPosition(Tag tag) {
if (tag instanceof ListTag listTag && listTag.size() == 3) {
if (tag instanceof ListTag<?> listTag && listTag.size() == 3) {
Tag x = listTag.get(0);
Tag y = listTag.get(1);
Tag z = listTag.get(2);
if (x instanceof DoubleTag && y instanceof DoubleTag && z instanceof DoubleTag) {
return this == OVERWORLD
? String.format("(%.2f, %.2f, %.2f)", x.getValue(), y.getValue(), z.getValue())
: String.format("%s (%.2f, %.2f, %.2f)", name, x.getValue(), y.getValue(), z.getValue());
if (listTag.getTag(0) instanceof DoubleTag x
&& listTag.getTag(1) instanceof DoubleTag y
&& listTag.getTag(2) instanceof DoubleTag z) {
return formatPosition(x.get(), y.get(), z.get());
}
return null;
}
if (tag instanceof IntArrayTag intArrayTag) {
int x = intArrayTag.get(0);
int y = intArrayTag.get(1);
int z = intArrayTag.get(2);
int x = intArrayTag.getValue(0);
int y = intArrayTag.getValue(1);
int z = intArrayTag.getValue(2);
return this == OVERWORLD
? String.format("(%d, %d, %d)", x, y, z)
: String.format("%s (%d, %d, %d)", name, x, y, z);
return formatPosition(x, y, z);
}
return null;
@@ -568,14 +585,14 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
String formatPosition(int x, int y, int z) {
return this == OVERWORLD
? String.format("(%d, %d, %d)", x, y, z)
: String.format("%s (%d, %d, %d)", name, x, y, z);
? "(%d, %d, %d)".formatted(x, y, z)
: "%s (%d, %d, %d)".formatted(name, x, y, z);
}
String formatPosition(double x, double y, double z) {
return this == OVERWORLD
? String.format("(%.2f, %.2f, %.2f)", x, y, z)
: String.format("%s (%.2f, %.2f, %.2f)", name, x, y, z);
? "(%.2f, %.2f, %.2f)".formatted(x, y, z)
: "%s (%.2f, %.2f, %.2f)".formatted(name, x, y, z);
}
}
@@ -588,6 +605,15 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
return (d >= 0 && d < items.size()) ? items.get(d) : null;
}
static Difficulty of(String name) {
for (Difficulty d : items) if (d.name().toLowerCase(Locale.ROOT).equals(name)) return d;
return null;
}
String getTagStringValue() {
return this.name().toLowerCase(Locale.ROOT);
}
@Override
public String toString() {
return i18n("world.info.difficulty." + name().toLowerCase(Locale.ROOT));
@@ -653,7 +679,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
Files.deleteIfExists(output);
iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png"));
} catch (IOException e) {
LOG.warning("Failed to delete world icon " + e.getMessage());
LOG.warning("Failed to delete world icon ", e);
}
}
}
@@ -382,7 +382,7 @@ public final class WorldListPage extends ListPageBase<World> implements VersionP
IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup);
exportMenuItem.setDisable(worldLocked);
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> page.delete(world), popup);
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> page.delete(world), popup);
deleteMenuItem.setDisable(worldLocked);
IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup);
@@ -67,7 +67,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
private final TabHeader header = new TabHeader(transitionPane);
private final TabHeader.Tab<WorldInfoPage> worldInfoTab = new TabHeader.Tab<>("worldInfoPage");
private final TabHeader.Tab<WorldBackupsPage> worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage");
private final TabHeader.Tab<DatapackListPage> datapackTab = new TabHeader.Tab<>("datapackListPage");
private final TabHeader.Tab<DataPackListPage> dataPackTab = new TabHeader.Tab<>("dataPackListPage");
public WorldManagePage(World world, Profile profile, String instanceId) {
this.world = world;
@@ -78,15 +78,15 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
updateSessionLockChannel();
try {
this.world.reloadLevelDat();
this.world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world level.dat of world: " + this.world.getFile(), e);
LOG.warning("Can not load world data of world: " + this.world.getFile(), e);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail());
}
worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this));
worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this));
datapackTab.setNodeSupplier(() -> new DatapackListPage(this));
dataPackTab.setNodeSupplier(() -> new DataPackListPage(this));
this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true));
@@ -106,9 +106,9 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
public void refresh() {
updateSessionLockChannel();
try {
world.reloadLevelDat();
world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world level.dat of world: " + world.getFile(), e);
LOG.warning("Can not load world data of world: " + world.getFile(), e);
closePageForLoadingFail();
return;
}
@@ -218,9 +218,9 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL)
.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL);
if (getSkinnable().world.supportDatapacks()) {
getSkinnable().header.getTabs().add(getSkinnable().datapackTab);
tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL);
if (getSkinnable().world.supportDataPacks()) {
getSkinnable().header.getTabs().add(getSkinnable().dataPackTab);
tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL);
}
}
@@ -273,7 +273,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(getSkinnable().world, getSkinnable().sessionLockChannel), managePopup),
new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> WorldManageUIUtils.delete(getSkinnable().world, () -> getSkinnable().fireEvent(new PageCloseEvent()), getSkinnable().sessionLockChannel), managePopup),
new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> WorldManageUIUtils.delete(getSkinnable().world, () -> getSkinnable().fireEvent(new PageCloseEvent()), getSkinnable().sessionLockChannel), managePopup),
new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(getSkinnable().world, null), managePopup)
);
@@ -23,8 +23,9 @@ import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.InputDialogPane;
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.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -89,20 +90,9 @@ public final class WorldManageUIUtils {
public static void copyWorld(World world, Runnable runnable) {
Path worldPath = world.getFile();
Controllers.dialog(new InputDialogPane(
Controllers.prompt(
i18n("world.duplicate.prompt"),
"",
(result, handler) -> {
if (StringUtils.isBlank(result)) {
handler.reject(i18n("world.duplicate.failed.empty_name"));
return;
}
if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) {
handler.reject(i18n("world.duplicate.failed.invalid_name"));
return;
}
Path targetDir = worldPath.resolveSibling(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("world.duplicate.failed.already_exists"));
@@ -125,7 +115,7 @@ public final class WorldManageUIUtils {
}
})
.start();
}));
}, "", new RequiredValidator(), new Validator(i18n("world.duplicate.failed.invalid_name"), FileUtils::isNameValid));
}
public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException {
@@ -37,6 +37,7 @@ import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -66,7 +67,7 @@ public final class UpdateHandler {
performMigration();
} catch (IOException e) {
LOG.warning("Failed to perform migration", e);
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e));
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.MANUAL_UPDATE_URL) + "\n" + StringUtils.getStackTrace(e));
}
return true;
}
@@ -81,7 +82,7 @@ public final class UpdateHandler {
applyUpdate(Paths.get(args[1]));
} catch (IOException e) {
LOG.warning("Failed to apply update", e);
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e));
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.MANUAL_UPDATE_URL) + "\n" + StringUtils.getStackTrace(e));
}
return true;
}
@@ -174,15 +175,25 @@ public final class UpdateHandler {
startJava(updateTo, "--apply-to", self.toString());
}
private static void startJava(Path jar, String... appArgs) throws IOException {
public static void startJava(Path jar, String... appArgs) throws IOException {
List<String> commandline = new ArrayList<>();
commandline.add(JavaRuntime.getDefault().getBinary().toString());
for (Map.Entry<Object, Object> entry : System.getProperties().entrySet()) {
Object key = entry.getKey();
if (key instanceof String && ((String) key).startsWith("hmcl.")) {
commandline.add("-D" + key + "=" + entry.getValue());
try {
for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) {
if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) {
commandline.add(inputArgument);
}
}
} catch (Throwable ignored) {
// ManagementFactory not available
for (Map.Entry<Object, Object> entry : System.getProperties().entrySet()) {
if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) {
commandline.add("-D" + key + "=" + entry.getValue());
}
}
}
commandline.add("-jar");
commandline.add(jar.toAbsolutePath().toString());
commandline.addAll(Arrays.asList(appArgs));
@@ -0,0 +1,80 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import java.time.LocalDate;
import java.time.Month;
import java.util.List;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
/// April Fools' Day utilities.
///
/// This class provides methods to check if it is April Fools' Day or near April Fools' Day.
/// It also provides a method to check if April Fools is enabled.
///
/// @author Glavo
public final class AprilFools {
private static final boolean ENABLED;
private static final boolean SHOW_APRIL_FOOLS_SETTINGS;
static {
var date = LocalDate.now();
// Some countries/regions may oppose April Fools' Day for various reasons.
// Therefore, we use a regional whitelist to avoid risks.
// Currently, we have only listed a limited set of countries/regions for testing.
// We will investigate more countries/regions in the future to expand this list.
boolean supportedRegion = List.of(
"CN", "TW", "HK", "MO", "JP", "KR", "VN", "SG", "MY",
"ES", "DE", "FR", "GB", "RU", "UA", "US"
).contains(LocaleUtils.SYSTEM_DEFAULT.getCountry());
boolean aprilFoolsMode;
String value = System.getProperty("hmcl.april_fools", System.getenv("HMCL_APRIL_FOOLS"));
if ("true".equalsIgnoreCase(value))
aprilFoolsMode = true;
else if ("false".equalsIgnoreCase(value) || !supportedRegion)
aprilFoolsMode = false;
else
aprilFoolsMode = date.getMonth() == Month.APRIL && date.getDayOfMonth() == 1;
ENABLED = aprilFoolsMode && !config().isDisableAprilFools();
SHOW_APRIL_FOOLS_SETTINGS = aprilFoolsMode || supportedRegion && date.getMonth() == Month.MARCH && date.getDayOfMonth() > 30;
}
/// Whether April Fools settings should be shown.
///
/// This method returns true if April Fools settings should be shown.
public static boolean isShowAprilFoolsSettings() {
return SHOW_APRIL_FOOLS_SETTINGS;
}
/// Whether April Fools is enabled.
///
/// This method returns true if April Fools is enabled.
public static boolean isEnabled() {
return ENABLED;
}
private AprilFools() {
}
}
@@ -31,21 +31,27 @@ public final class ChunkBaseApp {
private static final GameVersionNumber MIN_END_CITY_VERSION = GameVersionNumber.asGameVersion("1.13");
private static final String[] SEED_MAP_GAME_VERSIONS = {
"1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21", "1.20",
"1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15", "1.14", "1.13",
"1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
"26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21",
"1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15", "1.14",
"1.13", "1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
};
public static final String[] STRONGHOLD_FINDER_GAME_VERSIONS = {
"1.20", "1.19.3", "1.19", "1.18", "1.16", "1.13", "1.9", "1.7"
"26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21",
"1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15", "1.14",
"1.13", "1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
};
public static final String[] NETHER_FORTRESS_GAME_VERSIONS = {
"1.18", "1.16", "1.7"
"26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21",
"1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15", "1.14",
"1.13", "1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
};
public static final String[] END_CITY_GAME_VERSIONS = {
"1.19", "1.13"
"26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21",
"1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15", "1.14",
"1.13"
};
public static boolean isSupported(@NotNull World world) {
@@ -22,6 +22,7 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -29,18 +30,21 @@ import java.util.concurrent.locks.ReentrantLock;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
/// @author Glavo
public final class FileSaver extends Thread {
private static final Pair<Path, String> SHUTDOWN = Pair.pair(null, null);
private static final BlockingQueue<Pair<Path, String>> queue = new LinkedBlockingQueue<>();
private static final BlockingQueue<Action> queue = new LinkedBlockingQueue<>();
private static final AtomicBoolean running = new AtomicBoolean(false);
private static final ReentrantLock runningLock = new ReentrantLock();
private static volatile boolean shutdown = false;
private static void addAction(Action action) {
queue.add(action);
if (running.compareAndSet(false, true)) {
new FileSaver().start();
}
}
private static void doSave(Map<Path, String> map) {
for (Map.Entry<Path, String> entry : map.entrySet()) {
saveSync(entry.getKey(), entry.getValue());
@@ -53,10 +57,7 @@ public final class FileSaver extends Thread {
ShutdownHook.ensureInstalled();
queue.add(Pair.pair(file, content));
if (running.compareAndSet(false, true)) {
new FileSaver().start();
}
addAction(new DoSave(file, content));
}
public static void saveSync(Path file, String content) {
@@ -70,7 +71,17 @@ public final class FileSaver extends Thread {
public static void shutdown() {
shutdown = true;
queue.add(SHUTDOWN);
queue.add(Shutdown.INSTANCE);
}
/// Wait for all saves to complete.
///
/// This method should be called before the [#shutdown()] method.
public static void waitForAllSaves() throws InterruptedException {
assert !shutdown;
Wait wait = new Wait();
addAction(wait);
wait.await();
}
private FileSaver() {
@@ -92,34 +103,46 @@ public final class FileSaver extends Thread {
runningLock.lock();
try {
HashMap<Path, String> map = new HashMap<>();
ArrayList<Pair<Path, String>> buffer = new ArrayList<>();
ArrayList<Action> buffer = new ArrayList<>();
ArrayList<Wait> waits = new ArrayList<>();
while (!stopped) {
if (shutdown) {
stopCurrentSaver();
} else {
Pair<Path, String> head = queue.poll(30, TimeUnit.SECONDS);
if (head == null || head == SHUTDOWN) {
stopCurrentSaver();
} else {
map.put(head.getKey(), head.getValue());
Action head = queue.poll(30, TimeUnit.SECONDS);
if (head instanceof DoSave save) {
map.put(save.file(), save.content());
//noinspection BusyWait
Thread.sleep(200); // Waiting for more changes
} else if (head instanceof Wait wait) {
waits.add(wait);
} else if (head == null || head instanceof Shutdown) {
// Shutdown or timeout
stopCurrentSaver();
}
}
while (queue.drainTo(buffer) > 0) {
for (Pair<Path, String> pair : buffer) {
if (pair == SHUTDOWN)
for (Action action : buffer) {
if (action instanceof DoSave save) {
map.put(save.file(), save.content());
} else if (action instanceof Wait wait) {
waits.add(wait);
} else if (action instanceof Shutdown) {
stopCurrentSaver();
else
map.put(pair.getKey(), pair.getValue());
}
}
buffer.clear();
}
doSave(map);
map.clear();
for (Wait wait : waits) {
wait.countDown();
}
waits.clear();
}
} catch (InterruptedException e) {
throw new AssertionError("This thread cannot be interrupted", e);
@@ -128,6 +151,28 @@ public final class FileSaver extends Thread {
}
}
private sealed interface Action {
}
private record DoSave(Path file, String content) implements Action {
}
private static final class Wait implements Action {
private final CountDownLatch latch = new CountDownLatch(1);
public void await() throws InterruptedException {
latch.await();
}
public void countDown() {
latch.countDown();
}
}
private enum Shutdown implements Action {
INSTANCE
}
private static final class ShutdownHook extends Thread {
static {
@@ -144,9 +189,10 @@ public final class FileSaver extends Thread {
runningLock.lock();
try {
HashMap<Path, String> map = new HashMap<>();
for (Pair<Path, String> pair : queue) {
if (pair != SHUTDOWN)
map.put(pair.getKey(), pair.getValue());
for (Action action : queue) {
if (action instanceof DoSave save) {
map.put(save.file(), save.content());
}
}
doSave(map);
} finally {
@@ -63,6 +63,16 @@ public final class NativePatcher {
});
}
// https://github.com/LWJGL/lwjgl3/issues/1111
public static boolean needPatchMemoryUtil(Version version, int javaVersion) {
return javaVersion >= 25 && javaVersion <= 26 && version.getLibraries().stream().anyMatch(library ->
"org.lwjgl".equals(library.getGroupId())
&& "lwjgl".equals(library.getArtifactId())
&& "3.4.1".equals(library.getVersion())
&& library.getClassifier() == null
);
}
public static Version patchNative(DefaultGameRepository repository,
Version version, String gameVersion,
JavaRuntime javaVersion,
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.util;
import javafx.beans.value.WritableValue;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -33,18 +34,20 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public abstract class RemoteImageLoader {
private final DownloadProvider downloadProvider;
private final Map<URI, WeakReference<Image>> cache = new HashMap<>();
private final Map<URI, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
private final WeakHashMap<WritableValue<Image>, URI> reverseLookup = new WeakHashMap<>();
public RemoteImageLoader() {
public RemoteImageLoader(DownloadProvider downloadProvider) {
this.downloadProvider = downloadProvider;
}
protected @Nullable Image getPlaceholder() {
return null;
}
protected abstract @NotNull Task<Image> createLoadTask(@NotNull URI uri);
protected abstract @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris);
@FXThread
public void load(@NotNull WritableValue<Image> writableValue, String url) {
@@ -82,7 +85,7 @@ public abstract class RemoteImageLoader {
}
}
createLoadTask(uri).whenComplete(Schedulers.javafx(), (result, exception) -> {
createLoadTask(downloadProvider.injectURLWithCandidates(url)).whenComplete(Schedulers.javafx(), (result, exception) -> {
Image image;
if (exception == null) {
image = result;
@@ -0,0 +1,45 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.io.JarUtils;
import java.io.IOException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class Restarter {
/// Restart the current application.
public static void restartSelf() throws IOException {
LOG.info("Restarting HMCL");
Path thisJar = JarUtils.thisJarPath();
if (thisJar == null) {
throw new IOException("Failed to find current HMCL location");
}
UpdateHandler.startJava(thisJar);
}
private Restarter() {
}
}
@@ -155,7 +155,32 @@ public class Translator_lzh extends Translator {
case "23w13a_or_b" -> "二三週一三暨";
case "24w14potato" -> "二四週一四芋";
case "25w14craftmine" -> "二五週一四礦";
default -> version;
default -> {
if (version.length() >= 6 && version.charAt(2) == 'w') {
Matcher matcher = Pattern.compile("(?<year>[0-9]{2})w(?<week>[0-9]{2})(?<suffix>.+)").matcher(version);
if (matcher.matches()) {
int year = Integer.parseInt(matcher.group("year"));
int week = Integer.parseInt(matcher.group("week"));
String suffix = matcher.group("suffix");
char suffixFirstChar = suffix.charAt(0);
StringBuilder builder = new StringBuilder();
appendDigitByDigit(builder, String.valueOf(year));
builder.append('週');
appendDigitByDigit(builder, String.valueOf(week));
if (suffix.length() == 1
&& suffixFirstChar >= 'a' && (suffixFirstChar - 'a') < TIAN_GAN.length)
builder.append(TIAN_GAN[suffixFirstChar - 'a']);
else
builder.append(suffix);
yield builder.toString();
}
}
yield version;
}
};
} else {
return gameVersion.toString();
+17 -7
View File
@@ -31,7 +31,7 @@
},
{
"title" : "jsoup",
"subtitle" : "Copyright © 2009 - 2024 Jonathan Hedley (https://jsoup.org/)\nLicensed under the MIT License.",
"subtitle" : "Copyright © 2009 - 2025 Jonathan Hedley (https://jsoup.org/)\nLicensed under the MIT License.",
"externalLink" : "https://jsoup.org/"
},
{
@@ -45,9 +45,9 @@
"externalLink" : "https://github.com/jenkinsci/constant-pool-scanner"
},
{
"title" : "OpenNBT",
"subtitle" : "Copyright © 2013-2021 Steveice10.\nLicensed under the MIT License.",
"externalLink" : "https://github.com/GeyserMC/OpenNBT"
"title" : "HelloNBT",
"subtitle" : "Copyright © 2026 Glavo.\nLicensed under the Apache 2.0 License.",
"externalLink" : "https://github.com/HMCL-dev/HelloNBT"
},
{
"title" : "minecraft-jfx-skin",
@@ -66,22 +66,32 @@
},
{
"title" : "Java Animated PNG",
"subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
"subtitle" : "Copyright © 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
"externalLink" : "https://github.com/aellerton/japng"
},
{
"title" : "fxsvgimage",
"subtitle" : "Copyright (c) 2021, 2022, 2025 Hervé Girod.\nLicensed under the BSD 3-clause License.",
"externalLink" : "https://github.com/hervegirod/fxsvgimage"
},
{
"title": "Terracotta",
"subtitle": "Copyright (C) 2025 Burning_TNT.\nAll rights reserved.",
"subtitle": "Copyright © 2025 Burning_TNT.\nLicensed under the AGPL 3.0 License.",
"externalLink": "https://github.com/burningtnt/Terracotta"
},
{
"title": "EasyTier",
"subtitle": "Copyright 2024-present Easytier Programme within The Commons Conservancy.\nLicensed under the LGPL 3.0 License.",
"subtitle": "Copyright © 2024-present Easytier Programme within The Commons Conservancy.\nLicensed under the LGPL 3.0 License.",
"externalLink": "https://github.com/EasyTier/EasyTier"
},
{
"title": "MonetFX",
"subtitle": "Copyright © 2025 Glavo.\nLicensed under the Apache 2.0 License.",
"externalLink": "https://github.com/Glavo/MonetFX"
},
{
"title": "LWJGL Unsafe Agent",
"subtitle": "Copyright © 2026 Glavo.\nLicensed under the Apache 2.0 License.",
"externalLink": "https://github.com/HMCL-dev/lwjgl-unsafe-agent"
}
]
+70 -93
View File
@@ -29,8 +29,8 @@
.scroll-bar .thumb {
-fx-fill: -monet-surface-tint;
-fx-arc-width: 0;
-fx-arc-height: 0;
-fx-arc-width: 8;
-fx-arc-height: 8;
}
.title-label {
@@ -113,28 +113,26 @@
.memory-label {
}
.memory-used {
-fx-background-color: -monet-primary;
.memory-status-bar {
-fx-opacity: 1;
}
.memory-used:disabled {
.memory-status-bar:disabled {
-fx-opacity: 0.4;
}
.memory-allocate {
-fx-background-color: -monet-primary-transparent-50;
.memory-status-bar .track {
-fx-background-color: -monet-secondary-container;
-fx-background-radius: 2;
-fx-background-insets: 0;
}
.memory-allocate:disabled {
-fx-opacity: 0.4;
.memory-status-bar .memory-used {
-fx-fill: -monet-primary;
}
.memory-total {
-fx-background-color: -monet-surface-container;
}
.memory-total:disabled {
-fx-opacity: 0.4;
.memory-status-bar .memory-allocate {
-fx-fill: -monet-primary-transparent-50;
}
.update-label {
@@ -445,6 +443,7 @@
-fx-max-height: 55px;
-fx-min-height: 55px;
-fx-background-color: -monet-primary-container;
-fx-opacity: 1; /* Override the opacity of disabled button */
-fx-cursor: hand;
}
@@ -526,7 +525,8 @@
}
.jfx-dialog-layout {
-fx-padding: 24.0px 24.0px 16.0px 24.0px;
-fx-background-radius: 4px;
-fx-padding: 24px 24px 16px 24px;
-fx-background-color: -monet-surface-container-high;
-fx-text-fill: -monet-on-surface;
}
@@ -569,6 +569,7 @@
}
.task-executor-dialog-layout {
-fx-background-radius: 4px;
-fx-background-color: -monet-surface-container-high;
-fx-text-fill: -monet-on-surface;
}
@@ -912,28 +913,22 @@
* *
*******************************************************************************/
.jfx-progress-bar > .track {
-fx-background-color: -monet-secondary-container;
}
.jfx-progress-bar > .bar,
.jfx-progress-bar:indeterminate > .bar{
.jfx-progress-bar > .bar {
-fx-background-color: -monet-primary;
-fx-padding: 1.5;
}
.jfx-progress-bar > .secondary-bar,
.jfx-progress-bar:indeterminate > .secondary-bar {
-fx-background-color: -monet-secondary;
-fx-padding: 2;
}
.jfx-progress-bar > .track,
.jfx-progress-bar > .bar {
-fx-background-radius: 0;
-fx-background-radius: 2;
-fx-background-insets: 0;
}
/*******************************************************************************
* *
* JFX Textfield *
@@ -1170,7 +1165,7 @@
-fx-background-insets: 0px;
-jfx-toggle-color: -monet-primary;
-jfx-toggle-line-color: -monet-primary-container;
-jfx-toggle-line-color: -monet-secondary-container;
-jfx-untoggle-color: -monet-outline;
-jfx-untoggle-line-color: -monet-surface-container-highest;
-jfx-size: 10;
@@ -1456,20 +1451,8 @@
-fx-stroke: -monet-primary-container;
}
.jfx-spinner:determinate .percentage {
-fx-fill: -monet-on-surface-variant;
}
.first-spinner {
-jfx-radius: 20;
}
.first-spinner .arc {
-fx-stroke-width: 5.0;
}
.small-spinner {
-jfx-radius: 10;
-jfx-radius: 9;
}
.small-spinner .arc {
@@ -1477,59 +1460,12 @@
}
.small-spinner-pane .jfx-spinner {
-jfx-radius: 10;
-jfx-radius: 9;
}
.small-spinner-pane .jfx-spinner .arc {
-fx-stroke-width: 3.0;
}
.second-spinner {
-jfx-radius: 30;
}
.second-spinner .arc {
-fx-stroke-width: 5.0;
}
.third-spinner {
-jfx-radius: 40;
}
.third-spinner .arc {
-fx-stroke-width: 5.0;
}
.fourth-spinner {
-jfx-radius: 50;
}
.fourth-spinner .arc {
-fx-stroke-width: 5.0;
}
.fifth-spinner {
-jfx-radius: 60;
}
.fifth-spinner .arc {
-fx-stroke-width: 5.0;
}
.sixth-spinner {
-jfx-radius: 70;
}
.sixth-spinner .arc {
-fx-stroke-width: 5.0;
}
.seventh-spinner {
-jfx-radius: 80;
}
.seventh-spinner .arc {
-fx-stroke-width: 5.0;
.spinner-pane .spinner-pane-content {
-fx-padding: 10px;
-fx-spacing: 10px;
}
/*******************************************************************************
@@ -1546,6 +1482,11 @@
-fx-max-width: 1000000000;
}
.jfx-combo-box .arrow {
-fx-background-color: -monet-on-surface-variant;
-fx-shape: "M12 15 7 10H17L12 15Z";
}
.jfx-combo-box .text {
-fx-fill: -monet-on-surface;
}
@@ -1561,6 +1502,7 @@
.combo-box-popup .list-view .jfx-list-cell {
-fx-background-color: -monet-surface-container;
-fx-text-fill: -monet-on-surface;
-fx-cursor: hand;
-fx-background-insets: 0.0;
}
@@ -1570,6 +1512,10 @@
-fx-text-fill: -monet-on-secondary-container;
}
.combo-box-popup .list-view .list-cell:hover > .jfx-rippler > StackPane {
-fx-background-color: -monet-secondary-container-transparent-50;
}
.jfx-combo-box-warning {
-jfx-focus-color: #D34336;
-jfx-unfocus-color: #D34336;
@@ -1634,6 +1580,21 @@
-fx-background-color: -monet-on-surface;
}
.custom-color-dialog .jfx-color-dialog-actions {
-fx-background-color: -monet-surface;
-fx-padding: 0.0 10.0 5.0 0.0;
-fx-alignment: center-right;
}
.custom-color-dialog .jfx-color-dialog-accept {
-fx-padding: 0.7em 0.8em;
}
.custom-color-dialog .jfx-color-dialog-cancel {
-fx-text-fill: -monet-on-surface-variant;
-fx-padding: 0.7em 0.8em;
}
/*******************************************************************************
* *
* Table View *
@@ -1941,6 +1902,10 @@
-fx-padding: 10 16 10 16;
}
.line-component .subtitle-label {
-fx-text-fill: -monet-on-surface-variant;
}
.line-component.no-padding .line-component-container {
-fx-padding: 0;
}
@@ -1970,14 +1935,26 @@
-fx-font-size: 13px;
}
.line-select-button .menu-container .title-label {
-fx-text-fill: -monet-on-surface;
}
.line-select-button .menu-container .subtitle-label {
-fx-text-fill: -monet-on-surface-variant;
}
.line-select-button .menu-container:selected .title-label {
-fx-text-fill: -monet-primary;
}
.line-select-button .menu-container:selected .subtitle-label {
-fx-text-fill: -monet-primary;
-fx-text-fill: -monet-primary-container;
}
.line-toggle-button .jfx-toggle-button {
-fx-padding: 0;
}
}
.update-pane:active .subtitle-label {
-fx-text-fill: -monet-tertiary;
}
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more