Compare commits

...
441 Commits
Author SHA1 Message Date
6375558591 [release/3.7] 优化陶瓦联机 (#4751)
https://github.com/HMCL-dev/HMCL/pull/4723
https://github.com/HMCL-dev/HMCL/pull/4750
https://github.com/HMCL-dev/HMCL/pull/4735

---------

Co-authored-by: Burning_TNT <pangyl08@163.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-03 20:14:05 +08:00
GlavoandWulian233 12b525fdc1 [release/3.7] 更新 Chunkbase 种子版本 (#4753)
https://github.com/HMCL-dev/HMCL/pull/4742

Co-authored-by: Wulian233 <1055917385@qq.com>
2025-11-02 22:14:36 +08:00
Glavo ca69dd8032 [release/3.7] 修复 Platform.FREEBSD_X86_64 架构错误的问题 (#4752)
https://github.com/HMCL-dev/HMCL/pull/4736
2025-11-02 22:14:06 +08:00
Glavo 95cbc6f9b0 [release/3.7] Update PLATFORM.md (#4732)
https://github.com/HMCL-dev/HMCL/pull/4717
2025-11-01 16:45:37 +08:00
Glavo d0145d53d7 [release/3.7] Fix #4720: 修复 JFXListViewSkin 创建大量平台线程的问题 (#4731)
https://github.com/HMCL-dev/HMCL/pull/4721
2025-10-28 20:26:22 +08:00
Glavo ee0ae6e4ec [release/3.7] 将官方群组入口重新移动到主界面 (#4730)
https://github.com/HMCL-dev/HMCL/pull/4727
2025-10-28 20:18:34 +08:00
GlavoandBurning_TNT 210ab077cb [release/3.7] Bump Terracotta to 0.3.12 (#4729)
https://github.com/HMCL-dev/HMCL/pull/4718
https://github.com/HMCL-dev/HMCL/pull/4724

---------

Co-authored-by: Burning_TNT <pangyl08@163.com>
2025-10-28 20:16:10 +08:00
GlavoandBurning_TNT 341c60b0de [release/3.7] 优化陶瓦联机 (#4708)
https://github.com/HMCL-dev/HMCL/pull/4682
https://github.com/HMCL-dev/HMCL/pull/4686
https://github.com/HMCL-dev/HMCL/pull/4687
https://github.com/HMCL-dev/HMCL/pull/4688
https://github.com/HMCL-dev/HMCL/pull/4700
https://github.com/HMCL-dev/HMCL/pull/4705

---------

Co-authored-by: Burning_TNT <pangyl08@163.com>
2025-10-21 15:06:10 +08:00
Glavo 69f19115ea Bump HMCLauncher to 3.7.0.1 (#4681) 2025-10-16 15:29:50 +08:00
Glavo ecb4eedd12 指定使用 UTF-8 编码编译 buildSrc 子项目 (#4677) 2025-10-15 21:11:00 +08:00
Glavo df6f35bc8e Fix #4670: 修复不在场景中的按钮尝试取消自身焦点时触发 NPE 的问题 (#4674) 2025-10-15 15:05:21 +08:00
Glavo 58d3780553 Fix #4663: 排序模组时应忽略大小写 (#4665) 2025-10-13 15:13:46 +08:00
Glavo 58b357d4b8 修复模组下载页面图标模糊的问题 (#4660) 2025-10-12 21:14:18 +08:00
辞庐andGlavo d69d27af57 修复导出游戏崩溃信息后弹窗没有正常弹出的问题 (#4624)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-12 21:08:42 +08:00
Glavo f9a39f1c90 优化对无法识别的模组的提示 (#4659) 2025-10-12 20:57:47 +08:00
3gf8jv4dv f8a4261480 chore(l10n): add preview update option translation to spanish (#4599) 2025-10-12 20:54:53 +08:00
Glavo 2c428faa95 修复无法识别部分旧版本 Forge 模组的问题 (#4658) 2025-10-12 20:42:55 +08:00
Glavo da91fe15df 更新陶瓦联机页面文本 (#4607) 2025-10-12 20:05:14 +08:00
辞庐 1df962884c Fix #4613 修复本地安装升级 Cleanroom 相关问题 (#4625) 2025-10-11 21:37:33 +08:00
Glavo 8ad5d83946 修复无法识别部分 Forge 模组信息的问题 (#4654) 2025-10-11 21:27:48 +08:00
Glavo 74878bda0c 对于安装了信雅互联的实例抑制对 Fabric 模组的警告 (#4653) 2025-10-11 20:57:53 +08:00
Glavo b32d3304b0 支持读取 META-INF/jarjar/metadata.json (#4652) 2025-10-11 20:45:27 +08:00
Glavo 60375c9632 Add net.neoforged.fml.startup.Client to FORGE_OPTIFINE_MAIN (#4649) 2025-10-11 20:06:14 +08:00
Glavo 33877691f8 使用 Gradle Task 检查 I18N 中文件的全角括号 (#4650) 2025-10-10 22:02:23 +08:00
3gf8jv4dv 109d6874ab Fix #4645: mc 1.21.9+ instances with neoforge do not show their icons (#4646) 2025-10-10 21:43:26 +08:00
Burning_TNT a225e7fac4 更改陶瓦联机反馈表入口展示方式 (#4623) 2025-10-10 21:43:08 +08:00
Glavo 7dad43570e Fix #4647: 修复总是将同时兼容 Forge 和 NeoForge 的模组识别为 Forge 模组的问题 (#4648) 2025-10-10 21:35:11 +08:00
Glavo 8a166266d8 添加 SupportedLocale#getTextDirection() (#4644) 2025-10-10 21:34:14 +08:00
Glavo c53fbd0efb Close #4640: 支持读取 Embedded-Dependencies-Mod (#4641) 2025-10-10 20:32:34 +08:00
Glavo 1e74567a22 在游戏下载界面提示版本支持状态 (#4471) 2025-10-10 16:45:18 +08:00
3gf8jv4dv 0b9616f460 chore(docs&l10n): adjust preview update option text (#4598) 2025-10-10 15:55:54 +08:00
3gf8jv4dv 0b89612ec5 chore(l10n): fix loader mismatch warning on chinese (traditional) (#4642) 2025-10-10 15:54:22 +08:00
3gf8jv4dv 642893d2e9 chore(docs&l10n): highlight support for cleanroom! (#4615) 2025-10-10 15:53:21 +08:00
Glavo 2e878e43cb 修复模组列表页面加载模组图标相关问题 (#4638)
1. 修复高分屏上模组图标模糊的问题;
2. 修复快速滚动时可能会显示其他模组的图标的问题;
3. 修复快速滚动时可能重复加载图标的问题。
2025-10-09 21:33:35 +08:00
Glavo c8edf4be62 优化模组中文译名展示方式 (#4636)
1. 隐藏不包含中文字符的译名
2. 过滤译名中的 Emoji
2025-10-09 16:34:33 +08:00
Glavo 96f16382c0 将模组中文译名中的小括号统一为半角括号 (#4635) 2025-10-09 15:49:27 +08:00
Glavo 11c45e0ab8 优化模组管理页面模组信息的展示方式 (#4621) 2025-10-09 15:21:28 +08:00
Glavo 08f6f973c0 修复颠倒英语中括号显示错误的问题 (#4631) 2025-10-09 15:19:12 +08:00
Glavo 35e63fc8c8 重构本地化支持 (#4632) 2025-10-09 15:18:45 +08:00
Zkitefly 23037b8afc 使用 subname 匹配模组翻译 (#3231) 2025-10-08 22:36:19 +08:00
Glavo 25323dc385 清理 ModTranslations (#4634) 2025-10-08 21:09:19 +08:00
Glavo 496b403eeb 使用 checktyle 检查对 toLowerCase()/toUpperCase() 的误用 (#4633) 2025-10-08 16:27:57 +08:00
Burning_TNT 7be00f2408 修复 Terracotta 和部分 MacOS 版本的兼容性 (#4619) 2025-10-07 23:07:53 +08:00
Glavo 08d7974e27 Update versions.txt to 1.21.10 (#4626) 2025-10-07 22:50:53 +08:00
辞庐 f16cbed5b4 Bump authlib-injector to 1.2.6 (#4617) 2025-10-07 22:46:03 +08:00
Glavo 0988dc53cd 优化模组加载器检测机制 (#4622) 2025-10-07 16:26:26 +08:00
Wulian233 f1c8791961 优化模组信息对话框 UI 宽高 (#4616) 2025-10-06 16:20:06 +08:00
Glavo 07e5ac9e0e 添加 ReleaseSchedule.md (#4606) 2025-10-05 20:36:17 +08:00
Burning_TNT c98dbe6694 Feature: Display feedback form when updating Terracotta. (#4611) 2025-10-05 20:35:49 +08:00
Burning_TNT 460134cc1e 隐藏空的 Terracotta 玩家列表 (#4609) 2025-10-05 20:12:51 +08:00
Burning_TNT 9dd117c454 Bump Terracotta to 0.3.10 (#4608)
https://github.com/burningtnt/Terracotta/compare/v0.3.9-rc.10...v0.3.10
2025-10-04 23:24:43 +08:00
Wulian233 c60af8675f 修复部分按钮点击后始终保持焦点状态的问题 (#4585) 2025-10-04 21:16:35 +08:00
Burning_TNT 411efbe774 在全局设置中储存是否曾向用户展示过陶瓦联机用户须知 (#4601) 2025-10-04 21:15:22 +08:00
Glavo 2e09a40b69 修复 FetchTask 不遵循代理设置的问题 (#4596) 2025-10-04 21:11:44 +08:00
Glavo ba47c2d10d Update badges (#4595) 2025-10-03 21:46:09 +08:00
Glavo fd90ca8afe Add README_en_Qabs.md (#4594) 2025-10-03 21:41:18 +08:00
ZkiteflyandGlavo dda30274a4 优化模组管理布局 (#3229)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-03 21:20:52 +08:00
Glavo 2b43ee24a1 Fix Debug_zh.md (#4593) 2025-10-03 21:02:49 +08:00
3gf8jv4dv 66f1e60433 fix(i18n): correct the language name in literary chinese (#4592)
Fix #4591
2025-10-03 20:02:14 +08:00
Burning_TNTand3gf8jv4dv 3c3e2b8cfc Terracotta | 陶瓦联机 (#4215)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-10-03 20:01:08 +08:00
Burning_TNTandGlavo 365bb089c5 Fix #4572: Use String::lines to handle line separators on various OS (#4575)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-03 19:59:43 +08:00
Glavo 87af44328b 添加“提前预览 HMCL 版本”选项 (#4223) 2025-10-03 19:56:59 +08:00
Glavo 8731faaac7 更新 Jenkins CI 链接 (#4584) 2025-10-02 21:57:44 +08:00
Glavo 5abc1f1f9d 修复“不自动添加 Java 虚拟机优化参数”选项未保存至配置文件的问题 (#4583) 2025-10-02 20:47:34 +08:00
Glavo 40b4ecd42a 从 synchronized 切换至 ReentrantLock (#4564) 2025-10-02 15:50:56 +08:00
Glavo e0f5c4d91e 优化 EventBus (#4565) 2025-10-02 15:38:03 +08:00
辞庐 35ecd58460 Fix #4556 : 修复在短时间内连续点击 OptionToggleButton 只会更改一次状态的问题 (#4568) 2025-10-02 15:31:20 +08:00
辞庐 f29c2bc202 Fix 4555: 修复外观 - 不透明度滑块 刻度线在不同滑块位置时排布不统一的问题 (#4571) 2025-10-02 15:25:52 +08:00
Glavo 361e928e73 支持从 WorkflowMultiBranchProject 中检查最新版本 (#4576) 2025-10-02 15:22:15 +08:00
Glavo 484b65b513 优化对颠倒的英语的支持 (#4577) 2025-10-02 15:21:03 +08:00
3gf8jv4dv 19f02e3967 chore: update versions.txt to 1.21.9 (#4566) 2025-10-01 13:08:43 +08:00
Glavo 495da6b6b0 更新 Jenkins CI 服务器的发布模型 (#4559) 2025-10-01 13:04:19 +08:00
Glavo 168c4ac708 支持通过环境变量控制 UI 缩放比例 (#4521) 2025-09-29 16:51:57 +08:00
Glavo 7e8a6679cc 将 EventManager 中的 synchronized 替换为 ReentrantLock (#4562) 2025-09-29 15:49:18 +08:00
Glavo e90d5185f6 增强 ArchiveFileTree (#4547) 2025-09-29 15:19:37 +08:00
Glavo 8a89baee09 使用 Gradle 获取更新信息 (#4560) 2025-09-28 20:29:07 +08:00
辞庐 7f4c5fc4a0 清理代码 (#4519) 2025-09-28 00:01:00 +08:00
Glavo 75789c774d 修复切换下载源后需要重启启动器才会生效的问题 (#4296) 2025-09-27 16:26:38 +08:00
Glavo f0a16b3d14 GitHub Action 按文件类型分别上传构建产物 (#4537) 2025-09-27 15:56:28 +08:00
Glavo 710e69751f 重构 FetchTask 以使用 HttpClient 和虚拟线程 (#4546) 2025-09-26 20:54:22 +08:00
Glavo 938d8647ce 删除 InvocationDispatcher 中的 synchronized 块 (#4550) 2025-09-26 20:44:34 +08:00
Glavo 4b4dd592c3 将 Schedulers.io() 切换至虚拟线程 (#4548) 2025-09-26 20:12:01 +08:00
Glavo 225dba7448 Bump JNA to 5.18.0 (#4549) 2025-09-26 17:06:01 +08:00
Glavo 5b1414428e 添加 Debug_zh.md (#4542) 2025-09-24 15:22:50 +08:00
Glavo cb467ae240 优化本地化支持 (#4539)
- 简化代码
- 修复错误用词
- 优化语言名称显示
2025-09-23 15:47:46 +08:00
rootwhois 9416c0cfb3 修复日志页面选中条目时 hover 背景闪烁两次的问题 (#4517)
fix #4245
2025-09-23 11:46:18 +08:00
3gf8jv4dv 16ea98c95a chore(l10n): revise chinese (traditional) and english (#4530) 2025-09-23 11:30:54 +08:00
3gf8jv4dv a8552a92d1 chore(l10n): add new translations to spanish (#4531) 2025-09-23 11:30:03 +08:00
Glavo 36d71bd14e 支持颠倒的英语 (#4527) 2025-09-22 22:07:06 +08:00
3gf8jv4dv cbe6554390 feat(l10n): add boot_es.properties (#4532) 2025-09-22 21:48:09 +08:00
Glavo 1b65e0b6d7 Fix #4526: 修复将空字符串注册为 Access Token 后日志输出异常的问题 (#4528) 2025-09-22 16:07:16 +08:00
Glavo bd9ae189f8 优化本地化支持 (#4525) 2025-09-21 15:15:22 +08:00
Glavo 867a04dfba 支持在“实例管理”按钮上通过滚动滚轮切换游戏实例 (#4509) 2025-09-20 20:15:11 +08:00
Wulian233 dd7703c7a9 修复删除世界时无法删除 zip 格式世界的问题 (#4500) 2025-09-20 17:14:19 +08:00
Glavo 756b95ca8c 添加本地化说明文档 (#4516) 2025-09-20 16:41:58 +08:00
rootwhoisandGlavo ac0a0c7604 修复 AdvancedListBox 在非必要的情况下显示滚动条的问题 (#4514)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-09-20 16:28:59 +08:00
Glavo 9ee611c0f6 Bump Gradle to 9.1.0 (#4520) 2025-09-20 15:55:20 +08:00
Glavo c38371f454 优化 Java 自动选择规则 (#4515) 2025-09-19 21:35:44 +08:00
Glavo eb3be8c465 在未启用 JIT 或硬件渲染加速的环境中默认禁用动画 (#4510) 2025-09-19 16:01:39 +08:00
Glavo e6ac07f62b 修复使用 JavaFX 25 时游戏下载界面布局错误的问题 (#4508) 2025-09-18 21:11:20 +08:00
Glavo a24fea4a95 支持禁止生成 JVM 优化参数 (#4507) 2025-09-18 20:04:25 +08:00
Glavo 8bd104c207 使用 Java 25 启动游戏时默认开启 Compact Object Headers (#4506) 2025-09-18 17:24:59 +08:00
Glavo a14a265986 Bump JavaFX to 21.0.8/25 (#4454) 2025-09-18 17:09:48 +08:00
Glavo 66524613b4 支持以类型安全的方式读写 Navigation#getSettings() (#4504) 2025-09-18 16:58:07 +08:00
Glavo a0568e34a8 清理残留的 java.io.File 用例 (#4503) 2025-09-18 15:26:03 +08:00
Glavo 45f7719096 Close #2987: 从 java.io.File 迁移至 NIO (#4502) 2025-09-17 21:41:17 +08:00
Glavo 77fadf5d28 将 org.jackhuang.hmcl.ui 从 java.io.File 迁移至 NIO (#4501) 2025-09-17 21:14:16 +08:00
Glavo e9d7d0a33c 将 org.jackhuang.hmcl.mod 从 java.io.File 迁移至 NIO (#4499) 2025-09-17 19:29:59 +08:00
Glavo 94994ffdb6 将 org.jackhuang.hmcl.setting 从 java.io.File 迁移至 NIO (#4498) 2025-09-17 18:39:34 +08:00
Glavo 26e274a89a 将 GameRepository 从 java.io.File 迁移至 NIO (#4496)
https://github.com/HMCL-dev/HMCL/issues/2987
2025-09-17 16:07:59 +08:00
Glavo 6a497df0d1 清理 org.jackhuang.hmcl.download.game (#4495)
1. 将 `java.io.File` 迁移至 NIO;
2. 将部分类迁移至 record。
2025-09-16 21:22:02 +08:00
Glavo fc1cce5e5a 删除 FileUtils 中基于 java.io.File 的工具方法 (#4493) 2025-09-16 20:52:44 +08:00
Glavo b328ed2cc9 Fix #4481: 修复 .hmcl 目录为跨驱动器符号链接时无法卸载 Java 的问题 (#4492) 2025-09-16 19:46:59 +08:00
Glavo 5a8d567bd7 将 isNameValid 方法移动至 FileUtils (#4491) 2025-09-16 16:11:38 +08:00
Glavo 5005343d00 缓存文件时遵循 Cache-Control 设置 (#4462) 2025-09-16 15:15:30 +08:00
Glavo e4bc8f48c5 Bump Gson to 2.13.2 (#4490) 2025-09-16 14:55:59 +08:00
ZkiteflyandGlavo 831a8a9bb8 fix(fmllib): Forge 1.5.2 安装时下载 fmllib,修复无法启动的问题 (#3976)
Fix #3975

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-09-16 14:38:44 +08:00
7c024473d6 世界管理新增删除世界功能 (#4263)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-09-15 23:06:30 +08:00
Glavo f594abc37d 更新 HMCLBoot 以使用地区中立的文件命名 (#4488) 2025-09-15 21:28:35 +08:00
Glavo b66e249591 在未启用 JIT 或硬件渲染加速的环境中启动时弹出提示 (#4487) 2025-09-15 21:26:47 +08:00
Glavo 15e490f313 Convert Pair to record (#4485) 2025-09-15 15:31:17 +08:00
Glavo 7ce59cac2c 清理 org.jackhuang.hmcl.util.logging (#4484) 2025-09-15 15:21:14 +08:00
Glavo a7178802f8 Create FileUtils#deleteDirectory(Path) (#4482) 2025-09-14 21:08:05 +08:00
Glavo 51954163d4 创建 RawPreservingProperty 接口 (#4464) 2025-09-14 21:06:32 +08:00
Wulian233 19072bd617 修复download.hint翻译条目未添加光影的说明的问题 (#4474) 2025-09-14 20:54:28 +08:00
Burning_TNT a39a23f938 Fix: 无法安装带有 Fabric 的非标准 MMC 整合包 (#4034)
- 修复无法安装带有 Fabric 的非标准 MMC 整合包的漏洞
- 在安装时写入当前安装器实现信息和启动器信息,以在后续调试时获得更多信息

Fix #4049
2025-09-14 20:50:53 +08:00
Glavo a10e9a04b1 创建 OSVersion 工具类 (#4480) 2025-09-14 20:13:43 +08:00
Wulian233 c2d5a07053 修复数据包管理在低于1.13的版本意外出现的问题 (#4428) 2025-09-13 20:33:22 +08:00
Glavo e7526e39bf 将 IconedTwoLineListItem#tags 的类型修改为 ObservableList<Label> (#4473) 2025-09-13 20:17:05 +08:00
Glavo 811b1fb5f4 创建 JsonSerializable 接口 (#4472) 2025-09-13 19:53:56 +08:00
Glavo 72ae46c93d 优化 FXUtils#openLink (#4470) 2025-09-13 14:37:53 +08:00
Glavo 6b6276e6e2 在 Label 文本溢出时安装 Tooltip (#4467) 2025-09-13 14:30:27 +08:00
Glavo 60ff34a587 修复滚动切换游戏实例/账户功能 (#4466) 2025-09-13 14:16:24 +08:00
Glavo 356960cc82 新增辅助方法 GameVersionNumber#isAtLeast(String, String) (#4458) 2025-09-12 22:12:39 +08:00
Glavo 8708e7f8b1 Fix #4463: 修复启用代理身份认证且未设置密码时启动器崩溃的问题 (#4465) 2025-09-12 21:31:59 +08:00
Glavo 962ee0d108 使用 java.util.HexFormat 替代 Hex (#4457) 2025-09-12 19:07:43 +08:00
Glavo cf5957ffd1 删除 AwtUtils (#4460) 2025-09-12 18:47:03 +08:00
Glavo f41b008b42 不再使用 OperatingSystemMXBean 中被弃用的方法 (#4459) 2025-09-12 18:46:08 +08:00
ENC_Euphony 228a1e4887 修复 popup 第一次无法计算高度的问题 & 应用此方法到世界管理界面 (#4412)
Close #4287
2025-09-12 00:01:22 +08:00
Glavo 913ce55d43 简化调用 JavaFX 12+ API 的代码 (#4456) 2025-09-11 19:25:49 +08:00
Glavo 9ded2e489d 优化对于 ISO 639-3 语言代码的支持 (#4455) 2025-09-11 19:13:17 +08:00
Glavo c65e4d83a2 简化 StyleSheets (#4453)
现在我们总是使用 JavaFX 17+,所以不再需要为不支持 `data` URI 的 JavaFX 提供 workaround。
2025-09-11 16:01:59 +08:00
Glavo 3184f91227 使用 Java 17 构建 HMCL (#4452) 2025-09-11 15:51:04 +08:00
Glavo 8eb17b6664 检测到配置文件由高版本启动器创建时禁止保存配置文件 (#4414) 2025-09-11 00:51:41 +08:00
Glavo 36ea1dac94 更新模组和整合包数据 (#4439) 2025-09-10 23:16:20 +08:00
Glavo 3fbe199aab Fix #4435: 修复 backgroundType 为 null 时启动器崩溃的问题 (#4437) 2025-09-10 22:50:39 +08:00
Glavo 8db8ade9c7 文言时间使用古代时辰格式 (#4413) 2025-09-09 20:23:13 +08:00
Glavo 41e004f4d5 删除不必要的 System.gc() 调用 (#4416) 2025-09-09 20:21:47 +08:00
Glavo 37ad445620 序列化不透明的 Color 时省略 Alpha 通道的值 (#4415) 2025-09-09 20:21:18 +08:00
Glavo 480f8b6890 使用宏自动从英文文档复制部分内容 (#4406) 2025-09-08 20:40:23 +08:00
Glavo 24fd2cf521 Fix #4409: 修复导出的历史日志文件为空的问题 (#4411) 2025-09-07 22:31:44 +08:00
Glavo 68dbe6945b 优化语言设置中简体/繁体中文选项的显示方式 (#4410) 2025-09-07 21:06:26 +08:00
Glavo c3352d2b73 优化游戏下载界面 (#4405) 2025-09-07 16:33:49 +08:00
小宇 55a7169e21 Fix MultiMC modpack update not applying configuration overrides (#4382) 2025-09-07 15:53:47 +08:00
Glavo 50080ab875 修复地支中的「丑」被误写为「醜」的问题 (#4404) 2025-09-07 15:51:21 +08:00
GlavoandCopilot 5697cbd251 使用 Gradle 自动更新文档内容 (#4403)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-07 15:47:17 +08:00
Glavo 86ad055f8f 修复 Config 的序列化问题 (#4402) 2025-09-06 17:46:43 +08:00
+1 4883c3897b 文言翻译优化 (#4387)
Co-authored-by: 0xis-cn <mat-ling@139.com>
Co-authored-by: BeiDou114514 <201355137+BeiDou114514@users.noreply.github.com>
Co-authored-by: Furina-de-zh-Hant <229449402+Furina-de-zh-Hant@users.noreply.github.com>
Co-authored-by: Kaohaaa <178006033+Kaohaaa@users.noreply.github.com>
Co-authored-by: 365KW <132805104+365KW@users.noreply.github.com>
Co-authored-by: Andypsl8 <115411501+Andypsl8@users.noreply.github.com>
Co-authored-by: Au0727 <125126631+Au0727@users.noreply.github.com>
Co-authored-by: threwd <88961950+threwd@users.noreply.github.com>
Co-authored-by: vinds476283 <222576327+vinds476283@users.noreply.github.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-09-06 17:38:11 +08:00
Glavo 09c17fc85b 优化 checkTranslations Task (#4396) 2025-09-06 15:41:38 +08:00
Glavo fe59f495f6 优化地区判断逻辑 (#4401) 2025-09-06 15:40:37 +08:00
Glavo 9a475c2e92 更新 PLATFORM.md (#4399) 2025-09-06 02:13:24 +08:00
Glavo 8cd8a389b9 将 README.md 移动至 docs 文件夹中 (#4398) 2025-09-06 02:06:30 +08:00
Glavo 9cc45bc66a 将 README.md 翻译至更多语言 (#4397) 2025-09-06 01:41:36 +08:00
Glavo ea0b65be2e 在 README 中声明对 FreeBSD 的支持 (#4395) 2025-09-05 21:13:38 +08:00
GlavoandCopilot f4a8e3d1a6 重构 Config (#4394)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-05 20:24:01 +08:00
Glavo 7576bf6a01 在 LocaleUtils 中添加更多工具方法 (#4390) 2025-09-04 15:51:17 +08:00
3gf8jv4dv 3d63523f25 chore(l10n): change translation from 版本 to 实例 in zh-cn for term INSTANCE (#4376) 2025-09-04 15:35:31 +08:00
Glavo dbfd6c7599 更新文档结构 (#4389) 2025-09-04 10:59:59 +08:00
Glavo ed6c7387bc Update microsoft_auth.html (#4388) 2025-09-03 23:48:52 +08:00
Glavo 6ab216dcb6 增强本地化支持 (#4379) 2025-09-03 21:55:20 +08:00
GlavoandCopilot b38076f847 Fix #4377: 修复离线账户换肤功能使用无效 CSL API 导致启动失败的问题 (#4386)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-03 20:16:10 +08:00
Glavo 16816d8490 修复 Linux 平台上无法正确为安装了 Cleanroom 的游戏版本选择 Java 的问题 (#4385) 2025-09-03 16:56:47 +08:00
e8813fe153 feat: Cleanroom 自动安装 (#4272)
Co-authored-by: Zkitefly <64117916+zkitefly@users.noreply.github.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-09-03 15:50:57 +08:00
Glavo ce24e4e622 修复连续点击两次返回按钮时会最大化窗口的问题 (#4384) 2025-09-03 15:34:59 +08:00
Glavo 10e5e7bc9c 优化 Minecraft Wiki 跳转功能 (#4372) 2025-09-01 23:13:20 +08:00
Glavo 28b222e3dd 优化中文适配 (#4370) 2025-09-01 21:01:59 +08:00
Glavo 86085031f6 优化自动切换游戏语言功能 (#4353) 2025-08-31 14:57:23 +08:00
Glavo 30f47704f1 更新文言文翻译 (#4364) 2025-08-31 12:18:34 +08:00
Glavo 82e0cd1ca1 Fix #3669: 修复首次打开子页面时切换动画丢失的问题 (#4365) 2025-08-31 12:11:13 +08:00
Wulian233andGlavo 5374127241 修改部分文言条目 (#4359)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-31 00:59:14 +08:00
Glavo df489c0cfe 添加乌克兰语支持 (#4363) 2025-08-31 00:48:22 +08:00
BANERandGlavo d83f6106f5 Add I18N_uk.properties (#4346)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-31 00:33:16 +08:00
Glavo b7361c8da4 优化语言设置 (#4362) 2025-08-31 00:18:25 +08:00
Glavo 73531dbf60 优化文言文翻译 (#4361) 2025-08-30 22:21:45 +08:00
Andypsl8 5af6858d4c 更新文言文翻译 (#4356) 2025-08-30 02:06:33 +08:00
Glavo 1a3b473356 Fix #3076: 修复无法识别部分简体中文环境的问题 (#4350) 2025-08-29 20:31:05 +08:00
Glavo 67e4dddda1 添加文言文支持 (#4348) 2025-08-29 14:42:10 +08:00
Glavo 55816032b7 启动游戏时隐藏 GameAssetDownloadTask (#4349) 2025-08-29 12:25:49 +08:00
Glavo b9fd0e50a7 为迁移至 Java 17 做准备 (#4311) 2025-08-28 20:43:03 +08:00
Glavo 71d54955ad 修复 TaskListPane.StatusChangeListener 持有 ListCell 的强引用的问题 (#4347) 2025-08-28 20:21:55 +08:00
Burning_TNTandGlavo d34dc82db6 Feature: Decompress legacy HMCL log files while exporting. (#4342)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-27 21:44:33 +08:00
Glavo 73214ad599 不对回环地址应用代理 (#4338) 2025-08-27 21:43:54 +08:00
Wulian233 1ddb73595d 修复世界管理页面不支持显示极限模式的问题 (#4321) 2025-08-27 21:41:52 +08:00
Glavo f9ee9c8592 更严格地检测文件名合法性 (#4341) 2025-08-27 20:10:52 +08:00
Burning_TNT 0fdfc945c3 Fix: IJ support (#4343) 2025-08-26 22:43:26 +08:00
YuChuXi 97219c951e 游戏崩溃窗口中增加对拍照的提醒 (#4294) 2025-08-26 21:54:42 +08:00
Glavo 64cc64095d 增加 TaskListPane 中任务条目之间的间距 (#4340) 2025-08-26 21:51:59 +08:00
辞庐 91a613d966 HintPane 使用新版 Bootstrap 调色盘 (#4323) 2025-08-24 22:30:14 +08:00
Glavo 06b7b1e574 Fix #4325: 修复未将 15w14a 识别为愚人节版本的问题 (#4327) 2025-08-24 22:27:49 +08:00
Glavo 933b7cab2f 将 URI 中的错误代理对编码为 U+FFFD (#4329) 2025-08-24 22:27:18 +08:00
Glavo 885e25d07a 更新文档 (#4328) 2025-08-24 20:33:13 +08:00
Glavo 421f9a3b2b 支持以字符串形式序列化 VersionSetting 中的枚举成员 (#4299) 2025-08-21 21:56:49 +08:00
Glavo e52cfb036e 优化 Task.getCaller() (#4308) 2025-08-21 20:53:19 +08:00
3gf8jv4dv 87a3a4ece3 chore(locale): add new translations for russian and spanish (#4304) 2025-08-21 20:45:40 +08:00
3gf8jv4dv b152e2bfab fix(locale): add missing i18n when installing an instance with quilt api (#4303) 2025-08-21 20:45:08 +08:00
Glavo c9c97a45b7 修复 TaskListPane 中任务总数显示错误的问题 (#4310) 2025-08-21 20:43:19 +08:00
Glavo cba4ba2799 删除无用的 debug 日志 (#4309) 2025-08-21 20:42:51 +08:00
3gf8jv4dv ab9eea3620 fix(locale): revise mcmod-info-mirror description on thanks list (#4305) 2025-08-21 20:42:22 +08:00
Glavoand3gf8jv4dv 96f2d011ca 优化 TaskListPane 的显示效果 (#4306)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-21 16:45:07 +08:00
Glavo 9ee55cd17d 修复 ModrinthModpackExportTask 名称未被翻译的问题 (#4301) 2025-08-20 21:47:56 +08:00
Glavo 07ee7867da 修复 VersionIconType 成员的排列顺序 (#4298) 2025-08-20 21:20:31 +08:00
Glavo 940d4f722c 修复 TaskListPane 可能出现横向滚动条的问题 (#4297) 2025-08-20 20:34:12 +08:00
Glavo af56096162 优化对于 IntelliJ IDEA 的支持 (#4270) 2025-08-20 20:33:03 +08:00
Glavo 0e12c1a132 Fix #4290: 在 macOS 上禁止最大化和全屏模式 (#4293) 2025-08-20 15:40:48 +08:00
Glavo ca20509cb1 优化 TaskListPane (#4292) 2025-08-20 00:32:40 +08:00
HenryxjhandGlavo 58c93ff349 修复 Windows 平台 GPU 信息可能显示为 "[B@xxx" 的问题 (#4288)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-19 21:33:10 +08:00
Glavo aab3b22290 修复 TaskListPane 未正确处理 StagesHintTask 的问题 (#4285) 2025-08-19 16:22:51 +08:00
Wulian233 6cf933741f 基于实际屏幕分辨率添加游戏窗口分辨率选项 (#4275) 2025-08-18 20:05:34 +08:00
Glavo 9d17834227 Fix #4281: 支持解析 Indev 版本号 (#4282) 2025-08-18 20:04:22 +08:00
Glavo 7805508d4c Fix #2676: 优化版本号排序规则 (#4276) 2025-08-18 16:24:03 +08:00
Wulian233 72f6c2faf3 修复 1.21.5 以上的地图跳转种子地图错误 (#4274) 2025-08-18 16:21:07 +08:00
Glavo 7035767828 修复 pending 类型的游戏版本被错误识别为愚人节版的问题 (#4273) 2025-08-16 16:08:58 +08:00
Wulian233andGlavo 13a2777c87 为 NeoForge 下载添加正式版/快照分类 (#4268)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-16 15:58:31 +08:00
Glavo a4f9438d9e 移除 Pack200 支持 (#4267) 2025-08-16 15:45:12 +08:00
Glavo 4445021d66 Fix #4061: 在 Windows 7/8/8.1 上使用 llvmpipe-loader 加载 mesa (#4224) 2025-08-14 23:07:23 +08:00
Glavo f6a3b76a0b 暂时禁用 MCIM 加速 (#4265) 2025-08-14 20:20:46 +08:00
Glavo 42bb602ff5 优化游戏下载页 (#4264) 2025-08-14 20:14:40 +08:00
Glavo 4547448718 优化游戏窗口大小选择功能 (#4234) 2025-08-14 16:32:14 +08:00
Glavo 10bda7e6f4 转义 URI 中的中括号和大括号 (#4261) 2025-08-14 16:16:43 +08:00
Glavo f239140055 Update amdgpu.ids (#4262) 2025-08-14 15:54:30 +08:00
Glavo 6f4b557f63 支持最大化和全屏模式 (#4260) 2025-08-14 15:10:02 +08:00
Glavo 5118ce4e62 Fix #4256: 修复游戏下载页面可能崩溃的问题 (#4257) 2025-08-13 16:14:26 +08:00
ZkiteflyandGlavo a705b7cf95 Fix 3955: 启动时不应补全服务端自动更新整合包中已禁用的 mod (#3956)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-13 16:03:53 +08:00
GlavoandCopilot 0d5d1ae4e6 将 authlib-injector 嵌入启动器 (#4246)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 15:37:09 +08:00
Glavo b57547415b 在未安装游戏版本时支持一键下载启动游戏 (#4248) 2025-08-13 15:36:53 +08:00
Wulian233andGlavo b60d889bf7 为愚人节分类添加图标 (#4249)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-13 15:17:47 +08:00
Glavo 2f463c1eda 在 ./.hmcl/java 中搜索 Java (#4179) 2025-08-11 20:59:30 +08:00
Wulian233 d1e006ed29 修复版本列表排序错误的问题 (#4244) 2025-08-11 20:57:05 +08:00
GlavoandCopilot ba58905c9c 优化游戏下载页版本分类功能 (#4141)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-11 16:52:03 +08:00
Glavo de221b9dae 修复非 Windows 平台上游戏进程的 user.home 可能被设置为 null 的问题 (#4241) 2025-08-11 16:08:27 +08:00
Glavo 0913c5e710 支持通过 MCIM 加速模组搜索 (#4225) 2025-08-11 16:06:59 +08:00
ZkiteflyandGlavo 41fd38d8d1 导出日志时附带最近日志 (#4051)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-10 21:55:01 +08:00
ENC_EuphonyandGlavo 7ec880852f 修复部分版本 wiki 链接不正确的问题 (#4239)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-10 21:41:44 +08:00
Glavo 9213b7fb2e Fix 4232: 修复初始化 CacheRepository 导致启动器崩溃的问题 (#4235) 2025-08-10 21:41:10 +08:00
Glavo 80f1367d9a Fix #4233: 修复 RemoteMod::getIntegrityCheck 未选择正确的算法的问题 (#4238) 2025-08-10 15:39:13 +08:00
Glavo f521a041a9 Bump Shadow Plugin to 9.0.1 (#4220) 2025-08-10 15:23:44 +08:00
be9efbe6ee 修复版本 Wiki 映射 (#4211)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-10 15:06:31 +08:00
辞庐and3gf8jv4dv 8f3c716ca7 Fix #4127: 修改整合包下载页面描述 (#4200)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-10 15:05:57 +08:00
Glavo 3cdddfa86d 在实例名称中包含非字母、数字、减号和下划线字符时弹出警告 (#4222) 2025-08-09 21:29:41 +08:00
Glavo b1d8edc72a FetchTask 启用透明 GZip 压缩 (#4219) 2025-08-08 16:24:48 +08:00
Glavo bbc546e2a0 创建 ByteArray 辅助类 (#4214) 2025-08-07 16:14:10 +08:00
Liu Wenyuan f621776cdd 修复 APNG Truecolour16Alpha 处理器 clone 方法返回类型错误的问题 (#4210) 2025-08-07 15:04:57 +08:00
ENC_Euphony 7ba9d9eadd 智能选择 popup 弹出位置 (#4203) 2025-08-07 15:01:18 +08:00
ZkiteflyandGlavo ac4c3a7501 模组管理支持搜索根据更多数据搜索内容 (#3230)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-06 21:02:36 +08:00
Glavo f95ebe2224 优化 GetTask 中 ByteArrayOutputStream 的初始大小 (#4209) 2025-08-06 20:43:51 +08:00
辞庐and3gf8jv4dv 7ee2c51b81 将 Apple M1 平台 改为 Apple Silicon 平台 (#4199)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-06 20:40:24 +08:00
Glavo 192c90816b 修复 #3716 与 main 的冲突 (#4208) 2025-08-06 20:24:46 +08:00
1a772e8c65 添加 Modrinth 整合包导出 (#3716)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-06 20:11:39 +08:00
3gf8jv4dv 95bed4712c chore(locale): port and fix translation in native directory path preview (#4197) 2025-08-06 16:51:29 +08:00
Glavo 2c6ceb3cb0 优化 data URI 支持 (#4206) 2025-08-06 16:48:37 +08:00
Glavo 3118c87c65 实验性支持 APNG 图片 (#4205) 2025-08-06 16:10:45 +08:00
ENC_Euphony f945a85441 Fix: 修复一些 typo (#4202) 2025-08-05 22:51:06 +08:00
辞庐 72ffbb4176 为弹窗文本添加平滑滚动 (#4201) 2025-08-05 22:50:47 +08:00
辞庐 77dc5dbe06 修复整合包格式不匹配提示未被正确格式化的问题 (#4176) 2025-08-04 20:32:42 +08:00
Glavo 0a1d6be7b7 优化 ModTranslations::loadFromResource (#4178) 2025-08-04 20:31:29 +08:00
Wulian233 bb3d03fc72 修复一些代码中的 typo (#4190) 2025-08-04 20:30:36 +08:00
Glavo a0ddaf6766 GetTask 应当遵循 Content-Type 中的字符集设置 (#4183) 2025-08-04 20:28:46 +08:00
辞庐andGlavo 0bd2adc3c6 Fix #4187: 修复 LWJGL 路径显示错误的问题 (#4192)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-04 20:24:19 +08:00
Glavo 77c3ef6ced 修复未正确处理 EnumBackgroundImage.TRANSLUCENT 的问题 (#4195) 2025-08-04 20:18:29 +08:00
3gf8jv4dv a66ddb9a20 chore(locale): port character length limit translation to es and ru (#4191) 2025-08-04 20:17:23 +08:00
3gf8jv4dv eef365b37e chore(locale): port background option translation to es and ru (#4194) 2025-08-04 17:10:19 +08:00
3gf8jv4dv 5cab603b6e chore(locale): port font anti-aliasing option translation to es and ru (#4193) 2025-08-04 17:09:54 +08:00
神奇的赵二 (OrzMiku) 6c5d07adad fix(#4166): 新增内存显示条禁用样式 (#4185) 2025-08-04 17:08:19 +08:00
3gf8jv4dv 1935a7021d fix(locale): corrent some typos in zh-tw (#4189) 2025-08-04 16:46:11 +08:00
3gf8jv4dv 31c46c963d fix(locale): corrent residual jvm terms in chinese (#4188) 2025-08-04 16:09:21 +08:00
吞云(TuYw)andGlavo 4bc6ffa0f0 支持修改主页背景的不透明度 (#3205)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-04 16:05:47 +08:00
Glavo b62659de62 支持纯色背景 (#4184) 2025-08-04 15:17:46 +08:00
Glavo 9a27c27b9a 支持使用 data URI 作为背景图片源 (#4182) 2025-08-03 20:37:02 +08:00
Glavo 74c647cc97 统一转义 URI 中的特殊字符 (#4181) 2025-08-03 19:34:34 +08:00
Glavo e2df8e25b1 修复无法找到不包含 release 文件的 Java 的问题 (#4180) 2025-08-03 19:20:11 +08:00
Glavo 9969dc60c5 优化 ArchiveFileTree (#4177) 2025-08-02 19:33:44 +08:00
辞庐andGlavo 6230b436ad Fix #4158 : 缺少 Microsoft OAuth App ID 时禁用微软账户的刷新选项 (#4160)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-02 18:15:03 +08:00
Glavo 3873459aaa 通过 x-bmclapi-hash 缓存资源 (#4169) 2025-08-02 16:04:26 +08:00
3gf8jv4dv 4ee3857427 fix(locale): add missing modrinth category i18n keys (#4149) 2025-08-02 15:43:12 +08:00
Glavo 2617dd3630 修复无法加载 WebP 图标的问题 (#4171) 2025-08-02 15:37:48 +08:00
辞庐 e0425102c8 fix: 无法从 BMCLAPI 下载 NeoForge 所有所需文件 (#4174) 2025-08-02 15:33:42 +08:00
辞庐 a470475c21 模组下载列表翻页后自动回到顶部 (#4170) 2025-08-02 15:22:47 +08:00
Glavo bcd73e6d15 修复下载缓存不生效的问题 (#4167) 2025-08-01 20:53:32 +08:00
Glavo 7d6f21dc3a Bump Gradle to 9.0.0 (#4168) 2025-08-01 19:26:11 +08:00
GlavoandCopilot 3eddfa23b7 优化 JsonUtils (#4161)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-31 20:41:27 +08:00
Glavo d5fa232704 忽略 %ProgramFiles%\Common Files\Oracle\Java 中的 Java (#4142) 2025-07-31 20:17:27 +08:00
Glavo c84afb3155 通过主页右下角弹出菜单切换版本后隐藏菜单 (#4163) 2025-07-31 19:41:43 +08:00
Glavo 401fef5774 创建 checkstyle task (#4162) 2025-07-31 19:41:02 +08:00
Glavo 9298fe48e1 支持通过环境变量强制使用 GPU 渲染和控制动画帧率 (#4164) 2025-07-31 19:39:45 +08:00
Glavo 7f51e0395c 遵循系统动画开关设置 (#4148) 2025-07-31 15:37:40 +08:00
辞庐 4f410fb8ad 修复“下载线程数”的指示器被提示覆盖的问题 (#4153) 2025-07-31 15:05:23 +08:00
Glavo 9bcf95287a Fix #4146: 修复导入异常格式的数据包时启动器崩溃的问题 (#4152) 2025-07-31 15:04:56 +08:00
3gf8jv4dv 5703377626 fix(locale): restore accidentally removed translations (#4155) 2025-07-31 15:04:41 +08:00
Glavo 3adb3a67e9 将代码中的 URL 替换为 URI (#4131) 2025-07-30 18:19:46 +08:00
辞庐 7918b333e6 README 添加 CurseForge API Key 覆写参数 (#4147) 2025-07-30 15:34:29 +08:00
Glavo 329b0e8f3a 在 Windows 平台优先使用注册表探测 GPU (#4145) 2025-07-30 15:32:42 +08:00
辞庐 3b17e0a897 优化主页公告关闭按钮 (#4064) 2025-07-29 23:13:06 +08:00
辞庐 e10f1dac20 修复日志窗口内结束游戏进程会结束启动的所有游戏的问题 (#4130) 2025-07-29 17:37:42 +08:00
27Onion NebellandGlavo 86c171545e Update version check logic to support version like "26-ea" (#4128)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-07-29 17:28:24 +08:00
ShulkerSakuraandGlavo c3a3881cfc Added 16 characters length detection of username. (#4088)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-07-28 16:09:46 +08:00
Glavo 7f04cdd6b1 在日志中记录更多系统信息 (#4126) 2025-07-28 15:58:13 +08:00
Wulian233 27361c65fe Fix #4063: 修复游戏版本列表错误地过滤掉部分版本的问题 (#4066) 2025-07-27 03:01:39 +08:00
Burning_TNT ffa7afcbab 优化 GitHub Action 工作流 (#4037) 2025-07-27 00:46:11 +08:00
8Mi_Yile 9aa1367823 更新 unlisted-versions-of-minecraft 镜像源地址 (#4120) 2025-07-27 00:43:00 +08:00
Roj234andGlavo acc96b95c8 修复多次启动游戏时内存泄露的问题 (#4121)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-07-27 00:40:22 +08:00
Glavo 46502875c5 简化 FileUtils (#4119) 2025-07-26 20:10:38 +08:00
Glavo 43b769923b 使用 readAllBytes 和 transferTo 简化代码 (#4115) 2025-07-26 19:49:05 +08:00
Zkitefly fe3fc0413c 更换 unlisted-versions-of-minecraft 镜像源 (#4095) 2025-07-26 01:29:12 +08:00
Glavo fd13088726 添加字体抗锯齿算法设置选项 (#4118) 2025-07-26 01:28:35 +08:00
3gf8jv4dv 195db2ee6d fix(locale): unify terminology usage of 'JVM' and 'Java' (#3690) 2025-07-25 17:05:05 +08:00
Glavo 96f68e1017 不再使用反射调用 Java 9~11 中引入的 API (#4114) 2025-07-25 17:02:37 +08:00
Glavo 24d3bbf4fe Bump HMCLauncher to 3.7.0.0 (#4113) 2025-07-25 16:09:20 +08:00
Glavoand3gf8jv4dv d60a923841 使用 Java 11 构建 HMCL (#4078)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-07-25 16:05:09 +08:00
Glavo f9dd7a1e64 修复 InstallersPage 意外地显示滚动条的问题 (#4097)
This reverts commit c669026312.
2025-07-19 22:36:30 +08:00
Glavo cbb83d4f15 修复无法使用部分日志字体的问题 (#4096) 2025-07-19 14:56:31 +08:00
Glavo bab863287d Update versions.txt to 1.21.8 (#4085) 2025-07-18 00:08:44 +08:00
Glavo e4c479754b Bump HMCLauncher to 3.6.0.6 (#4086) 2025-07-16 18:50:57 +08:00
Glavo 8ebf58e8fa Fix #4072: 修复无法使用部分中文字体的问题 (#4074) 2025-07-09 19:39:28 +08:00
辞庐 064b39d753 修复添加游戏文件夹页面缺少灰色背景的问题 (#4065) 2025-07-06 15:46:08 +08:00
Glavo c669026312 Fix #4067: 修复使用 JavaFX 25 启动时下载界面布局错位的问题 (#4069) 2025-07-05 22:29:07 +08:00
Glavo d9b95b28b2 Update PLATFORM.md (#4062) 2025-07-04 17:06:41 +08:00
Glavo d492884927 使用 Java 8 运行时禁止更新 (#4032) 2025-07-02 23:44:27 +08:00
3gf8jv4dv cf4fde5592 chore(locale): add shader category translation for russian and spanish (#4042) 2025-07-01 07:58:40 +08:00
Glavo fd959c41c3 Update versions.txt to 1.21.7 (#4035) 2025-07-01 07:52:54 +08:00
3gf8jv4dv e34037c055 chore(locale): add world management translation for russian and spanish (#4041)
Some terminology is consistent with Minecraft Wiki.
2025-06-29 07:13:44 +08:00
3gf8jv4dv 417c804f48 chore(locale): add tip translation of deprecated java ver for ru and es (#4043)
and fix a previous typo.
2025-06-29 07:12:34 +08:00
Glavo ea6eced136 更新 mod_data.txt (#4040) 2025-06-29 04:07:48 +08:00
Glavo 432465ab46 修复部分 Linux 环境中未正确选择默认字体的问题 (#4036) 2025-06-27 17:23:27 +08:00
Burning_TNT 03c95cd726 Fix: 压制 DiscoJavaDistribution 编译警告 (#4038)
* Fix: Suppress compile-time warning.

* Fix: typo

* Adjust F*** -> Fucking.
2025-06-27 17:16:40 +08:00
Burning_TNT 730febca8c Fix: MultiMC allows libraries with the same artifact descriptor string. (#4016) 2025-06-21 21:18:53 +08:00
Kieran Moy bc41499d1c fix typo (#4015) 2025-06-19 17:19:09 +08:00
3gf8jv4dv 60fae5cc88 chore: update versions.txt to 1.21.6 (#4011) 2025-06-18 23:58:12 +08:00
Glavo c11837baa4 修复光影下载页面中标签名称未翻译的问题 (#4007) 2025-06-17 09:07:47 +08:00
Glavo aaf1218536 修复 macOS 上无法读取 CPU 信息的问题 (#4009) 2025-06-16 11:48:05 +08:00
Glavo 88637b33bc 更新 GROUPS_URL (#4004) 2025-06-15 09:34:24 +08:00
Burning_TNT 462eea3539 Fix #3997: 模组搜索返回结果为空时下载界面异常 (#3998) 2025-06-14 21:23:39 +08:00
Burning_TNT d624086594 解析 MultiMC 整合包中未声明但在依赖链上的组件 (#4003) 2025-06-14 21:23:21 +08:00
Burning_TNT fa8b3a952b Fix #3999: 自动安装更改 MC 版本后无法点击安装键 (#4001) 2025-06-14 21:12:59 +08:00
Burning_TNT c70a6c3372 移除漏洞反馈 Issue 模板中的系统信息 (#4000) 2025-06-14 21:12:33 +08:00
Glavo 0616c37de7 创建世界管理页面 (#3991) 2025-06-13 14:28:51 +08:00
Burning_TNT b72056f7fe Fix #3994: 修复 Controller 未能读取 JAVA_VERSION_TIP 的问题 (#3995) 2025-06-12 20:49:55 +08:00
Glavo 3ebe8dce44 修复在 schematics 文件夹不存在的情况下,打开投影管理页面会抛出异常的问题 (#3996) 2025-06-12 20:46:04 +08:00
Glavo a9c28573b8 Bump HMCLauncher to 3.6.0.4 (#3992) 2025-06-12 12:38:45 +08:00
9bead7e34e 支持下载光影 (#2794)
* Clean up duplicated codes in DownloadPage.

* Remove useless codes. Provide support for downloading shaderpacks from Modrinth.

* Fix.

* Update HMCL/src/main/resources/assets/lang/I18N_ja.properties

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>

* Update HMCL/src/main/resources/assets/lang/I18N_ru.properties

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>

* Update HMCL/src/main/resources/assets/lang/I18N_es.properties

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>

* Update Icon

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-06-11 19:30:47 +08:00
3gf8jv4dvandGlavo d0b36ffa15 fix(i18n): localize MiB in VersionSettingsPage (#3984)
For Slavic languages (Russian, Ukrainian, etc.) they need translations.

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-06-11 01:43:05 +08:00
辞庐andGlavo ea081333e7 自动安装页面隐藏 mcbbs 项 (#3836)
* update

* CheckStyle

* Update InstallerListPage.java

CheckStyle

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-06-11 01:25:59 +08:00
3gf8jv4dv 2b99da430f chore(locale): add folder.schematics to spanish and russian (#3983) 2025-06-11 01:16:20 +08:00
Wulian233andGlavo f1ea316bad feat: 更新日志中仅显示本次更新内容 (#3974)
* 更新日志中仅显示本次更新内容

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-06-11 00:57:30 +08:00
3gf8jv4dv 9565c3bdfe fix(locale): fix units of information in russian (#3985) 2025-06-11 00:32:28 +08:00
3gf8jv4dv 710fd0f075 fix(locale): fix units of information in japanese (#3986) 2025-06-11 00:29:23 +08:00
Burning_TNT 7d7dbb9eb5 重构 MultiMC 整合包兼容功能 (#3547)
Fix #3540
Fix #3706
2025-06-08 20:59:44 +08:00
3gf8jv4dv 88a7243b32 chore(locale): clean up chinese text style (#3811)
- Clean up most of the punctuation to bring it up to standard.
- Fine-tune some of the text wording.
2025-06-08 20:29:43 +08:00
Glavo a0e9a20782 优化 JavaVersionConstraint (#3981) 2025-06-08 20:21:05 +08:00
Glavo 8889cca878 使用独立线程保存设置 (#3929) 2025-06-08 12:33:13 +08:00
Glavo bc911b95a0 Bump HMCLauncher to 3.6.0.3 (#3979) 2025-06-08 10:01:54 +08:00
3gf8jv4dv a46991bd95 chore(i18n): unify reveal button i18n (#3965)
- Unify i18n for related buttons in other pages
to `reveal.in_file_manager`.
- Add translation of `reveal.in_file_manager` for
Spanish, Japanese, and Russian.
2025-06-08 09:49:32 +08:00
3gf8jv4dv 395823c2b5 chore(locale): revise settings.launcher.launcher_log.reveal (#3966)
For various languages. Add translation for Japanese.
2025-06-08 09:48:19 +08:00
3gf8jv4dv 9769cf3996 chore(locale): add translation of "ban query" to spanish and russian (#3970)
and revise english.
2025-06-08 09:46:41 +08:00
Glavo ee652f6f31 优化原理图管理功能 (#3978) 2025-06-08 09:45:50 +08:00
Glavo d317844b28 修复 Java 管理页面无法添加 IKVM 的问题 (#3968) 2025-06-08 09:45:23 +08:00
3gf8jv4dv d9d32eab1d fix(ui): refine PromptDialogPane (#3693)
- Limit the dialog width to avoid too long a width
- Avoid accidental blank line in the information box (That blue bg box)
2025-06-06 21:56:40 +08:00
3gf8jv4dv 24bb630afa fix(locale): correct zh-TW translation of "add" (#3964)
Another one of the previous mistakes.

Glossary:
- en-US: add
- zh-CN: 添加
- zh-TW: 新增
2025-06-06 21:54:34 +08:00
3gf8jv4dv 2359ad9ed7 chore(locale): add schematic translation to spanish and russian (#3972)
and revise english.

The relevant terminology is consistent with the Litematica mod.
2025-06-06 21:05:58 +08:00
辞庐andGlavo 17abfc1a55 添加Xbox封禁相关提示 (#3860)
---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-05-31 22:53:17 +08:00
Glavo caa98c8956 新增“原理图管理”界面 (#3951) 2025-05-31 21:53:16 +08:00
General_K1ng 6124b6711f 在切换目录时确保主页版本列表切换菜单正确更新 (#3949) 2025-05-31 21:48:24 +08:00
Glavo 3b3cc54cb5 Bump HMCLauncher to 3.6.0.2 (#3953) 2025-05-31 16:36:23 +08:00
Glavo 45a40ee675 优化 Linux 平台硬件检测 (#3946) 2025-05-31 11:38:28 +08:00
Glavo 4a75f1d462 修复通过 fc-match 获取字体时未正确处理 TTC 文件的问题 (#3950) 2025-05-30 22:33:19 +08:00
Glavo e0805fc25f Fix #3940: 修复未正确解析 IPv6 服务器地址的问题 (#3942) 2025-05-29 19:34:53 +08:00
General_K1ng f223d2bc41 fix(ui): 修复版本列表为空时显示的问题 (#3944) 2025-05-29 19:26:42 +08:00
3gf8jv4dv 5ab75b6a84 chore(locale): add translations of new text for spanish and russian (#3941) 2025-05-26 21:47:44 +08:00
Glavoand3gf8jv4dv 362969db95 使用 Java 8 启动启动器时弹出弃用警告 (#3938)
* 使用 Java 8 启动启动器时弹出弃用警告

* Update HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>

* Update HMCL/src/main/resources/assets/lang/I18N.properties

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>

* Update HMCL/src/main/resources/assets/lang/I18N_zh.properties

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>

---------

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-05-26 09:48:55 +08:00
Glavo fb37e3b0df 修复导出启动器时未正确从 .hmcl/background 中查找壁纸的问题 (#3936) 2025-05-25 13:16:40 +08:00
Glavo ad312a61fe 清理高通骁龙 SoC 名称 (#3935) 2025-05-25 13:05:54 +08:00
Glavo 58a4700a46 创建 WindowsVersion 工具类 (#3932) 2025-05-23 21:11:35 +08:00
Glavo 68c8a30bd7 导出整合包时将启动器配置集中存放在 .hmcl 文件夹中 (#3930) 2025-05-23 18:08:57 +08:00
Glavo bb37e3fafa Use StringUtils.isASCII to check if a string contains non-ASCII characters (#3931) 2025-05-23 00:25:28 +08:00
Glavo 942f7b703e 清理 CPU 名称 (#3928) 2025-05-21 14:22:27 +08:00
Burning_TNTandGlavo 267da46050 将日志 caller 信息中的 'org.jackhuang.hmcl.' 缩短为 '@.' (#3919)
* Shorten 'org.jackhuang.hmcl.' in logs to @.

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-05-21 00:08:41 +08:00
Glavo 7c40dca3ea Bump JavaFX to 17.0.15/24.0.1 (#3927) 2025-05-20 23:55:24 +08:00
Glavo 48e3f79965 将 OS X 重命名为 macOS (#3878) 2025-05-20 19:34:35 +08:00
Glavo c5f56eb5c4 清理 OperatingSystem (#3925) 2025-05-20 19:03:48 +08:00
Burning_TNT 0900ad3e5a Fix #3757: 切换游戏文件夹后,全局游戏设置应重新读取设置 (#3818) 2025-05-20 13:41:25 +08:00
Glavo d347b0c6cb 优化系统版本检测 (#3924) 2025-05-20 13:33:57 +08:00
Glavo 2f3bf61d94 调用 PowerShell 时添加 NoProfile 参数 (#3923) 2025-05-20 12:54:04 +08:00
3gf8jv4dv 5479607597 fix(i18n): refine tasklistpane i18n (#3600)
The current TaskListPane may not look different for Chinese users, but
the grammar is not quite right for English users.

My previous series of PRs did not address this aspect due to the
associated hard coding of the i18n key.

I believe that revisions in this aspect can further improve the
experience of Indo-European speakers.

All languages except Japanese are ready for this PR.
2025-05-20 11:53:01 +08:00
Glavo 4291ae5f16 在 Linux 平台通过 fc-match 查询默认字体 (#3922) 2025-05-20 00:25:56 +08:00
e74yp8andGlavo 1ac677365b Fix #3865 游戏版本名称在游戏版本更换时报红 (#3874)
---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-05-19 12:00:16 +08:00
Glavo b079e50916 修复启动器在部分 Linux 发行版上无法正确识别用户地区的问题 (#3921) 2025-05-19 11:06:45 +08:00
e74yp8 f986df9654 Fix #3896: 下载界面对于多行信息显示错误 (#3901) 2025-05-18 23:11:34 +08:00
RainChanandGlavo c576c48f9d Fix #3852: 修复无法一键更新整合包的问题 (#3888)
* Fix #3852 修复无法一键更新整合包

* 删除未使用的import

* fix checkstyle

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-05-17 07:54:13 +08:00
Glavo 5bb035c884 在非 x86 平台检测 CPU 物理核心数 (#3917) 2025-05-17 00:20:17 +08:00
Glavo 973b7a9716 启动时在日志中记录 CPU 信息 (#3914) 2025-05-16 14:50:43 +08:00
Glavo 6e05b5ee58 使用 JNA 查询注册表 (#3913) 2025-05-14 22:18:02 +08:00
Glavo 76ed9353bd 重构 Theme (#3910)
* update

* update

* update

* update

* update

* update
2025-05-14 14:18:50 +08:00
Glavo 9c0f823705 优化代理设置 (#3876) 2025-05-13 16:39:43 +08:00
e74yp8 b69602b735 Fix #3218: 修复复制游戏版本里的“复制存档”选项失效问题 (#3868) 2025-05-12 23:21:20 +08:00
3gf8jv4dv 7cdf4504d1 fix(locale): add i18n key curse.category.7669 (#3906) 2025-05-11 23:07:29 +08:00
Burning_TNT d95efc4cdc Use org.intellij.lang.annotations.Language (#3907) 2025-05-11 23:07:06 +08:00
Glavo 48ca2d6ee6 在 SystemInfo 中采集内存信息 (#3903) 2025-05-10 12:06:11 +08:00
Glavo 1da98d6d67 启动时在日志中记录当前平台的所有 GPU (#3893) 2025-05-09 10:08:08 +08:00
Glavo 06a89dd6a9 尝试从 HMCL.jar 所在文件夹加载默认字体 (#3899) 2025-05-08 22:36:33 +08:00
3gf8jv4dv 4eee79da17 chore(locale): revise invalid username prompt for spanish and russian (#3897) 2025-05-06 21:48:41 +08:00
Glavo 9788338b65 更新“关于”页面 (#3895) 2025-05-06 21:48:05 +08:00
3gf8jv4dv fb8da249b6 chore(locale): add/revise prompt about create hmcl dir failure (#3879) 2025-05-04 23:14:53 +08:00
Glavo 2d8b1df465 在 Windows 平台通过 GeoID 检测用户所处国家/区域 (#3891) 2025-05-04 23:13:40 +08:00
Glavo 1ab7ab0750 使用 JNA 调用本机 API (#3890) 2025-05-04 22:31:50 +08:00
3gf8jv4dv f283254ca0 fix(locale): revise version.empty translation (#3812)
Revise `.launch` and `.hint` to make text match the actual situation.
2025-05-04 09:31:35 +08:00
Wulian233andGlavo 2ea96fdaa7 Fix #3864 游戏内容下载的分页按钮不重置 (#3866)
* Fix #3864

* Update DownloadListPage.java

* Update DownloadListPage.java

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-05-03 04:45:47 +08:00
Glavo b32d3c354a Move authlib-injector.jar to .hmcl/dependencies/universal (#3886) 2025-05-03 04:45:25 +08:00
e74yp8 cd57b163a3 Fix #3851: 当用户名为中文名时,离线账户 - LittleSkin 换肤 功能失效 (#3883) 2025-05-02 22:21:55 +08:00
Glavo cc5484f9ed Fix #3881: 打开启动器时应当创建 HMCL_GLOBAL_DIRECTORY (#3882) 2025-05-01 11:59:34 +08:00
Glavo bfa9c1a697 修复启动器未压缩日志的问题 (#3877) 2025-04-30 14:56:03 +08:00
Glavo 9a37d412a3 使用工作路径下的 .hmcl 文件夹存放所有配置文件 (#3875) 2025-04-30 14:50:51 +08:00
Glavo dc5ebcf64d 使用 libs.versions.toml 管理依赖版本 (#3873) 2025-04-29 13:41:45 +08:00
Glavo 038f56b048 Bump Shadow Plugin to 8.3.6 (#3871) 2025-04-29 10:13:02 +08:00
Glavo 528f79e008 使用 Java 11 构建 buildSrc (#3870) 2025-04-29 10:04:29 +08:00
Glavo 75d0ec34f5 将构建项目所需的最低 JDK 版本升级至 11 (#3805) 2025-04-29 09:52:06 +08:00
Glavo d70712a18f Fix #3546: 修复游戏崩溃窗口乱码的问题 (#3867) 2025-04-27 22:15:30 +08:00
Glavo 05c5b53c52 自动探测日志文件编码 (#3863) 2025-04-27 20:39:49 +08:00
Glavo e98c7dfef1 Fix #3400: 逐行过滤日志中的 token (#3856) 2025-04-27 15:41:09 +08:00
3gf8jv4dv 8ba0e16dd9 chore(locale): add new translations for russian and spanish (#3838)
For relevant text of offline account restrictions.
2025-04-26 16:40:12 +08:00
Glavo 77397f6722 优化离线账户中包含非 ASCII 字符时的提示 (#3853) 2025-04-26 13:19:38 +08:00
Glavo c9ba789b7c Bump Gradle to 8.14 (#3857) 2025-04-26 00:05:20 +08:00
Glavo 91135b961e 右键主页“启动游戏”按钮时展开版本列表菜单 (#3854) 2025-04-25 00:31:52 +08:00
3gf8jv4dvandGlavo 435790aa03 fix(i18n): show installable translations according to different styles (#3624)
* fix(i18n): show installable translations according to different styles

when install a new instance (安装新游戏): Do not install (不安装)
on Edit Instance → Loaders (自动安装页面): Not installed (未安装)

resolves https://github.com/HMCL-dev/HMCL/issues/3297

* update

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-04-25 00:14:45 +08:00
Glavo c252d7027e 更新 Issue 模板 (#3847) 2025-04-22 23:59:24 +08:00
Glavo 9d0c8da310 拆分 HMCLauncher 至独立项目 (#3840) 2025-04-20 11:52:29 +08:00
Burning_TNTandGlavo 84b3312ebb Fix #3825: 修复 NeoForge 愚人节版本无法正常解析的问题 (#3826)
* Fix #2825.

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-04-19 11:49:42 +08:00
Burning_TNTandGlavo f351d1d133 Close #3829: 将模组/整合包/资源包的默认下载源调整为 Modrinth (#3830)
* Close #3829: Use Modrinth as default download source.

* Update HMCLLocalizedDownloadListPage.java

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2025-04-18 22:49:02 +08:00
Glavo 5ca1120223 Bump jsoup to 1.19.1 (#3835) 2025-04-18 22:42:10 +08:00
Glavo 6aa4019221 Bump Gson to 2.13.0 (#3834) 2025-04-18 22:35:05 +08:00
Glavo 73fb8bda10 优化默认 JVM 参数 (#3824) 2025-04-15 03:46:33 +08:00
Glavo cc16f84992 默认限制离线账户功能 (#3823) 2025-04-14 20:34:01 +08:00
辞庐 b4945a150e 统一导出和导入整合包页面样式 (#3822) 2025-04-13 22:34:22 +08:00
633 changed files with 44997 additions and 11793 deletions

No files matched your search

+1
View File
@@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false
ij_jspx_keep_indents_on_empty_lines = false
[{*.markdown,*.md}]
max_line_length = 200
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
+1 -11
View File
@@ -20,16 +20,6 @@ body:
* Your launcher is the **latest nightly build**. You can click [here](https://zkitefly.github.io/HMCL-Snapshot-Update/en) to download the latest nightly build.
If your issue does not fall into the above two categories, you can choose another type of issue or directly go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help.
- type: input
id: platform
attributes:
label: 平台 | Platform
description: |
请输入你遇到问题的平台。
Please enter the platform on which you encountered the bug.
placeholder: e.g. Windows 11
validations:
required: true
- type: textarea
id: bug-report
attributes:
@@ -56,4 +46,4 @@ body:
If your launcher does not crash, please DO NOT EXIT your launcher, click "Export Launcher Logs" in the "Settings → General → Debug" of the launcher, and drag the exported log to the following input field.
**ATTENTION: The crash report or log file is the key to resolving the bug. Please upload them!**
validations:
required: true
required: true
+11 -4
View File
@@ -9,12 +9,12 @@ body:
请确认 Issues 列表无重复的项目。
Please make sure that no duplicate issues have already been submitted.
- type: textarea
id: description
id: summary
attributes:
label: 描述 | Description
label: 概述 | Summary
description: |
请详细描述你想加入的新功能。
Please describe the new feature in detail.
请介绍你想加入的新功能。
Please describe the new feature.
validations:
required: true
- type: textarea
@@ -26,3 +26,10 @@ body:
Please describe why you want to add the feature or enhancement to HMCL.
validations:
required: true
- type: textarea
id: description
attributes:
label: 详情 | Description
description: |
在这里可以补充描述该功能的具体实现方式或建议。(可选)
Describe implementation details or suggestions here. (Optional)
-46
View File
@@ -1,46 +0,0 @@
name: Build HMCLauncher
on:
push:
paths:
- 'HMCLauncher/**'
- '.github/workflows/build-launcher.yml'
pull_request:
paths:
- 'HMCLauncher/**'
- '.github/workflows/build-launcher.yml'
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v2
with:
msbuild-architecture: x86
- name: Build HMCLauncher
run: msbuild /p:Configuration=Release /t:Rebuild /verbosity:detailed .\HMCLauncher\
- name: Copy HMCLauncher to assets
run: Copy-Item .\HMCLauncher\Release\HMCLauncher.exe -Destination .\HMCL\src\main\resources\assets\HMCLauncher.exe
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '11'
java-package: 'jdk+fx'
- name: Build with Gradle
run: .\gradlew makeExecutables --no-daemon
env:
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=$("${{ github.sha }}".SubString(0, 7))" >> $env:GITHUB_ENV
- name: Copy HMCLauncher to libs
run: Copy-Item .\HMCLauncher\Release\HMCLauncher.exe -Destination .\HMCL\build\libs\HMCLauncher.exe
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: HMCLauncher-${{ env.SHORT_SHA }}
path: HMCL/build/libs/*.exe
@@ -1,11 +1,13 @@
name: Check Translations
name: Check Codes
on:
push:
paths:
- '**.java'
- '**.properties'
pull_request:
paths:
- '**.java'
- '**.properties'
jobs:
@@ -13,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '11'
java-version: '17'
java-package: 'jdk+fx'
- name: Check style test
run: ./gradlew checkTranslations --no-daemon
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
-25
View File
@@ -1,25 +0,0 @@
name: Check Style
on:
push:
paths:
- '**.java'
pull_request:
paths:
- '**.java'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '11'
java-package: 'jdk+fx'
- name: Check style main
run: ./gradlew checkstyleMain --no-daemon
- name: Check style test
run: ./gradlew checkstyleTest --no-daemon
+33 -55
View File
@@ -2,8 +2,8 @@ name: Check Update
on:
workflow_dispatch:
schedule:
- cron: '30 * * * *'
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
@@ -13,39 +13,27 @@ jobs:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=v$HMCL_VERSION" >> $GITHUB_ENV
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -78,39 +66,28 @@ jobs:
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=release-$HMCL_VERSION" >> $GITHUB_ENV
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
- name: Check for existing tags
run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/artifact/HMCL/build/libs
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -122,9 +99,9 @@ jobs:
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
@@ -139,3 +116,4 @@ jobs:
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
+21 -6
View File
@@ -5,7 +5,6 @@ on:
pull_request:
paths-ignore:
- '**.md'
- 'HMCLauncher/**'
jobs:
build:
@@ -14,11 +13,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up JDK 8
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 8
java-version: '17'
java-package: 'jdk+fx'
- name: Build with Gradle
run: ./gradlew build --no-daemon
@@ -28,8 +27,24 @@ jobs:
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload Artifacts
- name: Upload JAR
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}
path: HMCL/build/libs
name: HMCL-${{ env.SHORT_SHA }}-jar
path: |
HMCL/build/libs/HMCL-*.jar
HMCL/build/libs/HMCL-*.jar.sha256
- name: Upload EXE
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: |
HMCL/build/libs/HMCL-*.exe
HMCL/build/libs/HMCL-*.exe.sha256
- name: Upload SH
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
+10 -2
View File
@@ -2,6 +2,7 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
*.hprof
.gradle
@@ -13,13 +14,18 @@ hs_err_pid*
/externalgames
NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
/.java/
/.local/
/.cache/
# gradle build
/build/
/HMCL/build/
/HMCLCore/build/
/HMCLTransformerDiscoveryService/build/
/HMCLBoot/build/
/minecraft/libraries/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/HMCLMultiMCBootstrap/build/
/buildSrc/build/
# idea
@@ -28,12 +34,14 @@ minecraft-exported-crash-info*
/HMCL/out/
/HMCLCore/out/
/minecraft/libraries/HMCLTransformerDiscoveryService/out/
/minecraft/libraries/HMCLMultiMCBootstrap/out/
# eclipse
/bin/
/HMCL/bin/
/HMCLCore/bin/
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
.classpath
.project
.settings
@@ -42,7 +50,6 @@ minecraft-exported-crash-info*
.nb-gradle
*.exe
!/HMCL/src/main/resources/assets/HMCLauncher.exe
# macos
.DS_Store
@@ -53,3 +60,4 @@ minecraft-exported-crash-info*
# test
/hmcl.json
/.hmcl.json
/.hmcl/
+208 -82
View File
@@ -1,4 +1,11 @@
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
@@ -9,36 +16,54 @@ import java.security.spec.PKCS8EncodedKeySpec
import java.util.zip.ZipFile
plugins {
id("com.github.johnrengelman.shadow") version "7.1.2"
alias(libs.plugins.shadow)
}
val isOfficial = System.getenv("HMCL_SIGNATURE_KEY") != null
|| (System.getenv("GITHUB_REPOSITORY_OWNER") == "HMCL-dev" && System.getenv("GITHUB_BASE_REF")
.isNullOrEmpty())
val projectConfig = PropertiesUtils.load(rootProject.file("config/project.properties").toPath())
val isOfficial = JenkinsUtils.IS_ON_CI || GitHubActionUtils.IS_ON_OFFICIAL_REPO
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
val offset = System.getenv("BUILD_NUMBER_OFFSET")?.toInt() ?: 0
if (number != null) {
(number - offset).toString()
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
val prefix = if (isOfficial) "dev" else "unofficial"
if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT"
}
}
val versionRoot = System.getenv("VERSION_ROOT") ?: "3.6"
val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial"
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
version = "$versionRoot.$buildNumber"
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt()
if (buildNumber != null) {
version = if (JenkinsUtils.IS_ON_CI && versionType == "dev") {
"$versionRoot.0.$buildNumber"
} else {
"$versionRoot.$buildNumber"
}
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
version = if (shortCommit.isNullOrBlank()) {
"$versionRoot.SNAPSHOT"
} else if (isOfficial) {
"$versionRoot.dev-$shortCommit"
} else {
"$versionRoot.unofficial-$shortCommit"
}
}
val embedResources by configurations.registering
dependencies {
implementation(project(":HMCLCore"))
implementation(project(":HMCLBoot"))
implementation("libs:JFoenix")
implementation("com.twelvemonkeys.imageio:imageio-webp:3.12.0")
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
embedResources(libs.authlib.injector)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -82,21 +107,63 @@ fun attachSignature(jar: File) {
}
}
val java11 = sourceSets.create("java11") {
java {
srcDir("src/main/java11")
}
tasks.withType<JavaCompile> {
sourceCompatibility = "17"
targetCompatibility = "17"
}
tasks.getByName<JavaCompile>(java11.compileJavaTaskName) {
if (JavaVersion.current() < JavaVersion.VERSION_11) {
javaCompiler.set(javaToolchains.compilerFor {
languageVersion.set(JavaLanguageVersion.of(11))
})
}
tasks.checkstyleMain {
// Third-party code is not checked
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
}
tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
sourceCompatibility = "11"
targetCompatibility = "11"
}
val addOpens = listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
"java.base/jdk.internal.loader",
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.graphics/javafx.css",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
add("hmcl.add-opens" to addOpens.joinToString(" "))
System.getenv("GITHUB_SHA")?.let {
add("hmcl.version.hash" to it)
}
add("hmcl.version.type" to versionType)
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
}
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
val createPropertiesFile by tasks.registering {
outputs.file(hmclPropertiesFile)
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
doLast {
val targetFile = hmclPropertiesFile.get().asFile
targetFile.parentFile.mkdir()
targetFile.bufferedWriter().use {
for ((k, v) in hmclProperties) {
it.write("$k=$v\n")
}
}
}
}
tasks.jar {
@@ -106,7 +173,9 @@ tasks.jar {
val jarPath = tasks.jar.get().archiveFile.get().asFile
tasks.getByName<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("shadowJar") {
tasks.shadowJar {
dependsOn(createPropertiesFile)
archiveClassifier.set(null as String?)
exclude("**/package-info.class")
@@ -115,42 +184,30 @@ tasks.getByName<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("sha
exclude("META-INF/services/javax.imageio.spi.ImageReaderSpi")
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
listOf(
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
).forEach { exclude("com/sun/jna/$it/**") }
minimize {
exclude(dependency("com.google.code.gson:.*:.*"))
exclude(dependency("net.java.dev.jna:jna:.*"))
exclude(dependency("libs:JFoenix:.*"))
exclude(project(":HMCLBoot"))
}
manifest {
attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Implementation-Version" to project.version,
"Microsoft-Auth-Id" to microsoftAuthId,
"Microsoft-Auth-Secret" to microsoftAuthSecret,
"CurseForge-Api-Key" to curseForgeApiKey,
"Build-Channel" to versionType,
"Class-Path" to "pack200.jar",
"Add-Opens" to listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
"java.base/jdk.internal.loader",
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.graphics/javafx.css",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
).joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
)
manifest.attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Implementation-Version" to project.version.toString(),
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Add-Opens" to addOpens.joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
)
System.getenv("GITHUB_SHA")?.also {
attributes("GitHub-SHA" to it)
if (launcherExe.isNotBlank()) {
into("assets") {
from(file(launcherExe))
}
}
@@ -160,29 +217,49 @@ tasks.getByName<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("sha
}
}
fun createExecutable(suffix: String, header: String) {
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + suffix)
output.outputStream().use {
it.write(File(project.projectDir, header).readBytes())
it.write(jarPath.readBytes())
}
createChecksum(output)
}
tasks.processResources {
into("META-INF/versions/11") {
from(sourceSets["java11"].output)
dependsOn(createPropertiesFile)
dependsOn(upsideDownTranslate)
dependsOn(createLocaleNamesResourceBundle)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(embedResources)
}
into("assets/lang") {
from(createLanguageList.map { it.outputFile })
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
dependsOn(tasks["java11Classes"])
}
val makeExecutables = tasks.create("makeExecutables") {
val makeExecutables by tasks.registering {
val extensions = listOf("exe", "sh")
dependsOn(tasks.jar)
inputs.file(jarPath)
outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
doLast {
createExecutable("exe", "src/main/resources/assets/HMCLauncher.exe")
createExecutable("sh", "src/main/resources/assets/HMCLauncher.sh")
val jarContent = jarPath.readBytes()
ZipFile(jarPath).use { zipFile ->
for (extension in extensions) {
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
?: throw GradleException("HMCLauncher.$extension not found")
output.outputStream().use { outputStream ->
zipFile.getInputStream(entry).use { it.copyTo(outputStream) }
outputStream.write(jarContent)
}
createChecksum(output)
}
}
}
}
@@ -190,9 +267,9 @@ tasks.build {
dependsOn(makeExecutables)
}
fun parseToolOptions(options: String?): List<String> {
fun parseToolOptions(options: String?): MutableList<String> {
if (options == null)
return listOf()
return mutableListOf()
val builder = StringBuilder()
val result = mutableListOf<String>()
@@ -240,7 +317,17 @@ fun parseToolOptions(options: String?): List<String> {
return result
}
tasks.create<JavaExec>("run") {
// For IntelliJ IDEA
tasks.withType<JavaExec> {
if (name != "run") {
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
// if (javaVersion >= JavaVersion.VERSION_24) {
// jvmArgs("--enable-native-access=ALL-UNNAMED")
// }
}
}
tasks.register<JavaExec>("run") {
dependsOn(tasks.jar)
group = "application"
@@ -249,12 +336,17 @@ tasks.create<JavaExec>("run") {
workingDir = rootProject.rootDir
val vmOptions = parseToolOptions(System.getenv("HMCL_JAVA_OPTS"))
if (vmOptions.none { it.startsWith("-Dhmcl.offline.auth.restricted=") })
vmOptions += "-Dhmcl.offline.auth.restricted=false"
jvmArgs(vmOptions)
val hmclJavaHome = System.getenv("HMCL_JAVA_HOME")
if (hmclJavaHome != null) {
this.executable(file(hmclJavaHome).resolve("bin")
.resolve(if (System.getProperty("os.name").lowercase().startsWith("windows")) "java.exe" else "java"))
this.executable(
file(hmclJavaHome).resolve("bin")
.resolve(if (System.getProperty("os.name").lowercase().startsWith("windows")) "java.exe" else "java")
)
}
doFirst {
@@ -263,6 +355,40 @@ tasks.create<JavaExec>("run") {
}
}
// Check Translations
tasks.register<CheckTranslations>("checkTranslations") {
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
englishFile.set(dir.file("I18N.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<ParseModDataTask>("parseModData") {
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,91 @@
// Copy from https://github.com/sshahine/JFoenix/blob/d427fd801a338f934307ba41ce604eb5c79f0b20/jfoenix/src/main/java/com/jfoenix/skins/JFXListViewSkin.java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ListViewSkin;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.effects.JFXDepthManager;
import javafx.scene.control.ListCell;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.layout.Region;
// https://github.com/HMCL-dev/HMCL/issues/4720
public class JFXListViewSkin<T> extends ListViewSkin<T> {
private final VirtualFlow<ListCell<T>> flow;
@SuppressWarnings("unchecked")
public JFXListViewSkin(final JFXListView<T> listView) {
super(listView);
flow = (VirtualFlow<ListCell<T>>) getChildren().get(0);
JFXDepthManager.setDepth(flow, listView.depthProperty().get());
listView.depthProperty().addListener((o, oldVal, newVal) -> JFXDepthManager.setDepth(flow, newVal));
}
@Override
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return 200;
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
final int itemsCount = getSkinnable().getItems().size();
if (getSkinnable().maxHeightProperty().isBound() || itemsCount <= 0) {
return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
}
final double fixedCellSize = getSkinnable().getFixedCellSize();
double computedHeight = fixedCellSize != Region.USE_COMPUTED_SIZE ?
fixedCellSize * itemsCount + snapVerticalInsets() : estimateHeight();
double height = super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
if (height > computedHeight) {
height = computedHeight;
}
if (getSkinnable().getMaxHeight() > 0 && computedHeight > getSkinnable().getMaxHeight()) {
return getSkinnable().getMaxHeight();
}
return height;
}
private double estimateHeight() {
// compute the border/padding for the list
double borderWidth = snapVerticalInsets();
// compute the gap between list cells
JFXListView<T> listview = (JFXListView<T>) getSkinnable();
double gap = listview.isExpanded() ? ((JFXListView<T>) getSkinnable()).getVerticalGap() * (getSkinnable().getItems()
.size()) : 0;
// compute the height of each list cell
double cellsHeight = 0;
for (int i = 0; i < flow.getCellCount(); i++) {
ListCell<T> cell = flow.getCell(i);
cellsHeight += cell.getHeight();
}
return cellsHeight + gap + borderWidth;
}
private double snapVerticalInsets() {
return getSkinnable().snappedBottomInset() + getSkinnable().snappedTopInset();
}
}
@@ -0,0 +1,255 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 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;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class EntryPoint {
private EntryPoint() {
}
public static void main(String[] args) {
System.getProperties().putIfAbsent("java.net.useSystemProxies", "true");
System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true");
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
createHMCLDirectories();
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
setupJavaFXVMOptions();
checkDirectoryPath();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
initIcon();
checkJavaFX();
verifyJavaFX();
addEnableNativeAccess();
enableUnsafeMemoryAccess();
Launcher.main(args);
}
public static void exit(int exitCode) {
FileSaver.shutdown();
LOG.shutdown();
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
try {
Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY);
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true);
} catch (IOException e) {
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e);
}
}
} catch (IOException e) {
// Logger has not been started yet, so print directly to System.err
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
}
}
if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) {
try {
Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY);
} catch (IOException e) {
LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e);
}
}
}
private static void initIcon() {
try {
if (java.awt.Taskbar.isTaskbarSupported()) {
var image = java.awt.Toolkit.getDefaultToolkit().getImage(EntryPoint.class.getResource("/assets/img/icon-mac.png"));
java.awt.Taskbar.getTaskbar().setIconImage(image);
}
} catch (Throwable e) {
LOG.warning("Failed to set application icon", e);
}
}
private static void checkDirectoryPath() {
String currentDir = System.getProperty("user.dir", "");
if (currentDir.contains("!")) {
LOG.error("The current working path contains an exclamation mark: " + currentDir);
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
+ "The path is " + currentDir);
}
}
private static void checkJavaFX() {
try {
SelfDependencyPatcher.patch();
} catch (SelfDependencyPatcher.PatchException e) {
LOG.error("Unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.missing"));
} catch (SelfDependencyPatcher.IncompatibleVersionException e) {
LOG.error("Unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.incompatible"));
} catch (CancellationException e) {
LOG.error("User cancels downloading JavaFX", e);
exit(0);
}
}
/**
* Check if JavaFX exists but is incomplete
*/
private static void verifyJavaFX() {
try {
Class.forName("javafx.beans.binding.Binding"); // javafx.base
Class.forName("javafx.stage.Stage"); // javafx.graphics
Class.forName("javafx.scene.control.Skin"); // javafx.controls
} catch (Exception e) {
LOG.warning("JavaFX is incomplete or not found", e);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
private static void addEnableNativeAccess() {
if (JavaRuntime.CURRENT_VERSION > 21) {
try {
// javafx.graphics
Module module = Class.forName("javafx.stage.Stage").getModule();
if (module.isNamed()) {
try {
MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(Module.class, MethodHandles.lookup());
MethodHandle implAddEnableNativeAccess = lookup.findVirtual(Module.class,
"implAddEnableNativeAccess", MethodType.methodType(Module.class));
Module ignored = (Module) implAddEnableNativeAccess.invokeExact(module);
} catch (Throwable e) {
e.printStackTrace(System.err);
}
}
} catch (ClassNotFoundException e) {
LOG.error("Failed to add enable native access for JavaFX", e);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
}
private static void enableUnsafeMemoryAccess() {
// https://openjdk.org/jeps/498
if (JavaRuntime.CURRENT_VERSION == 24 || JavaRuntime.CURRENT_VERSION == 25) {
try {
Class<?> clazz = Class.forName("sun.misc.Unsafe");
boolean ignored = (boolean) MethodHandles.privateLookupIn(clazz, MethodHandles.lookup())
.findStatic(clazz, "trySetMemoryAccessWarned", MethodType.methodType(boolean.class))
.invokeExact();
} catch (Throwable e) {
LOG.warning("Failed to enable unsafe memory access", e);
}
}
}
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
private static void showErrorAndExit(String message) {
SwingUtils.showErrorDialog(message);
exit(1);
}
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ObservableBooleanValue;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
@@ -27,6 +28,8 @@ import javafx.scene.input.DataFormat;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
@@ -38,11 +41,14 @@ 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 java.io.File;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.nio.file.Files;
@@ -50,9 +56,12 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
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;
@@ -66,27 +75,25 @@ public final class Launcher extends Application {
CookieHandler.setDefault(COOKIE_MANAGER);
LOG.info("JavaFX Version: " + System.getProperty("javafx.runtime.version"));
try {
Object pipeline = Class.forName("com.sun.prism.GraphicsPipeline").getMethod("getPipeline").invoke(null);
LOG.info("Prism pipeline: " + (pipeline == null ? "null" : pipeline.getClass().getName()));
} catch (Throwable e) {
LOG.warning("Failed to get prism pipeline", e);
}
LOG.info("Prism Pipeline: " + FXUtils.GRAPHICS_PIPELINE);
LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false));
LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false));
try {
try {
ConfigHolder.init();
} catch (SambaException ignored) {
Main.showWarningAndContinue(i18n("fatal.samba"));
} catch (SambaException e) {
showAlert(AlertType.WARNING, i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigInTempDir();
checkConfigOwner();
Main.showErrorAndExit(i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
EntryPoint.exit(1);
}
// https://lapcatsoftware.com/articles/app-translocation.html
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX
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)
@@ -100,8 +107,12 @@ public final class Launcher extends Application {
return;
}
if (Metadata.HMCL_DIRECTORY.toString().indexOf('=') >= 0) {
Main.showWarningAndContinue(i18n("fatal.illegal_char"));
if (ConfigHolder.isUnsupportedVersion()) {
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
}
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
}
// runLater to ensure ConfigHolder.init() finished initialization
@@ -148,7 +159,7 @@ public final class Launcher extends Application {
|| configPath.startsWith("/var/cache/")
|| configPath.startsWith("/dev/shm/")
|| configPath.contains("/Trash/");
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
return configPath.startsWith("/var/folders/")
|| configPath.startsWith("/private/var/folders/")
|| configPath.startsWith("/tmp/")
@@ -164,7 +175,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) {
Main.exit(0);
EntryPoint.exit(0);
}
}
@@ -186,8 +197,10 @@ public final class Launcher extends Application {
ArrayList<String> files = new ArrayList<>();
files.add(ConfigHolder.configLocation().toString());
if (Files.exists(Metadata.HMCL_DIRECTORY))
files.add(Metadata.HMCL_DIRECTORY.toString());
if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY))
files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString());
if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY))
files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
@@ -202,12 +215,13 @@ public final class Launcher extends Application {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
Main.exit(1);
EntryPoint.exit(1);
}
@Override
public void stop() throws Exception {
Controllers.onApplicationStop();
FileSaver.shutdown();
LOG.shutdown();
}
@@ -223,8 +237,8 @@ public final class Launcher extends Application {
try {
LOG.info("*** " + Metadata.TITLE + " ***");
LOG.info("Operating System: " + (OperatingSystem.OS_RELEASE_PRETTY_NAME == null
? OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION
: OperatingSystem.OS_RELEASE_PRETTY_NAME + " (" + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION + ')'));
? OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion()
: OperatingSystem.OS_RELEASE_PRETTY_NAME + " (" + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + ')'));
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER"));
}
@@ -238,21 +252,30 @@ public final class Launcher extends Application {
LOG.info("Java Version: " + System.getProperty("java.version") + ", " + System.getProperty("java.vendor"));
LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor"));
LOG.info("Java Home: " + System.getProperty("java.home"));
LOG.info("Current Directory: " + System.getProperty("user.dir"));
LOG.info("HMCL Directory: " + Metadata.HMCL_DIRECTORY);
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY);
LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("Memory: " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "MB");
LOG.info("Physical memory: " + OperatingSystem.TOTAL_MEMORY + " MB");
LOG.info("Metaspace: " + ManagementFactory.getMemoryPoolMXBeans().stream()
.filter(bean -> bean.getName().equals("Metaspace"))
.findAny()
.map(bean -> bean.getUsage().getUsed() / 1024 / 1024 + "MB")
.orElse("Unknown"));
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
try {
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
if ("Metaspace".equals(bean.getName())) {
long bytes = bean.getUsage().getUsed();
LOG.info("Metaspace: " + MEGABYTES.formatBytes(bytes));
break;
}
}
} catch (NoClassDefFoundError ignored) {
}
LOG.info("Native Backend: " + (NativeUtils.USE_JNA ? "JNA" : "None"));
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
LOG.info("XDG Session Type: " + System.getenv("XDG_SESSION_TYPE"));
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
}
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
launch(Launcher.class, args);
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
@@ -281,7 +304,7 @@ public final class Launcher extends Application {
Controllers.getStage().close();
Schedulers.shutdown();
Controllers.shutdown();
Lang.executeDelayed(OperatingSystem::forceGC, TimeUnit.SECONDS, 5, true);
Lang.executeDelayed(System::gc, TimeUnit.SECONDS, 5, true);
});
}
@@ -1,222 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 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;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import org.jackhuang.hmcl.ui.AwtUtils;
import org.jackhuang.hmcl.util.ModuleHelper;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.ui.SwingUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.Collections;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class Main {
private Main() {
}
public static void main(String[] args) {
System.getProperties().putIfAbsent("java.net.useSystemProxies", "true");
System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true");
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
LOG.start(Metadata.HMCL_DIRECTORY.resolve("logs"));
checkDirectoryPath();
if (JavaRuntime.CURRENT_VERSION < 9)
// This environment check will take ~300ms
thread(Main::fixLetsEncrypt, "CA Certificate Check", true);
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
initIcon();
checkJavaFX();
verifyJavaFX();
addEnableNativeAccess();
enableUnsafeMemoryAccess();
Launcher.main(args);
}
public static void exit(int exitCode) {
LOG.shutdown();
System.exit(exitCode);
}
private static void initIcon() {
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(Main.class.getResource("/assets/img/icon-mac.png"));
AwtUtils.setAppleIcon(image);
}
private static void checkDirectoryPath() {
String currentDirectory = new File("").getAbsolutePath();
if (currentDirectory.contains("!")) {
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
+ "The path is " + currentDirectory);
}
}
private static void checkJavaFX() {
try {
SelfDependencyPatcher.patch();
} catch (SelfDependencyPatcher.PatchException e) {
LOG.error("unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.missing"));
} catch (SelfDependencyPatcher.IncompatibleVersionException e) {
LOG.error("unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.incompatible"));
} catch (CancellationException e) {
LOG.error("User cancels downloading JavaFX", e);
exit(0);
}
}
/**
* Check if JavaFX exists but is incomplete
*/
private static void verifyJavaFX() {
try {
Class.forName("javafx.beans.binding.Binding"); // javafx.base
Class.forName("javafx.stage.Stage"); // javafx.graphics
Class.forName("javafx.scene.control.Skin"); // javafx.controls
} catch (Exception e) {
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
private static void addEnableNativeAccess() {
if (JavaRuntime.CURRENT_VERSION > 21) {
try {
ModuleHelper.addEnableNativeAccess(Class.forName("javafx.stage.Stage")); // javafx.graphics
} catch (ClassNotFoundException e) {
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
}
private static void enableUnsafeMemoryAccess() {
// https://openjdk.org/jeps/498
if (JavaRuntime.CURRENT_VERSION == 24 || JavaRuntime.CURRENT_VERSION == 25) {
try {
Class<?> clazz = Class.forName("sun.misc.Unsafe");
Method trySetMemoryAccessWarned = clazz.getDeclaredMethod("trySetMemoryAccessWarned");
trySetMemoryAccessWarned.setAccessible(true);
trySetMemoryAccessWarned.invoke(null);
} catch (Throwable e) {
e.printStackTrace(System.err);
}
}
}
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
static void showErrorAndExit(String message) {
System.err.println(message);
System.err.println("A fatal error has occurred, forcibly exiting.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.ERROR, message).showAndWait();
exit(1);
}
} catch (Throwable ignored) {
}
SwingUtils.showErrorDialog(message);
exit(1);
}
/**
* Indicates that potential issues have been detected, and that the application may not function properly (but it can still run).
*/
static void showWarningAndContinue(String message) {
System.err.println(message);
System.err.println("Potential issues have been detected.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.WARNING, message).showAndWait();
return;
}
} catch (Throwable ignored) {
}
SwingUtils.showWarningDialog(message);
}
private static void fixLetsEncrypt() {
try {
KeyStore defaultKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
Path ksPath = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts");
try (InputStream ksStream = Files.newInputStream(ksPath)) {
defaultKeyStore.load(ksStream, "changeit".toCharArray());
}
KeyStore letsEncryptKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
try (InputStream letsEncryptFile = Main.class.getResourceAsStream("/assets/lekeystore.jks")) {
letsEncryptKeyStore.load(letsEncryptFile, "supersecretpassword".toCharArray());
}
KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType());
merged.load(null, new char[0]);
for (String alias : Collections.list(letsEncryptKeyStore.aliases()))
merged.setCertificateEntry(alias, letsEncryptKeyStore.getCertificate(alias));
for (String alias : Collections.list(defaultKeyStore.aliases()))
merged.setCertificateEntry(alias, defaultKeyStore.getCertificate(alias));
TrustManagerFactory instance = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
instance.init(merged);
SSLContext tls = SSLContext.getInstance("TLS");
tls.init(null, instance.getTrustManagers(), null);
HttpsURLConnection.setDefaultSSLSocketFactory(tls.getSocketFactory());
LOG.info("Added Lets Encrypt root certificates as additional trust");
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException |
KeyManagementException e) {
LOG.error("Failed to load lets encrypt certificate. Expect problems", e);
}
}
}
@@ -19,24 +19,32 @@ package org.jackhuang.hmcl;
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.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.EnumSet;
/**
* Stores metadata about this application.
*/
public final class Metadata {
private Metadata() {}
private Metadata() {
}
public static final String NAME = "HMCL";
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getManifestAttribute("Implementation-Version", "@develop@"));
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
public static final int RECOMMENDED_JAVA_VERSION = 21;
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
public static final String ABOUT_URL = PUBLISH_URL + "/about";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
@@ -46,13 +54,16 @@ public final class Metadata {
public static final String CONTACT_URL = DOCS_URL + "/help.html";
public static final String CHANGELOG_URL = DOCS_URL + "/changelog/";
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
public static final String GROUPS_URL = DOCS_URL + "/groups.html";
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
public static final String BUILD_CHANNEL = JarUtils.getManifestAttribute("Build-Channel", "nightly");
public static final String GITHUB_SHA = JarUtils.getManifestAttribute("GitHub-SHA", null);
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
public static final Path HMCL_DIRECTORY;
public static final Path HMCL_GLOBAL_DIRECTORY;
public static final Path HMCL_CURRENT_DIRECTORY;
public static final Path DEPENDENCIES_DIRECTORY;
static {
String hmclHome = System.getProperty("hmcl.home");
@@ -60,16 +71,22 @@ public final class Metadata {
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
String xdgData = System.getenv("XDG_DATA_HOME");
if (StringUtils.isNotBlank(xdgData)) {
HMCL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath();
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
} else {
HMCL_DIRECTORY = Paths.get(System.getProperty("user.home", "."), ".local", "share", "hmcl").toAbsolutePath();
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
}
} else {
HMCL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
}
} else {
HMCL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
}
String hmclCurrentDir = System.getProperty("hmcl.dir");
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
: CURRENT_DIRECTORY.resolve(".hmcl");
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
}
public static boolean isStable() {
@@ -83,4 +100,31 @@ public final class Metadata {
public static boolean isNightly() {
return !isStable() && !isDev();
}
public static @Nullable String getSuggestedJavaDownloadLink() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html";
else {
EnumSet<Architecture> supportedArchitectures;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.X86, Architecture.ARM64);
else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX)
supportedArchitectures = EnumSet.of(
Architecture.X86_64, Architecture.X86,
Architecture.ARM64, Architecture.ARM32,
Architecture.RISCV64, Architecture.LOONGARCH64
);
else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.ARM64);
else
supportedArchitectures = EnumSet.noneOf(Architecture.class);
if (supportedArchitectures.contains(Architecture.SYSTEM_ARCH))
return String.format("https://docs.hmcl.net/downloads/%s/%s.html",
OperatingSystem.CURRENT_OS.getCheckedName(),
Architecture.SYSTEM_ARCH.getCheckedName()
);
else
return null;
}
}
}
@@ -42,7 +42,7 @@ public class CrashReport {
"\n Content: \n " +
stackTrace + "\n\n" +
"-- System Details --\n" +
" Operating System: " + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION + "\n" +
" Operating System: " + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + "\n" +
" System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName() + "\n" +
" Java Architecture: " + Architecture.CURRENT_ARCH.getDisplayName() + "\n" +
" Java Version: " + System.getProperty("java.version") + ", " + System.getProperty("java.vendor") + "\n" +
@@ -21,15 +21,17 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.launch.DefaultLauncher;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -58,61 +60,82 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
private void generateOptionsTxt() {
File optionsFile = new File(repository.getRunDirectory(version.getId()), "options.txt");
File configFolder = new File(repository.getRunDirectory(version.getId()), "config");
if (optionsFile.exists()) {
if (config().isDisableAutoGameOptions())
return;
}
if (configFolder.isDirectory()) {
if (findFiles(configFolder, "options.txt")) {
return;
Path runDir = repository.getRunDirectory(version.getId());
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
if (Files.exists(optionsFile))
return;
if (Files.isDirectory(configFolder)) {
try (Stream<Path> stream = Files.walk(configFolder, 2, FileVisitOption.FOLLOW_LINKS)) {
if (stream.anyMatch(file -> "options.txt".equals(FileUtils.getName(file))))
return;
} catch (IOException e) {
LOG.warning("Failed to visit config folder", e);
}
}
if (!I18n.isUseChinese()) {
return;
}
Locale locale = Locale.getDefault();
String lang;
/*
1.0- :没有语言选项,遇到这些版本时不设置
1.1 ~ 5 :zh_CN 时正常,zh_cn 时崩溃(最后两位字母必须大写,否则将会 NPE 崩溃)
1.6 ~ 10 :zh_CN 时正常,zh_cn 时自动切换为英文
1.11 ~ 12:zh_cn 时正常,zh_CN 时虽然显示了中文但语言设置会错误地显示选择英文
1.13+ :zh_cn 时正常,zh_CN 时自动切换为英文
* 1.0 : No language option, do not set for these versions
* 1.1 ~ 1.5 : zh_CN works fine, zh_cn will crash (the last two letters must be uppercase, otherwise it will cause an NPE crash)
* 1.6 ~ 1.10 : zh_CN works fine, zh_cn will automatically switch to English
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
*/
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
if (gameVersion.compareTo("1.1") < 0) {
lang = null;
} else if (gameVersion.compareTo("1.11") < 0) {
lang = "zh_CN";
} else {
lang = "zh_cn";
}
if (gameVersion.compareTo("1.1") < 0)
return;
if (lang != null) {
try {
FileUtils.writeText(optionsFile, String.format("lang:%s\n", lang));
} catch (IOException e) {
LOG.warning("Unable to generate options.txt", e);
}
String lang = normalizedLanguageTag(locale, gameVersion);
if (lang.isEmpty())
return;
if (gameVersion.compareTo("1.11") >= 0)
lang = lang.toLowerCase(Locale.ROOT);
try {
Files.createDirectories(optionsFile.getParent());
Files.writeString(optionsFile, String.format("lang:%s\n", lang));
} catch (IOException e) {
LOG.warning("Unable to generate options.txt", e);
}
}
private boolean findFiles(File folder, String fileName) {
File[] fs = folder.listFiles();
if (fs != null) {
for (File f : fs) {
if (f.isDirectory())
if (f.listFiles((dir, name) -> name.equals(fileName)) != null)
return true;
if (f.getName().equals(fileName))
return true;
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
String region = locale.getCountry();
return switch (LocaleUtils.getRootLanguage(locale)) {
case "es" -> "es_ES";
case "ja" -> "ja_JP";
case "ru" -> "ru_RU";
case "uk" -> "uk_UA";
case "zh" -> {
if ("lzh".equals(locale.getLanguage()) && gameVersion.compareTo("1.16") >= 0)
yield "lzh";
String script = LocaleUtils.getScript(locale);
if ("Hant".equals(script)) {
if ((region.equals("HK") || region.equals("MO") && gameVersion.compareTo("1.16") >= 0))
yield "zh_HK";
yield "zh_TW";
}
yield "zh_CN";
}
}
return false;
case "en" -> {
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
yield "en_UD";
}
yield "";
}
default -> "";
};
}
@Override
@@ -122,7 +145,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
@Override
public void makeLaunchScript(File scriptFile) throws IOException {
public void makeLaunchScript(Path scriptFile) throws IOException {
generateOptionsTxt();
super.makeLaunchScript(scriptFile);
}
@@ -30,7 +30,7 @@ import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.ProxyManager;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.VersionSetting;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -40,12 +40,13 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
@@ -56,7 +57,7 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public class HMCLGameRepository extends DefaultGameRepository {
public final class HMCLGameRepository extends DefaultGameRepository {
private final Profile profile;
// local version settings
@@ -65,7 +66,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
public HMCLGameRepository(Profile profile, File baseDirectory) {
public HMCLGameRepository(Profile profile, Path baseDirectory) {
super(baseDirectory);
this.profile = profile;
}
@@ -84,18 +85,20 @@ public class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public File getRunDirectory(String id) {
public Path getRunDirectory(String id) {
switch (getGameDirectoryType(id)) {
case VERSION_FOLDER:
return getVersionRoot(id);
case ROOT_FOLDER:
return super.getRunDirectory(id);
case CUSTOM:
File dir = new File(getVersionSetting(id).getGameDir());
if (!FileUtils.isValidPath(dir)) return getVersionRoot(id);
return dir;
try {
return Path.of(getVersionSetting(id).getGameDir());
} catch (InvalidPathException ignored) {
return getVersionRoot(id);
}
default:
throw new Error();
throw new AssertionError("Unreachable");
}
}
@@ -118,25 +121,24 @@ public class HMCLGameRepository extends DefaultGameRepository {
});
try {
File file = new File(getBaseDirectory(), "launcher_profiles.json");
if (!file.exists() && !versions.isEmpty())
FileUtils.writeText(file, PROFILE);
Path file = getBaseDirectory().resolve("launcher_profiles.json");
if (!Files.exists(file) && !versions.isEmpty()) {
Files.createDirectories(file.getParent());
Files.writeString(file, PROFILE);
}
} catch (IOException ex) {
LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex);
}
// https://github.com/HMCL-dev/HMCL/issues/938
System.gc();
}
public void changeDirectory(File newDirectory) {
public void changeDirectory(Path newDirectory) {
setBaseDirectory(newDirectory);
refreshVersionsAsync().start();
}
private void clean(File directory) throws IOException {
FileUtils.deleteDirectory(new File(directory, "crash-reports"));
FileUtils.deleteDirectory(new File(directory, "logs"));
private void clean(Path directory) throws IOException {
FileUtils.deleteDirectory(directory.resolve("crash-reports"));
FileUtils.deleteDirectory(directory.resolve("logs"));
}
public void clean(String id) throws IOException {
@@ -145,25 +147,33 @@ public class HMCLGameRepository extends DefaultGameRepository {
}
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
Path srcDir = getVersionRoot(srcId).toPath();
Path dstDir = getVersionRoot(dstId).toPath();
Path srcDir = getVersionRoot(srcId);
Path dstDir = getVersionRoot(dstId);
Version fromVersion = getVersion(srcId);
if (Files.exists(dstDir)) throw new IOException("Version exists");
FileUtils.copyDirectory(srcDir, dstDir);
List<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
blackList.add(srcId + ".jar");
blackList.add(srcId + ".json");
if (!copySaves)
blackList.add("saves");
Path fromJson = dstDir.resolve(srcId + ".json");
Path fromJar = dstDir.resolve(srcId + ".jar");
if (Files.exists(dstDir)) throw new IOException("Version exists");
Files.createDirectories(dstDir);
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
Path fromJson = srcDir.resolve(srcId + ".json");
Path fromJar = srcDir.resolve(srcId + ".jar");
Path toJson = dstDir.resolve(dstId + ".json");
Path toJar = dstDir.resolve(dstId + ".jar");
if (Files.exists(fromJar)) {
Files.move(fromJar, toJar);
Files.copy(fromJar, toJar);
}
Files.move(fromJson, toJson);
Files.copy(fromJson, toJson);
FileUtils.writeText(toJson.toFile(), JsonUtils.GSON.toJson(fromVersion.setId(dstId)));
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
@@ -172,28 +182,22 @@ public class HMCLGameRepository extends DefaultGameRepository {
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
saveVersionSetting(dstId);
File srcGameDir = getRunDirectory(srcId);
File dstGameDir = getRunDirectory(dstId);
List<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
blackList.add(srcId + ".jar");
blackList.add(srcId + ".json");
if (!copySaves)
blackList.add("saves");
Path srcGameDir = getRunDirectory(srcId);
Path dstGameDir = getRunDirectory(dstId);
if (originalGameDirType != GameDirectoryType.VERSION_FOLDER)
FileUtils.copyDirectory(srcGameDir.toPath(), dstGameDir.toPath(), path -> Modpack.acceptFile(path, blackList, null));
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
}
private File getLocalVersionSettingFile(String id) {
return new File(getVersionRoot(id), "hmclversion.cfg");
private Path getLocalVersionSettingFile(String id) {
return getVersionRoot(id).resolve("hmclversion.cfg");
}
private void loadLocalVersionSetting(String id) {
File file = getLocalVersionSettingFile(id);
if (file.exists())
Path file = getLocalVersionSettingFile(id);
if (Files.exists(file))
try {
VersionSetting versionSetting = GSON.fromJson(FileUtils.readText(file), VersionSetting.class);
VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class);
initLocalVersionSetting(id, versionSetting);
} catch (Exception ex) {
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
@@ -203,6 +207,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
/**
* Create new version setting if version id has no version setting.
*
* @param id the version id.
* @return new version setting, null if given version does not exist.
*/
@@ -225,7 +230,6 @@ public class HMCLGameRepository extends DefaultGameRepository {
* Get the version setting for version id.
*
* @param id version id
*
* @return corresponding version setting, null if the version has no its own version setting.
*/
@Nullable
@@ -256,12 +260,12 @@ public class HMCLGameRepository extends DefaultGameRepository {
return vs;
}
public Optional<File> getVersionIconFile(String id) {
File root = getVersionRoot(id);
public Optional<Path> getVersionIconFile(String id) {
Path root = getVersionRoot(id);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
File file = new File(root, "icon." + extension);
if (file.exists()) {
Path file = root.resolve("icon." + extension);
if (Files.exists(file)) {
return Optional.of(file);
}
}
@@ -269,7 +273,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
return Optional.empty();
}
public void setVersionIconFile(String id, File iconFile) throws IOException {
public void setVersionIconFile(String id, Path iconFile) throws IOException {
String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) {
throw new IllegalArgumentException("Unsupported icon file: " + ext);
@@ -277,13 +281,18 @@ public class HMCLGameRepository extends DefaultGameRepository {
deleteIconFile(id);
FileUtils.copyFile(iconFile, new File(getVersionRoot(id), "icon." + ext));
FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext));
}
public void deleteIconFile(String id) {
File root = getVersionRoot(id);
Path root = getVersionRoot(id);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
new File(root, "icon." + extension).delete();
Path file = root.resolve("icon." + extension);
try {
Files.deleteIfExists(file);
} catch (IOException e) {
LOG.warning("Failed to delete icon file: " + file, e);
}
}
}
@@ -296,10 +305,10 @@ public class HMCLGameRepository extends DefaultGameRepository {
if (iconType == VersionIconType.DEFAULT) {
Version version = getVersion(id).resolve(this);
Optional<File> iconFile = getVersionIconFile(id);
Optional<Path> iconFile = getVersionIconFile(id);
if (iconFile.isPresent()) {
try {
return FXUtils.loadImage(iconFile.get().toPath());
return FXUtils.loadImage(iconFile.get());
} catch (Exception e) {
LOG.warning("Failed to load version icon of " + id, e);
}
@@ -311,6 +320,8 @@ public class HMCLGameRepository extends DefaultGameRepository {
return VersionIconType.FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
return VersionIconType.FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
return VersionIconType.CLEANROOM.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
return VersionIconType.NEO_FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
@@ -329,26 +340,22 @@ public class HMCLGameRepository extends DefaultGameRepository {
}
}
public boolean saveVersionSetting(String id) {
public void saveVersionSetting(String id) {
if (!localVersionSettings.containsKey(id))
return false;
File file = getLocalVersionSettingFile(id);
if (!FileUtils.makeDirectory(file.getAbsoluteFile().getParentFile()))
return false;
LOG.info("Saving version setting: " + id);
return;
Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize();
try {
FileUtils.writeText(file, GSON.toJson(localVersionSettings.get(id)));
return true;
Files.createDirectories(file.getParent());
} catch (IOException e) {
LOG.error("Unable to save version setting of " + id, e);
return false;
LOG.warning("Failed to create directory: " + file.getParent(), e);
}
FileSaver.save(file, GSON.toJson(localVersionSettings.get(id)));
}
/**
* Make version use self version settings instead of the global one.
*
* @param id the version id.
* @return specialized version setting, null if given version does not exist.
*/
@@ -370,7 +377,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
vs.setUsesGlobal(true);
}
public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, File gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
VersionSetting vs = getVersionSetting(version);
LaunchOptions.Builder builder = new LaunchOptions.Builder()
@@ -381,9 +388,9 @@ public class HMCLGameRepository extends DefaultGameRepository {
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int)(getAllocatedMemory(
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
vs.getMaxMemory() * 1024L * 1024L,
OperatingSystem.getPhysicalMemoryStatus().getAvailable(),
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
vs.isAutoMemory()
) / 1024 / 1024))
.setMinMemory(vs.getMinMemory())
@@ -406,6 +413,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
.setPreLaunchCommand(vs.getPreLaunchCommand())
.setPostExitCommand(vs.getPostExitCommand())
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
.setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs())
.setNativesDirType(vs.getNativesDirType())
.setNativesDir(vs.getNativesDir())
.setProcessPriority(vs.getProcessPriority())
@@ -415,23 +423,27 @@ public class HMCLGameRepository extends DefaultGameRepository {
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
if (config().hasProxy()) {
builder.setProxy(ProxyManager.getProxy());
builder.setProxyType(config().getProxyType());
builder.setProxyHost(config().getProxyHost());
builder.setProxyPort(config().getProxyPort());
if (config().hasProxyAuth()) {
builder.setProxyUser(config().getProxyUser());
builder.setProxyPass(config().getProxyPass());
}
}
File json = getModpackConfiguration(version);
if (json.exists()) {
Path json = getModpackConfiguration(version);
if (Files.exists(json)) {
try {
String jsonText = FileUtils.readText(json);
String jsonText = Files.readString(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
e.printStackTrace();
LOG.warning("Failed to parse modpack configuration file " + json, e);
}
}
@@ -442,8 +454,8 @@ public class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public File getModpackConfiguration(String version) {
return new File(getVersionRoot(version), "modpack.cfg");
public Path getModpackConfiguration(String version) {
return getVersionRoot(version).resolve("modpack.cfg");
}
public void markVersionAsModpack(String id) {
@@ -456,16 +468,24 @@ public class HMCLGameRepository extends DefaultGameRepository {
public void markVersionLaunchedAbnormally(String id) {
try {
Files.createFile(getVersionRoot(id).toPath().resolve(".abnormal"));
Files.createFile(getVersionRoot(id).resolve(".abnormal"));
} catch (IOException ignored) {
}
}
public boolean unmarkVersionLaunchedAbnormally(String id) {
File file = new File(getVersionRoot(id), ".abnormal");
boolean result = file.isFile();
file.delete();
return result;
Path file = getVersionRoot(id).resolve(".abnormal");
if (Files.isRegularFile(file)) {
try {
Files.delete(file);
} catch (IOException e) {
LOG.warning("Failed to delete abnormal mark file: " + file, e);
}
return true;
} else {
return false;
}
}
private static final Gson GSON = new GsonBuilder()
@@ -487,7 +507,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
return false;
return OperatingSystem.isNameValid(id);
return FileUtils.isNameValid(id);
}
/**
@@ -509,16 +529,16 @@ public class HMCLGameRepository extends DefaultGameRepository {
public static long getAllocatedMemory(long minimum, long available, boolean auto) {
if (auto) {
available -= 384 * 1024 * 1024; // Reserve 384MiB memory for off-heap memory and HMCL itself
available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
if (available <= 0) {
return minimum;
}
final long threshold = 8L * 1024 * 1024 * 1024;
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
final long suggested = Math.min(available <= threshold
? (long) (available * 0.8)
: (long) (threshold * 0.8 + (available - threshold) * 0.2),
16384L * 1024 * 1024);
16L * 1024 * 1024 * 1024);
return Math.max(minimum, suggested);
} else {
return minimum;
@@ -28,16 +28,16 @@ import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class HMCLModpackInstallTask extends Task<Void> {
private final File zipFile;
private final Path zipFile;
private final String name;
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependency;
@@ -45,16 +45,16 @@ public final class HMCLModpackInstallTask extends Task<Void> {
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final List<Task<?>> dependents = new ArrayList<>(4);
public HMCLModpackInstallTask(Profile profile, File zipFile, Modpack modpack, String name) {
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
dependency = profile.getDependency();
repository = profile.getRepository();
this.zipFile = zipFile;
this.name = name;
this.modpack = modpack;
File run = repository.getRunDirectory(name);
File json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && !json.exists())
Path run = repository.getRunDirectory(name);
Path json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && Files.notExists(json))
throw new IllegalArgumentException("Version " + name + " already exists");
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
@@ -65,8 +65,8 @@ public final class HMCLModpackInstallTask extends Task<Void> {
ModpackConfiguration<Modpack> config = null;
try {
if (json.exists()) {
config = JsonUtils.GSON.fromJson(FileUtils.readText(json), ModpackConfiguration.typeOf(Modpack.class));
if (Files.exists(json)) {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
@@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
@@ -49,15 +48,14 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, File zipFile, Modpack modpack) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
if (!(dependencyManager.getGameRepository() instanceof HMCLGameRepository)) {
if (!(dependencyManager.getGameRepository() instanceof HMCLGameRepository repository)) {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
HMCLGameRepository repository = (HMCLGameRepository) dependencyManager.getGameRepository();
Profile profile = repository.getProfile();
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
@@ -79,9 +77,9 @@ public final class HMCLModpackProvider implements ModpackProvider {
return manifest;
}
private static class HMCLModpack extends Modpack {
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, File zipFile, String name) {
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
}
}
@@ -49,20 +49,23 @@ import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.lang.ref.WeakReference;
import static javafx.application.Platform.runLater;
import static javafx.application.Platform.setImplicitExit;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -74,7 +77,7 @@ public final class LauncherHelper {
private final Profile profile;
private Account account;
private final String selectedVersion;
private File scriptFile;
private Path scriptFile;
private final VersionSetting setting;
private LauncherVisibility launcherVisibility;
private boolean showLogs;
@@ -117,13 +120,15 @@ public final class LauncherHelper {
launch0();
}
public void makeLaunchScript(File scriptFile) {
public void makeLaunchScript(Path scriptFile) {
this.scriptFile = Objects.requireNonNull(scriptFile);
launch();
}
private void launch0() {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
HMCLGameRepository repository = profile.getRepository();
DefaultDependencyManager dependencyManager = profile.getDependency();
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
@@ -156,16 +161,16 @@ public final class LauncherHelper {
Task.composeAsync(() -> {
Renderer renderer = setting.getRenderer();
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Library lib = NativePatcher.getMesaLoader(java);
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
File file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
if (file.getAbsolutePath().indexOf('=') >= 0) {
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
}
String agent = file.getAbsolutePath() + "=" + renderer.name().toLowerCase(Locale.ROOT);
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT);
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
@@ -186,7 +191,7 @@ public final class LauncherHelper {
LaunchOptions launchOptions = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModManager(selectedVersion).getModsDirectory(), 10));
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
return new HMCLGameLauncher(
repository,
@@ -208,7 +213,7 @@ public final class LauncherHelper {
}
}).thenAcceptAsync(process -> { // process is LaunchTask's result
if (scriptFile == null) {
PROCESSES.add(process);
PROCESSES.add(new WeakReference<>(process));
if (launcherVisibility == LauncherVisibility.CLOSE)
Launcher.stopApplication();
else
@@ -219,7 +224,7 @@ public final class LauncherHelper {
} else {
runLater(() -> {
launchingStepsPane.fireEvent(new DialogCloseEvent());
Controllers.dialog(i18n("version.launch_script.success", scriptFile.getAbsolutePath()));
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
});
}
}).withFakeProgress(
@@ -262,27 +267,27 @@ public final class LauncherHelper {
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
int responseCode = rce.getResponseCode();
URL url = rce.getUrl();
String uri = rce.getUri();
if (responseCode == 404)
message += i18n("download.code.404", url);
message += i18n("download.code.404", uri);
else
message += i18n("download.failed", url, responseCode);
message += i18n("download.failed", uri, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException) {
URL url = ((DownloadException) ex).getUrl();
URI uri = ((DownloadException) ex).getUri();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", url);
message = i18n("install.failed.downloading.timeout", uri);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -293,11 +298,11 @@ public final class LauncherHelper {
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
int responseCode = rce.getResponseCode();
URL url = rce.getUrl();
String uri = rce.getUri();
if (responseCode == 404)
message = i18n("download.code.404", url);
message = i18n("download.code.404", uri);
else
message = i18n("download.failed", url, responseCode);
message = i18n("download.failed", uri, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
@@ -445,7 +450,9 @@ public final class LauncherHelper {
return result;
} else {
GameJavaVersion gameJavaVersion;
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM_JAVA_21))
gameJavaVersion = GameJavaVersion.JAVA_21;
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
@@ -535,6 +542,9 @@ public final class LauncherHelper {
case MODDED_JAVA_21:
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
break;
case CLEANROOM_JAVA_21:
suggestions.add(i18n("launch.advice.cleanroom"));
break;
case VANILLA_JAVA_8_51:
suggestions.add(i18n("launch.advice.java8_51_1_13"));
break;
@@ -553,8 +563,9 @@ public final class LauncherHelper {
}
// Cannot allocate too much memory exceeding free space.
if (OperatingSystem.TOTAL_MEMORY > 0 && OperatingSystem.TOTAL_MEMORY < setting.getMaxMemory()) {
suggestions.add(i18n("launch.advice.not_enough_space", OperatingSystem.TOTAL_MEMORY));
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) {
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
}
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
@@ -696,6 +707,7 @@ public final class LauncherHelper {
*/
private final class HMCLProcessListener implements ProcessListener {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final LaunchOptions launchOptions;
@@ -839,21 +851,27 @@ public final class LauncherHelper {
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
synchronized (this) {
lock.lock();
try {
logs.addLast(new Log(log, level));
if (logs.size() > Log.getLogLines())
logs.removeFirst();
} finally {
lock.unlock();
}
}
if (!lwjgl) {
String lowerCaseLog = log.toLowerCase(Locale.ROOT);
if (!detectWindow || lowerCaseLog.contains("lwjgl version") || lowerCaseLog.contains("lwjgl openal")) {
synchronized (this) {
lock.lock();
try {
if (!lwjgl) {
lwjgl = true;
finishLaunch();
}
} finally {
lock.unlock();
}
}
}
@@ -878,9 +896,12 @@ public final class LauncherHelper {
// Game crashed before opening the game window.
if (!lwjgl) {
synchronized (this) {
lock.lock();
try {
if (!lwjgl)
finishLaunch();
} finally {
lock.unlock();
}
}
@@ -894,10 +915,18 @@ public final class LauncherHelper {
}
public static final Queue<ManagedProcess> PROCESSES = new ConcurrentLinkedQueue<>();
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
public static int countMangedProcesses() {
PROCESSES.removeIf(it -> {
ManagedProcess process = it.get();
return process == null || !process.isRunning();
});
return PROCESSES.size();
}
public static void stopManagedProcesses() {
while (!PROCESSES.isEmpty())
Optional.ofNullable(PROCESSES.poll()).ifPresent(ManagedProcess::stop);
Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop);
}
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
@@ -41,9 +42,9 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public SearchResult search(String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
@@ -65,7 +66,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
@@ -17,12 +17,13 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.management.ManagementFactory;
@@ -43,8 +44,8 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
Path runDirectory = gameRepository.getRunDirectory(versionId).toPath();
Path baseDirectory = gameRepository.getBaseDirectory().toPath();
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
String currentVersionId = versionId;
@@ -93,9 +94,8 @@ public final class LogExporter {
if (Files.isRegularFile(file)) {
FileTime time = Files.readAttributes(file, BasicFileAttributes.class).lastModifiedTime();
if (time.toMillis() >= processStartTime) {
try {
String crashLog = Logger.filterForbiddenToken(FileUtils.readText(file, OperatingSystem.NATIVE_CHARSET));
zipper.putTextFile(crashLog, file.getFileName().toString());
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
} catch (IOException e) {
LOG.warning("Failed to read log file: " + file, e);
}
@@ -19,11 +19,13 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.mod.*;
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
@@ -42,7 +44,6 @@ import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
@@ -50,10 +51,7 @@ import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -61,7 +59,8 @@ import static org.jackhuang.hmcl.util.Lang.toIterable;
import static org.jackhuang.hmcl.util.Pair.pair;
public final class ModpackHelper {
private ModpackHelper() {}
private ModpackHelper() {
}
private static final Map<String, ModpackProvider> providers = mapOf(
pair(CurseModpackProvider.INSTANCE.getName(), CurseModpackProvider.INSTANCE),
@@ -72,12 +71,16 @@ public final class ModpackHelper {
pair(HMCLModpackProvider.INSTANCE.getName(), HMCLModpackProvider.INSTANCE)
);
static {
MultiMCComponents.setImplementation(Metadata.FULL_TITLE);
}
@Nullable
public static ModpackProvider getProviderByType(String type) {
return providers.get(type);
}
public static boolean isFileModpackByExtension(File file) {
public static boolean isFileModpackByExtension(Path file) {
String ext = FileUtils.getExtension(file);
return "zip".equals(ext) || "mrpack".equals(ext);
}
@@ -135,15 +138,12 @@ public final class ModpackHelper {
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
}
public static ModpackConfiguration<?> readModpackConfiguration(File file) throws IOException {
if (!file.exists())
throw new FileNotFoundException(file.getPath());
else
try {
return JsonUtils.GSON.fromJson(FileUtils.readText(file), ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration");
}
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
try {
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration");
}
}
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
@@ -174,21 +174,21 @@ public final class ModpackHelper {
return Files.exists(Paths.get("externalgames").resolve(name));
}
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, File zipFile, String name, Charset charset) {
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
if (isExternalGameNameConflicts(name)) {
throw new IllegalArgumentException("name existing");
}
return new ManuallyCreatedModpackInstallTask(profile, zipFile.toPath(), charset, name)
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
.thenAcceptAsync(Schedulers.javafx(), location -> {
Profile newProfile = new Profile(name, location.toFile());
Profile newProfile = new Profile(name, location);
newProfile.setUseRelativePath(true);
Profiles.getProfiles().add(newProfile);
Profiles.setSelectedProfile(newProfile);
});
}
public static Task<?> getInstallTask(Profile profile, File zipFile, String name, Modpack modpack) {
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) {
profile.getRepository().markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
@@ -210,14 +210,17 @@ public final class ModpackHelper {
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name));
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
else
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.javafx(), success, failure);
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
}
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
@@ -230,13 +233,17 @@ public final class ModpackHelper {
}
}
public static Task<?> getUpdateTask(Profile profile, File zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile.toPath(), charset);
public static Task<?> getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
ModpackProvider provider = getProviderByType(configuration.getType());
if (provider == null) {
throw new UnsupportedModpackException();
}
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
else
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
}
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
@@ -276,6 +283,24 @@ public final class ModpackHelper {
}
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
if (c.isOverrideCommands()) {
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
}
}
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
});
}
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -103,6 +104,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%close-page%", i18n("account.methods.microsoft.close_page"));
} catch (IOException e) {
LOG.error("Failed to load html", e);
@@ -158,13 +160,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
@Override
public String getClientId() {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.getManifestAttribute("Microsoft-Auth-Id", ""));
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.getManifestAttribute("Microsoft-Auth-Secret", ""));
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
}
@Override
@@ -26,8 +26,6 @@ import javafx.beans.value.ObservableValue;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
@@ -45,7 +43,6 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -87,7 +84,7 @@ public final class TexturesLoader {
}
private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS);
private static final Path TEXTURES_DIR = Metadata.HMCL_DIRECTORY.resolve("skins");
private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins");
private static Path getTexturePath(Texture texture) {
String url = texture.getUrl();
@@ -110,7 +107,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(new URL(texture.getUrl()), file.toFile()).run();
new FileDownloadTask(texture.getUrl(), file).run();
LOG.info("Texture downloaded: " + texture.getUrl());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -265,15 +262,8 @@ public final class TexturesLoader {
int scale = (int) skin.getWidth() / 64;
int faceOffset = (int) Math.round(size / 18.0);
try {
g.setImageSmoothing(false);
drawAvatar(g, skin, size, scale, faceOffset);
} catch (NoSuchMethodError ignored) {
// Earlier JavaFX did not support GraphicsContext::setImageSmoothing
// In order to prevent the blurring caused by bilinear interpolation,
// we use the self-implemented nearest neighbor interpolation to scale
drawAvatarSlow(g, skin, size, scale, faceOffset);
}
g.setImageSmoothing(false);
drawAvatar(g, skin, size, scale, faceOffset);
}
private static void drawAvatar(GraphicsContext g, Image skin, int size, int scale, int faceOffset) {
@@ -285,37 +275,6 @@ public final class TexturesLoader {
0, 0, size, size);
}
private static void drawAvatarSlow(GraphicsContext g, Image skin, int size, int scale, int faceOffset) {
PixelReader reader = skin.getPixelReader();
PixelWriter writer = g.getPixelWriter();
drawImage(writer, reader,
8 * scale, 8 * scale, 8 * scale, 8 * scale,
faceOffset, faceOffset, size - 2 * faceOffset, size - 2 * faceOffset);
drawImage(writer, reader,
40 * scale, 8 * scale, 8 * scale, 8 * scale,
0, 0, size, size);
}
/*
* Scale and draw image using nearest-neighbor interpolation
*/
private static void drawImage(PixelWriter writer, PixelReader reader,
int sx, int sy, int sw, int sh,
int dx, int dy, int dw, int dh) {
double xScale = ((double) sw) / dw;
double yScale = ((double) sh) / dh;
for (int xOffset = 0; xOffset < dw; xOffset++) {
for (int yOffset = 0; yOffset < dh; yOffset++) {
int color = reader.getArgb(sx + (int) (xOffset * xScale), sy + (int) (yOffset * yScale));
// Draw only non-transparent pixels
if ((color >>> 24) != 0)
writer.setArgb(dx + xOffset, dy + yOffset, color);
}
}
}
private static final class SkinBindingChangeListener implements ChangeListener<LoadedTexture> {
static final WeakHashMap<Canvas, SkinBindingChangeListener> hole = new WeakHashMap<>();
@@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.platform.Platform;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
@@ -83,7 +82,7 @@ public final class HMCLJavaRepository implements JavaRepository {
try {
return JavaManager.getExecutable(javaDir).toRealPath();
} catch (IOException ignored) {
if (platform.getOperatingSystem() == OperatingSystem.OSX) {
if (platform.getOperatingSystem() == OperatingSystem.MACOS) {
try {
return JavaManager.getMacExecutable(javaDir).toRealPath();
} catch (IOException ignored1) {
@@ -98,14 +97,8 @@ public final class HMCLJavaRepository implements JavaRepository {
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
}
@Override
public Collection<JavaRuntime> getAllJava(Platform platform) {
Path root = getPlatformRoot(platform);
if (!Files.isDirectory(root))
return Collections.emptyList();
ArrayList<JavaRuntime> list = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(root)) {
private static void getAllJava(List<JavaRuntime> list, Platform platform, Path platformRoot, boolean isManaged) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(platformRoot)) {
for (Path file : stream) {
try {
String name = file.getFileName().toString();
@@ -115,19 +108,15 @@ public final class HMCLJavaRepository implements JavaRepository {
try {
executable = JavaManager.getExecutable(javaDir).toRealPath();
} catch (IOException e) {
if (platform.getOperatingSystem() == OperatingSystem.OSX)
if (platform.getOperatingSystem() == OperatingSystem.MACOS)
executable = JavaManager.getMacExecutable(javaDir).toRealPath();
else
throw e;
}
if (Files.isDirectory(javaDir)) {
JavaManifest manifest;
try (InputStream input = Files.newInputStream(file)) {
manifest = JsonUtils.fromJsonFully(input, JavaManifest.class);
}
list.add(JavaRuntime.of(executable, manifest.getInfo(), true));
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
}
}
} catch (Throwable e) {
@@ -137,6 +126,23 @@ public final class HMCLJavaRepository implements JavaRepository {
} catch (IOException ignored) {
}
}
@Override
public Collection<JavaRuntime> 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);
}
return list;
}
@@ -149,7 +155,7 @@ public final class HMCLJavaRepository implements JavaRepository {
try {
executable = JavaManager.getExecutable(javaDir).toRealPath();
} catch (IOException e) {
if (platform.getOperatingSystem() == OperatingSystem.OSX)
if (platform.getOperatingSystem() == OperatingSystem.MACOS)
executable = JavaManager.getMacExecutable(javaDir).toRealPath();
else
throw e;
@@ -157,7 +163,7 @@ public final class HMCLJavaRepository implements JavaRepository {
JavaInfo info;
if (JavaManager.isCompatible(platform))
info = JavaInfo.fromExecutable(executable, false);
info = JavaInfoUtils.fromExecutable(executable, false);
else
info = new JavaInfo(platform, result.download.getVersion().getName(), null);
@@ -180,7 +186,7 @@ public final class HMCLJavaRepository implements JavaRepository {
});
JavaManifest manifest = new JavaManifest(info, update, files);
FileUtils.writeText(getManifestFile(platform, gameJavaVersion), JsonUtils.GSON.toJson(manifest));
JsonUtils.writeToJsonFile(getManifestFile(platform, gameJavaVersion), manifest);
return JavaRuntime.of(executable, info, true);
});
}
@@ -192,7 +198,7 @@ public final class HMCLJavaRepository implements JavaRepository {
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
FileUtils.writeText(getManifestFile(platform, name), JsonUtils.GSON.toJson(result));
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
return JavaRuntime.of(executable, result.getInfo(), true);
});
}
@@ -201,7 +207,7 @@ public final class HMCLJavaRepository implements JavaRepository {
public Task<Void> getUninstallJavaTask(Platform platform, String name) {
return Task.runAsync(() -> {
Files.deleteIfExists(getManifestFile(platform, name));
FileUtils.deleteDirectory(getJavaDir(platform, name).toFile());
FileUtils.deleteDirectory(getJavaDir(platform, name));
});
}
@@ -214,7 +220,7 @@ public final class HMCLJavaRepository implements JavaRepository {
if (relativized.getNameCount() > 1) {
String name = relativized.getName(0).toString();
Files.deleteIfExists(getManifestFile(java.getPlatform(), name));
FileUtils.deleteDirectory(getJavaDir(java.getPlatform(), name).toFile());
FileUtils.deleteDirectory(getJavaDir(java.getPlatform(), name));
}
});
}
@@ -0,0 +1,116 @@
/*
* 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.java;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
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;
/**
* @author Glavo
* @see <a href="https://github.com/Glavo/java-info">Glavo/java-info</a>
*/
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 {
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");
}
try {
Result result = JsonUtils.GSON.fromJson(SystemUtils.run(
executable.toString(),
"-classpath",
thisPath.toString(),
org.glavo.info.Main.class.getName()
), Result.class);
if (result == null) {
throw new IOException("Failed to get Java info from " + executable);
}
if (result.javaVersion == null) {
throw new IOException("Failed to get Java version from " + executable);
}
Architecture architecture = Architecture.parseArchName(result.osArch);
Platform platform = Platform.getPlatform(OperatingSystem.CURRENT_OS,
architecture != Architecture.UNKNOWN
? architecture
: Architecture.SYSTEM_ARCH);
return new JavaInfo(platform, result.javaVersion, result.javaVendor);
} catch (IOException e) {
throw e;
} catch (Throwable e) {
throw new IOException(e);
}
}
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;
}
}
@@ -20,17 +20,20 @@ package org.jackhuang.hmcl.java;
import kala.compress.archivers.ArchiveEntry;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.Hex;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import java.io.*;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -98,10 +101,9 @@ public final class JavaInstallTask extends Task<JavaManifest> {
}
if (tree.isExecutable(entry))
//noinspection ResultOfMethodCallIgnored
path.toFile().setExecutable(true);
FileUtils.setExecutable(path);
files.put(String.join("/", nameStack), new JavaLocalFiles.LocalFile(Hex.encodeHex(messageDigest.digest()), size));
files.put(String.join("/", nameStack), new JavaLocalFiles.LocalFile(HexFormat.of().formatHex(messageDigest.digest()), size));
}
nameStack.remove(nameStack.size() - 1);
}
@@ -31,18 +31,15 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.CountDownLatch;
@@ -58,7 +55,8 @@ public final class JavaManager {
private JavaManager() {
}
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_DIRECTORY.resolve("java"));
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"));
public static String getMojangJavaPlatform(Platform platform) {
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
@@ -75,7 +73,7 @@ public final class JavaManager {
} else if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "linux";
}
} else if (platform.getOperatingSystem() == OperatingSystem.OSX) {
} else if (platform.getOperatingSystem() == OperatingSystem.MACOS) {
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "mac-os";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
@@ -113,7 +111,7 @@ public final class JavaManager {
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
return architecture == Architecture.X86;
break;
case OSX:
case MACOS:
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
return architecture == Architecture.X86_64;
break;
@@ -162,7 +160,7 @@ public final class JavaManager {
return javaRuntime;
}
JavaInfo info = JavaInfo.fromExecutable(executable);
JavaInfo info = JavaInfoUtils.fromExecutable(executable, true);
return JavaRuntime.of(executable, info, false);
}
@@ -211,9 +209,18 @@ public final class JavaManager {
public static Task<Void> getUninstallJavaTask(JavaRuntime java) {
assert java.isManaged();
Path root = REPOSITORY.getPlatformRoot(java.getPlatform());
Path relativized = root.relativize(java.getBinary());
Path platformRoot;
try {
platformRoot = REPOSITORY.getPlatformRoot(java.getPlatform()).toRealPath();
} catch (Throwable ignored) {
return Task.completed(null);
}
if (!java.getBinary().startsWith(platformRoot))
return Task.completed(null);
Path relativized = platformRoot.relativize(java.getBinary());
if (relativized.getNameCount() > 1) {
FXUtils.runInFX(() -> {
try {
@@ -257,16 +264,15 @@ public final class JavaManager {
}
}
private static int compareJavaVersion(JavaRuntime java1, JavaRuntime java2, GameJavaVersion suggestedJavaVersion) {
if (suggestedJavaVersion != null) {
boolean b1 = java1.getParsedVersion() == suggestedJavaVersion.getMajorVersion();
boolean b2 = java2.getParsedVersion() == suggestedJavaVersion.getMajorVersion();
private static JavaRuntime chooseJava(@Nullable JavaRuntime java1, JavaRuntime java2) {
if (java1 == null)
return java2;
if (b1 != b2)
return b1 ? 1 : -1;
}
return java1.getVersionNumber().compareTo(java2.getVersionNumber());
if (java1.getParsedVersion() != java2.getParsedVersion())
// Prefer the Java version that is closer to the game's recommended Java version
return java1.getParsedVersion() < java2.getParsedVersion() ? java1 : java2;
else
return java1.getVersionNumber().compareTo(java2.getVersionNumber()) >= 0 ? java1 : java2;
}
@Nullable
@@ -279,12 +285,9 @@ public final class JavaManager {
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
&& (gameVersion == null || gameVersion.compareTo("1.6") < 0);
GameJavaVersion suggestedJavaVersion =
(version != null && gameVersion != null && gameVersion.compareTo("1.7.10") >= 0) ? version.getJavaVersion() : null;
JavaRuntime mandatory = null;
JavaRuntime suggested = null;
for (JavaRuntime java : javaRuntimes) {
@@ -312,15 +315,10 @@ public final class JavaManager {
}
if (!violationMandatory) {
if (mandatory == null) mandatory = java;
else if (compareJavaVersion(java, mandatory, suggestedJavaVersion) > 0)
mandatory = java;
mandatory = chooseJava(mandatory, java);
if (!violationSuggested) {
if (suggested == null) suggested = java;
else if (compareJavaVersion(java, suggested, suggestedJavaVersion) > 0)
suggested = java;
}
if (!violationSuggested)
suggested = chooseJava(suggested, java);
}
}
@@ -349,18 +347,18 @@ public final class JavaManager {
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
}
break;
case OSX:
case MACOS:
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
searchAllJavaInRepository(javaRuntimes, Platform.OSX_X86_64);
searchAllJavaInRepository(javaRuntimes, Platform.MACOS_X86_64);
break;
}
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
queryJavaInRegistryKey(javaRuntimes, "HKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\Java Runtime Environment\\");
queryJavaInRegistryKey(javaRuntimes, "HKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\Java Development Kit\\");
queryJavaInRegistryKey(javaRuntimes, "HKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JRE\\");
queryJavaInRegistryKey(javaRuntimes, "HKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\");
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");
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles", "C:\\Program Files");
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(x86)", "C:\\Program Files (x86)");
@@ -375,7 +373,7 @@ public final class JavaManager {
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib64/jvm")); // General locations
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
break;
case OSX:
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"));
@@ -405,24 +403,31 @@ public final class JavaManager {
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, 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);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
}
searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
String[] paths = System.getenv("PATH").split(OperatingSystem.PATH_SEPARATOR);
String[] paths = System.getenv("PATH").split(File.pathSeparator);
for (String path : paths) {
// https://github.com/HMCL-dev/HMCL/issues/4079
// https://github.com/Meloong-Git/PCL/issues/4261
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && path.toLowerCase(Locale.ROOT)
.contains("\\common files\\oracle\\java\\")) {
continue;
}
try {
tryAddJavaExecutable(javaRuntimes, Paths.get(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
} catch (InvalidPathException ignored) {
}
}
}
if (System.getenv("HMCL_JRES") != null) {
String[] paths = System.getenv("HMCL_JRES").split(OperatingSystem.PATH_SEPARATOR);
String[] paths = System.getenv("HMCL_JRES").split(File.pathSeparator);
for (String path : paths) {
try {
tryAddJavaHome(javaRuntimes, Paths.get(path));
@@ -483,12 +488,15 @@ public final class JavaManager {
try {
info = JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException e) {
try {
info = JavaInfo.fromExecutable(executable, false);
} catch (IOException e2) {
e2.addSuppressed(e);
LOG.warning("Failed to lookup Java executable at " + executable, e2);
}
LOG.warning("Failed to read release file " + releaseFile, e);
}
}
if (info == null) {
try {
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
}
}
@@ -509,7 +517,7 @@ public final class JavaManager {
JavaInfo info = null;
try {
info = JavaInfo.fromExecutable(executable);
info = JavaInfoUtils.fromExecutable(executable, true);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
}
@@ -548,7 +556,7 @@ public final class JavaManager {
}
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Path macPath = dir.resolve("jre.bundle/Contents/Home");
if (Files.exists(macPath)) {
tryAddJavaHome(javaRuntimes, macPath);
@@ -564,6 +572,10 @@ public final class JavaManager {
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) {
@@ -587,8 +599,8 @@ public final class JavaManager {
}
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.CURRENT_ARCH == Architecture.ARM64) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.OSX_X86_64), verify);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
}
}
@@ -646,67 +658,22 @@ public final class JavaManager {
}
// ==== Windows Registry Support ====
private static void queryJavaInRegistryKey(Map<Path, JavaRuntime> javaRuntimes, String location) {
for (String java : querySubFolders(location)) {
if (!querySubFolders(java).contains(java + "\\MSI"))
private static void queryJavaInRegistryKey(Map<Path, JavaRuntime> javaRuntimes, 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;
String home = queryRegisterValue(java, "JavaHome");
if (home != null) {
Object home = reg.queryValue(hkey, java, "JavaHome");
if (home instanceof String) {
try {
tryAddJavaHome(javaRuntimes, Paths.get(home));
tryAddJavaHome(javaRuntimes, Paths.get((String) home));
} catch (InvalidPathException e) {
LOG.warning("Invalid Java path in system registry: " + home);
}
}
}
}
private static List<String> querySubFolders(String location) {
List<String> res = new ArrayList<>();
try {
Process process = Runtime.getRuntime().exec(new String[]{"cmd", "/c", "reg", "query", location});
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), OperatingSystem.NATIVE_CHARSET))) {
for (String line; (line = reader.readLine()) != null; ) {
if (line.startsWith(location) && !line.equals(location)) {
res.add(line);
}
}
}
} catch (IOException e) {
LOG.warning("Failed to query sub folders of " + location, e);
}
return res;
}
private static String queryRegisterValue(String location, String name) {
boolean last = false;
try {
Process process = Runtime.getRuntime().exec(new String[]{"cmd", "/c", "reg", "query", location, "/v", name});
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), OperatingSystem.NATIVE_CHARSET))) {
for (String line; (line = reader.readLine()) != null; ) {
if (StringUtils.isNotBlank(line)) {
if (last && line.trim().startsWith(name)) {
int begins = line.indexOf(name);
if (begins > 0) {
String s2 = line.substring(begins + name.length());
begins = s2.indexOf("REG_SZ");
if (begins > 0) {
return s2.substring(begins + "REG_SZ".length()).trim();
}
}
}
if (location.equals(line.trim())) {
last = true;
}
}
}
}
} catch (IOException e) {
LOG.warning("Failed to query register value of " + location, e);
}
return null;
}
}
@@ -22,6 +22,7 @@ import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.*;
@@ -34,9 +35,8 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import javax.net.ssl.SSLException;
@@ -50,6 +50,7 @@ import java.util.*;
import static java.util.stream.Collectors.toList;
import static javafx.collections.FXCollections.observableArrayList;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -68,18 +69,6 @@ public final class Accounts {
private static final AuthlibInjectorArtifactProvider AUTHLIB_INJECTOR_DOWNLOADER = createAuthlibInjectorArtifactProvider();
private static void triggerAuthlibInjectorUpdateCheck() {
if (AUTHLIB_INJECTOR_DOWNLOADER instanceof AuthlibInjectorDownloader) {
Schedulers.io().execute(() -> {
try {
((AuthlibInjectorDownloader) AUTHLIB_INJECTOR_DOWNLOADER).checkUpdate();
} catch (IOException e) {
LOG.warning("Failed to check update for authlib-injector", e);
}
});
}
}
public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory();
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
@@ -173,7 +162,7 @@ public final class Accounts {
}
private static void loadGlobalAccountStorages() {
Path globalAccountsFile = Metadata.HMCL_DIRECTORY.resolve("accounts.json");
Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json");
if (Files.exists(globalAccountsFile)) {
try (Reader reader = Files.newBufferedReader(globalAccountsFile)) {
globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class))));
@@ -182,21 +171,8 @@ public final class Accounts {
}
}
InvocationDispatcher<String> dispatcher = InvocationDispatcher.runOn(Lang::thread, json -> {
LOG.info("Saving global accounts");
synchronized (globalAccountsFile) {
try {
synchronized (globalAccountsFile) {
FileUtils.saveSafely(globalAccountsFile, json);
}
} catch (IOException e) {
LOG.error("Failed to save global accounts", e);
}
}
});
globalAccountStorages.addListener(onInvalidating(() ->
dispatcher.accept(Config.CONFIG_GSON.toJson(globalAccountStorages))));
FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages))));
}
private static Account parseAccount(Map<Object, Object> storage) {
@@ -277,6 +253,30 @@ public final class Accounts {
selected = accounts.get(0);
}
if (!globalConfig().isEnableOfflineAccount())
for (Account account : accounts) {
if (account instanceof MicrosoftAccount) {
globalConfig().setEnableOfflineAccount(true);
break;
}
}
if (!globalConfig().isEnableOfflineAccount())
accounts.addListener(new ListChangeListener<Account>() {
@Override
public void onChanged(Change<? extends Account> change) {
while (change.next()) {
for (Account account : change.getAddedSubList()) {
if (account instanceof MicrosoftAccount) {
accounts.removeListener(this);
globalConfig().setEnableOfflineAccount(true);
return;
}
}
}
}
});
selectedAccount.set(selected);
InvalidationListener listener = o -> {
@@ -326,8 +326,6 @@ public final class Accounts {
});
}
triggerAuthlibInjectorUpdateCheck();
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
continue;
@@ -335,7 +333,7 @@ public final class Accounts {
try {
server.fetchMetadataResponse();
} catch (IOException e) {
LOG.warning("Failed to fetch authlib-injector server metdata: " + server, e);
LOG.warning("Failed to fetch authlib-injector server metadata: " + server, e);
}
});
}
@@ -360,24 +358,18 @@ public final class Accounts {
// ==== authlib-injector ====
private static AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProvider() {
String authlibinjectorLocation = System.getProperty("hmcl.authlibinjector.location");
if (authlibinjectorLocation == null) {
return new AuthlibInjectorDownloader(
Metadata.HMCL_DIRECTORY.resolve("authlib-injector.jar"),
DownloadProviders::getDownloadProvider) {
@Override
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
Optional<AuthlibInjectorArtifactInfo> local = super.getArtifactInfoImmediately();
if (local.isPresent()) {
return local;
}
// search authlib-injector.jar in current directory, it's used as a fallback
return parseArtifact(Paths.get("authlib-injector.jar"));
}
};
} else {
if (authlibinjectorLocation != null) {
LOG.info("Using specified authlib-injector: " + authlibinjectorLocation);
return new SimpleAuthlibInjectorArtifactProvider(Paths.get(authlibinjectorLocation));
}
String authlibInjectorVersion = JarUtils.getAttribute("hmcl.authlib-injector.version", null);
if (authlibInjectorVersion == null)
throw new AssertionError("Missing hmcl.authlib-injector.version");
String authlibInjectorFileName = "authlib-injector-" + authlibInjectorVersion + ".jar";
return new AuthlibInjectorExtractor(Accounts.class.getResource("/assets/" + authlibInjectorFileName),
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve(authlibInjectorFileName));
}
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
@@ -463,6 +455,8 @@ public final class Accounts {
return i18n("account.methods.microsoft.error.country_unavailable");
} else if (errorCode == MicrosoftService.XboxAuthorizationException.MISSING_XBOX_ACCOUNT) {
return i18n("account.methods.microsoft.error.missing_xbox_account");
} else if (errorCode == MicrosoftService.XboxAuthorizationException.BANNED) {
return i18n("account.methods.microsoft.error.banned");
} else {
return i18n("account.methods.microsoft.error.unknown", errorCode);
}
@@ -21,10 +21,10 @@ import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import java.io.IOException;
@@ -38,6 +38,7 @@ import java.util.concurrent.CopyOnWriteArraySet;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonSerializable
public final class AuthlibInjectorServers implements Validation {
public static final String CONFIG_FILENAME = "authlib-injectors.json";
@@ -73,8 +74,7 @@ public final class AuthlibInjectorServers implements Validation {
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
AuthlibInjectorServers configInstance;
try {
String content = FileUtils.readText(configLocation);
configInstance = JsonUtils.GSON.fromJson(content, AuthlibInjectorServers.class);
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
} catch (IOException | JsonParseException e) {
LOG.warning("Malformed authlib-injectors.json", e);
return;
File diff suppressed because it is too large. Load diff
@@ -19,8 +19,7 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
@@ -39,13 +38,14 @@ public final class ConfigHolder {
public static final String CONFIG_FILENAME = "hmcl.json";
public static final String CONFIG_FILENAME_LINUX = ".hmcl.json";
public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_DIRECTORY.resolve("config.json");
public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json");
private static Path configLocation;
private static Config configInstance;
private static GlobalConfig globalConfigInstance;
private static boolean newlyCreated;
private static boolean ownerChanged = false;
private static boolean unsupportedVersion = false;
public static Config config() {
if (configInstance == null) {
@@ -73,6 +73,10 @@ public final class ConfigHolder {
return ownerChanged;
}
public static boolean isUnsupportedVersion() {
return unsupportedVersion;
}
public static void init() throws IOException {
if (configInstance != null) {
throw new IllegalStateException("Configuration is already loaded");
@@ -83,10 +87,11 @@ public final class ConfigHolder {
LOG.info("Config location: " + configLocation);
configInstance = loadConfig();
configInstance.addListener(source -> markConfigDirty());
if (!unsupportedVersion)
configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson()));
globalConfigInstance = loadGlobalConfig();
globalConfigInstance.addListener(source -> markGlobalConfigDirty());
globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson()));
Locale.setDefault(config().getLocalization().getLocale());
I18n.setLocale(configInstance.getLocalization());
@@ -94,22 +99,15 @@ public final class ConfigHolder {
Settings.init();
if (newlyCreated) {
saveConfigSync();
// hide the config file on windows
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
Files.setAttribute(configLocation, "dos:hidden", true);
} catch (IOException e) {
LOG.warning("Failed to set hidden attribute of " + configLocation, e);
}
}
LOG.info("Creating config file " + configLocation);
FileUtils.saveSafely(configLocation, configInstance.toJson());
}
if (!Files.isWritable(configLocation)) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& configLocation.getFileSystem() == FileSystems.getDefault()
&& configLocation.toFile().canWrite()) {
LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug");
// There are some serious problems with the implementation of Samba or OpenJDK
throw new SambaException();
} else {
@@ -121,12 +119,14 @@ public final class ConfigHolder {
}
private static Path locateConfig() {
Path exePath = Paths.get("").toAbsolutePath();
Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME);
if (Files.isRegularFile(defaultConfigFile))
return defaultConfigFile;
try {
Path jarPath = JarUtils.thisJarPath();
if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) {
jarPath = jarPath.getParent();
exePath = jarPath;
Path config = jarPath.resolve(CONFIG_FILENAME);
if (Files.isRegularFile(config))
@@ -149,7 +149,7 @@ public final class ConfigHolder {
return dotConfig;
// create new
return exePath.resolve(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? CONFIG_FILENAME : CONFIG_FILENAME_LINUX);
return defaultConfigFile;
}
private static Config loadConfig() throws IOException {
@@ -164,12 +164,19 @@ public final class ConfigHolder {
LOG.warning("Failed to get owner");
}
try {
String content = FileUtils.readText(configLocation);
String content = Files.readString(configLocation);
Config deserialized = Config.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
} else {
ConfigUpgrader.upgradeConfig(deserialized, content);
int configVersion = deserialized.getConfigVersion();
if (configVersion < Config.CURRENT_VERSION) {
ConfigUpgrader.upgradeConfig(deserialized, content);
} else if (configVersion > Config.CURRENT_VERSION) {
unsupportedVersion = true;
LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion));
}
return deserialized;
}
} catch (JsonParseException e) {
@@ -177,40 +184,16 @@ public final class ConfigHolder {
}
}
LOG.info("Creating an empty config");
newlyCreated = true;
return new Config();
}
private static final InvocationDispatcher<String> configWriter = InvocationDispatcher.runOn(Lang::thread, content -> {
try {
writeToConfig(content);
} catch (IOException e) {
LOG.error("Failed to save config", e);
}
});
private static void writeToConfig(String content) throws IOException {
LOG.info("Saving config");
synchronized (configLocation) {
FileUtils.saveSafely(configLocation, content);
}
}
private static void markConfigDirty() {
configWriter.accept(configInstance.toJson());
}
private static void saveConfigSync() throws IOException {
writeToConfig(configInstance.toJson());
}
// Global Config
private static GlobalConfig loadGlobalConfig() throws IOException {
if (Files.exists(GLOBAL_CONFIG_PATH)) {
try {
String content = FileUtils.readText(GLOBAL_CONFIG_PATH);
String content = Files.readString(GLOBAL_CONFIG_PATH);
GlobalConfig deserialized = GlobalConfig.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
@@ -226,22 +209,4 @@ public final class ConfigHolder {
return new GlobalConfig();
}
private static final InvocationDispatcher<String> globalConfigWriter = InvocationDispatcher.runOn(Lang::thread, content -> {
try {
writeToGlobalConfig(content);
} catch (IOException e) {
LOG.error("Failed to save config", e);
}
});
private static void writeToGlobalConfig(String content) throws IOException {
LOG.info("Saving global config");
synchronized (GLOBAL_CONFIG_PATH) {
FileUtils.saveSafely(GLOBAL_CONFIG_PATH, content);
}
}
private static void markGlobalConfigDirty() {
globalConfigWriter.accept(globalConfigInstance.toJson());
}
}
@@ -31,8 +31,6 @@ final class ConfigUpgrader {
private ConfigUpgrader() {
}
private static final int CURRENT_VERSION = 2;
/**
* This method is for the compatibility with old HMCL versions.
*
@@ -42,17 +40,10 @@ final class ConfigUpgrader {
static void upgradeConfig(Config deserialized, String rawContent) {
int configVersion = deserialized.getConfigVersion();
if (configVersion == CURRENT_VERSION) {
if (configVersion >= Config.CURRENT_VERSION)
return;
}
if (configVersion > CURRENT_VERSION) {
LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", CURRENT_VERSION, configVersion));
deserialized.setConfigVersion(CURRENT_VERSION);
return;
}
LOG.info(String.format("Updating configuration from %d to %d.", configVersion, CURRENT_VERSION));
LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION));
Map<?, ?> rawJson = Collections.unmodifiableMap(new Gson().<Map<?, ?>>fromJson(rawContent, Map.class));
if (configVersion < 1) {
@@ -100,6 +91,6 @@ final class ConfigUpgrader {
}
}
deserialized.setConfigVersion(CURRENT_VERSION);
deserialized.setConfigVersion(Config.CURRENT_VERSION);
}
}
@@ -29,25 +29,23 @@ import org.jackhuang.hmcl.util.io.ResponseCodeException;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {}
private DownloadProviders() {
}
private static DownloadProvider currentDownloadProvider;
private static final DownloadProviderWrapper provider;
public static final Map<String, DownloadProvider> providersById;
public static final Map<String, DownloadProvider> rawProviders;
@@ -59,6 +57,7 @@ public final class DownloadProviders {
public static final String DEFAULT_PROVIDER_ID = "balanced";
public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi";
@SuppressWarnings("unused")
private static final InvalidationListener observer;
static {
@@ -68,95 +67,92 @@ public final class DownloadProviders {
MOJANG = new MojangDownloadProvider();
BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot);
rawProviders = mapOf(
pair("mojang", MOJANG),
pair("bmclapi", BMCLAPI)
rawProviders = Map.of(
"mojang", MOJANG,
"bmclapi", BMCLAPI
);
AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider();
fileProvider.setDownloadProviderCandidates(Arrays.asList(BMCLAPI, MOJANG));
fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG));
BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI);
providersById = mapOf(
pair("official", new AutoDownloadProvider(MOJANG, fileProvider)),
pair("balanced", new AutoDownloadProvider(balanced, fileProvider)),
pair("mirror", new AutoDownloadProvider(BMCLAPI, fileProvider)));
providersById = Map.of(
"official", new AutoDownloadProvider(MOJANG, fileProvider),
"balanced", new AutoDownloadProvider(balanced, fileProvider),
"mirror", new AutoDownloadProvider(BMCLAPI, fileProvider));
observer = FXUtils.observeWeak(() -> {
FetchTask.setDownloadExecutorConcurrency(
config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads());
}, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty());
provider = new DownloadProviderWrapper(MOJANG);
}
static void init() {
FXUtils.onChangeAndOperate(config().versionListSourceProperty(), versionListSource -> {
if (!providersById.containsKey(versionListSource)) {
config().setVersionListSource(DEFAULT_PROVIDER_ID);
return;
InvalidationListener onChangeDownloadSource = observable -> {
String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), "");
if (config().isAutoChooseDownloadType()) {
DownloadProvider currentDownloadProvider = providersById.get(versionListSource);
if (currentDownloadProvider == null)
currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID),
"default provider is null");
provider.setProvider(currentDownloadProvider);
} else {
provider.setProvider(fileDownloadProvider);
}
};
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
currentDownloadProvider = Optional.ofNullable(providersById.get(versionListSource))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
});
if (!rawProviders.containsKey(config().getDownloadType())) {
config().setDownloadType(DEFAULT_RAW_PROVIDER_ID);
}
onChangeDownloadSource.invalidated(null);
FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> {
DownloadProvider primary = Optional.ofNullable(rawProviders.get(downloadType))
.orElse(rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
fileDownloadProvider.setDownloadProviderCandidates(
Stream.concat(
Stream.of(primary),
rawProviders.values().stream().filter(x -> x != primary)
).collect(Collectors.toList())
);
DownloadProvider primary = Objects.requireNonNullElseGet(
rawProviders.get(Objects.requireNonNullElse(downloadType, "")),
() -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
List<DownloadProvider> providers = new ArrayList<>(rawProviders.size());
providers.add(primary);
for (DownloadProvider provider : rawProviders.values()) {
if (provider != primary)
providers.add(provider);
}
fileDownloadProvider.setDownloadProviderCandidates(providers);
});
}
public static String getPrimaryDownloadProviderId() {
String downloadType = config().getDownloadType();
if (providersById.containsKey(downloadType))
return downloadType;
else
return DEFAULT_PROVIDER_ID;
}
public static DownloadProvider getDownloadProviderByPrimaryId(String primaryId) {
return Optional.ofNullable(providersById.get(primaryId))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return config().isAutoChooseDownloadType() ? currentDownloadProvider : fileDownloadProvider;
return provider;
}
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException) {
URL url = ((DownloadException) exception).getUrl();
URI uri = ((DownloadException) exception).getUri();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", url);
return i18n("install.failed.downloading.timeout", uri);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
return i18n("download.code." + responseCodeException.getResponseCode(), url);
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", url);
return i18n("download.code.404", uri);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.artifact_malformed");
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.ssl_handshake");
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
@@ -22,5 +22,6 @@ public enum EnumBackgroundImage {
CUSTOM,
CLASSIC,
NETWORK,
TRANSLUCENT
TRANSLUCENT, // Deprecated
PAINT
}
@@ -0,0 +1,294 @@
/*
* 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.setting;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.text.Font;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class FontManager {
public static final String[] FONT_EXTENSIONS = {
"ttf", "otf", "woff"
};
public static final double DEFAULT_FONT_SIZE = 12.0f;
private static final Lazy<Font> DEFAULT_FONT = new Lazy<>(() -> {
Font font;
// Recommended
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
if (font != null)
return font;
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
if (font != null)
return font;
// Legacy
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
if (font != null)
return font;
font = tryLoadDefaultFont(Metadata.CURRENT_DIRECTORY);
if (font != null)
return font;
font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY);
if (font != null)
return font;
Path thisJar = JarUtils.thisJarPath();
if (thisJar != null && thisJar.getParent() != null) {
font = tryLoadDefaultFont(thisJar.getParent());
if (font != null)
return font;
}
// Default
String fcMatchPattern;
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()
&& !(fcMatchPattern = I18n.getLocale().getFcMatchPattern()).isEmpty())
return findByFcMatch(fcMatchPattern);
else
return null;
});
private static final ObjectProperty<FontReference> fontProperty;
static {
String fontFamily = config().getLauncherFontFamily();
if (fontFamily == null)
fontFamily = System.getProperty("hmcl.font.override");
if (fontFamily == null)
fontFamily = System.getenv("HMCL_FONT");
FontReference fontReference;
if (fontFamily == null) {
Font defaultFont = DEFAULT_FONT.get();
fontReference = defaultFont != null ? new FontReference(defaultFont) : null;
} else
fontReference = new FontReference(fontFamily);
fontProperty = new SimpleObjectProperty<>(fontReference);
LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System"));
fontProperty.addListener((obs, oldValue, newValue) -> {
if (newValue != null)
config().setLauncherFontFamily(newValue.getFamily());
else
config().setLauncherFontFamily(null);
});
}
private static Font tryLoadDefaultFont(Path dir) {
for (String extension : FONT_EXTENSIONS) {
Path path = dir.resolve("font." + extension);
if (Files.isRegularFile(path)) {
LOG.info("Load font file: " + path);
try {
Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (font != null) {
return font;
}
} catch (MalformedURLException ignored) {
}
LOG.warning("Failed to load font " + path);
}
}
return null;
}
private static Font tryLoadLocalizedFont(Path dir) {
Map<String, Map<String, Path>> fontFiles = LocaleUtils.findAllLocalizedFiles(dir, "font", Set.of(FONT_EXTENSIONS));
if (fontFiles.isEmpty())
return null;
List<Locale> candidateLocales = I18n.getLocale().getCandidateLocales();
for (Locale locale : candidateLocales) {
Map<String, Path> extToFiles = fontFiles.get(LocaleUtils.toLanguageKey(locale));
if (extToFiles != null) {
for (String ext : FONT_EXTENSIONS) {
Path fontFile = extToFiles.get(ext);
if (fontFile != null) {
LOG.info("Load font file: " + fontFile);
try {
Font font = Font.loadFont(
fontFile.toAbsolutePath().normalize().toUri().toURL().toExternalForm(),
DEFAULT_FONT_SIZE);
if (font != null)
return font;
} catch (MalformedURLException ignored) {
}
LOG.warning("Failed to load font " + fontFile);
}
}
}
}
return null;
}
public static Font findByFcMatch(String pattern) {
Path fcMatch = SystemUtils.which("fc-match");
if (fcMatch == null)
return null;
try {
String result = SystemUtils.run(fcMatch.toString(),
pattern,
"--format", "%{family}\\n%{file}").trim();
String[] results = result.split("\\n");
if (results.length != 2 || results[0].isEmpty() || results[1].isEmpty()) {
LOG.warning("Unexpected output from fc-match: " + result);
return null;
}
String family = results[0].trim();
String path = results[1];
Path file = Paths.get(path).toAbsolutePath().normalize();
if (!Files.isRegularFile(file)) {
LOG.warning("Font file does not exist: " + path);
return null;
}
LOG.info("Load font file: " + path);
Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (fonts == null) {
LOG.warning("Failed to load font from " + path);
return null;
} else if (fonts.length == 0) {
LOG.warning("No fonts loaded from " + path);
return null;
}
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(family)) {
return font;
}
}
if (family.indexOf(',') >= 0) {
for (String candidateFamily : family.split(",")) {
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(candidateFamily)) {
return font;
}
}
}
}
LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path));
return fonts[0];
} catch (Throwable e) {
LOG.warning("Failed to get default font with fc-match", e);
return null;
}
}
public static ObjectProperty<FontReference> fontProperty() {
return fontProperty;
}
public static FontReference getFont() {
return fontProperty.get();
}
public static void setFont(FontReference font) {
fontProperty.set(font);
}
public static void setFontFamily(String fontFamily) {
setFont(fontFamily != null ? new FontReference(fontFamily) : null);
}
// https://github.com/HMCL-dev/HMCL/issues/4072
public static final class FontReference {
private final @NotNull String family;
private final @Nullable String style;
public FontReference(@NotNull String family) {
this.family = Objects.requireNonNull(family);
this.style = null;
}
public FontReference(@NotNull Font font) {
this.family = font.getFamily();
this.style = font.getStyle();
}
public @NotNull String getFamily() {
return family;
}
public @Nullable String getStyle() {
return style;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof FontReference))
return false;
FontReference that = (FontReference) o;
return Objects.equals(family, that.family) && Objects.equals(style, that.style);
}
@Override
public int hashCode() {
return Objects.hash(family, style);
}
@Override
public String toString() {
return String.format("FontReference[family='%s', style='%s']", family, style);
}
}
private FontManager() {
}
}
@@ -21,8 +21,7 @@ import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
@@ -49,10 +48,16 @@ public final class GlobalConfig implements Observable {
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
private final IntegerProperty logRetention = new SimpleIntegerProperty();
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
@@ -91,6 +96,18 @@ public final class GlobalConfig implements Observable {
this.agreementVersion.set(agreementVersion);
}
public int getTerracottaAgreementVersion() {
return terracottaAgreementVersion.get();
}
public IntegerProperty terracottaAgreementVersionProperty() {
return terracottaAgreementVersion;
}
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
@@ -115,6 +132,30 @@ public final class GlobalConfig implements Observable {
this.logRetention.set(logRetention);
}
public boolean isEnableOfflineAccount() {
return enableOfflineAccount.get();
}
public BooleanProperty enableOfflineAccountProperty() {
return enableOfflineAccount;
}
public void setEnableOfflineAccount(boolean value) {
enableOfflineAccount.set(value);
}
public StringProperty fontAntiAliasingProperty() {
return fontAntiAliasing;
}
public String getFontAntiAliasing() {
return fontAntiAliasing.get();
}
public void setFontAntiAliasing(String value) {
this.fontAntiAliasing.set(value);
}
public ObservableSet<String> getUserJava() {
return userJava;
}
@@ -126,10 +167,13 @@ public final class GlobalConfig implements Observable {
public static final class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
"agreementVersion",
"terracottaAgreementVersion",
"platformPromptVersion",
"logRetention",
"userJava",
"disabledJava"
"disabledJava",
"enableOfflineAccount",
"fontAntiAliasing"
));
@Override
@@ -140,8 +184,13 @@ public final class GlobalConfig implements Observable {
JsonObject jsonObject = new JsonObject();
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("terracottaAgreementVersion", context.serialize(src.getTerracottaAgreementVersion()));
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
if (src.enableOfflineAccount.get())
jsonObject.addProperty("enableOfflineAccount", true);
if (!src.getUserJava().isEmpty())
jsonObject.add("userJava", context.serialize(src.getUserJava()));
@@ -163,8 +212,11 @@ public final class GlobalConfig implements Observable {
GlobalConfig config = new GlobalConfig();
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setTerracottaAgreementVersion(Optional.ofNullable(obj.get("terracottaAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null));
JsonElement userJava = obj.get("userJava");
if (userJava != null && userJava.isJsonArray()) {
@@ -35,8 +35,8 @@ import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import java.io.File;
import java.lang.reflect.Type;
import java.nio.file.Path;
import java.util.Optional;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
@@ -65,17 +65,17 @@ public final class Profile implements Observable {
this.selectedVersion.set(selectedVersion);
}
private final ObjectProperty<File> gameDir;
private final ObjectProperty<Path> gameDir;
public ObjectProperty<File> gameDirProperty() {
public ObjectProperty<Path> gameDirProperty() {
return gameDir;
}
public File getGameDir() {
public Path getGameDir() {
return gameDir.get();
}
public void setGameDir(File gameDir) {
public void setGameDir(Path gameDir) {
this.gameDir.set(gameDir);
}
@@ -103,7 +103,7 @@ public final class Profile implements Observable {
this.name.set(name);
}
private BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false);
private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false);
public BooleanProperty useRelativePathProperty() {
return useRelativePath;
@@ -118,18 +118,18 @@ public final class Profile implements Observable {
}
public Profile(String name) {
this(name, new File(".minecraft"));
this(name, Path.of(".minecraft"));
}
public Profile(String name, File initialGameDir) {
public Profile(String name, Path initialGameDir) {
this(name, initialGameDir, new VersionSetting());
}
public Profile(String name, File initialGameDir, VersionSetting global) {
public Profile(String name, Path initialGameDir, VersionSetting global) {
this(name, initialGameDir, global, null, false);
}
public Profile(String name, File initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) {
public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) {
this.name = new SimpleStringProperty(this, "name", name);
gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir);
repository = new HMCLGameRepository(this, initialGameDir);
@@ -234,7 +234,7 @@ public final class Profile implements Observable {
JsonObject jsonObject = new JsonObject();
jsonObject.add("global", context.serialize(src.getGlobal()));
jsonObject.addProperty("gameDir", src.getGameDir().getPath());
jsonObject.addProperty("gameDir", src.getGameDir().toString());
jsonObject.addProperty("useRelativePath", src.isUseRelativePath());
jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion());
@@ -243,12 +243,11 @@ public final class Profile implements Observable {
@Override
public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == JsonNull.INSTANCE || !(json instanceof JsonObject)) return null;
JsonObject obj = (JsonObject) json;
if (!(json instanceof JsonObject obj)) return null;
String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("");
return new Profile("Default",
new File(gameDir),
Path.of(gameDir),
context.deserialize(obj.get("global"), VersionSetting.class),
Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""),
Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false));
@@ -26,7 +26,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -48,20 +48,17 @@ public final class Profiles {
}
public static String getProfileDisplayName(Profile profile) {
switch (profile.getName()) {
case Profiles.DEFAULT_PROFILE:
return i18n("profile.default");
case Profiles.HOME_PROFILE:
return i18n("profile.home");
default:
return profile.getName();
}
return switch (profile.getName()) {
case Profiles.DEFAULT_PROFILE -> i18n("profile.default");
case Profiles.HOME_PROFILE -> i18n("profile.home");
default -> profile.getName();
};
}
private static final ObservableList<Profile> profiles = observableArrayList(profile -> new Observable[] { profile });
private static final ReadOnlyListWrapper<Profile> profilesWrapper = new ReadOnlyListWrapper<>(profiles);
private static ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<Profile>() {
private static final ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<Profile>() {
{
profiles.addListener(onInvalidating(this::invalidated));
}
@@ -104,8 +101,8 @@ public final class Profiles {
private static void checkProfiles() {
if (profiles.isEmpty()) {
Profile current = new Profile(Profiles.DEFAULT_PROFILE, new File(".minecraft"), new VersionSetting(), null, true);
Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY.toFile());
Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true);
Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY);
Platform.runLater(() -> profiles.addAll(current, home));
}
}
@@ -17,100 +17,169 @@
*/
package org.jackhuang.hmcl.setting;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.value.ObservableObjectValue;
import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.Proxy;
import java.net.Proxy.Type;
import java.io.IOException;
import java.net.*;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ProxyManager {
private ProxyManager() {
private static final SimpleProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
private static final ProxySelector SYSTEM_DEFAULT;
static {
ProxySelector systemProxySelector = ProxySelector.getDefault();
SYSTEM_DEFAULT = systemProxySelector != null
? new ProxySelectorWrapper(systemProxySelector)
: NO_PROXY;
}
private static ObjectBinding<Proxy> proxyProperty;
private static volatile @NotNull ProxySelector defaultProxySelector = SYSTEM_DEFAULT;
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
public static Proxy getProxy() {
return proxyProperty.get();
private static ProxySelector getProxySelector() {
if (config().hasProxy()) {
Proxy.Type proxyType = config().getProxyType();
String host = config().getProxyHost();
int port = config().getProxyPort();
if (proxyType == Proxy.Type.DIRECT || StringUtils.isBlank(host)) {
return NO_PROXY;
} else if (port < 0 || port > 0xFFFF) {
LOG.warning("Illegal proxy port: " + port);
return NO_PROXY;
} else {
return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port))));
}
} else {
return ProxyManager.SYSTEM_DEFAULT;
}
}
public static ObservableObjectValue<Proxy> proxyProperty() {
return proxyProperty;
private static SimpleAuthenticator getAuthenticator() {
if (config().hasProxy() && config().hasProxyAuth()) {
String username = config().getProxyUser();
String password = config().getProxyPass();
if (username != null || password != null)
return new SimpleAuthenticator(
Objects.requireNonNullElse(username, ""),
Objects.requireNonNullElse(password, "").toCharArray()
);
else
return null;
} else
return null;
}
static void init() {
proxyProperty = Bindings.createObjectBinding(
() -> {
String host = config().getProxyHost();
int port = config().getProxyPort();
if (!config().hasProxy() || StringUtils.isBlank(host) || config().getProxyType() == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY;
} else {
if (port < 0 || port > 0xFFFF) {
LOG.warning("Illegal proxy port: " + port);
return Proxy.NO_PROXY;
}
return new Proxy(config().getProxyType(), new InetSocketAddress(host, port));
}
},
config().proxyTypeProperty(),
config().proxyHostProperty(),
config().proxyPortProperty(),
config().hasProxyProperty());
proxyProperty.addListener(any -> updateSystemProxy());
updateSystemProxy();
ProxySelector.setDefault(new ProxySelector() {
@Override
public List<Proxy> select(URI uri) {
return defaultProxySelector.select(uri);
}
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
defaultProxySelector.connectFailed(uri, sa, ioe);
}
});
Authenticator.setDefault(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
if (config().hasProxyAuth()) {
String username = config().getProxyUser();
String password = config().getProxyPass();
if (username != null && password != null) {
return new PasswordAuthentication(username, password.toCharArray());
}
}
return null;
var defaultAuthenticator = ProxyManager.defaultAuthenticator;
return defaultAuthenticator != null ? defaultAuthenticator.getPasswordAuthentication() : null;
}
});
defaultProxySelector = getProxySelector();
InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector();
config().proxyTypeProperty().addListener(updateProxySelector);
config().proxyHostProperty().addListener(updateProxySelector);
config().proxyPortProperty().addListener(updateProxySelector);
config().hasProxyProperty().addListener(updateProxySelector);
defaultAuthenticator = getAuthenticator();
InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator();
config().hasProxyProperty().addListener(updateAuthenticator);
config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator);
}
private static void updateSystemProxy() {
Proxy proxy = proxyProperty.get();
if (proxy.type() == Proxy.Type.DIRECT) {
System.clearProperty("http.proxyHost");
System.clearProperty("http.proxyPort");
System.clearProperty("https.proxyHost");
System.clearProperty("https.proxyPort");
System.clearProperty("socksProxyHost");
System.clearProperty("socksProxyPort");
} else {
InetSocketAddress address = (InetSocketAddress) proxy.address();
String host = address.getHostString();
String port = String.valueOf(address.getPort());
if (proxy.type() == Type.HTTP) {
System.clearProperty("socksProxyHost");
System.clearProperty("socksProxyPort");
System.setProperty("http.proxyHost", host);
System.setProperty("http.proxyPort", port);
System.setProperty("https.proxyHost", host);
System.setProperty("https.proxyPort", port);
} else if (proxy.type() == Type.SOCKS) {
System.clearProperty("http.proxyHost");
System.clearProperty("http.proxyPort");
System.clearProperty("https.proxyHost");
System.clearProperty("https.proxyPort");
System.setProperty("socksProxyHost", host);
System.setProperty("socksProxyPort", port);
private static abstract class AbstractProxySelector extends ProxySelector {
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
if (uri == null || sa == null || ioe == null) {
throw new IllegalArgumentException("Arguments can't be null.");
}
}
}
private static final class SimpleProxySelector extends AbstractProxySelector {
private final List<Proxy> proxies;
SimpleProxySelector(Proxy proxy) {
this.proxies = List.of(proxy);
}
@Override
public List<Proxy> select(URI uri) {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
return proxies;
}
@Override
public String toString() {
return "SimpleProxySelector" + proxies;
}
}
/// Wraps another ProxySelector to avoid using proxy for loopback addresses.
private static final class ProxySelectorWrapper extends AbstractProxySelector {
private final ProxySelector source;
ProxySelectorWrapper(ProxySelector source) {
this.source = source;
}
@Override
public List<Proxy> select(URI uri) {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
if (NetworkUtils.isLoopbackAddress(uri))
return NO_PROXY.proxies;
return source.select(uri);
}
}
private static final class SimpleAuthenticator extends Authenticator {
private final String username;
private final char[] password;
private SimpleAuthenticator(String username, char[] password) {
this.username = username;
this.password = password;
}
@Override
public PasswordAuthentication getPasswordAuthentication() {
return getRequestorType() == RequestorType.PROXY ? new PasswordAuthentication(username, password) : null;
}
}
private ProxyManager() {
}
}
@@ -0,0 +1,141 @@
/*
* 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.setting;
import javafx.beans.binding.Bindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
/**
* @author Glavo
*/
public final class StyleSheets {
private static final int FONT_STYLE_SHEET_INDEX = 0;
private static final int THEME_STYLE_SHEET_INDEX = 1;
private static final ObservableList<String> stylesheets;
static {
String[] array = new String[]{
getFontStyleSheet(),
getThemeStyleSheet(),
"/assets/css/root.css"
};
stylesheets = FXCollections.observableList(Arrays.asList(array));
FontManager.fontProperty().addListener(o -> stylesheets.set(FONT_STYLE_SHEET_INDEX, getFontStyleSheet()));
config().themeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()));
}
private static String toStyleSheetUri(String styleSheet) {
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
}
private static String getFontStyleSheet() {
final String defaultCss = "/assets/css/font.css";
final FontManager.FontReference font = FontManager.getFont();
if (font == null || "System".equals(font.getFamily()))
return defaultCss;
String fontFamily = font.getFamily();
String style = font.getStyle();
String weight = null;
String posture = null;
if (style != null) {
style = style.toLowerCase(Locale.ROOT);
if (style.contains("thin"))
weight = "100";
else if (style.contains("extralight") || style.contains("extra light") || style.contains("ultralight") | style.contains("ultra light"))
weight = "200";
else if (style.contains("medium"))
weight = "500";
else if (style.contains("semibold") || style.contains("semi bold") || style.contains("demibold") || style.contains("demi bold"))
weight = "600";
else if (style.contains("extrabold") || style.contains("extra bold") || style.contains("ultrabold") || style.contains("ultra bold"))
weight = "800";
else if (style.contains("black") || style.contains("heavy"))
weight = "900";
else if (style.contains("light"))
weight = "lighter";
else if (style.contains("bold"))
weight = "bold";
posture = style.contains("italic") || style.contains("oblique") ? "italic" : null;
}
StringBuilder builder = new StringBuilder();
builder.append(".root {");
builder.append("-fx-font-family:\"").append(fontFamily).append("\";");
if (weight != null)
builder.append("-fx-font-weight:").append(weight).append(";");
if (posture != null)
builder.append("-fx-font-style:").append(posture).append(";");
builder.append('}');
return toStyleSheetUri(builder.toString());
}
private static String rgba(Color color, double opacity) {
return String.format("rgba(%d, %d, %d, %.1f)",
(int) Math.ceil(color.getRed() * 256),
(int) Math.ceil(color.getGreen() * 256),
(int) Math.ceil(color.getBlue() * 256),
opacity);
}
private static String getThemeStyleSheet() {
final String blueCss = "/assets/css/blue.css";
Theme theme = config().getTheme();
if (theme == null || theme.getPaint().equals(Theme.BLUE.getPaint()))
return blueCss;
return toStyleSheetUri(".root {" +
"-fx-base-color:" + theme.getColor() + ';' +
"-fx-base-darker-color: derive(-fx-base-color, -10%);" +
"-fx-base-check-color: derive(-fx-base-color, 30%);" +
"-fx-rippler-color:" + rgba(theme.getPaint(), 0.3) + ';' +
"-fx-base-rippler-color: derive(" + rgba(theme.getPaint(), 0.3) + ", 100%);" +
"-fx-base-disabled-text-fill:" + rgba(theme.getForegroundColor(), 0.7) + ";" +
"-fx-base-text-fill:" + Theme.getColorDisplayName(theme.getForegroundColor()) + ";" +
"-theme-thumb:" + rgba(theme.getPaint(), 0.7) + ";" +
'}');
}
public static void init(Scene scene) {
Bindings.bindContent(scene.getStylesheets(), stylesheets);
}
private StyleSheets() {
}
}
@@ -23,31 +23,16 @@ import com.google.gson.stream.JsonWriter;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonAdapter(Theme.TypeAdapter.class)
public class Theme {
public final class Theme {
public static final Theme BLUE = new Theme("blue", "#5C6BC0");
public static final Color BLACK = Color.web("#292929");
public static final Color[] SUGGESTED_COLORS = new Color[]{
@@ -59,42 +44,6 @@ public class Theme {
Color.web("#B71C1C") // red
};
private static final Lazy<Font> FONT = new Lazy<>(() -> {
String[] fileNames = {"font.ttf", "font.otf", "font.woff"};
for (String fileName : fileNames) {
Path path = Paths.get(fileName).toAbsolutePath();
if (Files.isRegularFile(path)) {
try {
Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), 0);
if (font != null) {
return font;
}
} catch (MalformedURLException ignored) {
}
LOG.warning("Failed to load font " + path);
}
}
for (String fileName : fileNames) {
Path path = Metadata.HMCL_DIRECTORY.resolve(fileName);
if (Files.isRegularFile(path)) {
try {
Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), 0);
if (font != null) {
return font;
}
} catch (MalformedURLException ignored) {
}
LOG.warning("Failed to load font " + path);
}
}
return null;
});
public static Theme getTheme() {
Theme theme = config().getTheme();
return theme == null ? BLUE : theme;
@@ -118,6 +67,10 @@ public class Theme {
return color;
}
public Color getPaint() {
return paint;
}
public boolean isCustom() {
return name.startsWith("#");
}
@@ -130,119 +83,6 @@ public class Theme {
return isLight() ? Color.BLACK : Color.WHITE;
}
private static String rgba(Color color, double opacity) {
return String.format("rgba(%d, %d, %d, %.1f)",
(int) Math.ceil(color.getRed() * 256),
(int) Math.ceil(color.getGreen() * 256),
(int) Math.ceil(color.getBlue() * 256),
opacity);
}
private static Pair<String, String> parseFontStyle(String style) {
if (style == null) {
return null;
}
style = style.toLowerCase(Locale.ROOT);
String weight;
String posture;
if (style.contains("thin")) {
weight = "100";
} else if (style.contains("extralight") || style.contains("extra light") || style.contains("ultralight") | style.contains("ultra light")) {
weight = "200";
} else if (style.contains("medium")) {
weight = "500";
} else if (style.contains("semibold") || style.contains("semi bold") || style.contains("demibold") || style.contains("demi bold")) {
weight = "600";
} else if (style.contains("extrabold") || style.contains("extra bold") || style.contains("ultrabold") || style.contains("ultra bold")) {
weight = "800";
} else if (style.contains("black") || style.contains("heavy")) {
weight = "900";
} else if (style.contains("light")) {
weight = "lighter";
} else if (style.contains("bold")) {
weight = "bold";
} else {
weight = null;
}
if (style.contains("italic") || style.contains("oblique")) {
posture = "italic";
} else {
posture = null;
}
return Pair.pair(weight, posture);
}
public String[] getStylesheets(String overrideFontFamily) {
String css = "/assets/css/blue.css";
String fontFamily = overrideFontFamily == null
? System.getProperty("hmcl.font.override", System.getenv("HMCL_FONT"))
: overrideFontFamily;
Pair<String, String> fontStyle = null;
if (fontFamily == null) {
Font font = FONT.get();
if (font != null) {
fontFamily = font.getFamily();
fontStyle = parseFontStyle(font.getStyle());
}
}
if (fontFamily != null || !this.color.equalsIgnoreCase(BLUE.color)) {
Color textFill = getForegroundColor();
StringBuilder themeBuilder = new StringBuilder(512);
themeBuilder.append(".root {")
.append("-fx-base-color:").append(color).append(';')
.append("-fx-base-darker-color: derive(-fx-base-color, -10%);")
.append("-fx-base-check-color: derive(-fx-base-color, 30%);")
.append("-fx-rippler-color:").append(rgba(paint, 0.3)).append(';')
.append("-fx-base-rippler-color: derive(").append(rgba(paint, 0.3)).append(", 100%);")
.append("-fx-base-disabled-text-fill:").append(rgba(textFill, 0.7)).append(";")
.append("-fx-base-text-fill:").append(getColorDisplayName(getForegroundColor())).append(";")
.append("-theme-thumb:").append(rgba(paint, 0.7)).append(";");
if (fontFamily == null)
// https://github.com/HMCL-dev/HMCL/pull/3423
themeBuilder.append("-fx-font-family: -fx-base-font-family;");
else
themeBuilder.append("-fx-font-family:\"").append(fontFamily).append("\";");
if (fontStyle != null) {
if (fontStyle.getKey() != null)
themeBuilder.append("-fx-font-weight:").append(fontStyle.getKey()).append(";");
if (fontStyle.getValue() != null)
themeBuilder.append("-fx-font-style:").append(fontStyle.getValue()).append(";");
}
themeBuilder.append('}');
if (FXUtils.JAVAFX_MAJOR_VERSION >= 17)
// JavaFX 17+ support loading stylesheets from data URIs
// https://bugs.openjdk.org/browse/JDK-8267554
css = "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(themeBuilder.toString().getBytes(StandardCharsets.UTF_8));
else
try {
File temp = File.createTempFile("hmcl", ".css");
// For JavaFX 17 or earlier, CssParser uses the default charset
// https://bugs.openjdk.org/browse/JDK-8279328
FileUtils.writeText(temp, themeBuilder.toString(), Charset.defaultCharset());
temp.deleteOnExit();
css = temp.toURI().toString();
} catch (IOException | NullPointerException e) {
LOG.error("Unable to create theme stylesheet. Fallback to blue theme.", e);
}
}
return new String[]{css, "/assets/css/root.css"};
}
public static Theme custom(String color) {
if (!color.startsWith("#"))
throw new IllegalArgumentException();
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.ui.FXUtils;
public enum VersionIconType {
@@ -33,10 +34,24 @@ public enum VersionIconType {
FORGE("/assets/img/forge.png"),
NEO_FORGE("/assets/img/neoforge.png"),
FURNACE("/assets/img/furnace.png"),
QUILT("/assets/img/quilt.png");
QUILT("/assets/img/quilt.png"),
APRIL_FOOLS("/assets/img/april_fools.png"),
CLEANROOM("/assets/img/cleanroom.png");
// Please append new items at last
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
return switch (modLoaderType) {
case FORGE -> VersionIconType.FORGE;
case NEO_FORGED -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.FABRIC;
case QUILT -> VersionIconType.QUILT;
case LITE_LOADER -> VersionIconType.CHICKEN;
case CLEANROOM -> VersionIconType.CLEANROOM;
default -> VersionIconType.COMMAND;
};
}
private final String resourceUrl;
VersionIconType(String resourceUrl) {
@@ -29,7 +29,7 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jackhuang.hmcl.util.javafx.PropertyUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
@@ -39,6 +39,7 @@ import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -47,7 +48,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonAdapter(VersionSetting.Serializer.class)
public final class VersionSetting implements Cloneable, Observable {
private transient ObservableHelper helper = new ObservableHelper(this);
private static final int SUGGESTED_MEMORY;
static {
double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
SUGGESTED_MEMORY = totalMemoryMB >= 32768
? 8192
: Integer.max((int) (Math.round(totalMemoryMB / 4.0 / 128.0) * 128), 256);
}
private final transient ObservableHelper helper = new ObservableHelper(this);
public VersionSetting() {
PropertyUtils.attachListener(this, helper);
@@ -219,7 +229,7 @@ public final class VersionSetting implements Cloneable, Observable {
permSizeProperty.set(permSize);
}
private final IntegerProperty maxMemoryProperty = new SimpleIntegerProperty(this, "maxMemory", OperatingSystem.SUGGESTED_MEMORY);
private final IntegerProperty maxMemoryProperty = new SimpleIntegerProperty(this, "maxMemory", SUGGESTED_MEMORY);
public IntegerProperty maxMemoryProperty() {
return maxMemoryProperty;
@@ -370,6 +380,20 @@ public final class VersionSetting implements Cloneable, Observable {
noJVMArgsProperty.set(noJVMArgs);
}
private final BooleanProperty noOptimizingJVMArgsProperty = new SimpleBooleanProperty(this, "noOptimizingJVMArgs", false);
public BooleanProperty noOptimizingJVMArgsProperty() {
return noOptimizingJVMArgsProperty;
}
public boolean isNoOptimizingJVMArgs() {
return noOptimizingJVMArgsProperty.get();
}
public void setNoOptimizingJVMArgs(boolean noOptimizingJVMArgs) {
noOptimizingJVMArgsProperty.set(noOptimizingJVMArgs);
}
private final BooleanProperty notCheckJVMProperty = new SimpleBooleanProperty(this, "notCheckJVM", false);
public BooleanProperty notCheckJVMProperty() {
@@ -734,7 +758,7 @@ public final class VersionSetting implements Cloneable, Observable {
obj.addProperty("javaArgs", src.getJavaArgs());
obj.addProperty("minecraftArgs", src.getMinecraftArgs());
obj.addProperty("environmentVariables", src.getEnvironmentVariables());
obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? OperatingSystem.SUGGESTED_MEMORY : src.getMaxMemory());
obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? SUGGESTED_MEMORY : src.getMaxMemory());
obj.addProperty("minMemory", src.getMinMemory());
obj.addProperty("autoMemory", src.isAutoMemory());
obj.addProperty("permSize", src.getPermSize());
@@ -747,6 +771,7 @@ public final class VersionSetting implements Cloneable, Observable {
obj.addProperty("wrapper", src.getWrapper());
obj.addProperty("fullscreen", src.isFullscreen());
obj.addProperty("noJVMArgs", src.isNoJVMArgs());
obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs());
obj.addProperty("notCheckGame", src.isNotCheckGame());
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
@@ -787,22 +812,14 @@ public final class VersionSetting implements Cloneable, Observable {
return obj;
}
private static <T> T getOrDefault(T[] values, JsonElement index, T defaultValue) {
if (index == null)
return defaultValue;
int idx = index.getAsInt();
return idx >= 0 && idx < values.length ? values[idx] : defaultValue;
}
@Override
public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject))
return null;
JsonObject obj = (JsonObject) json;
int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), OperatingSystem.SUGGESTED_MEMORY);
if (maxMemoryN <= 0) maxMemoryN = OperatingSystem.SUGGESTED_MEMORY;
int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), SUGGESTED_MEMORY);
if (maxMemoryN <= 0) maxMemoryN = SUGGESTED_MEMORY;
VersionSetting vs = new VersionSetting();
@@ -825,18 +842,19 @@ public final class VersionSetting implements Cloneable, Observable {
vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse(""));
vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setLauncherVisibility(getOrDefault(LauncherVisibility.values(), obj.get("launcherVisibility"), LauncherVisibility.HIDE));
vs.setProcessPriority(getOrDefault(ProcessPriority.values(), obj.get("processPriority"), ProcessPriority.NORMAL));
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false));
vs.setGameDirType(getOrDefault(GameDirectoryType.values(), obj.get("gameDirType"), GameDirectoryType.ROOT_FOLDER));
vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER));
vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null));
vs.setNativesDirType(getOrDefault(NativesDirectoryType.values(), obj.get("nativesDirType"), NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(getOrDefault(VersionIconType.values(), obj.get("versionIcon"), VersionIconType.DEFAULT));
vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT));
if (obj.get("javaVersionType") != null) {
JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO);
@@ -0,0 +1,320 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaManager {
private TerracottaManager() {
}
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
static {
Schedulers.io().execute(() -> {
try {
if (TerracottaMetadata.PROVIDER == null)
throw new IOException("Unsupported platform: " + org.jackhuang.hmcl.util.platform.Platform.CURRENT_PLATFORM);
switch (TerracottaMetadata.PROVIDER.status()) {
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
case READY -> launch(setState(new TerracottaState.Launching()));
}
} catch (Exception e) {
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
});
}
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
return STATE.getReadOnlyProperty();
}
static {
Lang.thread(() -> {
while (true) {
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
LockSupport.parkNanos(500_000);
continue;
}
int port = portSpecific.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(jsonString -> {
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
if (object.index <= index) {
return null;
}
object.port = port;
return object;
})
.setSignificance(Task.TaskSignificance.MINOR)
.run();
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
if (next != null) {
compareAndSet(state, next);
}
LockSupport.parkNanos(500_000);
}
}, "Terracotta Background Daemon", true);
}
public static boolean validate(Path file) {
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
public static TerracottaState.Preparing install(@Nullable Path file) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
Task.supplyAsync(Schedulers.io(), () -> {
return file != null ? TarFileTree.open(file) : null;
}).thenComposeAsync(Schedulers.javafx(), tree -> {
return getProvider().install(preparing, tree).whenComplete(exception -> {
if (tree != null) {
tree.close();
}
if (exception != null) {
throw exception;
}
});
}).whenComplete(exception -> {
if (exception == null) {
try {
TerracottaMetadata.removeLegacyVersionFiles();
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching);
}
} else if (exception instanceof CancellationException) {
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
LOG.warning("Cannot install terracotta from local package.", exception);
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
private static ITerracottaProvider getProvider() {
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static TerracottaState recover(@Nullable Path file) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
return null;
}
try {
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> install(file);
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching);
yield launching;
}
};
} catch (NullPointerException | IOException e) {
LOG.warning("Cannot determine Terracotta state.", e);
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}
private static void launch(TerracottaState.Launching state) {
Task.supplyAsync(() -> {
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
long exitTime = -1;
while (true) {
if (Files.exists(path)) {
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
return object.get("port").getAsInt();
}
if (!process.isRunning()) {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException("Process has exited for 10s.");
}
}
}
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
compareAndSet(state, next);
}).start();
}
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR);
}
return Task.completed(null);
}
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return new TerracottaState.Waiting(-1, -1, null);
}
return null;
}
private static String getPlayerName() {
Account account = Accounts.getSelectedAccount();
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
}
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
).setSignificance(Task.TaskSignificance.MINOR).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
} else {
return null;
}
}
private static <T extends TerracottaState> T setState(T value) {
if (value == null) {
throw new AssertionError();
}
STATE_V.set(value);
STATE_D.accept(value);
return value;
}
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
if (next == null) {
throw new AssertionError();
}
if (STATE_V.compareAndSet(previous, next)) {
STATE_D.accept(next);
return true;
} else {
return false;
}
}
}
@@ -0,0 +1,205 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaMetadata {
private TerracottaMetadata() {
}
public record Link(@SerializedName("desc") LocalizedText description, String link) {
}
private record Config(
@SerializedName("version_legacy") String legacy,
@SerializedName("version_recent") List<String> recent,
@SerializedName("version_latest") String latest,
@SerializedName("classifiers") Map<String, String> classifiers,
@SerializedName("downloads") List<String> downloads,
@SerializedName("downloads_CN") List<String> downloadsCN,
@SerializedName("links") List<Link> links
) {
private @Nullable TerracottaNative of(String classifier) {
String hash = this.classifiers.get(classifier);
if (hash == null)
return null;
if (!hash.startsWith("sha256:"))
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
hash = hash.substring("sha256:".length());
List<URI> links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size());
for (String download : LocaleUtils.IS_CHINA_MAINLAND
? Lang.merge(this.downloadsCN, this.downloads)
: Lang.merge(this.downloads, this.downloadsCN)) {
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
}
return new TerracottaNative(
Collections.unmodifiableList(links),
Metadata.DEPENDENCIES_DIRECTORY.resolve(
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
).toAbsolutePath(),
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
);
}
}
public static final ITerracottaProvider PROVIDER;
public static final String PACKAGE_NAME;
public static final List<Link> PACKAGE_LINKS;
public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of(
"v", "v1",
"launcher_version", Metadata.VERSION
));
private static final Pattern LEGACY;
private static final List<String> RECENT;
private static final String LATEST;
static {
Config config;
try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) {
config = JsonUtils.fromNonNullJsonFully(is, Config.class);
} catch (IOException e) {
throw new ExceptionInInitializerError(e);
}
LEGACY = Pattern.compile(config.legacy);
RECENT = config.recent;
LATEST = config.latest;
ProviderContext context = locateProvider(config);
PROVIDER = context != null ? context.provider() : null;
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
if (context != null) {
List<Link> packageLinks = new ArrayList<>(config.links.size());
for (Link link : config.links) {
packageLinks.add(new Link(
link.description,
link.link.replace("${version}", LATEST)
.replace("${classifier}", context.branch)
));
}
Collections.shuffle(packageLinks);
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
} else {
PACKAGE_LINKS = null;
}
}
private record ProviderContext(ITerracottaProvider provider, String branch) {
ProviderContext(ITerracottaProvider provider, String system, String arch) {
this(provider, system + "-" + arch);
}
}
@Nullable
private static ProviderContext locateProvider(Config config) {
String arch = Architecture.SYSTEM_ARCH.getCheckedName();
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
yield null;
TerracottaNative target = config.of("windows-%s.exe".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "windows", arch)
: null;
}
case LINUX -> {
TerracottaNative target = config.of("linux-%s".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "linux", arch)
: null;
}
case MACOS -> {
TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch));
TerracottaNative binary = config.of("macos-%s".formatted(arch));
yield installer != null && binary != null
? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch)
: null;
}
default -> null;
};
}
public static void removeLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
continue;
}
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
}
}
public static boolean hasLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,147 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.CancellationException;
public final class TerracottaNative {
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck checking;
private final Path path;
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
this.links = links;
this.path = path;
this.checking = checking;
}
public Path getPath() {
return path;
}
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
if (tree == null) {
return new FileDownloadTask(links, path, checking) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
delegate.withResult(success);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
if (!context.hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess() && !context.requestInstallFence()) {
throw new CancellationException();
}
delegate.close();
}
};
}
};
}
return Task.runAsync(() -> {
String name = FileUtils.getName(path);
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
if (entry == null) {
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
}
if (!context.requestInstallFence()) {
throw new CancellationException();
}
Files.createDirectories(path.toAbsolutePath().getParent());
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
try (
InputStream stream = tree.getInputStream(entry);
OutputStream os = Files.newOutputStream(path)
) {
stream.transferTo(new OutputStream() {
@Override
public void write(int b) throws IOException {
os.write(b);
digest.update((byte) b);
}
@Override
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
os.write(buffer, offset, len);
digest.update(buffer, offset, len);
}
});
}
String checksum = HexFormat.of().formatHex(digest.digest());
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
Files.delete(path);
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
}
});
}
public ITerracottaProvider.Status status() throws IOException {
if (Files.exists(path)) {
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
return ITerracottaProvider.Status.READY;
}
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return ITerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
}
return ITerracottaProvider.Status.NOT_EXIST;
}
}
@@ -0,0 +1,304 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
protected TerracottaState() {
}
public boolean isUIFakeState() {
return false;
}
public boolean isForkOf(TerracottaState state) {
return false;
}
public static final class Bootstrap extends TerracottaState {
static final Bootstrap INSTANCE = new Bootstrap();
private Bootstrap() {
}
}
public static final class Uninitialized extends TerracottaState {
private final boolean hasLegacy;
Uninitialized(boolean hasLegacy) {
this.hasLegacy = hasLegacy;
}
public boolean hasLegacy() {
return hasLegacy;
}
}
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean installFence = new AtomicBoolean(false);
Preparing(ReadOnlyDoubleWrapper progress) {
this.progress = progress;
}
public ReadOnlyDoubleProperty progressProperty() {
return progress.getReadOnlyProperty();
}
@Override
public void bindProgress(ObservableValue<? extends Number> value) {
progress.bind(value);
}
@Override
public boolean requestInstallFence() {
return installFence.compareAndSet(false, true);
}
@Override
public boolean hasInstallFence() {
return !installFence.get();
}
}
public static final class Launching extends TerracottaState {
Launching() {
}
}
static abstract sealed class PortSpecific extends TerracottaState {
transient int port;
protected PortSpecific(int port) {
this.port = port;
}
}
@JsonType(
property = "state",
subtypes = {
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
}
)
static abstract sealed class Ready extends PortSpecific {
@SerializedName("index")
final int index;
@SerializedName("state")
private final String state;
Ready(int port, int index, String state) {
super(port);
this.index = index;
this.state = state;
}
@Override
public boolean isUIFakeState() {
return this.index == -1;
}
}
public static final class Unknown extends PortSpecific {
Unknown(int port) {
super(port);
}
}
public static final class Waiting extends Ready {
Waiting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostScanning extends Ready {
HostScanning(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostStarting extends Ready {
HostStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostOK extends Ready implements Validation {
@SerializedName("room")
private final String code;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.code = code;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (code == null) {
throw new JsonParseException("code is null");
}
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getCode() {
return code;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
}
}
public static final class GuestStarting extends Ready {
GuestStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.url = url;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getUrl() {
return url;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
}
}
public static final class Exception extends Ready implements Validation {
public enum Type {
PING_HOST_FAIL,
PING_HOST_RST,
GUEST_ET_CRASH,
HOST_ET_CRASH,
PING_SERVER_RST,
SCAFFOLDING_INVALID_RESPONSE
}
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
@SerializedName("type")
private final int type;
Exception(int port, int index, String state, int type) {
super(port, index, state);
this.type = type;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (type < 0 || type >= LOOKUP.length) {
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
}
}
public Type getType() {
return LOOKUP[type];
}
}
public static final class Fatal extends TerracottaState {
public enum Type {
OS,
NETWORK,
INSTALL,
TERRACOTTA,
UNKNOWN;
}
private final Type type;
public Fatal(Type type) {
this.type = type;
}
public Type getType() {
return type;
}
public boolean isRecoverable() {
return this.type != Type.UNKNOWN;
}
}
}
@@ -0,0 +1,29 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public enum ProfileKind {
@SerializedName("HOST")
HOST,
@SerializedName("LOCAL")
LOCAL,
@SerializedName("GUEST")
GUEST
}
@@ -0,0 +1,57 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public final class TerracottaProfile {
@SerializedName("machine_id")
private final String machineID;
@SerializedName("name")
private final String name;
@SerializedName("vendor")
private final String vendor;
@SerializedName("kind")
private final ProfileKind type;
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
this.machineID = machineID;
this.name = name;
this.vendor = vendor;
this.type = type;
}
public String getMachineID() {
return machineID;
}
public String getName() {
return name;
}
public String getVendor() {
return vendor;
}
public ProfileKind getType() {
return type;
}
}
@@ -0,0 +1,67 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
public final class GeneralProvider implements ITerracottaProvider {
private final TerracottaNative target;
public GeneralProvider(TerracottaNative target) {
this.target = target;
}
@Override
public Status status() throws IOException {
return target.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
Task<?> task = target.install(context, tree);
context.bindProgress(task.progressProperty());
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)));
}
return task;
}
@Override
public List<String> ofCommandLine(Path path) {
return List.of(target.getPath().toString(), "--hmcl", path.toString());
}
}
@@ -0,0 +1,77 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
public interface ITerracottaProvider {
enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
interface Context {
void bindProgress(ObservableValue<? extends Number> value);
boolean requestInstallFence();
boolean hasInstallFence();
}
abstract class ProviderException extends IOException {
public ProviderException(String message) {
super(message);
}
public ProviderException(String message, Throwable cause) {
super(message, cause);
}
public ProviderException(Throwable cause) {
super(cause);
}
}
final class ArchiveFileMissingException extends ProviderException {
public ArchiveFileMissingException(String message) {
super(message);
}
public ArchiveFileMissingException(String message, Throwable cause) {
super(message, cause);
}
public ArchiveFileMissingException(Throwable cause) {
super(cause);
}
}
Status status() throws IOException;
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
List<String> ofCommandLine(Path path);
}
@@ -0,0 +1,121 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MacOSProvider implements ITerracottaProvider {
public final TerracottaNative installer, binary;
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
this.installer = installer;
this.binary = binary;
}
@Override
public Status status() throws IOException {
assert binary != null;
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
return Status.NOT_EXIST;
}
return binary.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
assert installer != null && binary != null;
Task<?> installerTask = installer.install(context, tree);
Task<?> binaryTask = binary.install(context, tree);
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
return Task.allOf(
installerTask.thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer.getPath()));
Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING);
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
pkg, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(pkg.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d",
pkg,
process.getExitCode()
));
}
});
}),
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)))
);
}
@Override
public List<String> ofCommandLine(Path path) {
assert binary != null;
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
}
}
@@ -1,20 +0,0 @@
package org.jackhuang.hmcl.ui;
import java.awt.*;
import java.lang.reflect.Method;
public final class AwtUtils {
private AwtUtils() {
}
public static void setAppleIcon(Image image) {
try {
Class<?> taskbarClass = Class.forName("java.awt.Taskbar");
Method getTaskBarMethod = taskbarClass.getDeclaredMethod("getTaskbar");
Object taskBar = getTaskBarMethod.invoke(null);
Method setIconImageMethod = taskbarClass.getDeclaredMethod("setIconImage", Image.class);
setIconImageMethod.invoke(taskBar, image);
} catch (Throwable ignore) {
}
}
}
@@ -40,8 +40,10 @@ import javafx.stage.StageStyle;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
@@ -54,14 +56,17 @@ import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
import org.jackhuang.hmcl.ui.versions.GameListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -70,6 +75,10 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class Controllers {
public static final String JAVA_VERSION_TIP = "javaVersion";
public static final String JAVA_INTERPRETED_MODE_TIP = "javaInterpretedMode";
public static final String SOFTWARE_RENDERING = "softwareRendering";
public static final int MIN_WIDTH = 800 + 2 + 16; // bg width + border width*2 + shadow width*2
public static final int MIN_HEIGHT = 450 + 2 + 40 + 16; // bg height + border width*2 + toolbar height + shadow width*2
public static final Screen SCREEN = Screen.getPrimary();
@@ -87,7 +96,7 @@ public final class Controllers {
gameListPage.selectedProfileProperty().bindBidirectional(Profiles.selectedProfileProperty());
gameListPage.profilesProperty().bindContent(Profiles.profilesProperty());
FXUtils.applyDragListener(gameListPage, ModpackHelper::isFileModpackByExtension, modpacks -> {
File modpack = modpacks.get(0);
Path modpack = modpacks.get(0);
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), modpack), i18n("install.modpack"));
});
return gameListPage;
@@ -103,6 +112,7 @@ public final class Controllers {
return accountListPage;
});
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
private static Lazy<TerracottaPage> terracottaPage = new Lazy<>(TerracottaPage::new);
private Controllers() {
}
@@ -145,6 +155,11 @@ public final class Controllers {
return downloadPage.get();
}
// FXThread
public static Node getTerracottaPage() {
return terracottaPage.get();
}
// FXThread
public static DecoratorController getDecorator() {
return decorator;
@@ -173,6 +188,18 @@ public final class Controllers {
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
if (System.getProperty("prism.lcdtext") == null) {
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
LOG.info("Enable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "true");
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) {
LOG.info("Disable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "false");
}
}
Controllers.stage = stage;
stageSizeChangeListener = o -> {
@@ -202,7 +229,11 @@ public final class Controllers {
if (targetProperty != null
&& Controllers.stage != null
&& !Controllers.stage.isIconified()) {
&& !Controllers.stage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ||
!Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized())
) {
targetProperty.set(sourceProperty.get());
}
};
@@ -265,7 +296,7 @@ public final class Controllers {
stage.setMinHeight(MIN_HEIGHT);
decorator.getDecorator().prefWidthProperty().bind(scene.widthProperty());
decorator.getDecorator().prefHeightProperty().bind(scene.heightProperty());
scene.getStylesheets().setAll(Theme.getTheme().getStylesheets(config().getLauncherFontFamily()));
StyleSheets.init(scene);
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
@@ -293,8 +324,8 @@ public final class Controllers {
if (!Architecture.SYSTEM_ARCH.isX86() && globalConfig().getPlatformPromptVersion() < 1) {
Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1);
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
Controllers.dialog(i18n("fatal.unsupported_platform.osx_arm64"), null, MessageType.INFO, continueAction);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
Controllers.dialog(i18n("fatal.unsupported_platform.macos_arm64"), null, MessageType.INFO, continueAction);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
Controllers.dialog(i18n("fatal.unsupported_platform.windows_arm64"), null, MessageType.INFO, continueAction);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX &&
@@ -307,6 +338,44 @@ public final class Controllers {
}
}
if (JavaRuntime.CURRENT_VERSION < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) {
Number shownTipVersion = null;
try {
shownTipVersion = (Number) config().getShownTips().get(JAVA_VERSION_TIP);
} catch (ClassCastException e) {
LOG.warning("Invalid type for shown tips key: " + JAVA_VERSION_TIP, e);
}
if (shownTipVersion == null || shownTipVersion.intValue() < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) {
MessageDialogPane.Builder builder = new MessageDialogPane.Builder(i18n("fatal.deprecated_java_version"), null, MessageType.WARNING);
String downloadLink = Metadata.getSuggestedJavaDownloadLink();
if (downloadLink != null)
builder.addHyperLink(
i18n("fatal.deprecated_java_version.download_link", Metadata.RECOMMENDED_JAVA_VERSION),
downloadLink
);
Controllers.dialog(builder
.ok(() -> config().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION))
.build());
}
}
// Check whether JIT is enabled in the current environment
if (!JavaRuntime.CURRENT_JIT_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(JAVA_INTERPRETED_MODE_TIP))) {
Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.java_interpreted_mode"), i18n("message.warning"), MessageType.WARNING)
.ok(null)
.addCancel(i18n("button.do_not_show_again"), () ->
config().getShownTips().put(JAVA_INTERPRETED_MODE_TIP, true))
.build());
}
// Check whether hardware acceleration is enabled
if (!FXUtils.GPU_ACCELERATION_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(SOFTWARE_RENDERING))) {
Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.software_rendering"), i18n("message.warning"), MessageType.WARNING)
.ok(null)
.addCancel(i18n("button.do_not_show_again"), () ->
config().getShownTips().put(SOFTWARE_RENDERING, true))
.build());
}
if (globalConfig().getAgreementVersion() < 1) {
JFXDialogLayout agreementPane = new JFXDialogLayout();
@@ -365,6 +434,38 @@ public final class Controllers {
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
}
public static void confirmWithCountdown(String text, String title, int seconds, MessageType messageType,
@Nullable Runnable ok, @Nullable Runnable cancel) {
if (seconds <= 0)
throw new IllegalArgumentException("Seconds must be greater than 0");
JFXButton btnOk = new JFXButton(i18n("button.ok"));
btnOk.getStyleClass().add(messageType == MessageType.WARNING || messageType == MessageType.ERROR
? "dialog-error"
: "dialog-accept");
if (ok != null)
btnOk.setOnAction(e -> ok.run());
btnOk.setDisable(true);
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
for (int i = 0; i < seconds; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(btnOk.textProperty(), i18n("button.ok.countdown", seconds - i)));
}
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
new KeyValue(btnOk.textProperty(), i18n("button.ok")),
new KeyValue(btnOk.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
confirmAction(text, title, messageType, btnOk, () -> {
timeline.stop();
if (cancel != null)
cancel.run();
});
timeline.play();
}
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult) {
return prompt(title, onResult, "");
}
@@ -411,6 +512,10 @@ public final class Controllers {
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
break;
case "hmcl://game/launch":
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
break;
}
} else {
FXUtils.openLink(href);
File diff suppressed because it is too large. Load diff
@@ -37,20 +37,17 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
import java.io.IOException;
import java.nio.file.Files;
@@ -64,10 +61,10 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameCrashWindow extends Stage {
private final Version version;
@@ -98,7 +95,7 @@ public class GameCrashWindow extends Stage {
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " MB").orElse("-");
total_memory = OperatingSystem.TOTAL_MEMORY + " MB";
total_memory = MEGABYTES.formatBytes(SystemInfo.getTotalMemorySize());
this.java = launchOptions.getJava().getArchitecture() == Architecture.SYSTEM_ARCH
? launchOptions.getJava().getVersion()
@@ -109,7 +106,7 @@ public class GameCrashWindow extends Stage {
this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction));
setScene(new Scene(view, 800, 480));
getScene().getStylesheets().addAll(Theme.getTheme().getStylesheets(config().getLauncherFontFamily()));
StyleSheets.init(getScene());
setTitle(i18n("game.crash.title"));
FXUtils.setIcon(this);
@@ -133,22 +130,22 @@ public class GameCrashWindow extends Stage {
crashReport = CrashReportAnalyzer.extractCrashReport(rawLog);
}
return pair(CrashReportAnalyzer.anaylze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
}), Task.supplyAsync(() -> {
Path latestLog = repository.getRunDirectory(version.getId()).toPath().resolve("logs/latest.log");
Path latestLog = repository.getRunDirectory(version.getId()).resolve("logs/latest.log");
if (!Files.isReadable(latestLog)) {
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
}
String log;
try {
log = FileUtils.readText(latestLog);
log = FileUtils.readTextMaybeNativeEncoding(latestLog);
} catch (IOException e) {
LOG.warning("Failed to read logs/latest.log", e);
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
}
return pair(CrashReportAnalyzer.anaylze(log), CrashReportAnalyzer.findKeywordsFromCrashReport(log));
return pair(CrashReportAnalyzer.analyze(log), CrashReportAnalyzer.findKeywordsFromCrashReport(log));
})).whenComplete(Schedulers.javafx(), (taskResult, exception) -> {
loading.set(false);
@@ -271,7 +268,7 @@ public class GameCrashWindow extends Stage {
Path logFile = Paths.get("minecraft-exported-crash-info-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".zip").toAbsolutePath();
CompletableFuture.supplyAsync(() ->
logs.stream().map(Log::getLog).collect(Collectors.joining(OperatingSystem.LINE_SEPARATOR)))
logs.stream().map(Log::getLog).collect(Collectors.joining("\n")))
.thenComposeAsync(logs ->
LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString()))
.handleAsync((result, exception) -> {
@@ -289,7 +286,7 @@ public class GameCrashWindow extends Stage {
alert.showAndWait();
return null;
});
}, Schedulers.javafx());
}
private final class View extends VBox {
@@ -385,7 +382,7 @@ public class GameCrashWindow extends Stage {
TwoLineListItem gameDir = new TwoLineListItem();
gameDir.getStyleClass().setAll("two-line-item-second-large");
gameDir.setTitle(i18n("game.directory"));
gameDir.setSubtitle(launchOptions.getGameDir().getAbsolutePath());
gameDir.setSubtitle(launchOptions.getGameDir().toAbsolutePath().toString());
FXUtils.installFastTooltip(gameDir, i18n("game.directory"));
TwoLineListItem javaDir = new TwoLineListItem();
@@ -22,6 +22,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.util.StringUtils;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
@@ -159,28 +160,19 @@ public final class HTMLRenderer {
private void appendImage(Node node) {
String src = node.absUrl("src");
URI imageUri = null;
try {
if (!src.isEmpty())
imageUri = URI.create(src);
} catch (Exception ignored) {
}
String alt = node.attr("alt");
if (imageUri != null) {
URI uri = URI.create(src);
if (StringUtils.isNotBlank(src)) {
String widthAttr = node.attr("width");
String heightAttr = node.attr("height");
double width = 0;
double height = 0;
int width = 0;
int height = 0;
if (!widthAttr.isEmpty() && !heightAttr.isEmpty()) {
try {
width = Double.parseDouble(widthAttr);
height = Double.parseDouble(heightAttr);
width = (int) Double.parseDouble(widthAttr);
height = (int) Double.parseDouble(heightAttr);
} catch (NumberFormatException ignored) {
}
@@ -190,10 +182,12 @@ public final class HTMLRenderer {
}
}
Image image = FXUtils.newRemoteImage(uri.toString(), width, height, true, true, false);
if (image.isError()) {
LOG.warning("Failed to load image: " + uri, image.getException());
} else {
try {
Image image = FXUtils.getRemoteImageTask(src, width, height, true, true)
.run();
if (image == null)
throw new AssertionError("Image loading task returned null");
ImageView imageView = new ImageView(image);
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
@@ -204,6 +198,8 @@ public final class HTMLRenderer {
}
children.add(imageView);
return;
} catch (Throwable e) {
LOG.warning("Failed to load image: " + src, e);
}
}
@@ -22,6 +22,7 @@ import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -138,6 +139,9 @@ public class InstallerItem extends Control {
case "forge":
iconType = VersionIconType.FORGE;
break;
case "cleanroom":
iconType = VersionIconType.CLEANROOM;
break;
case "liteloader":
iconType = VersionIconType.CHICKEN;
break;
@@ -232,6 +236,7 @@ public class InstallerItem extends Control {
InstallerItem fabric = new InstallerItem(FABRIC, style);
InstallerItem fabricApi = new InstallerItem(FABRIC_API, style);
InstallerItem forge = new InstallerItem(FORGE, style);
InstallerItem cleanroom = new InstallerItem(CLEANROOM, style);
InstallerItem neoForge = new InstallerItem(NEO_FORGE, style);
InstallerItem liteLoader = new InstallerItem(LITELOADER, style);
InstallerItem optiFine = new InstallerItem(OPTIFINE, style);
@@ -239,11 +244,11 @@ public class InstallerItem extends Control {
InstallerItem quiltApi = new InstallerItem(QUILT_API, style);
Map<InstallerItem, Set<InstallerItem>> incompatibleMap = new HashMap<>();
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge);
addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge);
addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge);
addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine);
addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine);
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom);
addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom);
for (Map.Entry<InstallerItem, Set<InstallerItem>> entry : incompatibleMap.entrySet()) {
InstallerItem item = entry.getKey();
@@ -277,16 +282,24 @@ public class InstallerItem extends Control {
game.versionProperty.set(new InstalledState(gameVersion, false, false));
}
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi};
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, cleanroom};
for (InstallerItem item : all) {
if (!item.resolvedStateProperty.isBound()) {
item.resolvedStateProperty.bind(item.versionProperty);
item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> {
InstalledState itemVersion = item.versionProperty.get();
if (itemVersion != null) {
return itemVersion;
}
return InstallableState.INSTANCE;
}, item.versionProperty));
}
}
if (gameVersion == null) {
this.libraries = all;
} else if (gameVersion.equals("1.12.2")) {
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, optiFine};
} else if (GameVersionNumber.compare(gameVersion, "1.13") < 0) {
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine};
} else {
@@ -306,7 +319,9 @@ public class InstallerItem extends Control {
private static final class InstallerItemSkin extends SkinBase<InstallerItem> {
private static final PseudoClass LIST_ITEM = PseudoClass.getPseudoClass("list-item");
private static final PseudoClass CARD = PseudoClass.getPseudoClass("card");
private static final WeakListenerHolder holder = new WeakListenerHolder();
@SuppressWarnings({"FieldCanBeLocal", "unused"})
private final ChangeListener<Number> holder;
InstallerItemSkin(InstallerItem control) {
super(control);
@@ -314,9 +329,10 @@ public class InstallerItem extends Control {
Pane pane;
if (control.style == Style.CARD) {
pane = new VBox();
holder.add(FXUtils.onWeakChange(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7)));
holder = FXUtils.onWeakChangeAndOperate(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7));
} else {
pane = new HBox();
holder = null;
}
pane.getStyleClass().add("installer-item");
RipplerContainer container = new RipplerContainer(pane);
@@ -362,7 +378,9 @@ public class InstallerItem extends Control {
}
return i18n("install.installer.version", s.version);
} else if (state instanceof InstallableState) {
return i18n("install.installer.not_installed");
return control.style == Style.CARD
? i18n("install.installer.do_not_install")
: i18n("install.installer.not_installed");
} else if (state instanceof IncompatibleState) {
return i18n("install.installer.incompatible", i18n("install.installer." + ((IncompatibleState) state).incompatibleItemName));
} else {
@@ -383,7 +401,10 @@ public class InstallerItem extends Control {
if (control.id.equals(MINECRAFT.getPatchId())) {
removeButton.setVisible(false);
} else {
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> control.resolvedStateProperty.get() instanceof InstalledState, control.resolvedStateProperty));
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
State state = control.resolvedStateProperty.get();
return state instanceof InstalledState && !((InstalledState) state).external;
}, control.resolvedStateProperty));
}
removeButton.managedProperty().bind(removeButton.visibleProperty());
removeButton.setOnAction(e -> {
@@ -64,11 +64,9 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
vBox.setAlignment(Pos.BOTTOM_RIGHT);
vBox.setPickOnBounds(false);
JFXButton btnAdd = new JFXButton();
JFXButton btnAdd = FXUtils.newRaisedButton("");
FXUtils.setLimitWidth(btnAdd, 40);
FXUtils.setLimitHeight(btnAdd, 40);
btnAdd.getStyleClass().add("jfx-button-raised-round");
btnAdd.setButtonType(JFXButton.ButtonType.RAISED);
btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1));
btnAdd.setOnAction(e -> skinnable.add());
@@ -38,9 +38,9 @@ import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.CircularArrayList;
@@ -92,7 +92,7 @@ public final class LogWindow extends Stage {
this.logs = logs;
this.impl = new LogWindowImpl();
setScene(new Scene(impl, 800, 480));
getScene().getStylesheets().addAll(Theme.getTheme().getStylesheets(config().getLauncherFontFamily()));
StyleSheets.init(getScene());
setTitle(i18n("logwindow.title"));
FXUtils.setIcon(this);
@@ -186,7 +186,7 @@ public final class LogWindow extends Stage {
}
private void onTerminateGame() {
LauncherHelper.stopManagedProcesses();
LogWindow.this.gameProcess.stop();
}
private void onClear() {
@@ -224,7 +224,7 @@ public final class LogWindow extends Stage {
try {
if (gameProcess.isRunning()) {
GameDumpGenerator.writeDumpTo(gameProcess.getPID(), dumpFile);
GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile);
FXUtils.showFileInExplorer(dumpFile);
}
} catch (Throwable e) {
@@ -307,11 +307,12 @@ public final class LogWindow extends Stage {
listView.scrollTo(listView.getItems().size() - 1);
});
listView.setStyle("-fx-font-family: " + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT)
+ "; -fx-font-size: " + config().getFontSize() + "px;");
listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT)
+ "\"; -fx-font-size: " + config().getFontSize() + "px;");
Holder<Object> lastCell = new Holder<>();
listView.setCellFactory(x -> new ListCell<Log>() {
{
x.setSelectionModel(new NoneMultipleSelectionModel<>());
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
@@ -50,6 +50,7 @@ public enum SVG {
CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"),
CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"),
CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"),
DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"),
DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"),
DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"),
@@ -57,24 +58,29 @@ public enum SVG {
DRESSER("M4 21V5Q4 4.175 4.5875 3.5875T6 3H18Q18.825 3 19.4125 3.5875T20 5V21H18V19H6V21H4ZM6 11H11V5H6V11ZM13 7H18V5H13V7ZM13 11H18V9H13V11ZM10 16H14V14H10V16ZM6 13V17H18V13H6ZM6 13V17 13Z"),
EDIT("M5 19H6.425L16.2 9.225 14.775 7.8 5 17.575V19ZM3 21V16.75L16.2 3.575Q16.5 3.3 16.8625 3.15T17.625 3Q18.025 3 18.4 3.15T19.05 3.6L20.425 5Q20.725 5.275 20.8625 5.65T21 6.4Q21 6.8 20.8625 7.1625T20.425 7.825L7.25 21H3ZM19 6.4 17.6 5 19 6.4ZM15.475 8.525 14.775 7.8 16.2 9.225 15.475 8.525Z"),
ERROR("M12 17Q12.425 17 12.7125 16.7125T13 16Q13 15.575 12.7125 15.2875T12 15Q11.575 15 11.2875 15.2875T11 16Q11 16.425 11.2875 16.7125T12 17ZM11 13H13V7H11V13ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
EXPLORE("M12 12Zm0 8q-3.325 0-5.6625-2.3375T4 12Q4 8.675 6.3375 6.3375T12 4q3.325-0 5.6625 2.3375T20 12q0 3.325-2.3375 5.6625T12 20Zm0 2q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22Zm0-8.5q.625 0 1.0625-.4375T13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5Zm-4.5 3 2-7 7-2-2 7-7 2Z"),
EXTENSION("M8.8 21H5Q4.175 21 3.5875 20.4125T3 19V15.2Q4.2 15.2 5.1 14.4375T6 12.5Q6 11.325 5.1 10.5625T3 9.8V6Q3 5.175 3.5875 4.5875T5 4H9Q9 2.95 9.725 2.225T11.5 1.5Q12.55 1.5 13.275 2.225T14 4H18Q18.825 4 19.4125 4.5875T20 6V10Q21.05 10 21.775 10.725T22.5 12.5Q22.5 13.55 21.775 14.275T20 15V19Q20 19.825 19.4125 20.4125T18 21H14.2Q14.2 19.75 13.4125 18.875T11.5 18Q10.375 18 9.5875 18.875T8.8 21ZM5 19H7.125Q7.725 17.35 9.05 16.675T11.5 16Q12.625 16 13.95 16.675T15.875 19H18V13H20Q20.2 13 20.35 12.85T20.5 12.5Q20.5 12.3 20.35 12.15T20 12H18V6H12V4Q12 3.8 11.85 3.65T11.5 3.5Q11.3 3.5 11.15 3.65T11 4V6H5V8.2Q6.35 8.7 7.175 9.875T8 12.5Q8 13.925 7.175 15.1T5 16.8V19ZM11.5 12.5Z"),
FEEDBACK("M12 15Q12.425 15 12.7125 14.7125T13 14Q13 13.575 12.7125 13.2875T12 13Q11.575 13 11.2875 13.2875T11 14Q11 14.425 11.2875 14.7125T12 15ZM11 11H13V5H11V11ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"),
FOLDER("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8V18Q22 18.825 21.4125 19.4125T20 20H4ZM4 18H20V8H11.175L9.175 6H4V18ZM4 18V6 18Z"),
FOLDER_COPY("M3 21Q2.175 21 1.5875 20.4125T1 19V6H3V19H20V21H3ZM7 17Q6.175 17 5.5875 16.4125T5 15V4Q5 3.175 5.5875 2.5875T7 2H12L14 4H21Q21.825 4 22.4125 4.5875T23 6V15Q23 15.825 22.4125 16.4125T21 17H7ZM7 15H21V6H13.175L11.175 4H7V15ZM7 15V4 15Z"),
FOLDER_OPEN("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8H11.175L9.175 6H4V18L6.4 10H23.5L20.925 18.575Q20.725 19.225 20.1875 19.6125T19 20H4ZM6.1 18H19L20.8 12H7.9L6.1 18ZM6.1 18 7.9 12 6.1 18ZM4 8V6 8Z"),
FORMAT_LIST_BULLETED("M9 19V17H21V19H9ZM9 13V11H21V13H9ZM9 7V5H21V7H9ZM5 20Q4.175 20 3.5875 19.4125T3 18Q3 17.175 3.5875 16.5875T5 16Q5.825 16 6.4125 16.5875T7 18Q7 18.825 6.4125 19.4125T5 20ZM5 14Q4.175 14 3.5875 13.4125T3 12Q3 11.175 3.5875 10.5875T5 10Q5.825 10 6.4125 10.5875T7 12Q7 12.825 6.4125 13.4125T5 14ZM5 8Q4.175 8 3.5875 7.4125T3 6Q3 5.175 3.5875 4.5875T5 4Q5.825 4 6.4125 4.5875T7 6Q7 6.825 6.4125 7.4125T5 8Z"),
FORT("M1 21V17l2-2V9L1 7V3H3V5H5V3H7V5H9V3h2V7L9 9v1h6V9L13 7V3h2V5h2V3h2V5h2V3h2V7L21 9v6l2 2v4H14V18q0-.825-.5875-1.4125T12 16q-.825 0-1.4125.5875T10 18v3H1Zm2-2H8V18q0-1.65 1.175-2.825T12 14q1.65 0 2.825 1.175T16 18v1h5V17.825l-2-2V8.175L20.175 7h-4.35L17 8.175V12H7V8.175L8.175 7H3.825L5 8.175v7.65l-2 2V19Zm9-6Z"),
FOR_YOU("M12 12Q14.025 12 16.225 11.5875T20 10.5V20.5Q18.5 21.175 16.35 21.5875T12 22Q9.8 22 7.65 21.5875T4 20.5V10.5Q5.575 11.175 7.775 11.5875T12 12ZM18 19V13.25Q16.75 13.6 15.1125 13.8T12 14Q10.525 14 8.8875 13.8T6 13.25V19Q7.25 19.45 8.875 19.725T12 20Q13.5 20 15.125 19.725T18 19ZM12 2Q13.65 2 14.825 3.175T16 6Q16 7.65 14.825 8.825T12 10Q10.35 10 9.175 8.825T8 6Q8 4.35 9.175 3.175T12 2ZM12 8Q12.825 8 13.4125 7.4125T14 6Q14 5.175 13.4125 4.5875T12 4Q11.175 4 10.5875 4.5875T10 6Q10 6.825 10.5875 7.4125T12 8ZM12 6ZM12 16.625Z"),
GAMEPAD("M12 7.65ZM16.35 12ZM7.65 12ZM12 16.35ZM12 10.5 9 7.5V2H15V7.5L12 10.5ZM16.5 15 13.5 12 16.5 9H22V15H16.5ZM2 15V9H7.5L10.5 12 7.5 15H2ZM9 22V16.5L12 13.5 15 16.5V22H9ZM12 7.65 13 6.65V4H11V6.65L12 7.65ZM4 13H6.65L7.65 12 6.65 11H4V13ZM11 20H13V17.35L12 16.35 11 17.35V20ZM17.35 13H20V11H17.35L16.35 12 17.35 13Z"),
GLOBE_BOOK("M3.075 13Q3.05 12.75 3.0375 12.5T3.025 12Q3.025 10.125 3.725 8.4875T5.65 5.6375Q6.875 4.425 8.5 3.7125T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 12.25 20.9875 12.5T20.95 13H18.925Q18.975 12.75 18.9875 12.5T19 12Q19 11.75 18.9875 11.5T18.925 11H15.975Q16 11.25 16 11.5V12.5Q16 12.75 15.975 13H14V12.175Q14 11.875 13.9875 11.575T13.95 11H10.075Q10.05 11.275 10.0375 11.575T10.025 12.175V13H8.05Q8.025 12.75 8.025 12.5V11.5Q8.025 11.25 8.05 11H5.1Q5.05 11.25 5.0375 11.5T5.025 12Q5.025 12.25 5.0375 12.5T5.1 13H3.075ZM5.7 9H8.275Q8.475 7.925 8.775 7.0625T9.425 5.5Q8.225 5.95 7.25 6.8625T5.7 9ZM10.35 9H13.65Q13.4 7.925 13.025 6.9T12 5Q11.35 5.875 10.9625 6.9T10.35 9ZM15.75 9H18.325Q17.75 7.775 16.7625 6.8625T14.575 5.5Q14.925 6.25 15.2375 7.0875T15.75 9ZM11 21V20Q11 18.75 10.125 17.875T8 17H2V15H8Q9.2 15 10.2375 15.525T12 17Q12.725 16.05 13.7625 15.525T16 15H22V17H16Q14.75 17 13.875 17.875T13 20V21H11Z"),
GRAPH2("M5 22q-1.25 0-2.125-.875T2 19q0-.975.5625-1.75T4 16.175V14q0-1.25.875-2.125T7 11h4V7.825q-.875-.3-1.4375-1.075T9 5q0-1.25.875-2.125T12 2t2.125.875T15 5q0 .975-.5625 1.75T13 7.825V11h4q1.25 0 2.125.875T20 14v2.175q.875.3 1.4375 1.075T22 19q0 1.25-.875 2.125T19 22t-2.125-.875T16 19q0-.975.5625-1.75T18 16.175V14q0-.425-.2875-.7125T17 13H13v3.175q.875.3 1.4375 1.075T15 19q0 1.25-.875 2.125T12 22t-2.125-.875T9 19q0-.975.5625-1.75T11 16.175V13H7q-.425 0-.7125.2875T6 14v2.175q.875.3 1.4375 1.075T8 19q0 1.25-.875 2.125T5 22Zm0-2q.425 0 .7125-.2875T6 19q0-.425-.2875-.7125T5 18q-.425 0-.7125.2875T4 19q0 .425.2875.7125T5 20Zm7 0q.425 0 .7125-.2875T13 19q0-.425-.2875-.7125T12 18q-.425 0-.7125.2875T11 19q0 .425.2875.7125T12 20Zm7 0q.425 0 .7125-.2875T20 19q0-.425-.2875-.7125T19 18q-.425 0-.7125.2875T18 19q0 .425.2875.7125T19 20ZM12 6q.425 0 .7125-.2875T13 5q0-.425-.2875-.7125T12 4q-.425 0-.7125.2875T11 5q0 .425.2875.7125T12 6Z"),
HELP("M11.95 18Q12.475 18 12.8375 17.6375T13.2 16.75Q13.2 16.225 12.8375 15.8625T11.95 15.5Q11.425 15.5 11.0625 15.8625T10.7 16.75Q10.7 17.275 11.0625 17.6375T11.95 18ZM11.05 14.15H12.9Q12.9 13.325 13.0875 12.85T14.15 11.55Q14.8 10.9 15.175 10.3125T15.55 8.9Q15.55 7.5 14.525 6.75T12.1 6Q10.675 6 9.7875 6.75T8.55 8.55L10.2 9.2Q10.325 8.75 10.7625 8.225T12.1 7.7Q12.9 7.7 13.3 8.1375T13.7 9.1Q13.7 9.6 13.4 10.0375T12.65 10.85Q11.55 11.825 11.3 12.325T11.05 14.15ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
HOME("M6 19H9V13H15V19H18V10L12 5.5 6 10V19ZM4 21V9L12 3 20 9V21H13V15H11V21H4ZM12 12.25Z"),
HOST("M4 21Q3.175 21 2.5875 20.4125T2 19V5Q2 4.175 2.5875 3.5875T4 3H9Q9.825 3 10.4125 3.5875T11 5V19Q11 19.825 10.4125 20.4125T9 21H4ZM15 21Q14.175 21 13.5875 20.4125T13 19V5Q13 4.175 13.5875 3.5875T15 3H20Q20.825 3 21.4125 3.5875T22 5V19Q22 19.825 21.4125 20.4125T20 21H15ZM4 19H9V5H4V19ZM15 19H20V5H15V19ZM5 15H8V13H5V15ZM16 15H19V13H16V15ZM5 12H8V10H5V12ZM16 12H19V10H16V12ZM5 9H8V7H5V9ZM16 9H19V7H16V9ZM4 19H9 4ZM15 19H20 15Z"),
INFO("M11 17H13V11H11V17ZM12 9Q12.425 9 12.7125 8.7125T13 8Q13 7.575 12.7125 7.2875T12 7Q11.575 7 11.2875 7.2875T11 8Q11 8.425 11.2875 8.7125T12 9ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
KEYBOARD_ARROW_DOWN("M12 15.4 6 9.4 7.4 8 12 12.6 16.6 8 18 9.4 12 15.4Z"),
KEYBOARD_ARROW_UP("M12 10.8 7.4 15.4 6 14 12 8 18 14 16.6 15.4 12 10.8Z"),
LANDSCAPE("M1 18l6-8 4.5 6H19L14 9.35l-2.5 3.3L10.25 11 14 6l9 12H1Zm13.025-2ZM5 16H9L7 13.325 5 16ZH9 5Z"),
LIST("M7 9V7H21V9H7ZM7 13V11H21V13H7ZM7 17V15H21V17H7ZM4 9Q3.575 9 3.2875 8.7125T3 8Q3 7.575 3.2875 7.2875T4 7Q4.425 7 4.7125 7.2875T5 8Q5 8.425 4.7125 8.7125T4 9ZM4 13Q3.575 13 3.2875 12.7125T3 12Q3 11.575 3.2875 11.2875T4 11Q4.425 11 4.7125 11.2875T5 12Q5 12.425 4.7125 12.7125T4 13ZM4 17Q3.575 17 3.2875 16.7125T3 16Q3 15.575 3.2875 15.2875T4 15Q4.425 15 4.7125 15.2875T5 16Q5 16.425 4.7125 16.7125T4 17Z"),
LISTS("M2 20V16H6V20H2ZM8 20V16H22V20H8ZM2 14V10H6V14H2ZM8 14V10H22V14H8ZM2 8V4H6V8H2ZM8 8V4H22V8H8Z"),
LOCAL_CAFE("M4 21V19H20V21H4ZM8 17Q6.35 17 5.175 15.825T4 13V3H20Q20.825 3 21.4125 3.5875T22 5V8Q22 8.825 21.4125 9.4125T20 10H18V13Q18 14.65 16.825 15.825T14 17H8ZM8 15H14Q14.825 15 15.4125 14.4125T16 13V5H6V13Q6 13.825 6.5875 14.4125T8 15ZM18 8H20V5H18V8ZM8 15H6 16 8Z"),
LOCATION_CITY("M3 21V7H9V5l3-3 3 3v6h6V21H3Zm2-2H7V17H5v2Zm0-4H7V13H5v2Zm0-4H7V9H5v2Zm6 8h2V17H11v2Zm0-4h2V13H11v2Zm0-4h2V9H11v2Zm0-4h2V5H11V7Zm6 12h2V17H17v2Zm0-4h2V13H17v2Z"),
MENU("M3 18V16H21V18H3ZM3 13V11H21V13H3ZM3 8V6H21V8H3Z"),
MICROSOFT("M4 20H22v2H4V13H20v7h2V4H20v7H4V4h7V20h2V4h9V2H2V22H4"), // Not Material
MINIMIZE("M6 21V19H18V21H6Z"),
@@ -91,6 +97,7 @@ public enum SVG {
RELEASE_CIRCLE("M9,7H13A2,2 0 0,1 15,9V11C15,11.84 14.5,12.55 13.76,12.85L15,17H13L11.8,13H11V17H9V7M11,9V11H13V9H11M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,16.41 7.58,20 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material
RESTORE("M12 21Q8.55 21 5.9875 18.7125T3.05 13H5.1Q5.45 15.6 7.4125 17.3T12 19Q14.925 19 16.9625 16.9625T19 12Q19 9.075 16.9625 7.0375T12 5Q10.275 5 8.775 5.8T6.25 8H9V10H3V4H5V6.35Q6.275 4.75 8.1125 3.875T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 13.875 20.2875 15.5125T18.3625 18.3625Q17.15 19.575 15.5125 20.2875T12 21Z"), // Not Material
ROCKET_LAUNCH("M5.65 10.025 7.6 10.85Q7.95 10.15 8.325 9.5T9.15 8.2L7.75 7.925 5.65 10.025ZM9.2 12.1 12.05 14.925Q13.1 14.525 14.3 13.7T16.55 11.825Q18.3 10.075 19.2875 7.9375T20.15 4Q18.35 3.875 16.2 4.8625T12.3 7.6Q11.25 8.65 10.425 9.85T9.2 12.1ZM13.65 10.475Q13.075 9.9 13.075 9.0625T13.65 7.65Q14.225 7.075 15.075 7.075T16.5 7.65Q17.075 8.225 17.075 9.0625T16.5 10.475Q15.925 11.05 15.075 11.05T13.65 10.475ZM14.125 18.5 16.225 16.4 15.95 15Q15.3 15.45 14.65 15.8125T13.3 16.525L14.125 18.5ZM21.95 2.175Q22.425 5.2 21.3625 8.0625T17.7 13.525L18.2 16Q18.3 16.5 18.15 16.975T17.65 17.8L13.45 22 11.35 17.075 7.075 12.8 2.15 10.7 6.325 6.5Q6.675 6.15 7.1625 6T8.15 5.95L10.625 6.45Q13.225 3.85 16.075 2.775T21.95 2.175ZM3.925 15.975Q4.8 15.1 6.0625 15.0875T8.2 15.95Q9.075 16.825 9.0625 18.0875T8.175 20.225Q7.55 20.85 6.0875 21.3T2.05 22.1Q2.4 19.525 2.85 18.0625T3.925 15.975ZM5.35 17.375Q5.1 17.625 4.85 18.2875T4.5 19.625Q5.175 19.525 5.8375 19.2875T6.75 18.8Q7.05 18.5 7.075 18.075T6.8 17.35Q6.5 17.05 6.075 17.0625T5.35 17.375Z"),
SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"),
SCREENSHOT_MONITOR("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8ZM4 17H20V5H4V17ZM4 17V5 17Z"),
SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material
SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"),
@@ -102,6 +109,8 @@ public enum SVG {
TRIP("M4 21Q3.175 21 2.5875 20.4125T2 19V8Q2 7.175 2.5875 6.5875T4 6H8V4Q8 3.175 8.5875 2.5875T10 2H14Q14.825 2 15.4125 2.5875T16 4V6H20Q20.825 6 21.4125 6.5875T22 8V19Q22 19.825 21.4125 20.4125T20 21H4ZM10 6H14V4H10V6ZM6 8H4V19H6V8ZM16 19V8H8V19H16ZM18 8V19H20V8H18ZM12 13.5Z"),
TUNE("M11 21V15H13V17H21V19H13V21H11ZM3 19V17H9V19H3ZM7 15V13H3V11H7V9H9V15H7ZM11 13V11H21V13H11ZM15 9V3H17V5H21V7H17V9H15ZM3 7V5H13V7H3Z"),
UPDATE("M12 21Q10.125 21 8.4875 20.2875T5.6375 18.3625Q4.425 17.15 3.7125 15.5125T3 12Q3 10.125 3.7125 8.4875T5.6375 5.6375Q6.85 4.425 8.4875 3.7125T12 3Q14.05 3 15.8875 3.875T19 6.35V4H21V10H15V8H17.75Q16.725 6.6 15.225 5.8T12 5Q9.075 5 7.0375 7.0375T5 12Q5 14.925 7.0375 16.9625T12 19Q14.625 19 16.5875 17.3T18.9 13H20.95Q20.575 16.425 18.0125 18.7125T12 21ZM14.8 16.2 11 12.4V7H13V11.6L16.2 14.8 14.8 16.2Z"),
VISIBILITY("M12 16q1.875 0 3.1875-1.3125T16.5 11.5 15.1875 8.3125 12 7 8.8125 8.3125 7.5 11.5t1.3125 3.1875T12 16Zm0-1.8q-1.125 0-1.9125-.7875T9.3 11.5t.7875-1.9125T12 8.8q1.125 0 1.9125.7875T14.7 11.5q0 1.125-.7875 1.9125T12 14.2ZM12 19q-3.65 0-6.65-2.0375T1 11.5Q2.35 8.075 5.35 6.0375T12 4q3.65 0 6.65 2.0375T23 11.5q-1.35 3.425-4.35 5.4625T12 19Zm0-7.5ZM12 17q2.825 0 5.1875-1.4875T20.8 11.5q-1.25-2.525-3.6125-4.0125T12 6 6.8125 7.4875 3.2 11.5q1.25 2.525 3.6125 4.0125T12 17Z"),
VISIBILITY_OFF("M16.1 13.3l-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.425-.2.8625-.3T12 7q1.875 0 3.1875 1.3125T16.5 11.5q0 .5-.1.9375t-.3.8625Zm3.2 3.15-1.45-1.4q.95-.725 1.6875-1.5875T20.8 11.5q-1.25-2.525-3.5875-4.0125T12 6q-.725 0-1.425.1T9.2 6.4L7.65 4.85q1.025-.425 2.1-.6375T12 4q3.775 0 6.725 2.0875T23 11.5q-.575 1.475-1.5125 2.7375T19.3 16.45Zm.5 6.15-4.2-4.15q-.875.275-1.7625.4125T12 19q-3.775 0-6.725-2.0875T1 11.5q.525-1.325 1.325-2.4625T4.15 7L1.4 4.2 2.8 2.8 21.2 21.2l-1.4 1.4ZM5.55 8.4q-.725.65-1.325 1.425T3.2 11.5q1.25 2.525 3.5875 4.0125T12 17q.5 0 .975-.0625T13.95 16.8l-.9-.95q-.275.075-.525.1125T12 16q-1.875 0-3.1875-1.3125T7.5 11.5q0-.275.0375-.525T7.65 10.45L5.55 8.4Zm7.975 2.325ZM9.75 12.6Z"),
WARNING("M1 21 12 2 23 21H1ZM4.45 19H19.55L12 6 4.45 19ZM12 18Q12.425 18 12.7125 17.7125T13 17Q13 16.575 12.7125 16.2875T12 16Q11.575 16 11.2875 16.2875T11 17Q11 17.425 11.2875 17.7125T12 18ZM11 15H13V10H11V15ZM12 12.5Z"),
WB_SUNNY("M11 4V1H13V4H11ZM11 23V20H13V23H11ZM20 13V11H23V13H20ZM1 13V11H4V13H1ZM18.7 6.7 17.3 5.3 19.05 3.5 20.5 4.95 18.7 6.7ZM4.95 20.5 3.5 19.05 5.3 17.3 6.7 18.7 4.95 20.5ZM19.05 20.5 17.3 18.7 18.7 17.3 20.5 19.05 19.05 20.5ZM5.3 6.7 3.5 4.95 4.95 3.5 6.7 5.3 5.3 6.7ZM12 18Q9.5 18 7.75 16.25T6 12Q6 9.5 7.75 7.75T12 6Q14.5 6 16.25 7.75T18 12Q18 14.5 16.25 16.25T12 18ZM12 16Q13.675 16 14.8375 14.8375T16 12Q16 10.325 14.8375 9.1625T12 8Q10.325 8 9.1625 9.1625T8 12Q8 13.675 9.1625 14.8375T12 16ZM12 12Z"),
;
@@ -125,11 +134,10 @@ public enum SVG {
return pane;
}
Group svg = new Group(path);
double scale = size / 24;
svg.setScaleX(scale);
svg.setScaleY(scale);
return svg;
path.setScaleX(scale);
path.setScaleY(scale);
return new Group(path);
}
public Node createIcon(ObservableValue<? extends Paint> fill, double size) {
@@ -100,7 +100,10 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
ret.getStyleClass().add("jfx-tool-bar-button");
ret.setGraphic(wrap(svg.createIcon(Theme.blackFill(), -1)));
ret.setText(text);
ret.setOnAction(e -> onClick.run());
ret.setOnAction(e -> {
onClick.run();
FXUtils.clearFocus(ret);
});
return ret;
}
@@ -1,123 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui;
import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public class TooltipInstaller {
public static final TooltipInstaller INSTALLER;
static {
TooltipInstaller installer = null;
try {
installer = new NewInstaller();
} catch (Throwable e) {
try {
installer = new OldInstaller();
} catch (Throwable e2) {
e2.addSuppressed(e);
LOG.warning("Failed to initialize TooltipInstaller", e2);
}
}
INSTALLER = installer != null ? installer : new TooltipInstaller();
}
TooltipInstaller() {
}
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
Tooltip.install(node, tooltip);
}
// For Java 8
private static final class OldInstaller extends TooltipInstaller {
private static final Constructor<?> createTooltipBehavior;
private static final Method installTooltipBehavior;
static {
try {
Class<?> behaviorClass = Class.forName("javafx.scene.control.Tooltip$TooltipBehavior");
createTooltipBehavior = behaviorClass.getDeclaredConstructor(Duration.class, Duration.class, Duration.class, boolean.class);
createTooltipBehavior.setAccessible(true);
installTooltipBehavior = behaviorClass.getDeclaredMethod("install", Node.class, Tooltip.class);
installTooltipBehavior.setAccessible(true);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Override
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
try {
Object behavior = createTooltipBehavior.newInstance(showDelay, showDuration, hideDelay, false);
installTooltipBehavior.invoke(behavior, node, tooltip);
} catch (ReflectiveOperationException e) {
LOG.warning("Failed to set tooltip show delay", e);
Tooltip.install(node, tooltip);
}
}
}
// For Java 9+
private static final class NewInstaller extends TooltipInstaller {
private static final MethodHandle setTooltipShowDelay;
private static final MethodHandle setTooltipShowDuration;
private static final MethodHandle setTooltipHideDelay;
static {
try {
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
MethodType methodType = MethodType.methodType(void.class, Duration.class);
setTooltipShowDelay = lookup.findVirtual(Tooltip.class, "setShowDelay", methodType);
setTooltipShowDuration = lookup.findVirtual(Tooltip.class, "setShowDuration", methodType);
setTooltipHideDelay = lookup.findVirtual(Tooltip.class, "setHideDelay", methodType);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Override
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
try {
setTooltipShowDelay.invokeExact(tooltip, showDelay);
setTooltipShowDuration.invokeExact(tooltip, showDuration);
setTooltipHideDelay.invokeExact(tooltip, hideDelay);
} catch (Throwable e) {
LOG.warning("Failed to set tooltip show delay", e);
}
Tooltip.install(node, tooltip);
}
}
}
@@ -48,11 +48,22 @@ public final class UpgradeDialog extends JFXDialogLayout {
setBody(new ProgressIndicator());
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
boolean isPreview = remoteVersion.isPreview();
Task.supplyAsync(Schedulers.io(), () -> {
Document document = Jsoup.parse(new URL(url), 30 * 1000);
Node node = document.selectFirst("#nowchange");
if (node == null)
throw new IOException("Cannot find #nowchange in document");
String id = null;
Node node = null;
if (isPreview) {
id = "nowpreview";
node = document.selectFirst("#" + id);
}
if (node == null) {
id = "nowchange";
node = document.selectFirst("#" + id);
}
if (node == null || !"h1".equals(node.nodeName()))
throw new IOException("Cannot find current changelog in document");
HTMLRenderer renderer = new HTMLRenderer(uri -> {
LOG.info("Open link: " + uri);
@@ -60,6 +71,9 @@ public final class UpgradeDialog extends JFXDialogLayout {
});
do {
if ("h1".equals(node.nodeName()) && !id.equals(node.attr("id"))) {
break;
}
renderer.appendNode(node);
node = node.nextSibling();
} while (node != null);
@@ -21,7 +21,6 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Tooltip;
import org.jackhuang.hmcl.auth.Account;
@@ -77,18 +76,9 @@ public class AccountAdvancedListItem extends AdvancedListItem {
setActionButtonVisible(false);
setOnScroll(event -> {
Account current = account.get();
if (current == null) return;
ObservableList<Account> accounts = Accounts.getAccounts();
int currentIndex = accounts.indexOf(account.get());
if (event.getDeltaY() > 0) { // up
currentIndex--;
} else { // down
currentIndex++;
}
Accounts.setSelectedAccount(accounts.get((currentIndex + accounts.size()) % accounts.size()));
});
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(account.get()),
Accounts::setSelectedAccount);
}
public ObjectProperty<Account> accountProperty() {
@@ -41,13 +41,14 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogController;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import org.jackhuang.hmcl.util.skin.NormalizedSkin;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CancellationException;
@@ -116,8 +117,7 @@ public class AccountListItem extends RadioButton {
}
public ObservableBooleanValue canUploadSkin() {
if (account instanceof AuthlibInjectorAccount) {
AuthlibInjectorAccount aiAccount = (AuthlibInjectorAccount) account;
if (account instanceof AuthlibInjectorAccount aiAccount) {
ObjectBinding<Optional<CompleteGameProfile>> profile = aiAccount.getYggdrasilService().getProfileRepository().binding(aiAccount.getUUID());
return createBooleanBinding(() -> {
Set<TextureType> uploadableTextures = profile.get()
@@ -148,7 +148,7 @@ public class AccountListItem extends RadioButton {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("account.skin.upload"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png"));
File selectedFile = chooser.showOpenDialog(Controllers.getStage());
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (selectedFile == null) {
return null;
}
@@ -156,7 +156,7 @@ public class AccountListItem extends RadioButton {
return refreshAsync()
.thenRunAsync(() -> {
Image skinImg;
try (FileInputStream input = new FileInputStream(selectedFile)) {
try (var input = Files.newInputStream(selectedFile)) {
skinImg = new Image(input);
} catch (IOException e) {
throw new InvalidSkinException("Failed to read skin image", e);
@@ -167,7 +167,7 @@ public class AccountListItem extends RadioButton {
NormalizedSkin skin = new NormalizedSkin(skinImg);
String model = skin.isSlim() ? "slim" : "";
LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]");
account.uploadSkin(skin.isSlim(), selectedFile.toPath());
account.uploadSkin(skin.isSlim(), selectedFile);
})
.thenComposeAsync(refreshAsync())
.whenComplete(Schedulers.javafx(), e -> {
@@ -33,6 +33,7 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
@@ -128,6 +129,10 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
JFXButton btnRefresh = new JFXButton();
SpinnerPane spinnerRefresh = new SpinnerPane();
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
btnRefresh.setDisable(true);
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
}
btnRefresh.setOnAction(e -> {
spinnerRefresh.showSpinner();
skinnable.refreshAsync()
@@ -19,7 +19,14 @@ package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.JFXButton;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -40,18 +47,41 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.ClassTitle;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.net.URI;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage {
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
static {
String property = System.getProperty("hmcl.offline.auth.restricted", "auto");
if ("false".equals(property)
|| "auto".equals(property) && LocaleUtils.IS_CHINA_MAINLAND
|| globalConfig().isEnableOfflineAccount())
RESTRICTED.set(false);
else
globalConfig().enableOfflineAccountProperty().addListener(new ChangeListener<Boolean>() {
@Override
public void changed(ObservableValue<? extends Boolean> o, Boolean oldValue, Boolean newValue) {
if (newValue) {
globalConfig().enableOfflineAccountProperty().removeListener(this);
RESTRICTED.set(false);
}
}
});
}
public class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ObservableList<AccountListItem> items;
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("account.manage")));
private final ListProperty<Account> accounts = new SimpleListProperty<>(this, "accounts", FXCollections.observableArrayList());
@@ -88,6 +118,7 @@ public class AccountListPage extends DecoratorAnimatedPage implements DecoratorP
private static class AccountListPageSkin extends DecoratorAnimatedPageSkin<AccountListPage> {
private final ObservableList<AdvancedListItem> authServerItems;
private ChangeListener<Boolean> holder;
public AccountListPageSkin(AccountListPage skinnable) {
super(skinnable);
@@ -96,24 +127,21 @@ public class AccountListPage extends DecoratorAnimatedPage implements DecoratorP
VBox boxMethods = new VBox();
{
boxMethods.getStyleClass().add("advanced-list-box-content");
boxMethods.getChildren().add(new ClassTitle(i18n("account.create").toUpperCase(Locale.ROOT)));
FXUtils.setLimitWidth(boxMethods, 200);
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
offlineItem.setActionButtonVisible(false);
offlineItem.setTitle(i18n("account.methods.offline"));
offlineItem.setLeftGraphic(wrap(SVG.PERSON));
offlineItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE)));
boxMethods.getChildren().add(offlineItem);
AdvancedListItem microsoftItem = new AdvancedListItem();
microsoftItem.getStyleClass().add("navigation-drawer-item");
microsoftItem.setActionButtonVisible(false);
microsoftItem.setTitle(i18n("account.methods.microsoft"));
microsoftItem.setLeftGraphic(wrap(SVG.MICROSOFT));
microsoftItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT)));
boxMethods.getChildren().add(microsoftItem);
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
offlineItem.setActionButtonVisible(false);
offlineItem.setTitle(i18n("account.methods.offline"));
offlineItem.setLeftGraphic(wrap(SVG.PERSON));
offlineItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE)));
VBox boxAuthServers = new VBox();
authServerItems = MappedObservableList.create(skinnable.authServersProperty(), server -> {
@@ -137,7 +165,7 @@ public class AccountListPage extends DecoratorAnimatedPage implements DecoratorP
item.titleProperty().bind(title);
String host = "";
try {
host = URI.create(server.getUrl()).getHost();
host = NetworkUtils.toURI(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
}
@@ -149,7 +177,29 @@ public class AccountListPage extends DecoratorAnimatedPage implements DecoratorP
return item;
});
Bindings.bindContent(boxAuthServers.getChildren(), authServerItems);
boxMethods.getChildren().add(boxAuthServers);
ClassTitle title = new ClassTitle(i18n("account.create").toUpperCase(Locale.ROOT));
if (RESTRICTED.get()) {
VBox wrapper = new VBox(offlineItem, boxAuthServers);
wrapper.setPadding(Insets.EMPTY);
FXUtils.installFastTooltip(wrapper, i18n("account.login.restricted"));
offlineItem.setDisable(true);
boxAuthServers.setDisable(true);
boxMethods.getChildren().setAll(title, microsoftItem, wrapper);
holder = FXUtils.onWeakChange(RESTRICTED, value -> {
if (!value) {
holder = null;
offlineItem.setDisable(false);
boxAuthServers.setDisable(false);
boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers);
}
});
} else {
boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers);
}
}
AdvancedListItem addAuthServerItem = new AdvancedListItem();
@@ -31,7 +31,6 @@ import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import javax.net.ssl.SSLException;
import java.io.IOException;
@@ -196,7 +195,8 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
lblServerName.setText(serverBeingAdded.getName());
lblServerUrl.setText(serverBeingAdded.getUrl());
lblServerWarning.setVisible("http".equals(NetworkUtils.toURL(serverBeingAdded.getUrl()).getProtocol()));
//noinspection HttpUrlsUsage
lblServerWarning.setVisible(serverBeingAdded.getUrl().startsWith("http://"));
this.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT);
} else {
@@ -108,12 +108,17 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
public CreateAccountPane(AccountFactory<?> factory) {
if (factory == null) {
showMethodSwitcher = true;
String preferred = config().getPreferredLoginType();
try {
factory = Accounts.getAccountFactory(preferred);
} catch (IllegalArgumentException e) {
factory = Accounts.FACTORY_OFFLINE;
if (AccountListPage.RESTRICTED.get()) {
showMethodSwitcher = false;
factory = Accounts.FACTORY_MICROSOFT;
} else {
showMethodSwitcher = true;
String preferred = config().getPreferredLoginType();
try {
factory = Accounts.getAccountFactory(preferred);
} catch (IllegalArgumentException e) {
factory = Accounts.FACTORY_OFFLINE;
}
}
} else {
showMethodSwitcher = false;
@@ -258,17 +263,13 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
}).executor(true);
};
if (factory instanceof OfflineAccountFactory && username != null && !USERNAME_CHECKER_PATTERN.matcher(username).matches()) {
Controllers.confirm(
i18n("account.methods.offline.name.invalid"), i18n("message.warning"),
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
Controllers.confirmWithCountdown(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), 10,
MessageDialogPane.MessageType.WARNING,
doCreate,
() -> {
lblErrorMessage.setText(i18n("account.methods.offline.name.invalid.tip"));
doCreate, () -> {
body.setDisable(false);
spinner.hideSpinner();
}
);
});
} else {
doCreate.run();
}
@@ -322,7 +323,9 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx");
JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings"));
createProfileLink.setExternalLink("https://www.minecraft.net/msaprofile/mygames/editprofile");
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink);
JFXHyperlink bannedQueryLink = new JFXHyperlink(i18n("account.methods.ban_query"));
bannedQueryLink.setExternalLink("https://enforcement.xbox.com/enforcement/showenforcementhistory");
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink, bannedQueryLink);
GridPane.setColumnSpan(box, 2);
if (!IntegrityChecker.isOfficial()) {
@@ -39,8 +39,9 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.UUID;
@@ -81,16 +82,16 @@ public class OfflineAccountSkinPane extends StackPane {
canvas.addEventHandler(DragEvent.DRAG_OVER, e -> {
if (e.getDragboard().hasFiles()) {
File file = e.getDragboard().getFiles().get(0);
if (file.getAbsolutePath().endsWith(".png"))
Path file = e.getDragboard().getFiles().get(0).toPath();
if (FileUtils.getName(file).endsWith(".png"))
e.acceptTransferModes(TransferMode.COPY);
}
});
canvas.addEventHandler(DragEvent.DRAG_DROPPED, e -> {
if (e.isAccepted()) {
File skin = e.getDragboard().getFiles().get(0);
Path skin = e.getDragboard().getFiles().get(0).toPath();
Platform.runLater(() -> {
skinSelector.setValue(skin.getAbsolutePath());
skinSelector.setValue(FileUtils.getAbsolutePath(skin));
skinItem.setSelectedData(Skin.Type.LOCAL_FILE);
});
}
@@ -30,8 +30,7 @@ public class TransitionPane extends StackPane implements AnimationHandler {
private Duration duration;
private Node previousNode, currentNode;
{
currentNode = getChildren().stream().findFirst().orElse(null);
public TransitionPane() {
FXUtils.setOverflowHidden(this);
}
@@ -91,8 +90,12 @@ public class TransitionPane extends StackPane implements AnimationHandler {
private void updateContent(Node newView) {
if (getWidth() > 0 && getHeight() > 0) {
previousNode = currentNode;
if (previousNode == null)
previousNode = EMPTY_PANE;
if (previousNode == null) {
if (getChildren().isEmpty())
previousNode = EMPTY_PANE;
else
previousNode = getChildren().get(0);
}
} else
previousNode = EMPTY_PANE;
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -40,8 +41,16 @@ public class AdvancedListBox extends ScrollPane {
setFitToHeight(true);
setFitToWidth(true);
setHbarPolicy(ScrollBarPolicy.NEVER);
setVbarPolicy(ScrollBarPolicy.NEVER);
container.getStyleClass().add("advanced-list-box-content");
this.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> {
if (container.getHeight() > getHeight())
setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
});
this.addEventFilter(MouseEvent.MOUSE_EXITED,
event -> setVbarPolicy(ScrollBarPolicy.NEVER));
}
public AdvancedListBox add(Node child) {
@@ -73,8 +73,7 @@ final class ComponentListCell extends StackPane {
@SuppressWarnings("unchecked")
private void updateLayout() {
if (content instanceof ComponentList) {
ComponentList list = (ComponentList) content;
if (content instanceof ComponentList list) {
content.getStyleClass().remove("options-list");
content.getStyleClass().add("options-sublist");
@@ -130,7 +129,10 @@ final class ComponentListCell extends StackPane {
groupNode.getChildren().add(headerRippler);
VBox container = new VBox();
container.setPadding(new Insets(8, 16, 10, 16));
boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding();
if (hasPadding) {
container.setPadding(new Insets(8, 16, 10, 16));
}
FXUtils.setLimitHeight(container, 0);
FXUtils.setOverflowHidden(container);
container.getChildren().setAll(content);
@@ -149,7 +151,8 @@ final class ComponentListCell extends StackPane {
}
Platform.runLater(() -> {
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + 8 + 10) * (expanded ? 1 : -1);
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1);
double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth());
double contentHeight = expanded ? newAnimatedHeight : 0;
@@ -18,7 +18,9 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.DefaultProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Node;
@@ -27,6 +29,7 @@ public class ComponentSublist extends ComponentList {
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft");
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true);
public ComponentSublist() {
super();
@@ -55,4 +58,16 @@ public class ComponentSublist extends ComponentList {
public void setHeaderRight(Node headerRight) {
this.headerRight.set(headerRight);
}
public boolean hasComponentPadding() {
return componentPadding.get();
}
public BooleanProperty componentPaddingProperty() {
return componentPadding;
}
public void setComponentPadding(boolean componentPadding) {
this.componentPadding.set(componentPadding);
}
}
@@ -27,17 +27,19 @@ import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.DirectoryChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class FileItem extends BorderPane {
private final Label lblPath = new Label();
@@ -77,14 +79,14 @@ public class FileItem extends BorderPane {
private String processPath(String path) {
Path given;
try {
given = Paths.get(path).toAbsolutePath();
given = Path.of(path).toAbsolutePath().normalize();
} catch (IllegalArgumentException e) {
return path;
}
if (isConvertToRelativePath()) {
try {
return Paths.get(".").normalize().toAbsolutePath().relativize(given).normalize().toString();
return Metadata.CURRENT_DIRECTORY.relativize(given).normalize().toString();
} catch (IllegalArgumentException e) {
// the given path can't be relativized against current path
}
@@ -95,17 +97,22 @@ public class FileItem extends BorderPane {
public void onExplore() {
DirectoryChooser chooser = new DirectoryChooser();
if (path.get() != null) {
File file = new File(path.get());
if (file.exists()) {
if (file.isFile())
file = file.getAbsoluteFile().getParentFile();
else if (file.isDirectory())
file = file.getAbsoluteFile();
chooser.setInitialDirectory(file);
Path file;
try {
file = Path.of(path.get());
if (Files.exists(file)) {
if (Files.isRegularFile(file))
file = file.toAbsolutePath().normalize().getParent();
else if (Files.isDirectory(file))
file = file.toAbsolutePath().normalize();
chooser.setInitialDirectory(file.toFile());
}
} catch (InvalidPathException e) {
LOG.warning("Failed to resolve path: " + path.get());
}
}
chooser.titleProperty().bind(titleProperty());
File selectedDir = chooser.showDialog(Controllers.getStage());
var selectedDir = chooser.showDialog(Controllers.getStage());
if (selectedDir != null) {
path.set(processPath(selectedDir.toString()));
}
@@ -31,8 +31,9 @@ import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -86,9 +87,9 @@ public class FileSelector extends HBox {
if (directory) {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle(chooserTitle);
File dir = chooser.showDialog(Controllers.getStage());
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
if (dir != null) {
String path = dir.getAbsolutePath();
String path = FileUtils.getAbsolutePath(dir);
customField.setText(path);
value.setValue(path);
}
@@ -96,9 +97,9 @@ public class FileSelector extends HBox {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().addAll(getExtensionFilters());
chooser.setTitle(chooserTitle);
File file = chooser.showOpenDialog(Controllers.getStage());
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (file != null) {
String path = file.getAbsolutePath();
String path = FileUtils.getAbsolutePath(file);
customField.setText(path);
value.setValue(path);
}
@@ -30,7 +30,7 @@ import com.jfoenix.controls.JFXListCell;
import javafx.beans.binding.Bindings;
import javafx.scene.text.Font;
public class FontComboBox extends JFXComboBox<String> {
public final class FontComboBox extends JFXComboBox<String> {
private boolean loaded = false;
@@ -10,6 +10,7 @@ import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
@@ -21,7 +22,7 @@ import org.jackhuang.hmcl.util.StringUtils;
public class IconedTwoLineListItem extends HBox {
private final StringProperty title = new SimpleStringProperty(this, "title");
private final ObservableList<String> tags = FXCollections.observableArrayList();
private final ObservableList<Label> tags = FXCollections.observableArrayList();
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
private final StringProperty externalLink = new SimpleStringProperty(this, "externalLink");
private final ObjectProperty<Image> image = new SimpleObjectProperty<>(this, "image");
@@ -62,7 +63,7 @@ public class IconedTwoLineListItem extends HBox {
this.title.set(title);
}
public ObservableList<String> getTags() {
public ObservableList<Label> getTags() {
return tags;
}
@@ -108,6 +108,7 @@ public final class MessageDialogPane extends HBox {
content.getChildren().setAll(textFlow);
else {
ScrollPane scrollPane = new ScrollPane(textFlow);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setPrefHeight(350);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
@@ -160,6 +161,13 @@ public final class MessageDialogPane extends HBox {
this.dialog = new MessageDialogPane(text, title, type);
}
public Builder addHyperLink(String text, String externalLink) {
JFXHyperlink link = new JFXHyperlink(text);
link.setExternalLink(externalLink);
dialog.actions.getChildren().add(link);
return this;
}
public Builder addAction(Node actionNode) {
dialog.addButton(actionNode);
actionNode.getStyleClass().add("dialog-accept");
@@ -24,11 +24,13 @@ import javafx.beans.property.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Paint;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
@@ -297,4 +299,35 @@ public final class MultiFileItem<T> extends VBox {
return pane;
}
}
public static final class PaintOption<T> extends Option<T> {
private final ColorPicker colorPicker = new ColorPicker();
public PaintOption(String title, T data) {
super(title, data);
}
public PaintOption<T> bindBidirectional(Property<Paint> property) {
FXUtils.bindPaint(colorPicker, property);
return this;
}
@Override
protected Node createItem(ToggleGroup group) {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(3));
FXUtils.setLimitHeight(pane, 30);
left.setText(title);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
left.setToggleGroup(group);
left.setUserData(data);
pane.setLeft(left);
colorPicker.disableProperty().bind(left.selectedProperty().not());
BorderPane.setAlignment(colorPicker, Pos.CENTER_RIGHT);
pane.setRight(colorPicker);
return pane;
}
}
}
@@ -25,6 +25,8 @@ import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -61,7 +63,12 @@ public class OptionToggleButton extends StackPane {
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected()));
container.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY) {
toggleButton.setSelected(!toggleButton.isSelected());
e.consume();
}
});
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
if (StringUtils.isNotBlank(subtitle)) {
@@ -49,6 +49,7 @@ public class PromptDialogPane extends DialogPane {
public PromptDialogPane(Builder builder) {
this.builder = builder;
setTitle(builder.title);
setPrefWidth(560);
GridPane body = new GridPane();
body.setVgap(8);
@@ -72,13 +73,13 @@ public class PromptDialogPane extends DialogPane {
if (StringUtils.isNotBlank(question.question.get())) {
body.addRow(rowIndex++, new Label(question.question.get()), textField);
} else {
GridPane.setColumnSpan(textField, 2);
GridPane.setColumnSpan(textField, 1);
body.addRow(rowIndex++, textField);
}
GridPane.setMargin(textField, new Insets(0, 0, 20, 0));
} else if (question instanceof Builder.BooleanQuestion) {
HBox hBox = new HBox();
GridPane.setColumnSpan(hBox, 2);
GridPane.setColumnSpan(hBox, 1);
JFXCheckBox checkBox = new JFXCheckBox();
hBox.getChildren().setAll(checkBox);
HBox.setMargin(checkBox, new Insets(0, 0, 0, -10));
@@ -95,12 +96,12 @@ public class PromptDialogPane extends DialogPane {
if (StringUtils.isNotBlank(question.question.get())) {
body.addRow(rowIndex++, new Label(question.question.get()), comboBox);
} else {
GridPane.setColumnSpan(comboBox, 2);
GridPane.setColumnSpan(comboBox, 1);
body.addRow(rowIndex++, comboBox);
}
} else if (question instanceof Builder.HintQuestion) {
HintPane pane = new HintPane();
GridPane.setColumnSpan(pane, 2);
GridPane.setColumnSpan(pane, 1);
pane.textProperty().bind(question.question);
body.addRow(rowIndex++, pane);
}
@@ -191,7 +191,7 @@ public class RipplerContainer extends StackPane {
return StyleableProperties.FACTORY.getCssMetaData();
}
private static class StyleableProperties {
private final static class StyleableProperties {
private static final StyleablePropertyFactory<RipplerContainer> FACTORY = new StyleablePropertyFactory<>(StackPane.getClassCssMetaData());
private static final CssMetaData<RipplerContainer, Paint> RIPPLER_FILL = FACTORY.createPaintCssMetaData("-jfx-rippler-fill", s -> s.ripplerFill, Color.rgb(0, 200, 255));
@@ -1,62 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 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.construct;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ServerAddressValidator extends ValidatorBase {
private final boolean nullable;
public ServerAddressValidator() {
this(false);
}
public ServerAddressValidator(@NamedArg("nullable") boolean nullable) {
this(i18n("input.url"), nullable);
}
public ServerAddressValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) {
super(message);
this.nullable = nullable;
}
@Override
protected void eval() {
if (srcControl.get() instanceof TextInputControl) {
evalTextInputField();
}
}
private static final Pattern PATTERN = Pattern.compile("[-a-zA-Z0-9@:%._+~#=]{1,256}(:\\d+)?");
private void evalTextInputField() {
TextInputControl textField = ((TextInputControl) srcControl.get());
if (StringUtils.isBlank(textField.getText()))
hasErrors.set(!nullable);
else
hasErrors.set(!PATTERN.matcher(textField.getText()).matches());
}
}
@@ -225,7 +225,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class HeadersRegion extends StackPane {
private final class HeadersRegion extends StackPane {
private SideAction action;
private final ObjectProperty<Side> side = new SimpleObjectProperty<Side>() {
@Override
@@ -379,7 +379,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Top extends Horizontal {
private final class Top extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -404,7 +404,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Bottom extends Horizontal {
private final class Bottom extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -519,7 +519,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Left extends Vertical {
private final class Left extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -543,7 +543,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Right extends Vertical {
private final class Right extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -22,13 +22,10 @@ import javafx.application.Platform;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jetbrains.annotations.NotNull;
@@ -43,7 +40,8 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TaskExecutorDialogPane extends BorderPane {
private TaskExecutor executor;
private TaskCancellationAction onCancel;
private final Consumer<FileDownloadTask.SpeedEvent> speedEventHandler;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
private final Label lblTitle;
private final Label lblProgress;
@@ -61,16 +59,10 @@ public class TaskExecutorDialogPane extends BorderPane {
lblTitle = new Label();
lblTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: BOLD;");
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
{
taskListPane = new TaskListPane();
scrollPane.setContent(taskListPane);
}
taskListPane = new TaskListPane();
VBox.setVgrow(taskListPane, Priority.ALWAYS);
center.getChildren().setAll(lblTitle, scrollPane);
center.getChildren().setAll(lblTitle, taskListPane);
}
BorderPane bottom = new BorderPane();
@@ -93,24 +85,21 @@ public class TaskExecutorDialogPane extends BorderPane {
}
});
speedEventHandler = speedEvent -> {
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
String unit = "B/s";
double speed = speedEvent.getSpeed();
if (speed > 1024) {
speed /= 1024;
unit = "KB/s";
unit = "KiB/s";
}
if (speed > 1024) {
speed /= 1024;
unit = "MB/s";
unit = "MiB/s";
}
double finalSpeed = speed;
String finalUnit = unit;
Platform.runLater(() -> {
lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit));
});
};
FileDownloadTask.speedEvent.channel(FileDownloadTask.SpeedEvent.class).registerWeak(speedEventHandler);
String message = String.format("%.1f %s", speed, unit);
Platform.runLater(() -> lblProgress.setText(message));
});
onEscPressed(this, btnCancel::fire);
}
@@ -17,17 +17,23 @@
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXProgressBar;
import javafx.application.Platform;
import javafx.beans.WeakListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
@@ -52,6 +58,7 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackCompletionTask;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthCompletionTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthInstallTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.mod.server.ServerModpackCompletionTask;
@@ -63,54 +70,86 @@ import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.lang.ref.WeakReference;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class TaskListPane extends StackPane {
private static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 0);
private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 26);
private TaskExecutor executor;
private final AdvancedListBox listBox = new AdvancedListBox();
private final JFXListView<Node> listView = new JFXListView<>();
private final Map<Task<?>, ProgressListNode> nodes = new HashMap<>();
private final List<StageNode> stageNodes = new ArrayList<>();
private final Map<String, StageNode> stageNodes = new HashMap<>();
private final ObjectProperty<Insets> progressNodePadding = new SimpleObjectProperty<>(Insets.EMPTY);
private final DoubleProperty cellWidth = new SimpleDoubleProperty();
private Cell lastCell;
public TaskListPane() {
listBox.setSpacing(0);
listView.setPadding(new Insets(12, 0, 0, 0));
listView.setCellFactory(l -> new Cell());
listView.setSelectionModel(null);
FXUtils.onChangeAndOperate(listView.widthProperty(), width -> {
double w = width.doubleValue();
cellWidth.set(w <= 12.0 ? w : w - 12.0);
});
getChildren().setAll(listBox);
getChildren().setAll(listView);
}
@FXThread
private void addStages(@NotNull Collection<String> stages) {
for (String stage : stages) {
stageNodes.computeIfAbsent(stage, s -> {
StageNode node = new StageNode(stage);
listView.getItems().add(node);
return node;
});
}
}
@FXThread
private void updateProgressNodePadding() {
progressNodePadding.set(stageNodes.isEmpty() ? DEFAULT_PROGRESS_NODE_PADDING : STAGED_PROGRESS_NODE_PADDING);
}
public void setExecutor(TaskExecutor executor) {
List<String> stages = Lang.removingDuplicates(executor.getStages());
this.executor = executor;
executor.addTaskListener(new TaskListener() {
@Override
public void onStart() {
Platform.runLater(() -> {
stageNodes.clear();
listBox.clear();
stageNodes.addAll(stages.stream().map(StageNode::new).collect(Collectors.toList()));
stageNodes.forEach(listBox::add);
if (stages.isEmpty()) progressNodePadding.setValue(new Insets(0, 0, 8, 0));
else progressNodePadding.setValue(new Insets(0, 0, 8, 26));
listView.getItems().clear();
addStages(executor.getStages());
updateProgressNodePadding();
});
}
@Override
public void onReady(Task<?> task) {
if (task instanceof Task.StagesHintTask) {
Platform.runLater(() -> {
addStages(((Task<?>.StagesHintTask) task).getStages());
updateProgressNodePadding();
});
}
if (task.getStage() != null) {
Platform.runLater(() -> {
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::begin);
StageNode node = stageNodes.get(task.getStage());
if (node != null)
node.begin();
});
}
}
@@ -123,7 +162,11 @@ public final class TaskListPane extends StackPane {
if (task instanceof GameAssetDownloadTask) {
task.setName(i18n("assets.download_all"));
} else if (task instanceof GameInstallTask) {
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
return;
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
} else if (task instanceof CleanroomInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
@@ -147,47 +190,47 @@ public final class TaskListPane extends StackPane {
} else if (task instanceof ModpackUpdateTask) {
task.setName(i18n("modpack.update"));
} else if (task instanceof CurseInstallTask) {
task.setName(i18n("modpack.install", i18n("modpack.type.curse")));
task.setName(i18n("modpack.installing.given", i18n("modpack.type.curse")));
} else if (task instanceof MultiMCModpackInstallTask) {
task.setName(i18n("modpack.install", i18n("modpack.type.multimc")));
task.setName(i18n("modpack.installing.given", i18n("modpack.type.multimc")));
} else if (task instanceof ModrinthInstallTask) {
task.setName(i18n("modpack.install", i18n("modpack.type.modrinth")));
task.setName(i18n("modpack.installing.given", i18n("modpack.type.modrinth")));
} else if (task instanceof ServerModpackLocalInstallTask) {
task.setName(i18n("modpack.install", i18n("modpack.type.server")));
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.install", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
task.setName(i18n("modpack.export"));
} else if (task instanceof MinecraftInstanceTask) {
task.setName(i18n("modpack.scan"));
} else if (task instanceof MojangJavaDownloadTask) {
task.setName(i18n("download.java"));
} else if (task instanceof JavaInstallTask) {
task.setName(i18n("java.install"));
task.setName(i18n("java.installing"));
}
Platform.runLater(() -> {
ProgressListNode node = new ProgressListNode(task);
nodes.put(task, node);
StageNode stageNode = stageNodes.stream().filter(x -> x.stage.equals(task.getInheritedStage())).findAny().orElse(null);
listBox.add(listBox.indexOf(stageNode) + 1, node);
StageNode stageNode = stageNodes.get(task.getInheritedStage());
listView.getItems().add(listView.getItems().indexOf(stageNode) + 1, node);
});
}
@Override
public void onFinished(Task<?> task) {
if (task.getStage() != null) {
Platform.runLater(() -> {
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::succeed);
});
}
Platform.runLater(() -> {
if (task.getStage() != null) {
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.succeed();
}
ProgressListNode node = nodes.remove(task);
if (node == null)
return;
node.unbind();
listBox.remove(node);
if (node != null) {
node.unbind();
listView.getItems().remove(node);
}
});
}
@@ -195,25 +238,23 @@ public final class TaskListPane extends StackPane {
public void onFailed(Task<?> task, Throwable throwable) {
if (task.getStage() != null) {
Platform.runLater(() -> {
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::fail);
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.fail();
});
}
ProgressListNode node = nodes.remove(task);
if (node == null)
return;
Platform.runLater(() -> {
node.setThrowable(throwable);
});
if (node != null)
Platform.runLater(() -> node.setThrowable(throwable));
}
@Override
public void onPropertiesUpdate(Task<?> task) {
if (task instanceof Task.CountTask) {
runInFX(() -> {
stageNodes.stream()
.filter(x -> x.stage.equals(((Task<?>.CountTask) task).getCountStage()))
.findAny()
.ifPresent(StageNode::count);
StageNode stageNode = stageNodes.get(((Task<?>.CountTask) task).getCountStage());
if (stageNode != null)
stageNode.count();
});
return;
@@ -222,121 +263,252 @@ public final class TaskListPane extends StackPane {
if (task.getStage() != null) {
int total = tryCast(task.getProperties().get("total"), Integer.class).orElse(0);
runInFX(() -> {
stageNodes.stream()
.filter(x -> x.stage.equals(task.getStage()))
.findAny()
.ifPresent(stageNode -> {
stageNode.setTotal(total);
});
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.addTotal(total);
});
}
}
});
}
private static class StageNode extends BorderPane {
private final String stage;
private final class Cell extends ListCell<Node> {
private static final double STATUS_ICON_SIZE = 14;
private final BorderPane pane = new BorderPane();
private final StackPane left = new StackPane();
private final Label title = new Label();
private final Label message = new Label();
private final JFXProgressBar bar = new JFXProgressBar();
private WeakReference<StageNode> prevStageNodeRef;
private StatusChangeListener statusChangeListener;
private Cell() {
setPadding(new Insets(0, 0, 4, 0));
prefWidthProperty().bind(cellWidth);
FXUtils.setLimitHeight(left, STATUS_ICON_SIZE);
FXUtils.setLimitWidth(left, STATUS_ICON_SIZE);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
BorderPane.setMargin(left, new Insets(0, 12, 0, 0));
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
pane.setCenter(title);
DoubleBinding barWidth = Bindings.createDoubleBinding(() -> {
Insets padding = pane.getPadding();
Insets insets = pane.getInsets();
return pane.getWidth() - padding.getLeft() - padding.getRight() - insets.getLeft() - insets.getRight();
}, pane.paddingProperty(), pane.widthProperty());
bar.minWidthProperty().bind(barWidth);
bar.prefWidthProperty().bind(barWidth);
bar.maxWidthProperty().bind(barWidth);
setGraphic(pane);
}
private void updateLeftIcon(StageNode.Status status) {
left.getChildren().setAll(status.svg.createIcon(Theme.blackFill(), STATUS_ICON_SIZE));
}
@Override
protected void updateItem(Node item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell && !isVisible())
return;
lastCell = this;
pane.paddingProperty().unbind();
title.textProperty().unbind();
message.textProperty().unbind();
bar.progressProperty().unbind();
StageNode prevStageNode;
if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null)
prevStageNode.status.removeListener(statusChangeListener);
if (item instanceof ProgressListNode) {
var progressListNode = (ProgressListNode) item;
title.setText(progressListNode.title);
message.textProperty().bind(progressListNode.message);
bar.progressProperty().bind(progressListNode.progress);
pane.paddingProperty().bind(progressNodePadding);
pane.setLeft(null);
pane.setRight(message);
pane.setBottom(bar);
} else if (item instanceof StageNode) {
var stageNode = (StageNode) item;
title.textProperty().bind(stageNode.title);
message.setText("");
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
pane.setPadding(Insets.EMPTY);
pane.setLeft(left);
pane.setRight(message);
pane.setBottom(null);
updateLeftIcon(stageNode.status.get());
if (statusChangeListener == null)
statusChangeListener = new StatusChangeListener(this);
stageNode.status.addListener(statusChangeListener);
prevStageNodeRef = new WeakReference<>(stageNode);
} else { // item == null
title.setText("");
message.setText("");
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
pane.setPadding(Insets.EMPTY);
pane.setLeft(null);
pane.setRight(null);
pane.setBottom(null);
}
}
}
private static final class StatusChangeListener implements ChangeListener<StageNode.Status>, WeakListener {
private final WeakReference<Cell> cellRef;
private StatusChangeListener(Cell cell) {
this.cellRef = new WeakReference<>(cell);
}
@Override
public boolean wasGarbageCollected() {
return cellRef.get() == null;
}
@Override
public void changed(ObservableValue<? extends StageNode.Status> observable,
StageNode.Status oldValue,
StageNode.Status newValue) {
Cell cell = cellRef.get();
if (cell == null) {
if (observable != null)
observable.removeListener(this);
return;
}
cell.updateLeftIcon(newValue);
}
}
private static abstract class Node {
}
private static final class StageNode extends Node {
private enum Status {
WAITING(SVG.MORE_HORIZ),
RUNNING(SVG.ARROW_FORWARD),
SUCCESS(SVG.CHECK),
FAILED(SVG.CLOSE);
private final SVG svg;
Status(SVG svg) {
this.svg = svg;
}
}
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.WAITING);
private final StringProperty title = new SimpleStringProperty();
private final String message;
private int count = 0;
private int total = 0;
private boolean started = false;
public StageNode(String stage) {
this.stage = stage;
private StageNode(String stage) {
String stageKey;
String stageValue;
String stageKey = StringUtils.substringBefore(stage, ':');
String stageValue = StringUtils.substringAfter(stage, ':');
int idx = stage.indexOf(':');
if (idx >= 0) {
stageKey = stage.substring(0, idx);
stageValue = stage.substring(idx + 1);
} else {
stageKey = stage;
stageValue = null;
}
// CHECKSTYLE:OFF
// @formatter:off
switch (stageKey) {
case "hmcl.modpack": message = i18n("install.modpack"); break;
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
case "hmcl.install.assets": message = i18n("assets.download"); break;
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
case "hmcl.modpack": message = i18n("install.modpack"); break;
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
case "hmcl.install.assets": message = i18n("assets.download"); break;
case "hmcl.install.libraries": message = i18n("libraries.download"); break;
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
case "hmcl.install.cleanroom": message = i18n("install.installer.install", i18n("install.installer.cleanroom") + " " + stageValue); break;
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
case "hmcl.install.liteloader": message = i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); break;
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
case "hmcl.install.fabric-api": message = i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); break;
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
default: message = i18n(stageKey); break;
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
case "hmcl.install.quilt-api": message = i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); break;
default: message = i18n(stageKey); break;
}
// @formatter:on
// CHECKSTYLE:ON
title.setText(message);
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
BorderPane.setMargin(title, new Insets(0, 0, 0, 8));
setPadding(new Insets(0, 0, 8, 4));
setCenter(title);
setLeft(FXUtils.limitingSize(SVG.MORE_HORIZ.createIcon(Theme.blackFill(), 14), 14, 14));
title.set(message);
}
public void begin() {
if (started) return;
started = true;
setLeft(FXUtils.limitingSize(SVG.ARROW_FORWARD.createIcon(Theme.blackFill(), 14), 14, 14));
private void begin() {
if (status.get() == Status.WAITING) {
status.set(Status.RUNNING);
}
}
public void fail() {
setLeft(FXUtils.limitingSize(SVG.CLOSE.createIcon(Theme.blackFill(), 14), 14, 14));
status.set(Status.FAILED);
}
public void succeed() {
setLeft(FXUtils.limitingSize(SVG.CHECK.createIcon(Theme.blackFill(), 14), 14, 14));
status.set(Status.SUCCESS);
}
public void count() {
updateCounter(++count, total);
}
public void setTotal(int total) {
this.total = total;
public void addTotal(int n) {
this.total += n;
updateCounter(count, total);
}
public void updateCounter(int count, int total) {
if (total > 0)
title.setText(String.format("%s - %d/%d", message, count, total));
else
title.setText(message);
title.setValue(total > 0
? message + " - " + count + "/" + total
: message
);
}
}
private class ProgressListNode extends BorderPane {
private final JFXProgressBar bar = new JFXProgressBar();
private final Label title = new Label();
private final Label state = new Label();
private final DoubleBinding binding = Bindings.createDoubleBinding(() ->
getWidth() - getPadding().getLeft() - getPadding().getRight() - getInsets().getLeft() - getInsets().getRight(),
paddingProperty(), widthProperty());
private static final class ProgressListNode extends Node {
private final String title;
private final StringProperty message = new SimpleStringProperty("");
private final DoubleProperty progress = new SimpleDoubleProperty(0.0);
public ProgressListNode(Task<?> task) {
bar.progressProperty().bind(task.progressProperty());
title.setText(task.getName());
state.textProperty().bind(task.messageProperty());
setLeft(title);
setRight(state);
setBottom(bar);
bar.minWidthProperty().bind(binding);
bar.prefWidthProperty().bind(binding);
bar.maxWidthProperty().bind(binding);
paddingProperty().bind(progressNodePadding);
private ProgressListNode(Task<?> task) {
this.title = task.getName();
message.bind(task.messageProperty());
progress.bind(task.progressProperty());
}
public void unbind() {
bar.progressProperty().unbind();
state.textProperty().unbind();
message.unbind();
progress.unbind();
}
public void setThrowable(Throwable throwable) {
unbind();
state.setText(throwable.getLocalizedMessage());
bar.setProgress(0);
message.set(throwable.getLocalizedMessage());
progress.set(0.);
}
}
}
@@ -29,16 +29,21 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.AggregatedObservableList;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
public class TwoLineListItem extends VBox {
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
private static Label createTagLabel(String tag) {
Label tagLabel = new Label();
tagLabel.setText(tag);
HBox.setMargin(tagLabel, new Insets(0, 8, 0, 0));
return tagLabel;
}
private final StringProperty title = new SimpleStringProperty(this, "title");
private final ObservableList<String> tags = FXCollections.observableArrayList();
private final ObservableList<Label> tags = FXCollections.observableArrayList();
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
private final ObservableList<Node> tagLabels;
private final AggregatedObservableList<Node> firstLineChildren;
public TwoLineListItem(String titleString, String subtitleString) {
@@ -58,16 +63,9 @@ public class TwoLineListItem extends VBox {
lblTitle.getStyleClass().add("title");
lblTitle.textProperty().bind(title);
tagLabels = MappedObservableList.create(tags, tag -> {
Label tagLabel = new Label();
tagLabel.getStyleClass().add("tag");
tagLabel.setText(tag);
HBox.setMargin(tagLabel, new Insets(0, 8, 0, 0));
return tagLabel;
});
firstLineChildren = new AggregatedObservableList<>();
firstLineChildren.appendList(FXCollections.singletonObservableList(lblTitle));
firstLineChildren.appendList(tagLabels);
firstLineChildren.appendList(tags);
Bindings.bindContent(firstLine.getChildren(), firstLineChildren.getAggregatedList());
Label lblSubtitle = new Label();
@@ -111,7 +109,19 @@ public class TwoLineListItem extends VBox {
this.subtitle.set(subtitle);
}
public ObservableList<String> getTags() {
public void addTag(String tag) {
Label tagLabel = createTagLabel(tag);
tagLabel.getStyleClass().add("tag");
getTags().add(tagLabel);
}
public void addTagWarning(String tag) {
Label tagLabel = createTagLabel(tag);
tagLabel.getStyleClass().add("tag-warning");
getTags().add(tagLabel);
}
public ObservableList<Label> getTags() {
return tags;
}
@@ -21,10 +21,7 @@ import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -58,9 +55,9 @@ public class URLValidator extends ValidatorBase {
hasErrors.set(!nullable);
else {
try {
new URL(textField.getText()).toURI();
NetworkUtils.toURI(textField.getText());
hasErrors.set(false);
} catch (IOException | URISyntaxException e) {
} catch (IllegalArgumentException e) {
hasErrors.set(true);
}
}
@@ -31,18 +31,21 @@ import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.image.Image;
import javafx.scene.input.DragEvent;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.setting.EnumBackgroundImage;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
@@ -54,26 +57,23 @@ import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.*;
import java.util.stream.Stream;
import static java.util.stream.Collectors.toList;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.io.FileUtils.getExtension;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DecoratorController {
private static final String PROPERTY_DIALOG_CLOSE_HANDLER = DecoratorController.class.getName() + ".dialog.closeListener";
@@ -123,18 +123,13 @@ public class DecoratorController {
// Setup background
decorator.setContentBackground(getBackground());
changeBackgroundListener = o -> {
final int currentCount = ++this.changeBackgroundCount;
CompletableFuture.supplyAsync(this::getBackground, Schedulers.io())
.thenAcceptAsync(background -> {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
}, Schedulers.javafx());
};
changeBackgroundListener = o -> updateBackground();
WeakInvalidationListener weakListener = new WeakInvalidationListener(changeBackgroundListener);
config().backgroundImageTypeProperty().addListener(weakListener);
config().backgroundImageProperty().addListener(weakListener);
config().backgroundImageUrlProperty().addListener(weakListener);
config().backgroundPaintProperty().addListener(weakListener);
config().backgroundImageOpacityProperty().addListener(weakListener);
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
@@ -170,17 +165,23 @@ public class DecoratorController {
// press ESC to go back
onEscPressed(navigator, this::back);
try {
// For JavaFX 12+
MouseButton button = MouseButton.valueOf("BACK");
navigator.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == button) {
back();
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
// press F11 to toggle full screen
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.F11) {
stage.setFullScreen(!stage.isFullScreen());
e.consume();
}
});
} catch (IllegalArgumentException ignored) {
}
navigator.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.BACK) {
back();
e.consume();
}
});
}
public Decorator getDecorator() {
@@ -195,8 +196,24 @@ public class DecoratorController {
@SuppressWarnings("FieldCanBeLocal") // Strong reference
private final InvalidationListener changeBackgroundListener;
private void updateBackground() {
final int currentCount = ++this.changeBackgroundCount;
Task.supplyAsync(Schedulers.io(), this::getBackground)
.setName("Update background")
.whenComplete(Schedulers.javafx(), (background, exception) -> {
if (exception == null) {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
} else {
LOG.warning("Failed to update background", exception);
}
}).start();
}
private Background getBackground() {
EnumBackgroundImage imageType = config().getBackgroundImageType();
if (imageType == null)
imageType = EnumBackgroundImage.DEFAULT;
Image image = null;
switch (imageType) {
@@ -213,7 +230,7 @@ public class DecoratorController {
String backgroundImageUrl = config().getBackgroundImageUrl();
if (backgroundImageUrl != null) {
try {
image = FXUtils.loadImage(new URL(backgroundImageUrl));
image = FXUtils.loadImage(backgroundImageUrl);
} catch (Exception e) {
LOG.warning("Couldn't load background image", e);
}
@@ -222,25 +239,82 @@ public class DecoratorController {
case CLASSIC:
image = newBuiltinImage("/assets/img/background-classic.jpg");
break;
case TRANSLUCENT:
case TRANSLUCENT: // Deprecated
return new Background(new BackgroundFill(new Color(1, 1, 1, 0.5), CornerRadii.EMPTY, Insets.EMPTY));
case PAINT:
Paint paint = config().getBackgroundPaint();
double opacity = Lang.clamp(0, config().getBackgroundImageOpacity(), 100) / 100.;
if (paint instanceof Color || paint == null) {
Color color = (Color) paint;
if (color == null)
color = Color.WHITE; // Default to white if no color is set
if (opacity < 1.)
color = new Color(color.getRed(), color.getGreen(), color.getBlue(), opacity);
return new Background(new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY));
} else {
// TODO: Support opacity for non-color paints
return new Background(new BackgroundFill(paint, CornerRadii.EMPTY, Insets.EMPTY));
}
}
if (image == null) {
image = loadDefaultBackgroundImage();
}
return new Background(new BackgroundImage(image, BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, BackgroundPosition.DEFAULT, new BackgroundSize(800, 480, false, false, true, true)));
return createBackgroundWithOpacity(image, config().getBackgroundImageOpacity());
}
private Background createBackgroundWithOpacity(Image image, int opacity) {
if (opacity <= 0) {
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
} else if (opacity >= 100) {
return new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
} else {
WritableImage tempImage = new WritableImage((int) image.getWidth(), (int) image.getHeight());
PixelReader pixelReader = image.getPixelReader();
PixelWriter pixelWriter = tempImage.getPixelWriter();
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
Color color = pixelReader.getColor(x, y);
Color newColor = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * opacity / 100);
pixelWriter.setColor(x, y, newColor);
}
}
return new Background(new BackgroundImage(
tempImage,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
}
}
/**
* Load background image from bg/, background.png, background.jpg, background.gif
*/
private Image loadDefaultBackgroundImage() {
Image image = randomImageIn(Paths.get("bg"));
Image image = randomImageIn(Metadata.HMCL_CURRENT_DIRECTORY.resolve("background"));
if (image != null)
return image;
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
image = tryLoadImage(Paths.get("background." + extension));
image = tryLoadImage(Metadata.HMCL_CURRENT_DIRECTORY.resolve("background." + extension));
if (image != null)
return image;
}
image = randomImageIn(Metadata.CURRENT_DIRECTORY.resolve("bg"));
if (image != null)
return image;
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
image = tryLoadImage(Metadata.CURRENT_DIRECTORY.resolve("background." + extension));
if (image != null)
return image;
}
@@ -300,8 +374,9 @@ public class DecoratorController {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
// FIXME: Get WorldPage working first, and revisit this later
page.closePage();
if (page.isPageCloseable()) {
page.closePage();
return;
}
}
@@ -18,8 +18,11 @@
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -29,6 +32,7 @@ import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
@@ -43,6 +47,7 @@ import org.jackhuang.hmcl.ui.animation.AnimationProducer;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
public class DecoratorSkin extends SkinBase<Decorator> {
private final StackPane root, parent;
@@ -50,6 +55,10 @@ public class DecoratorSkin extends SkinBase<Decorator> {
private final Stage primaryStage;
private final TransitionPane navBarPane;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener onWindowsStatusChange;
private final EventHandler<MouseEvent> onTitleBarDoubleClick;
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
/**
@@ -80,9 +89,41 @@ public class DecoratorSkin extends SkinBase<Decorator> {
skinnable.getSnackbar().registerSnackbarContainer(parent);
root.addEventFilter(MouseEvent.MOUSE_RELEASED, this::onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
onWindowsStatusChange = observable -> {
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
} else {
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
};
onTitleBarDoubleClick = event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
primaryStage.setMaximized(!primaryStage.isMaximized());
event.consume();
}
};
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
onWindowsStatusChange.invalidated(null);
} else {
onWindowsStatusChange = null;
onTitleBarDoubleClick = null;
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
shadowContainer.getChildren().setAll(parent);
root.getChildren().setAll(shadowContainer);
@@ -282,6 +323,8 @@ public class DecoratorSkin extends SkinBase<Decorator> {
BorderPane.setAlignment(titleNode, Pos.CENTER_LEFT);
BorderPane.setMargin(titleNode, new Insets(0, 0, 0, 8));
}
if (onTitleBarDoubleClick != null)
center.setOnMouseClicked(onTitleBarDoubleClick);
navBar.setCenter(center);
if (canRefresh) {
@@ -23,8 +23,8 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.wizard.*;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import java.util.concurrent.ConcurrentLinkedQueue;
public class DecoratorWizardDisplayer extends DecoratorTransitionPage implements WizardDisplayer {
@@ -94,7 +94,7 @@ public class DecoratorWizardDisplayer extends DecoratorTransitionPage implements
}
@Override
public void handleTask(Map<String, Object> settings, Task<?> task) {
public void handleTask(SettingsMap settings, Task<?> task) {
displayer.handleTask(settings, task);
}
@@ -0,0 +1,146 @@
/*
* 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.download;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.ui.Controllers;
import javafx.scene.layout.BorderPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public abstract class AbstractInstallersPage extends Control implements WizardPage {
protected final WizardController controller;
protected InstallerItem.InstallerItemGroup group;
protected JFXTextField txtName = new JFXTextField();
protected BooleanProperty installable = new SimpleBooleanProperty();
public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) {
this.controller = controller;
this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle());
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId();
if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue;
library.setOnInstall(() -> {
if (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId)) {
Controllers.dialog(i18n("install.installer.fabric-api.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING);
}
if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState))
controller.onNext(new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameVersion, downloadProvider, libraryId, () -> controller.onPrev(false)));
});
library.setOnRemove(() -> {
controller.getSettings().remove(libraryId);
reload();
});
}
}
protected InstallerItem.Style getInstallerItemStyle() {
return InstallerItem.Style.CARD;
}
@Override
public abstract String getTitle();
protected abstract void reload();
@Override
public void onNavigate(SettingsMap settings) {
reload();
}
@Override
public abstract void cleanup(SettingsMap settings);
protected abstract void onInstall();
@Override
protected Skin<?> createDefaultSkin() {
return new InstallersPageSkin(this);
}
protected static class InstallersPageSkin extends SkinBase<AbstractInstallersPage> {
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
protected InstallersPageSkin(AbstractInstallersPage control) {
super(control);
BorderPane root = new BorderPane();
root.setPadding(new Insets(16));
{
HBox versionNamePane = new HBox(8);
versionNamePane.getStyleClass().add("card-non-transparent");
versionNamePane.setStyle("-fx-padding: 20 8 20 16");
versionNamePane.setAlignment(Pos.CENTER_LEFT);
control.txtName.setMaxWidth(300);
versionNamePane.getChildren().setAll(new Label(i18n("version.name")), control.txtName);
root.setTop(versionNamePane);
}
{
InstallerItem[] libraries = control.group.getLibraries();
FlowPane libraryPane = new FlowPane(16, 16, libraries);
ScrollPane scrollPane = new ScrollPane(libraryPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
root.setCenter(scrollPane);
if (libraries.length <= 8)
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
}
{
JFXButton installButton = FXUtils.newRaisedButton(i18n("button.install"));
installButton.disableProperty().bind(control.installable.not());
installButton.setPrefWidth(100);
installButton.setPrefHeight(40);
installButton.setOnAction(e -> control.onInstall());
BorderPane.setAlignment(installButton, Pos.CENTER_RIGHT);
root.setBottom(installButton);
}
getChildren().setAll(root);
}
}
}
@@ -29,33 +29,28 @@ import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
class AdditionalInstallersPage extends InstallersPage {
class AdditionalInstallersPage extends AbstractInstallersPage {
protected final BooleanProperty compatible = new SimpleBooleanProperty();
protected final GameRepository repository;
protected final String gameVersion;
protected final Version version;
public AdditionalInstallersPage(String gameVersion, Version version, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
super(controller, repository, gameVersion, downloadProvider);
super(controller, gameVersion, downloadProvider);
this.gameVersion = gameVersion;
this.version = version;
this.repository = repository;
txtName.getValidators().clear();
txtName.setText(version.getId());
txtName.setEditable(false);
installable.bind(Bindings.createBooleanBinding(
() -> compatible.get() && txtName.validate(),
txtName.textProperty(), compatible));
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId();
if (libraryId.equals("game")) continue;
@@ -64,6 +59,8 @@ class AdditionalInstallersPage extends InstallersPage {
reload();
});
}
installable.bind(Bindings.createBooleanBinding(() -> compatible.get() && txtName.validate(), txtName.textProperty(), compatible));
}
@Override
@@ -112,6 +109,6 @@ class AdditionalInstallersPage extends InstallersPage {
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
}
}
Loaded 100 of 633 files, more files were not shown because too many files have changed in this diff. Show more