Compare commits

...
240 Commits
Author SHA1 Message Date
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
411 changed files with 22486 additions and 5836 deletions

No files matched your search

+1
View File
@@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false
ij_jspx_keep_indents_on_empty_lines = false
[{*.markdown,*.md}]
max_line_length = 200
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
+1 -1
View File
@@ -22,4 +22,4 @@ jobs:
java-version: '17'
java-package: 'jdk+fx'
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
+33 -55
View File
@@ -2,8 +2,8 @@ name: Check Update
on:
workflow_dispatch:
schedule:
- cron: '30 * * * *'
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
@@ -13,39 +13,27 @@ jobs:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=v$HMCL_VERSION" >> $GITHUB_ENV
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -78,39 +66,28 @@ jobs:
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=release-$HMCL_VERSION" >> $GITHUB_ENV
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
- name: Check for existing tags
run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/artifact/HMCL/build/libs
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -122,9 +99,9 @@ jobs:
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
@@ -139,3 +116,4 @@ jobs:
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
+19 -3
View File
@@ -27,8 +27,24 @@ jobs:
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload Artifacts
- name: Upload JAR
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}
path: HMCL/build/libs
name: HMCL-${{ env.SHORT_SHA }}-jar
path: |
HMCL/build/libs/HMCL-*.jar
HMCL/build/libs/HMCL-*.jar.sha256
- name: Upload EXE
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: |
HMCL/build/libs/HMCL-*.exe
HMCL/build/libs/HMCL-*.exe.sha256
- name: Upload SH
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
+8 -1
View File
@@ -2,6 +2,7 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
*.hprof
.gradle
@@ -13,14 +14,18 @@ hs_err_pid*
/externalgames
NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
/.java/
/.local/
/.cache/
# gradle build
/build/
/HMCL/build/
/HMCLCore/build/
/HMCLBoot/build/
/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/HMCLMultiMCBootstrap/build/
/buildSrc/build/
# idea
@@ -29,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
+154 -53
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
@@ -12,30 +19,38 @@ plugins {
alias(libs.plugins.shadow)
}
val isOfficial = System.getenv("HMCL_SIGNATURE_KEY") != null
|| (System.getenv("GITHUB_REPOSITORY_OWNER") == "HMCL-dev" && System.getenv("GITHUB_BASE_REF")
.isNullOrEmpty())
val projectConfig = PropertiesUtils.load(rootProject.file("config/project.properties").toPath())
val isOfficial = JenkinsUtils.IS_ON_CI || GitHubActionUtils.IS_ON_OFFICIAL_REPO
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
val offset = System.getenv("BUILD_NUMBER_OFFSET")?.toInt() ?: 0
if (number != null) {
(number - offset).toString()
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
val prefix = if (isOfficial) "dev" else "unofficial"
if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT"
}
}
val versionRoot = System.getenv("VERSION_ROOT") ?: "3.6"
val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial"
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
version = "$versionRoot.$buildNumber"
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt()
if (buildNumber != null) {
version = if (JenkinsUtils.IS_ON_CI && versionType == "dev") {
"$versionRoot.0.$buildNumber"
} else {
"$versionRoot.$buildNumber"
}
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
version = if (shortCommit.isNullOrBlank()) {
"$versionRoot.SNAPSHOT"
} else if (isOfficial) {
"$versionRoot.dev-$shortCommit"
} else {
"$versionRoot.unofficial-$shortCommit"
}
}
val embedResources by configurations.registering
dependencies {
implementation(project(":HMCLCore"))
@@ -44,9 +59,11 @@ dependencies {
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
if (launcherExe == null) {
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
embedResources(libs.authlib.injector)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -91,8 +108,8 @@ fun attachSignature(jar: File) {
}
tasks.withType<JavaCompile> {
sourceCompatibility = "11"
targetCompatibility = "11"
sourceCompatibility = "17"
targetCompatibility = "17"
}
tasks.checkstyleMain {
@@ -104,6 +121,51 @@ tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
}
val addOpens = listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
"java.base/jdk.internal.loader",
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.graphics/javafx.css",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
add("hmcl.add-opens" to addOpens.joinToString(" "))
System.getenv("GITHUB_SHA")?.let {
add("hmcl.version.hash" to it)
}
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 {
enabled = false
dependsOn(tasks["shadowJar"])
@@ -112,6 +174,8 @@ tasks.jar {
val jarPath = tasks.jar.get().archiveFile.get().asFile
tasks.shadowJar {
dependsOn(createPropertiesFile)
archiveClassifier.set(null as String?)
exclude("**/package-info.class")
@@ -132,41 +196,16 @@ tasks.shadowJar {
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 != null) {
if (launcherExe.isNotBlank()) {
into("assets") {
from(file(launcherExe))
}
@@ -178,6 +217,24 @@ tasks.shadowJar {
}
}
tasks.processResources {
dependsOn(createPropertiesFile)
dependsOn(upsideDownTranslate)
dependsOn(createLocaleNamesResourceBundle)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(embedResources)
}
into("assets/lang") {
from(createLanguageList.map { it.outputFile })
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
}
val makeExecutables by tasks.registering {
val extensions = listOf("exe", "sh")
@@ -260,6 +317,16 @@ fun parseToolOptions(options: String?): MutableList<String> {
return result
}
// For IntelliJ IDEA
tasks.withType<JavaExec> {
if (name != "run") {
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
// if (javaVersion >= JavaVersion.VERSION_24) {
// jvmArgs("--enable-native-access=ALL-UNNAMED")
// }
}
}
tasks.register<JavaExec>("run") {
dependsOn(tasks.jar)
@@ -288,6 +355,40 @@ tasks.register<JavaExec>("run") {
}
}
// Check Translations
tasks.register<CheckTranslations>("checkTranslations") {
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
englishFile.set(dir.file("I18N.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<ParseModDataTask>("parseModData") {
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -18,7 +18,6 @@
package org.jackhuang.hmcl;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.ui.AwtUtils;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
@@ -47,21 +46,7 @@ public final class EntryPoint {
createHMCLDirectories();
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU")))
System.getProperties().putIfAbsent("prism.forceGPU", "true");
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
setupJavaFXVMOptions();
checkDirectoryPath();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -81,6 +66,71 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
try {
@@ -110,8 +160,14 @@ public final class EntryPoint {
}
private static void initIcon() {
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(EntryPoint.class.getResource("/assets/img/icon-mac.png"));
AwtUtils.setAppleIcon(image);
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() {
@@ -75,13 +75,7 @@ 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));
@@ -113,6 +107,10 @@ public final class Launcher extends Application {
return;
}
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"));
}
@@ -239,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"));
}
@@ -19,10 +19,13 @@ 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.
@@ -33,11 +36,15 @@ public final class Metadata {
public static final String NAME = "HMCL";
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.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";
@@ -49,8 +56,8 @@ public final class Metadata {
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
public static final String BUILD_CHANNEL = JarUtils.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");
@@ -94,4 +101,30 @@ public final class Metadata {
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);
}
@@ -44,9 +44,9 @@ import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
@@ -57,7 +57,7 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public class HMCLGameRepository extends DefaultGameRepository {
public final class HMCLGameRepository extends DefaultGameRepository {
private final Profile profile;
// local version settings
@@ -66,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;
}
@@ -85,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");
}
}
@@ -119,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 {
@@ -146,8 +147,8 @@ 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);
@@ -181,22 +182,22 @@ public class HMCLGameRepository extends DefaultGameRepository {
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
saveVersionSetting(dstId);
File srcGameDir = getRunDirectory(srcId);
File dstGameDir = getRunDirectory(dstId);
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(Files.readString(file.toPath()), 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.
@@ -259,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);
}
}
@@ -272,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);
@@ -280,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);
}
}
}
@@ -299,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);
}
@@ -314,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))
@@ -335,7 +343,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
public void saveVersionSetting(String id) {
if (!localVersionSettings.containsKey(id))
return;
Path file = getLocalVersionSettingFile(id).toPath().toAbsolutePath().normalize();
Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
@@ -369,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()
@@ -380,7 +388,7 @@ 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,
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
vs.isAutoMemory()
@@ -405,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())
@@ -426,15 +435,15 @@ public class HMCLGameRepository extends DefaultGameRepository {
}
}
File json = getModpackConfiguration(version);
if (json.exists()) {
Path json = getModpackConfiguration(version);
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json.toPath());
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);
}
}
@@ -445,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) {
@@ -459,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()
@@ -490,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);
}
/**
@@ -29,14 +29,15 @@ import org.jackhuang.hmcl.task.Task;
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.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;
@@ -44,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());
@@ -64,8 +65,8 @@ public final class HMCLModpackInstallTask extends Task<Void> {
ModpackConfiguration<Modpack> config = null;
try {
if (json.exists()) {
config = JsonUtils.fromJsonFile(json.toPath(), 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,15 +49,16 @@ import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.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;
@@ -76,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;
@@ -119,9 +120,8 @@ public final class LauncherHelper {
launch0();
}
public void makeLaunchScript(File scriptFile) {
public void makeLaunchScript(Path scriptFile) {
this.scriptFile = Objects.requireNonNull(scriptFile);
launch();
}
@@ -161,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)
@@ -191,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,
@@ -224,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(
@@ -450,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);
@@ -540,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;
@@ -702,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;
@@ -845,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();
}
}
}
@@ -884,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();
}
}
@@ -900,7 +915,15 @@ public final class LauncherHelper {
}
public static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
public static int countMangedProcesses() {
PROCESSES.removeIf(it -> {
ManagedProcess process = it.get();
return process == null || !process.isRunning();
});
return PROCESSES.size();
}
public static void stopManagedProcesses() {
while (!PROCESSES.isEmpty())
@@ -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?");
@@ -44,8 +44,8 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
Path runDirectory = gameRepository.getRunDirectory(versionId).toPath();
Path baseDirectory = gameRepository.getBaseDirectory().toPath();
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
String currentVersionId = versionId;
@@ -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.fromJsonFile(file.toPath(), 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;
@@ -264,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) {
@@ -284,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<>();
@@ -207,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));
});
}
@@ -220,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));
}
});
}
@@ -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);
}
@@ -56,6 +56,7 @@ public final class JavaManager {
}
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) {
@@ -208,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 {
@@ -254,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,9 +288,6 @@ public final class JavaManager {
&& (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) {
@@ -309,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);
}
}
@@ -571,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) {
@@ -36,6 +36,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import javax.net.ssl.SSLException;
@@ -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);
@@ -337,8 +326,6 @@ public final class Accounts {
});
}
triggerAuthlibInjectorUpdateCheck();
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
continue;
@@ -371,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.DEPENDENCIES_DIRECTORY.resolve("universal").resolve("authlib-injector.jar"),
DownloadProviders::getDownloadProvider) {
@Override
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
Optional<AuthlibInjectorArtifactInfo> local = super.getArtifactInfoImmediately();
if (local.isPresent()) {
return local;
}
// search authlib-injector.jar in current directory, it's used as a fallback
return parseArtifact(Metadata.CURRENT_DIRECTORY.resolve("authlib-injector.jar"));
}
};
} else {
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) {
@@ -21,6 +21,7 @@ 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;
@@ -37,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";
File diff suppressed because it is too large. Load diff
@@ -45,6 +45,7 @@ public final class ConfigHolder {
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) {
@@ -72,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");
@@ -82,7 +87,8 @@ public final class ConfigHolder {
LOG.info("Config location: " + configLocation);
configInstance = loadConfig();
configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson()));
if (!unsupportedVersion)
configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson()));
globalConfigInstance = loadGlobalConfig();
globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson()));
@@ -163,7 +169,14 @@ public final class ConfigHolder {
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) {
@@ -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);
}
}
@@ -31,23 +31,21 @@ import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {}
private DownloadProviders() {
}
private static DownloadProvider currentDownloadProvider;
private static final DownloadProviderWrapper provider;
public static final Map<String, DownloadProvider> providersById;
public static final Map<String, DownloadProvider> rawProviders;
@@ -59,6 +57,7 @@ public final class DownloadProviders {
public static final String DEFAULT_PROVIDER_ID = "balanced";
public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi";
@SuppressWarnings("unused")
private static final InvalidationListener observer;
static {
@@ -68,71 +67,68 @@ public final class DownloadProviders {
MOJANG = new MojangDownloadProvider();
BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot);
rawProviders = mapOf(
pair("mojang", MOJANG),
pair("bmclapi", BMCLAPI)
rawProviders = Map.of(
"mojang", MOJANG,
"bmclapi", BMCLAPI
);
AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider();
fileProvider.setDownloadProviderCandidates(Arrays.asList(BMCLAPI, MOJANG));
fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG));
BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI);
providersById = mapOf(
pair("official", new AutoDownloadProvider(MOJANG, fileProvider)),
pair("balanced", new AutoDownloadProvider(balanced, fileProvider)),
pair("mirror", new AutoDownloadProvider(BMCLAPI, fileProvider)));
providersById = Map.of(
"official", new AutoDownloadProvider(MOJANG, fileProvider),
"balanced", new AutoDownloadProvider(balanced, fileProvider),
"mirror", new AutoDownloadProvider(BMCLAPI, fileProvider));
observer = FXUtils.observeWeak(() -> {
FetchTask.setDownloadExecutorConcurrency(
config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads());
}, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty());
provider = new DownloadProviderWrapper(MOJANG);
}
static void init() {
FXUtils.onChangeAndOperate(config().versionListSourceProperty(), versionListSource -> {
if (!providersById.containsKey(versionListSource)) {
config().setVersionListSource(DEFAULT_PROVIDER_ID);
return;
InvalidationListener onChangeDownloadSource = observable -> {
String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), "");
if (config().isAutoChooseDownloadType()) {
DownloadProvider currentDownloadProvider = providersById.get(versionListSource);
if (currentDownloadProvider == null)
currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID),
"default provider is null");
provider.setProvider(currentDownloadProvider);
} else {
provider.setProvider(fileDownloadProvider);
}
};
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
currentDownloadProvider = Optional.ofNullable(providersById.get(versionListSource))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
});
if (!rawProviders.containsKey(config().getDownloadType())) {
config().setDownloadType(DEFAULT_RAW_PROVIDER_ID);
}
onChangeDownloadSource.invalidated(null);
FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> {
DownloadProvider primary = Optional.ofNullable(rawProviders.get(downloadType))
.orElse(rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
fileDownloadProvider.setDownloadProviderCandidates(
Stream.concat(
Stream.of(primary),
rawProviders.values().stream().filter(x -> x != primary)
).collect(Collectors.toList())
);
DownloadProvider primary = Objects.requireNonNullElseGet(
rawProviders.get(Objects.requireNonNullElse(downloadType, "")),
() -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
List<DownloadProvider> providers = new ArrayList<>(rawProviders.size());
providers.add(primary);
for (DownloadProvider provider : rawProviders.values()) {
if (provider != primary)
providers.add(provider);
}
fileDownloadProvider.setDownloadProviderCandidates(providers);
});
}
public static String getPrimaryDownloadProviderId() {
String downloadType = config().getDownloadType();
if (providersById.containsKey(downloadType))
return downloadType;
else
return DEFAULT_PROVIDER_ID;
}
public static DownloadProvider getDownloadProviderByPrimaryId(String primaryId) {
return Optional.ofNullable(providersById.get(primaryId))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return config().isAutoChooseDownloadType() ? currentDownloadProvider : fileDownloadProvider;
return provider;
}
public static String localizeErrorMessage(Throwable exception) {
@@ -22,6 +22,8 @@ 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;
@@ -32,8 +34,7 @@ import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Objects;
import java.util.*;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -50,7 +51,21 @@ public final class FontManager {
public static final double DEFAULT_FONT_SIZE = 12.0f;
private static final Lazy<Font> DEFAULT_FONT = new Lazy<>(() -> {
Font font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
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;
@@ -69,10 +84,12 @@ public final class FontManager {
return font;
}
// Default
String fcMatchPattern;
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()
&& Locale.getDefault() != Locale.ROOT
&& !"en".equals(Locale.getDefault().getLanguage()))
return findByFcMatch();
&& !(fcMatchPattern = I18n.getLocale().getFcMatchPattern()).isEmpty())
return findByFcMatch(fcMatchPattern);
else
return null;
});
@@ -108,6 +125,7 @@ public final class FontManager {
for (String extension : FONT_EXTENSIONS) {
Path path = dir.resolve("font." + extension);
if (Files.isRegularFile(path)) {
LOG.info("Load font file: " + path);
try {
Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (font != null) {
@@ -123,16 +141,47 @@ public final class FontManager {
return null;
}
public static Font findByFcMatch() {
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(),
":lang=" + Locale.getDefault().toLanguageTag(),
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);
@@ -148,6 +197,7 @@ public final class FontManager {
return null;
}
LOG.info("Load font file: " + path);
Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (fonts == null) {
LOG.warning("Failed to load font from " + path);
@@ -48,6 +48,8 @@ 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();
@@ -94,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();
}
@@ -153,6 +167,7 @@ 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",
@@ -169,6 +184,7 @@ 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()));
@@ -196,6 +212,7 @@ 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));
@@ -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));
}
}
@@ -19,12 +19,12 @@ package org.jackhuang.hmcl.setting;
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.io.IOException;
import java.net.*;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -33,8 +33,15 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ProxyManager {
private static final ProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
private static final ProxySelector SYSTEM_DEFAULT = Objects.requireNonNullElse(ProxySelector.getDefault(), NO_PROXY);
private static final SimpleProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
private static final ProxySelector SYSTEM_DEFAULT;
static {
ProxySelector systemProxySelector = ProxySelector.getDefault();
SYSTEM_DEFAULT = systemProxySelector != null
? new ProxySelectorWrapper(systemProxySelector)
: NO_PROXY;
}
private static volatile @NotNull ProxySelector defaultProxySelector = SYSTEM_DEFAULT;
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
@@ -51,7 +58,7 @@ public final class ProxyManager {
LOG.warning("Illegal proxy port: " + port);
return NO_PROXY;
} else {
return new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port)));
return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port))));
}
} else {
return ProxyManager.SYSTEM_DEFAULT;
@@ -64,7 +71,10 @@ public final class ProxyManager {
String password = config().getProxyPass();
if (username != null || password != null)
return new SimpleAuthenticator(username, password.toCharArray());
return new SimpleAuthenticator(
Objects.requireNonNullElse(username, ""),
Objects.requireNonNullElse(password, "").toCharArray()
);
else
return null;
} else
@@ -106,15 +116,41 @@ public final class ProxyManager {
config().proxyPassProperty().addListener(updateAuthenticator);
}
private static final class SimpleProxySelector extends ProxySelector {
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(Collections.singletonList(proxy));
this.proxies = List.of(proxy);
}
SimpleProxySelector(List<Proxy> proxies) {
this.proxies = proxies;
@Override
public List<Proxy> select(URI uri) {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
return proxies;
}
@Override
public 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
@@ -122,19 +158,10 @@ public final class ProxyManager {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
return proxies;
}
if (NetworkUtils.isLoopbackAddress(uri))
return NO_PROXY.proxies;
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
if (uri == null || sa == null || ioe == null) {
throw new IllegalArgumentException("Arguments can't be null.");
}
}
@Override
public String toString() {
return "SimpleProxySelector" + proxies;
return source.select(uri);
}
}
@@ -22,19 +22,13 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.FXUtils;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
@@ -57,23 +51,8 @@ public final class StyleSheets {
config().themeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()));
}
private static String toStyleSheetUri(String styleSheet, String fallback) {
if (FXUtils.JAVAFX_MAJOR_VERSION >= 17)
// JavaFX 17+ support loading stylesheets from data URIs
// https://bugs.openjdk.org/browse/JDK-8267554
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
else
try {
Path temp = Files.createTempFile("hmcl", ".css");
// For JavaFX 17 or earlier, CssParser uses the default charset
// https://bugs.openjdk.org/browse/JDK-8279328
Files.writeString(temp, styleSheet, Charset.defaultCharset());
temp.toFile().deleteOnExit();
return temp.toUri().toString();
} catch (IOException | NullPointerException e) {
LOG.error("Unable to create stylesheet, fallback to " + fallback, e);
return fallback;
}
private static String toStyleSheetUri(String styleSheet) {
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
}
private static String getFontStyleSheet() {
@@ -123,7 +102,7 @@ public final class StyleSheets {
builder.append('}');
return toStyleSheetUri(builder.toString(), fontFamily);
return toStyleSheetUri(builder.toString());
}
private static String rgba(Color color, double opacity) {
@@ -150,7 +129,7 @@ public final class StyleSheets {
"-fx-base-disabled-text-fill:" + rgba(theme.getForegroundColor(), 0.7) + ";" +
"-fx-base-text-fill:" + Theme.getColorDisplayName(theme.getForegroundColor()) + ";" +
"-theme-thumb:" + rgba(theme.getPaint(), 0.7) + ";" +
'}', blueCss);
'}');
}
public static void init(Scene scene) {
@@ -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) {
@@ -380,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() {
@@ -757,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());
@@ -797,14 +812,6 @@ 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))
@@ -835,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,325 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaManager {
private TerracottaManager() {
}
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
static {
Task.runAsync(() -> {
if (TerracottaMetadata.PROVIDER == null) {
setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.OS));
LOG.warning("Terracotta hasn't support your OS: " + org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM);
} else {
switch (TerracottaMetadata.PROVIDER.status()) {
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
case READY -> launch(setState(new TerracottaState.Launching()));
}
}
}).whenComplete(exception -> {
if (exception != null) {
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}).start();
}
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
return STATE.getReadOnlyProperty();
}
static {
Lang.thread(() -> {
while (true) {
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
LockSupport.parkNanos(500_000);
continue;
}
int port = portSpecific.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(jsonString -> {
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
if (object.index <= index) {
return null;
}
object.port = port;
return object;
})
.setSignificance(Task.TaskSignificance.MINOR)
.run();
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
if (next != null) {
compareAndSet(state, next);
}
LockSupport.parkNanos(500_000);
}
}, "Terracotta Background Daemon", true);
}
public static boolean validate(Path file) {
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
public static TerracottaState.Preparing install(@Nullable Path file) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
return null;
}
TerracottaState.Preparing preparing;
if (state instanceof TerracottaState.Preparing it) {
preparing = it;
} else {
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
}
Task.supplyAsync(Schedulers.io(), () -> {
return file != null ? TarFileTree.open(file) : null;
}).thenComposeAsync(Schedulers.javafx(), tree -> {
return getProvider().install(preparing, tree).whenComplete(exception -> {
if (tree != null) {
tree.close();
}
if (exception != null) {
throw exception;
}
});
}).whenComplete(exception -> {
if (exception == null) {
try {
TerracottaMetadata.removeLegacyVersionFiles();
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching);
}
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
LOG.warning("Cannot install terracotta from local package.", exception);
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return setState(preparing);
}
private static ITerracottaProvider getProvider() {
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static TerracottaState recover(@Nullable Path file) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
return null;
}
try {
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> install(file);
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching);
yield launching;
}
};
} catch (NullPointerException | IOException e) {
LOG.warning("Cannot determine Terracotta state.", e);
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}
private static void launch(TerracottaState.Launching state) {
Task.supplyAsync(() -> {
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
long exitTime = -1;
while (true) {
if (Files.exists(path)) {
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
return object.get("port").getAsInt();
}
if (!process.isRunning()) {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException("Process has exited for 10s.");
}
}
}
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
compareAndSet(state, next);
}).start();
}
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR);
}
return Task.completed(null);
}
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return new TerracottaState.Waiting(-1, -1, null);
}
return null;
}
private static String getPlayerName() {
Account account = Accounts.getSelectedAccount();
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
}
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
).setSignificance(Task.TaskSignificance.MINOR).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
} else {
return null;
}
}
private static <T extends TerracottaState> T setState(T value) {
if (value == null) {
throw new AssertionError();
}
STATE_V.set(value);
STATE_D.accept(value);
return value;
}
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
if (next == null) {
throw new AssertionError();
}
if (STATE_V.compareAndSet(previous, next)) {
STATE_D.accept(next);
return true;
} else {
return false;
}
}
}
@@ -0,0 +1,204 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.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.Objects;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaMetadata {
private TerracottaMetadata() {
}
public record Link(@SerializedName("desc") LocalizedText description, String link) {
}
private record Config(
@SerializedName("version_legacy") String legacy,
@SerializedName("version_recent") List<String> recent,
@SerializedName("version_latest") String latest,
@SerializedName("classifiers") Map<String, String> classifiers,
@SerializedName("downloads") List<String> downloads,
@SerializedName("links") List<Link> links
) {
private TerracottaNative of(String classifier) {
List<URI> links = new ArrayList<>(this.downloads.size());
for (String download : this.downloads) {
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
}
String hash = Objects.requireNonNull(this.classifiers.get(classifier), String.format("Classifier %s doesn't exist.", classifier));
if (!hash.startsWith("sha256:")) {
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
}
hash = hash.substring("sha256:".length());
return new TerracottaNative(
Collections.unmodifiableList(links),
Metadata.DEPENDENCIES_DIRECTORY.resolve(
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
).toAbsolutePath(),
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
);
}
}
public static final ITerracottaProvider PROVIDER;
public static final String PACKAGE_NAME;
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 architecture = switch (Architecture.SYSTEM_ARCH) {
case X86_64 -> "x86_64";
case ARM64 -> "arm64";
default -> null;
};
if (architecture == null) {
return null;
}
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_8_1)) {
yield new ProviderContext(
new GeneralProvider(config.of(String.format("windows-%s.exe", architecture))),
"windows", architecture
);
}
yield null;
}
case LINUX -> new ProviderContext(
new GeneralProvider(config.of(String.format("linux-%s", architecture))),
"linux", architecture
);
case MACOS -> new ProviderContext(
new MacOSProvider(
config.of(String.format("macos-%s.pkg", architecture)),
config.of(String.format("macos-%s", architecture))
),
"macos", architecture
);
default -> null;
};
}
public static void removeLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
continue;
}
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
}
}
public static boolean hasLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,147 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.CancellationException;
public final class TerracottaNative {
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck checking;
private final Path path;
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
this.links = links;
this.path = path;
this.checking = checking;
}
public Path getPath() {
return path;
}
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
if (tree == null) {
return new FileDownloadTask(links, path, checking) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
delegate.withResult(success);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
if (!context.hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess() && !context.requestInstallFence()) {
throw new CancellationException();
}
delegate.close();
}
};
}
};
}
return Task.runAsync(() -> {
String name = FileUtils.getName(path);
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
if (entry == null) {
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
}
if (!context.requestInstallFence()) {
throw new CancellationException();
}
Files.createDirectories(path.toAbsolutePath().getParent());
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
try (
InputStream stream = tree.getInputStream(entry);
OutputStream os = Files.newOutputStream(path)
) {
stream.transferTo(new OutputStream() {
@Override
public void write(int b) throws IOException {
os.write(b);
digest.update((byte) b);
}
@Override
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
os.write(buffer, offset, len);
digest.update(buffer, offset, len);
}
});
}
String checksum = HexFormat.of().formatHex(digest.digest());
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
Files.delete(path);
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
}
});
}
public ITerracottaProvider.Status status() throws IOException {
if (Files.exists(path)) {
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
return ITerracottaProvider.Status.READY;
}
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return ITerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
}
return ITerracottaProvider.Status.NOT_EXIST;
}
}
@@ -0,0 +1,304 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
protected TerracottaState() {
}
public boolean isUIFakeState() {
return false;
}
public boolean isForkOf(TerracottaState state) {
return false;
}
public static final class Bootstrap extends TerracottaState {
static final Bootstrap INSTANCE = new Bootstrap();
private Bootstrap() {
}
}
public static final class Uninitialized extends TerracottaState {
private final boolean hasLegacy;
Uninitialized(boolean hasLegacy) {
this.hasLegacy = hasLegacy;
}
public boolean hasLegacy() {
return hasLegacy;
}
}
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean installFence = new AtomicBoolean(false);
Preparing(ReadOnlyDoubleWrapper progress) {
this.progress = progress;
}
public ReadOnlyDoubleProperty progressProperty() {
return progress.getReadOnlyProperty();
}
@Override
public void bindProgress(ObservableValue<? extends Number> value) {
progress.bind(value);
}
@Override
public boolean requestInstallFence() {
return installFence.compareAndSet(false, true);
}
@Override
public boolean hasInstallFence() {
return !installFence.get();
}
}
public static final class Launching extends TerracottaState {
Launching() {
}
}
static abstract sealed class PortSpecific extends TerracottaState {
transient int port;
protected PortSpecific(int port) {
this.port = port;
}
}
@JsonType(
property = "state",
subtypes = {
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
}
)
static abstract sealed class Ready extends PortSpecific {
@SerializedName("index")
final int index;
@SerializedName("state")
private final String state;
Ready(int port, int index, String state) {
super(port);
this.index = index;
this.state = state;
}
@Override
public boolean isUIFakeState() {
return this.index == -1;
}
}
public static final class Unknown extends PortSpecific {
Unknown(int port) {
super(port);
}
}
public static final class Waiting extends Ready {
Waiting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostScanning extends Ready {
HostScanning(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostStarting extends Ready {
HostStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostOK extends Ready implements Validation {
@SerializedName("room")
private final String code;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.code = code;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (code == null) {
throw new JsonParseException("code is null");
}
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getCode() {
return code;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
}
}
public static final class GuestStarting extends Ready {
GuestStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.url = url;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getUrl() {
return url;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
}
}
public static final class Exception extends Ready implements Validation {
public enum Type {
PING_HOST_FAIL,
PING_HOST_RST,
GUEST_ET_CRASH,
HOST_ET_CRASH,
PING_SERVER_RST,
SCAFFOLDING_INVALID_RESPONSE
}
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
@SerializedName("type")
private final int type;
Exception(int port, int index, String state, int type) {
super(port, index, state);
this.type = type;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (type < 0 || type >= LOOKUP.length) {
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
}
}
public Type getType() {
return LOOKUP[type];
}
}
public static final class Fatal extends TerracottaState {
public enum Type {
OS,
NETWORK,
INSTALL,
TERRACOTTA,
UNKNOWN;
}
private final Type type;
public Fatal(Type type) {
this.type = type;
}
public Type getType() {
return type;
}
public boolean isRecoverable() {
return this.type != Type.OS && this.type != Type.UNKNOWN;
}
}
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -15,33 +15,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.event;
package org.jackhuang.hmcl.terracotta.profile;
/**
*
* @author huang
*/
public class FailedEvent<T> extends Event {
private final int failedTime;
private T newResult;
public FailedEvent(Object source, int failedTime, T newResult) {
super(source);
this.failedTime = failedTime;
this.newResult = newResult;
}
public int getFailedTime() {
return failedTime;
}
public T getNewResult() {
return newResult;
}
public void setNewResult(T newResult) {
this.newResult = newResult;
}
import com.google.gson.annotations.SerializedName;
public enum ProfileKind {
@SerializedName("HOST")
HOST,
@SerializedName("LOCAL")
LOCAL,
@SerializedName("GUEST")
GUEST
}
@@ -0,0 +1,57 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public final class TerracottaProfile {
@SerializedName("machine_id")
private final String machineID;
@SerializedName("name")
private final String name;
@SerializedName("vendor")
private final String vendor;
@SerializedName("kind")
private final ProfileKind type;
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
this.machineID = machineID;
this.name = name;
this.vendor = vendor;
this.type = type;
}
public String getMachineID() {
return machineID;
}
public String getName() {
return name;
}
public String getVendor() {
return vendor;
}
public ProfileKind getType() {
return type;
}
}
@@ -0,0 +1,67 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
public final class GeneralProvider implements ITerracottaProvider {
private final TerracottaNative target;
public GeneralProvider(TerracottaNative target) {
this.target = target;
}
@Override
public Status status() throws IOException {
return target.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
Task<?> task = target.install(context, tree);
context.bindProgress(task.progressProperty());
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)));
}
return task;
}
@Override
public List<String> ofCommandLine(Path path) {
return List.of(target.getPath().toString(), "--hmcl", path.toString());
}
}
@@ -0,0 +1,77 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
public interface ITerracottaProvider {
enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
interface Context {
void bindProgress(ObservableValue<? extends Number> value);
boolean requestInstallFence();
boolean hasInstallFence();
}
abstract class ProviderException extends IOException {
public ProviderException(String message) {
super(message);
}
public ProviderException(String message, Throwable cause) {
super(message, cause);
}
public ProviderException(Throwable cause) {
super(cause);
}
}
final class ArchiveFileMissingException extends ProviderException {
public ArchiveFileMissingException(String message) {
super(message);
}
public ArchiveFileMissingException(String message, Throwable cause) {
super(message, cause);
}
public ArchiveFileMissingException(Throwable cause) {
super(cause);
}
}
Status status() throws IOException;
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
List<String> ofCommandLine(Path path);
}
@@ -0,0 +1,97 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MacOSProvider implements ITerracottaProvider {
public final TerracottaNative installer, binary;
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
this.installer = installer;
this.binary = binary;
}
@Override
public Status status() throws IOException {
assert binary != null;
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
return Status.NOT_EXIST;
}
return binary.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
assert installer != null && binary != null;
Task<?> installerTask = installer.install(context, tree);
Task<?> binaryTask = binary.install(context, tree);
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
return Task.allOf(
installerTask.thenComposeAsync(() -> {
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
"osascript",
"-e",
String.format(
"do shell script \"installer -pkg %s -target /\" with prompt \"%s\" with administrator privileges",
installer.getPath(),
i18n("terracotta.sudo_installing")
)
));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit());
}),
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)))
);
}
@Override
public List<String> ofCommandLine(Path path) {
assert binary != null;
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
}
}
@@ -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,16 @@ 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 java.io.File;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -71,6 +75,8 @@ 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
@@ -89,7 +95,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;
@@ -105,6 +111,7 @@ public final class Controllers {
return accountListPage;
});
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
private static Lazy<TerracottaPage> terracottaPage = new Lazy<>(TerracottaPage::new);
private Controllers() {
}
@@ -147,6 +154,11 @@ public final class Controllers {
return downloadPage.get();
}
// FXThread
public static Node getTerracottaPage() {
return terracottaPage.get();
}
// FXThread
public static DecoratorController getDecorator() {
return decorator;
@@ -216,7 +228,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());
}
};
@@ -321,6 +337,45 @@ 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();
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
@@ -378,6 +433,30 @@ public final class Controllers {
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
}
public static void confirmActionDanger(String text, String title, Runnable resolve, Runnable cancel) {
JFXButton btnYes = new JFXButton(i18n("button.ok"));
btnYes.getStyleClass().add("dialog-error");
btnYes.setOnAction(e -> resolve.run());
btnYes.setDisable(true);
int countdown = 10;
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
for (int i = 0; i < countdown; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
}
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
new KeyValue(btnYes.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
confirmAction(text, title, MessageType.WARNING, btnYes, () -> {
timeline.stop();
cancel.run();
});
timeline.play();
}
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult) {
return prompt(title, onResult, "");
}
@@ -424,6 +503,10 @@ public final class Controllers {
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
break;
case "hmcl://game/launch":
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
break;
}
} else {
FXUtils.openLink(href);
@@ -25,18 +25,18 @@ import javafx.beans.Observable;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.WeakListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.beans.value.*;
import javafx.collections.ObservableMap;
import javafx.event.Event;
import javafx.event.EventDispatcher;
import javafx.event.EventType;
import javafx.geometry.Bounds;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
@@ -50,11 +50,11 @@ import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.*;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -78,23 +78,25 @@ import org.xml.sax.SAXException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.*;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.ref.WeakReference;
import java.net.*;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.List;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.Lang.tryCast;
@@ -107,11 +109,32 @@ public final class FXUtils {
public static final int JAVAFX_MAJOR_VERSION;
public static final String GRAPHICS_PIPELINE;
public static final boolean GPU_ACCELERATION_ENABLED;
static {
String pipelineName = "";
try {
Object pipeline = Class.forName("com.sun.prism.GraphicsPipeline").getMethod("getPipeline").invoke(null);
if (pipeline != null) {
pipelineName = pipeline.getClass().getName();
}
} catch (Throwable e) {
LOG.warning("Failed to get prism pipeline", e);
}
GRAPHICS_PIPELINE = pipelineName;
GPU_ACCELERATION_ENABLED = !pipelineName.endsWith(".SWPipeline");
}
/// @see Platform.Preferences
public static final @Nullable ObservableMap<String, Object> PREFERENCES;
public static final @Nullable ObservableBooleanValue DARK_MODE;
public static final @Nullable Boolean REDUCED_MOTION;
public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY;
static {
String jfxVersion = System.getProperty("javafx.version");
int majorVersion = -1;
@@ -156,6 +179,20 @@ public final class FXUtils {
PREFERENCES = preferences;
DARK_MODE = darkMode;
REDUCED_MOTION = reducedMotion;
MethodHandle textTruncatedProperty = null;
if (JAVAFX_MAJOR_VERSION >= 23) {
try {
textTruncatedProperty = MethodHandles.publicLookup().findVirtual(
Labeled.class,
"textTruncatedProperty",
MethodType.methodType(ReadOnlyBooleanProperty.class)
);
} catch (Throwable e) {
LOG.warning("Failed to lookup textTruncatedProperty", e);
}
}
TEXT_TRUNCATED_PROPERTY = textTruncatedProperty;
}
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
@@ -355,6 +392,22 @@ public final class FXUtils {
ScrollUtils.addSmoothScrolling(scrollPane);
}
/// If the current environment is JavaFX 23 or higher, this method returns [Labeled#textTruncatedProperty()];
/// Otherwise, it returns `null`.
public static @Nullable ReadOnlyBooleanProperty textTruncatedProperty(Labeled labeled) {
if (TEXT_TRUNCATED_PROPERTY != null) {
try {
return (ReadOnlyBooleanProperty) TEXT_TRUNCATED_PROPERTY.invokeExact(labeled);
} catch (RuntimeException | Error e) {
throw e;
} catch (Throwable e) {
throw new RuntimeException(e);
}
} else {
return null;
}
}
private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50);
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
@@ -406,20 +459,27 @@ public final class FXUtils {
}
}
public static void openFolder(File file) {
if (!FileUtils.makeDirectory(file)) {
LOG.error("Unable to make directory " + file);
public static void openFolder(Path file) {
if (file.getFileSystem() != FileSystems.getDefault()) {
LOG.warning("Cannot open folder as the file system is not supported: " + file);
return;
}
String path = file.getAbsolutePath();
try {
Files.createDirectories(file);
} catch (IOException e) {
LOG.warning("Failed to create directory " + file);
return;
}
String path = FileUtils.getAbsolutePath(file);
String openCommand;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
openCommand = "explorer.exe";
else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
openCommand = "/usr/bin/open";
else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && new File("/usr/bin/xdg-open").exists())
else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && Files.exists(Path.of("/usr/bin/xdg-open")))
openCommand = "/usr/bin/xdg-open";
else
openCommand = null;
@@ -441,7 +501,7 @@ public final class FXUtils {
// Fallback to java.awt.Desktop::open
try {
java.awt.Desktop.getDesktop().open(file);
java.awt.Desktop.getDesktop().open(file.toFile());
} catch (Throwable e) {
LOG.error("Unable to open " + path + " by java.awt.Desktop.getDesktop()::open", e);
}
@@ -484,11 +544,11 @@ public final class FXUtils {
}
// Fallback to open folder
openFolder(file.getParent().toFile());
openFolder(file.getParent());
});
} else {
// We do not have a universal method to show file in file manager.
openFolder(file.getParent().toFile());
openFolder(file.getParent());
}
}
@@ -511,37 +571,35 @@ public final class FXUtils {
if (link == null)
return;
String uri = NetworkUtils.encodeLocation(link);
thread(() -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", link});
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri});
return;
} catch (Throwable e) {
LOG.warning("An exception occurred while calling rundll32", e);
}
}
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
for (String browser : linuxBrowsers) {
Path path = SystemUtils.which(browser);
if (path != null) {
try {
Runtime.getRuntime().exec(new String[]{path.toString(), link});
return;
} catch (Throwable ignored) {
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Runtime.getRuntime().exec(new String[]{"open", uri});
return;
} else {
for (String browser : linuxBrowsers) {
Path path = SystemUtils.which(browser);
if (path != null) {
try {
Runtime.getRuntime().exec(new String[]{path.toString(), uri});
return;
} catch (Throwable ignored) {
}
}
}
LOG.warning("No known browser found");
}
}
try {
java.awt.Desktop.getDesktop().browse(new URI(link));
} catch (Throwable e) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
try {
Runtime.getRuntime().exec(new String[]{"/usr/bin/open", link});
} catch (IOException ex) {
LOG.warning("Unable to open link: " + link, ex);
}
LOG.warning("Failed to open link: " + link + ", fallback to java.awt.Desktop", e);
}
try {
java.awt.Desktop.getDesktop().browse(new URI(uri));
} catch (Throwable e) {
LOG.warning("Failed to open link: " + link, e);
}
});
@@ -832,10 +890,144 @@ public final class FXUtils {
property.addListener(binding);
}
public static void unbindColorPicker(ColorPicker colorPicker, Property<Paint> property) {
PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property);
colorPicker.valueProperty().removeListener(binding);
property.removeListener(binding);
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<String>> comboBoxRef;
private final WeakReference<IntegerProperty> widthPropertyRef;
private final WeakReference<IntegerProperty> heightPropertyRef;
private final int hashCode;
private boolean updating = false;
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
IntegerProperty widthProperty,
IntegerProperty heightProperty) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.widthPropertyRef = new WeakReference<>(widthProperty);
this.heightPropertyRef = new WeakReference<>(heightProperty);
this.hashCode = System.identityHashCode(comboBox)
^ System.identityHashCode(widthProperty)
^ System.identityHashCode(heightProperty);
}
@Override
public void invalidated(Observable observable) {
if (!updating) {
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null) {
if (comboBox != null) {
comboBox.focusedProperty().removeListener(this);
comboBox.sceneProperty().removeListener(this);
}
if (widthProperty != null)
widthProperty.removeListener(this);
if (heightProperty != null)
heightProperty.removeListener(this);
} else {
updating = true;
try {
int width = widthProperty.get();
int height = heightProperty.get();
if (observable instanceof ReadOnlyProperty<?>
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
String value = comboBox.valueProperty().get();
if (value == null)
value = "";
int idx = value.indexOf('x');
if (idx < 0)
idx = value.indexOf('*');
if (idx < 0) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
String widthStr = value.substring(0, idx).trim();
String heightStr = value.substring(idx + 1).trim();
int newWidth;
int newHeight;
try {
newWidth = Integer.parseInt(widthStr);
newHeight = Integer.parseInt(heightStr);
} catch (NumberFormatException e) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
widthProperty.set(newWidth);
heightProperty.set(newHeight);
} else {
comboBox.setValue(width + "x" + height);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return this.comboBoxRef.get() == null
|| this.widthPropertyRef.get() == null
|| this.heightPropertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof WindowsSizeBidirectionalBinding))
return false;
var that = (WindowsSizeBidirectionalBinding) obj;
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
var thatComboBox = that.comboBoxRef.get();
var thatWidthProperty = that.widthPropertyRef.get();
var thatHeightProperty = that.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
return false;
}
return comboBox == thatComboBox
&& widthProperty == thatWidthProperty
&& heightProperty == thatHeightProperty;
}
}
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().addListener(binding);
comboBox.sceneProperty().addListener(binding);
widthProperty.addListener(binding);
heightProperty.addListener(binding);
}
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().removeListener(binding);
comboBox.sceneProperty().removeListener(binding);
widthProperty.removeListener(binding);
heightProperty.removeListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
@@ -1024,29 +1216,57 @@ public final class FXUtils {
public static JFXButton newBorderButton(String text) {
JFXButton button = new JFXButton(text);
button.getStyleClass().add("jfx-button-border");
button.setButtonType(JFXButton.ButtonType.RAISED);
return button;
}
public static Label truncatedLabel(String text, int limit) {
Label label = new Label();
if (text.length() <= limit) {
label.setText(text);
} else {
label.setText(StringUtils.truncate(text, limit));
installFastTooltip(label, text);
}
public static JFXButton newToggleButton4(SVG icon) {
JFXButton button = new JFXButton();
button.getStyleClass().add("toggle-icon4");
button.setGraphic(icon.createIcon(Theme.blackFill(), -1));
return button;
}
public static Label newSafeTruncatedLabel(String text) {
Label label = new Label(text);
label.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS);
showTooltipWhenTruncated(label);
return label;
}
public static void applyDragListener(Node node, FileFilter filter, Consumer<List<File>> callback) {
private static final String LABEL_FULL_TEXT_PROP_KEY = FXUtils.class.getName() + ".LABEL_FULL_TEXT";
public static void showTooltipWhenTruncated(Labeled labeled) {
ReadOnlyBooleanProperty textTruncatedProperty = textTruncatedProperty(labeled);
if (textTruncatedProperty != null) {
ChangeListener<Boolean> listener = (observable, oldValue, newValue) -> {
var label = (Labeled) ((ReadOnlyProperty<?>) observable).getBean();
var tooltip = (Tooltip) label.getProperties().get(LABEL_FULL_TEXT_PROP_KEY);
if (newValue) {
if (tooltip == null) {
tooltip = new Tooltip();
tooltip.textProperty().bind(label.textProperty());
label.getProperties().put(LABEL_FULL_TEXT_PROP_KEY, tooltip);
}
FXUtils.installFastTooltip(label, tooltip);
} else if (tooltip != null) {
Tooltip.uninstall(label, tooltip);
}
};
listener.changed(textTruncatedProperty, false, textTruncatedProperty.get());
textTruncatedProperty.addListener(listener);
}
}
public static void applyDragListener(Node node, PathMatcher filter, Consumer<List<Path>> callback) {
applyDragListener(node, filter, callback, null);
}
public static void applyDragListener(Node node, FileFilter filter, Consumer<List<File>> callback, Runnable dragDropped) {
public static void applyDragListener(Node node, PathMatcher filter, Consumer<List<Path>> callback, Runnable dragDropped) {
node.setOnDragOver(event -> {
if (event.getGestureSource() != node && event.getDragboard().hasFiles()) {
if (event.getDragboard().getFiles().stream().anyMatch(filter::accept))
if (event.getDragboard().getFiles().stream().map(File::toPath).anyMatch(filter::matches))
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
}
event.consume();
@@ -1055,7 +1275,7 @@ public final class FXUtils {
node.setOnDragDropped(event -> {
List<File> files = event.getDragboard().getFiles();
if (files != null) {
List<File> acceptFiles = files.stream().filter(filter::accept).collect(Collectors.toList());
List<Path> acceptFiles = files.stream().map(File::toPath).filter(filter::matches).toList();
if (!acceptFiles.isEmpty()) {
callback.accept(acceptFiles);
event.setDropCompleted(true);
@@ -1147,6 +1367,37 @@ public final class FXUtils {
});
}
public static <T> void onScroll(Node node, List<T> list,
ToIntFunction<List<T>> finder,
Consumer<T> updater
) {
node.addEventHandler(ScrollEvent.SCROLL, event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
int index = finder.applyAsInt(list);
if (index < 0) return;
if (deltaY > 0) // up
index--;
else // down
index++;
updater.accept(list.get((index + list.size()) % list.size()));
event.consume();
});
}
public static void clearFocus(Node node) {
Scene scene = node.getScene();
if (scene != null) {
Parent root = scene.getRoot();
if (root != null) {
root.requestFocus();
}
}
}
public static void copyOnDoubleClick(Labeled label) {
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
@@ -1239,4 +1490,65 @@ public final class FXUtils {
return new FileChooser.ExtensionFilter(i18n("extension.png"),
IMAGE_EXTENSIONS.stream().map(ext -> "*." + ext).toArray(String[]::new));
}
/**
* Intelligently determines the popup position to prevent the menu from exceeding screen boundaries.
* Supports multi-monitor setups by detecting the current screen where the component is located.
* Now handles first-time popup display by forcing layout measurement.
*
* @param root the root node to calculate position relative to
* @param popupInstance the popup instance to position
* @return the optimal vertical position for the popup menu
*/
public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, JFXPopup popupInstance) {
// Get the screen bounds in screen coordinates
Bounds screenBounds = root.localToScreen(root.getBoundsInLocal());
// Convert Bounds to Rectangle2D for getScreensForRectangle method
Rectangle2D boundsRect = new Rectangle2D(
screenBounds.getMinX(), screenBounds.getMinY(),
screenBounds.getWidth(), screenBounds.getHeight()
);
// Find the screen that contains this component (supports multi-monitor)
List<Screen> screens = Screen.getScreensForRectangle(boundsRect);
Screen currentScreen = screens.isEmpty() ? Screen.getPrimary() : screens.get(0);
Rectangle2D visualBounds = currentScreen.getVisualBounds();
double screenHeight = visualBounds.getHeight();
double screenMinY = visualBounds.getMinY();
double itemScreenY = screenBounds.getMinY();
// Calculate available space relative to the current screen
double availableSpaceAbove = itemScreenY - screenMinY;
double availableSpaceBelow = screenMinY + screenHeight - itemScreenY - root.getBoundsInLocal().getHeight();
// Get popup content and ensure it's properly measured
Region popupContent = popupInstance.getPopupContent();
double menuHeight;
if (popupContent.getHeight() <= 0) {
// Force layout measurement if height is not yet available
popupContent.autosize();
popupContent.applyCss();
popupContent.layout();
// Get the measured height, or use a reasonable fallback
menuHeight = popupContent.getHeight();
if (menuHeight <= 0) {
// Fallback: estimate based on number of menu items
// Each menu item is roughly 36px height + separators + padding
menuHeight = 300; // Conservative estimate for the current menu structure
}
} else {
menuHeight = popupContent.getHeight();
}
// Add some margin for safety
menuHeight += 20;
return (availableSpaceAbove > menuHeight && availableSpaceBelow < menuHeight)
? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward
: JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward
}
}
@@ -41,9 +41,12 @@ 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.*;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
import java.io.IOException;
@@ -59,9 +62,9 @@ import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
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;
@@ -129,7 +132,7 @@ public class GameCrashWindow extends Stage {
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
}), Task.supplyAsync(() -> {
Path latestLog = repository.getRunDirectory(version.getId()).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>());
}
@@ -283,7 +286,7 @@ public class GameCrashWindow extends Stage {
alert.showAndWait();
return null;
});
}, Schedulers.javafx());
}
private final class View extends VBox {
@@ -379,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.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,7 +282,7 @@ public class InstallerItem extends Control {
game.versionProperty.set(new InstalledState(gameVersion, false, false));
}
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi};
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, cleanroom};
for (InstallerItem item : all) {
if (!item.resolvedStateProperty.isBound()) {
@@ -293,6 +298,8 @@ public class InstallerItem extends Control {
if (gameVersion == null) {
this.libraries = all;
} else if (gameVersion.equals("1.12.2")) {
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, optiFine};
} else if (GameVersionNumber.compare(gameVersion, "1.13") < 0) {
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine};
} else {
@@ -312,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);
@@ -320,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);
@@ -64,11 +64,9 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
vBox.setAlignment(Pos.BOTTOM_RIGHT);
vBox.setPickOnBounds(false);
JFXButton btnAdd = new JFXButton();
JFXButton btnAdd = FXUtils.newRaisedButton("");
FXUtils.setLimitWidth(btnAdd, 40);
FXUtils.setLimitHeight(btnAdd, 40);
btnAdd.getStyleClass().add("jfx-button-raised-round");
btnAdd.setButtonType(JFXButton.ButtonType.RAISED);
btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1));
btnAdd.setOnAction(e -> skinnable.add());
@@ -40,6 +40,7 @@ import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.CircularArrayList;
@@ -311,6 +312,7 @@ public final class LogWindow extends Stage {
Holder<Object> lastCell = new Holder<>();
listView.setCellFactory(x -> new ListCell<Log>() {
{
x.setSelectionModel(new NoneMultipleSelectionModel<>());
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
@@ -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;
}
@@ -48,11 +48,22 @@ public final class UpgradeDialog extends JFXDialogLayout {
setBody(new ProgressIndicator());
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
boolean isPreview = remoteVersion.isPreview();
Task.supplyAsync(Schedulers.io(), () -> {
Document document = Jsoup.parse(new URL(url), 30 * 1000);
Node node = document.selectFirst("#nowchange");
String id = null;
Node node = null;
if (isPreview) {
id = "nowpreview";
node = document.selectFirst("#" + id);
}
if (node == null) {
id = "nowchange";
node = document.selectFirst("#" + id);
}
if (node == null || !"h1".equals(node.nodeName()))
throw new IOException("Cannot find #nowchange in document");
throw new IOException("Cannot find current changelog in document");
HTMLRenderer renderer = new HTMLRenderer(uri -> {
LOG.info("Open link: " + uri);
@@ -60,7 +71,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
});
do {
if ("h1".equals(node.nodeName()) && !"nowchange".equals(node.attr("id"))) {
if ("h1".equals(node.nodeName()) && !id.equals(node.attr("id"))) {
break;
}
renderer.appendNode(node);
@@ -21,7 +21,6 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Tooltip;
import org.jackhuang.hmcl.auth.Account;
@@ -77,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 -> {
@@ -41,6 +41,7 @@ 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;
@@ -49,8 +50,9 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import java.time.Duration;
import java.time.ZoneId;
import java.util.Arrays;
import java.time.ZonedDateTime;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
@@ -63,27 +65,22 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
private static boolean isExemptedRegion() {
String zoneId = ZoneId.systemDefault().getId();
if (Arrays.asList(
"Asia/Shanghai",
// Although Asia/Beijing is not a legal name, Deepin uses it
"Asia/Beijing",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Harbin"
).contains(zoneId))
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
return true;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
Kernel32 kernel32 = Kernel32.INSTANCE;
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
// Check if the time zone is UTC+8
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
return true;
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && "GMT+08:00".equals(zoneId))
// Some Linux distributions may use invalid time zone ids (e.g., Asia/Beijing)
// Java may not be able to resolve this name and use GMT+08:00 instead.
return true;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
Kernel32 kernel32 = Kernel32.INSTANCE;
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
return true;
}
}
return false;
}
@@ -19,9 +19,6 @@ package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.*;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.beans.binding.BooleanBinding;
@@ -39,7 +36,6 @@ import javafx.scene.control.Label;
import javafx.scene.control.TextInputControl;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.CharacterSelector;
@@ -268,33 +264,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
};
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
JFXButton btnYes = new JFXButton(i18n("button.ok"));
btnYes.getStyleClass().add("dialog-error");
btnYes.setOnAction(e -> doCreate.run());
btnYes.setDisable(true);
int countdown = 10;
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
for (int i = 0; i < countdown; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
}
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
new KeyValue(btnYes.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
Controllers.confirmAction(
i18n("account.methods.offline.name.invalid"), i18n("message.warning"),
MessageDialogPane.MessageType.WARNING,
btnYes,
() -> {
timeline.stop();
body.setDisable(false);
spinner.hideSpinner();
}
);
timeline.play();
Controllers.confirmActionDanger(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), doCreate, () -> {
body.setDisable(false);
spinner.hideSpinner();
});
} else {
doCreate.run();
}
@@ -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);
});
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui.animation;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
/**
@@ -37,7 +36,7 @@ public final class AnimationUtils {
public static void init() {
}
private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled() && FXUtils.REDUCED_MOTION != Boolean.TRUE;
private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled();
private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD();
public static boolean isAnimationEnabled() {
@@ -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);
}
@@ -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;
}
@@ -25,6 +25,8 @@ import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -61,7 +63,12 @@ public class OptionToggleButton extends StackPane {
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected()));
container.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY) {
toggleButton.setSelected(!toggleButton.isSelected());
e.consume();
}
});
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
if (StringUtils.isNotBlank(subtitle)) {
@@ -191,7 +191,7 @@ public class RipplerContainer extends StackPane {
return StyleableProperties.FACTORY.getCssMetaData();
}
private static class StyleableProperties {
private final static class StyleableProperties {
private static final StyleablePropertyFactory<RipplerContainer> FACTORY = new StyleablePropertyFactory<>(StackPane.getClassCssMetaData());
private static final CssMetaData<RipplerContainer, Paint> RIPPLER_FILL = FACTORY.createPaintCssMetaData("-jfx-rippler-fill", s -> s.ripplerFill, Color.rgb(0, 200, 255));
@@ -225,7 +225,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class HeadersRegion extends StackPane {
private final class HeadersRegion extends StackPane {
private SideAction action;
private final ObjectProperty<Side> side = new SimpleObjectProperty<Side>() {
@Override
@@ -379,7 +379,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Top extends Horizontal {
private final class Top extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -404,7 +404,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Bottom extends Horizontal {
private final class Bottom extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -519,7 +519,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Left extends Vertical {
private final class Left extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -543,7 +543,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Right extends Vertical {
private final class Right extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -22,7 +22,6 @@ 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;
@@ -41,6 +40,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TaskExecutorDialogPane extends BorderPane {
private TaskExecutor executor;
private TaskCancellationAction onCancel;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
private final Label lblTitle;
@@ -59,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();
@@ -91,7 +85,7 @@ public class TaskExecutorDialogPane extends BorderPane {
}
});
speedEventHandler = speedEvent -> {
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
String unit = "B/s";
double speed = speedEvent.getSpeed();
if (speed > 1024) {
@@ -102,11 +96,10 @@ public class TaskExecutorDialogPane extends BorderPane {
speed /= 1024;
unit = "MiB/s";
}
double finalSpeed = speed;
String finalUnit = unit;
Platform.runLater(() -> lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit)));
};
FileDownloadTask.speedEvent.channel(FetchTask.SpeedEvent.class).registerWeak(speedEventHandler);
String message = String.format("%.1f %s", speed, unit);
Platform.runLater(() -> lblProgress.setText(message));
});
onEscPressed(this, btnCancel::fire);
}
@@ -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) {
@@ -156,7 +199,7 @@ public final class TaskListPane extends StackPane {
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask) {
} 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"));
@@ -169,25 +212,25 @@ public final class TaskListPane extends StackPane {
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;
}
@@ -34,10 +34,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.DragEvent;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
@@ -61,6 +58,7 @@ 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;
@@ -167,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() {
@@ -208,6 +212,8 @@ public class DecoratorController {
private Background getBackground() {
EnumBackgroundImage imageType = config().getBackgroundImageType();
if (imageType == null)
imageType = EnumBackgroundImage.DEFAULT;
Image image = null;
switch (imageType) {
@@ -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);
}
@@ -35,8 +35,7 @@ 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 java.util.Map;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -80,12 +79,12 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
protected abstract void reload();
@Override
public void onNavigate(Map<String, Object> settings) {
public void onNavigate(SettingsMap settings) {
reload();
}
@Override
public abstract void cleanup(Map<String, Object> settings);
public abstract void cleanup(SettingsMap settings);
protected abstract void onInstall();
@@ -120,20 +119,15 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
{
InstallerItem[] libraries = control.group.getLibraries();
FlowPane libraryPane = new FlowPane(libraries);
libraryPane.setVgap(16);
libraryPane.setHgap(16);
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) {
BorderPane.setMargin(libraryPane, new Insets(16, 0, 16, 0));
root.setCenter(libraryPane);
} else {
ScrollPane scrollPane = new ScrollPane(libraryPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
root.setCenter(scrollPane);
}
if (libraries.length <= 8)
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
}
{
@@ -29,8 +29,8 @@ import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
@@ -109,6 +109,6 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
}
}
@@ -51,14 +51,13 @@ import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.function.Supplier;
@@ -145,10 +144,10 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
public static void download(Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) {
if (version == null) version = profile.getSelectedVersion();
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version).toPath() : profile.getRepository().getBaseDirectory().toPath();
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory();
Controllers.prompt(i18n("archive.file.name"), (result, resolve, reject) -> {
if (!OperatingSystem.isNameValid(result)) {
if (!FileUtils.isNameValid(result)) {
reject.accept(i18n("install.new_game.malformed"));
return;
}
@@ -223,7 +222,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
private static final class DownloadNavigator implements Navigation {
private final Map<String, Object> settings = new HashMap<>();
private final SettingsMap settings = new SettingsMap();
@Override
public void onStart() {
@@ -260,7 +259,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
@Override
public Map<String, Object> getSettings() {
public SettingsMap getSettings() {
return settings;
}
@@ -287,37 +286,39 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
@Override
public void start(Map<String, Object> settings) {
settings.put(PROFILE, profile);
public void start(SettingsMap settings) {
settings.put(ModpackPage.PROFILE, profile);
settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion);
}
private Task<Void> finishVersionDownloadingAsync(Map<String, Object> settings) {
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
GameBuilder builder = dependencyManager.gameBuilder();
String name = (String) settings.get("name");
builder.name(name);
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
for (Map.Entry<String, Object> entry : settings.entrySet())
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(entry.getKey()) && entry.getValue() instanceof RemoteVersion)
builder.version((RemoteVersion) entry.getValue());
settings.asStringMap().forEach((key, value) -> {
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key)
&& value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
return builder.buildAsync().whenComplete(any -> profile.getRepository().refreshVersions())
.thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name));
}
@Override
public Object finish(Map<String, Object> settings) {
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.new_game.installation"));
settings.put("success_message", i18n("install.success"));
settings.put("failure_callback", (FailureCallback) (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
return finishVersionDownloadingAsync(settings);
}
@Override
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new InstallersPage(controller, profile.getRepository(), ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider);
@@ -330,7 +331,5 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
public boolean cancel() {
return true;
}
public static final String PROFILE = "PROFILE";
}
}
@@ -27,8 +27,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import java.util.Map;
import org.jackhuang.hmcl.util.SettingsMap;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -73,7 +72,7 @@ public class InstallersPage extends AbstractInstallersPage {
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
}
private static boolean checkName(String name) {
@@ -130,6 +129,9 @@ public class InstallersPage extends AbstractInstallersPage {
case NEO_FORGE:
loaderName = i18n("install.installer.neoforge");
break;
case CLEANROOM:
loaderName = i18n("install.installer.cleanroom");
break;
case FABRIC:
loaderName = i18n("install.installer.fabric");
break;
@@ -36,16 +36,14 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.nio.charset.Charset;
import java.util.Map;
import java.util.Optional;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -58,11 +56,11 @@ public final class LocalModpackPage extends ModpackPage {
public LocalModpackPage(WizardController controller) {
super(controller);
Profile profile = (Profile) controller.getSettings().get("PROFILE");
Profile profile = controller.getSettings().get(ModpackPage.PROFILE);
Optional<String> name = tryCast(controller.getSettings().get(MODPACK_NAME), String.class);
if (name.isPresent()) {
txtModpackName.setText(name.get());
String name = controller.getSettings().get(MODPACK_NAME);
if (name != null) {
txtModpackName.setText(name);
txtModpackName.setDisable(true);
} else {
FXUtils.onChangeAndOperate(installAsVersion, installAsVersion -> {
@@ -82,15 +80,15 @@ public final class LocalModpackPage extends ModpackPage {
btnDescription.setVisible(false);
File selectedFile;
Optional<File> filePath = tryCast(controller.getSettings().get(MODPACK_FILE), File.class);
if (filePath.isPresent()) {
selectedFile = filePath.get();
Path selectedFile;
Path filePath = controller.getSettings().get(MODPACK_FILE);
if (filePath != null) {
selectedFile = filePath;
} else {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
selectedFile = chooser.showOpenDialog(Controllers.getStage());
selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (selectedFile == null) {
controller.onEnd();
return;
@@ -100,19 +98,19 @@ public final class LocalModpackPage extends ModpackPage {
}
showSpinner();
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile.toPath()))
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile))
.thenApplyAsync(encoding -> {
charset = encoding;
manifest = ModpackHelper.readModpackManifest(selectedFile.toPath(), encoding);
manifest = ModpackHelper.readModpackManifest(selectedFile, encoding);
return manifest;
})
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
if (exception instanceof ManuallyCreatedModpackException) {
hideSpinner();
lblName.setText(selectedFile.getName());
lblName.setText(FileUtils.getName(selectedFile));
installAsVersion.set(false);
if (!name.isPresent()) {
if (name == null) {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
txtModpackName.setText(FileUtils.getNameWithoutExtension(selectedFile));
}
@@ -133,7 +131,7 @@ public final class LocalModpackPage extends ModpackPage {
lblVersion.setText(manifest.getVersion());
lblAuthor.setText(manifest.getAuthor());
if (!name.isPresent()) {
if (name == null) {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
txtModpackName.setText(manifest.getName().trim());
}
@@ -144,7 +142,7 @@ public final class LocalModpackPage extends ModpackPage {
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
settings.remove(MODPACK_FILE);
}
@@ -177,9 +175,9 @@ public final class LocalModpackPage extends ModpackPage {
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
}
public static final String MODPACK_FILE = "MODPACK_FILE";
public static final String MODPACK_NAME = "MODPACK_NAME";
public static final String MODPACK_MANIFEST = "MODPACK_MANIFEST";
public static final String MODPACK_CHARSET = "MODPACK_CHARSET";
public static final String MODPACK_MANUALLY_CREATED = "MODPACK_MANUALLY_CREATED";
public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("MODPACK_FILE");
public static final SettingsMap.Key<String> MODPACK_NAME = new SettingsMap.Key<>("MODPACK_NAME");
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
}
@@ -32,26 +32,25 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Map;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ModpackInstallWizardProvider implements WizardProvider {
public final class ModpackInstallWizardProvider implements WizardProvider {
private final Profile profile;
private final File file;
private final Path file;
private final String updateVersion;
public ModpackInstallWizardProvider(Profile profile) {
this(profile, null, null);
}
public ModpackInstallWizardProvider(Profile profile, File modpackFile) {
public ModpackInstallWizardProvider(Profile profile, Path modpackFile) {
this(profile, modpackFile, null);
}
@@ -59,28 +58,28 @@ public class ModpackInstallWizardProvider implements WizardProvider {
this(profile, null, updateVersion);
}
public ModpackInstallWizardProvider(Profile profile, File modpackFile, String updateVersion) {
public ModpackInstallWizardProvider(Profile profile, Path modpackFile, String updateVersion) {
this.profile = profile;
this.file = modpackFile;
this.updateVersion = updateVersion;
}
@Override
public void start(Map<String, Object> settings) {
public void start(SettingsMap settings) {
if (file != null)
settings.put(LocalModpackPage.MODPACK_FILE, file);
if (updateVersion != null)
settings.put(LocalModpackPage.MODPACK_NAME, updateVersion);
settings.put(PROFILE, profile);
settings.put(ModpackPage.PROFILE, profile);
}
private Task<?> finishModpackInstallingAsync(Map<String, Object> settings) {
File selected = tryCast(settings.get(LocalModpackPage.MODPACK_FILE), File.class).orElse(null);
ServerModpackManifest serverModpackManifest = tryCast(settings.get(RemoteModpackPage.MODPACK_SERVER_MANIFEST), ServerModpackManifest.class).orElse(null);
Modpack modpack = tryCast(settings.get(LocalModpackPage.MODPACK_MANIFEST), Modpack.class).orElse(null);
String name = tryCast(settings.get(LocalModpackPage.MODPACK_NAME), String.class).orElse(null);
Charset charset = tryCast(settings.get(LocalModpackPage.MODPACK_CHARSET), Charset.class).orElse(null);
boolean isManuallyCreated = tryCast(settings.get(LocalModpackPage.MODPACK_MANUALLY_CREATED), Boolean.class).orElse(false);
private Task<?> finishModpackInstallingAsync(SettingsMap settings) {
Path selected = settings.get(LocalModpackPage.MODPACK_FILE);
ServerModpackManifest serverModpackManifest = settings.get(RemoteModpackPage.MODPACK_SERVER_MANIFEST);
Modpack modpack = settings.get(LocalModpackPage.MODPACK_MANIFEST);
String name = settings.get(LocalModpackPage.MODPACK_NAME);
Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET);
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);
if (isManuallyCreated) {
return ModpackHelper.getInstallManuallyCreatedModpackTask(profile, selected, name, charset);
@@ -119,21 +118,18 @@ public class ModpackInstallWizardProvider implements WizardProvider {
}
@Override
public Object finish(Map<String, Object> settings) {
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.modpack.installation"));
settings.put("success_message", i18n("install.success"));
settings.put("failure_callback", new FailureCallback() {
@Override
public void onFail(Map<String, Object> settings, Exception exception, Runnable next) {
if (exception instanceof ModpackCompletionException) {
if (exception.getCause() instanceof FileNotFoundException) {
Controllers.dialog(i18n("modpack.type.curse.not_found"), i18n("install.failed"), MessageType.ERROR, next);
} else {
Controllers.dialog(i18n("install.success"), i18n("install.success"), MessageType.SUCCESS, next);
}
settings.put(FailureCallback.KEY, (ignored, exception, next) -> {
if (exception instanceof ModpackCompletionException) {
if (exception.getCause() instanceof FileNotFoundException) {
Controllers.dialog(i18n("modpack.type.curse.not_found"), i18n("install.failed"), MessageType.ERROR, next);
} else {
UpdateInstallerWizardProvider.alertFailureMessage(exception, next);
Controllers.dialog(i18n("install.success"), i18n("install.success"), MessageType.SUCCESS, next);
}
} else {
UpdateInstallerWizardProvider.alertFailureMessage(exception, next);
}
});
@@ -141,7 +137,7 @@ public class ModpackInstallWizardProvider implements WizardProvider {
}
@Override
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new ModpackSelectionPage(controller);
@@ -161,6 +157,4 @@ public class ModpackInstallWizardProvider implements WizardProvider {
public boolean cancel() {
return true;
}
public static final String PROFILE = "PROFILE";
}
@@ -7,16 +7,20 @@ import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public abstract class ModpackPage extends SpinnerPane implements WizardPage {
public static final SettingsMap.Key<Profile> PROFILE = new SettingsMap.Key<>("PROFILE");
protected final WizardController controller;
protected final Label lblName;
@@ -78,8 +82,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
BorderPane descriptionPane = new BorderPane();
{
btnDescription = new JFXButton(i18n("modpack.description"));
btnDescription.getStyleClass().add("jfx-button-border");
btnDescription = FXUtils.newBorderButton(i18n("modpack.description"));
btnDescription.setOnAction(e -> onDescribe());
descriptionPane.setLeft(btnDescription);
@@ -36,20 +36,18 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.ui.download.LocalModpackPage.MODPACK_FILE;
import static org.jackhuang.hmcl.ui.download.LocalModpackPage.MODPACK_NAME;
import static org.jackhuang.hmcl.ui.download.RemoteModpackPage.MODPACK_SERVER_MANIFEST;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackSelectionPage extends VBox implements WizardPage {
@@ -71,14 +69,14 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
createButton("repository", this::onChooseRepository)
);
Optional<File> filePath = tryCast(controller.getSettings().get(MODPACK_FILE), File.class);
if (filePath.isPresent()) {
controller.getSettings().put(MODPACK_FILE, filePath.get());
Path filePath = controller.getSettings().get(MODPACK_FILE);
if (filePath != null) {
controller.getSettings().put(MODPACK_FILE, filePath);
Platform.runLater(controller::onNext);
}
FXUtils.applyDragListener(this, ModpackHelper::isFileModpackByExtension, modpacks -> {
File modpack = modpacks.get(0);
Path modpack = modpacks.get(0);
controller.getSettings().put(MODPACK_FILE, modpack);
controller.onNext();
});
@@ -112,7 +110,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip", "*.mrpack"));
File selectedFile = chooser.showOpenDialog(Controllers.getStage());
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (selectedFile == null) {
Platform.runLater(controller::onEnd);
return;
@@ -150,7 +148,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
resolve.run();
controller.getSettings().put(MODPACK_FILE, modpack.toFile());
controller.getSettings().put(MODPACK_FILE, modpack);
controller.onNext();
} else {
reject.accept(e.getMessage());
@@ -167,14 +165,14 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}
public void onChooseRepository() {
String modPackName = (String) controller.getSettings().get(MODPACK_NAME);
String modPackName = controller.getSettings().get(MODPACK_NAME);
DownloadPage downloadPage = new DownloadPage(modPackName);
downloadPage.showModpackDownloads();
Controllers.navigate(downloadPage);
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
}
@Override
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.download;
import javafx.application.Platform;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.ui.Controllers;
@@ -27,13 +28,11 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class RemoteModpackPage extends ModpackPage {
@@ -42,8 +41,9 @@ public final class RemoteModpackPage extends ModpackPage {
public RemoteModpackPage(WizardController controller) {
super(controller);
manifest = tryCast(controller.getSettings().get(MODPACK_SERVER_MANIFEST), ServerModpackManifest.class)
.orElseThrow(() -> new IllegalStateException("MODPACK_SERVER_MANIFEST should exist"));
manifest = controller.getSettings().get(MODPACK_SERVER_MANIFEST);
if (manifest == null)
throw new IllegalStateException("MODPACK_SERVER_MANIFEST should exist");
try {
controller.getSettings().put(MODPACK_MANIFEST, manifest.toModpack(null));
@@ -57,10 +57,10 @@ public final class RemoteModpackPage extends ModpackPage {
lblVersion.setText(manifest.getVersion());
lblAuthor.setText(manifest.getAuthor());
Profile profile = (Profile) controller.getSettings().get("PROFILE");
Optional<String> name = tryCast(controller.getSettings().get(MODPACK_NAME), String.class);
if (name.isPresent()) {
txtModpackName.setText(name.get());
Profile profile = controller.getSettings().get(ModpackPage.PROFILE);
String name = controller.getSettings().get(MODPACK_NAME);
if (name != null) {
txtModpackName.setText(name);
txtModpackName.setDisable(true);
} else {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
@@ -75,7 +75,7 @@ public final class RemoteModpackPage extends ModpackPage {
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
settings.remove(MODPACK_SERVER_MANIFEST);
}
@@ -89,7 +89,7 @@ public final class RemoteModpackPage extends ModpackPage {
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
}
public static final String MODPACK_SERVER_MANIFEST = "MODPACK_SERVER_MANIFEST";
public static final String MODPACK_NAME = "MODPACK_NAME";
public static final String MODPACK_MANIFEST = "MODPACK_MANIFEST";
public static final SettingsMap.Key<ServerModpackManifest> MODPACK_SERVER_MANIFEST = new SettingsMap.Key<>("MODPACK_SERVER_MANIFEST");
public static final SettingsMap.Key<String> MODPACK_NAME = new SettingsMap.Key<>("MODPACK_NAME");
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
}
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
@@ -40,7 +41,6 @@ import java.net.SocketTimeoutException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.zip.ZipException;
@@ -66,29 +66,29 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
@Override
public void start(Map<String, Object> settings) {
public void start(SettingsMap settings) {
}
@Override
public Object finish(Map<String, Object> settings) {
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.change_version.process"));
settings.put("success_message", i18n("install.success"));
settings.put("failure_callback", (FailureCallback) (settings1, exception, next) -> alertFailureMessage(exception, next));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next));
// We remove library but not save it,
// so if installation failed will not break down current version.
Task<Version> ret = Task.supplyAsync(() -> version);
List<String> stages = new ArrayList<>();
for (Object value : settings.values()) {
if (value instanceof RemoteVersion) {
RemoteVersion remoteVersion = (RemoteVersion) value;
for (Object value : settings.asStringMap().values()) {
if (value instanceof RemoteVersion remoteVersion) {
ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion));
stages.add(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()));
if ("game".equals(remoteVersion.getLibraryId())) {
stages.add("hmcl.install.libraries");
stages.add("hmcl.install.assets");
}
} else if (value instanceof RemoveVersionAction) {
ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, ((RemoveVersionAction) value).libraryId));
} else if (value instanceof RemoveVersionAction removeVersionAction) {
ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.libraryId));
}
}
@@ -96,7 +96,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
@Override
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameVersion, downloadProvider, libraryId, () -> {
@@ -28,8 +28,7 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import java.util.Map;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -45,36 +44,37 @@ public final class VanillaInstallWizardProvider implements WizardProvider {
}
@Override
public void start(Map<String, Object> settings) {
settings.put(PROFILE, profile);
public void start(SettingsMap settings) {
settings.put(ModpackPage.PROFILE, profile);
}
private Task<Void> finishVersionDownloadingAsync(Map<String, Object> settings) {
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
GameBuilder builder = dependencyManager.gameBuilder();
String name = (String) settings.get("name");
builder.name(name);
builder.gameVersion(((RemoteVersion) settings.get("game")).getGameVersion());
for (Map.Entry<String, Object> entry : settings.entrySet())
if (!"game".equals(entry.getKey()) && entry.getValue() instanceof RemoteVersion)
builder.version((RemoteVersion) entry.getValue());
settings.asStringMap().forEach((key, value) -> {
if (!"game".equals(key) && value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
return builder.buildAsync().whenComplete(any -> profile.getRepository().refreshVersions())
.thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name));
}
@Override
public Object finish(Map<String, Object> settings) {
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.new_game.installation"));
settings.put("success_message", i18n("install.success"));
settings.put("failure_callback", (FailureCallback) (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
return finishVersionDownloadingAsync(settings);
}
@Override
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer.game")), "", downloadProvider, "game",
@@ -88,6 +88,4 @@ public final class VanillaInstallWizardProvider implements WizardProvider {
public boolean cancel() {
return true;
}
public static final String PROFILE = "PROFILE";
}
@@ -17,27 +17,23 @@
*/
package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSpinner;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import com.jfoenix.controls.*;
import javafx.beans.InvalidationListener;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.VersionList;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
@@ -47,7 +43,6 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -56,290 +51,78 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.NativePatcher;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.wrap;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class VersionsPage extends BorderPane implements WizardPage, Refreshable {
public final class VersionsPage extends Control implements WizardPage, Refreshable {
private final String gameVersion;
private final String libraryId;
private final String title;
private final Navigation navigation;
private final JFXListView<RemoteVersion> list;
private final JFXSpinner spinner;
private final StackPane failedPane;
private final StackPane emptyPane;
private final TransitionPane root;
private final JFXCheckBox chkRelease;
private final JFXCheckBox chkSnapshot;
private final JFXCheckBox chkOld;
private final ComponentList centrePane;
private final StackPane center;
private final DownloadProvider downloadProvider;
private final VersionList<?> versionList;
private Task<?> executor;
private final Runnable callback;
private final HBox searchBar;
private final StringProperty queryString = new SimpleStringProperty();
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, String libraryId, Runnable callback) {
public VersionsPage(Navigation navigation,
String title, String gameVersion,
DownloadProvider downloadProvider,
String libraryId,
Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.navigation = navigation;
HintPane hintPane = new HintPane();
hintPane.setText(i18n("sponsor.bmclapi"));
hintPane.getStyleClass().add("sponsor-pane");
FXUtils.onClicked(hintPane, this::onSponsor);
BorderPane.setMargin(hintPane, new Insets(10, 10, 0, 10));
this.setTop(hintPane);
root = new TransitionPane();
BorderPane toolbarPane = new BorderPane();
JFXButton btnRefresh;
{
spinner = new JFXSpinner();
center = new StackPane();
center.setStyle("-fx-padding: 10;");
{
centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
HBox checkPane = new HBox();
checkPane.setSpacing(10);
{
chkRelease = new JFXCheckBox(i18n("version.game.releases"));
chkRelease.setSelected(true);
HBox.setMargin(chkRelease, new Insets(10, 0, 10, 0));
chkSnapshot = new JFXCheckBox(i18n("version.game.snapshots"));
HBox.setMargin(chkSnapshot, new Insets(10, 0, 10, 0));
chkOld = new JFXCheckBox(i18n("version.game.old"));
HBox.setMargin(chkOld, new Insets(10, 0, 10, 0));
checkPane.getChildren().setAll(chkRelease, chkSnapshot, chkOld);
}
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
TransitionPane rightToolbarPane = new TransitionPane();
{
HBox refreshPane = new HBox();
refreshPane.setAlignment(Pos.CENTER_RIGHT);
btnRefresh = new JFXButton(i18n("button.refresh"));
btnRefresh.getStyleClass().add("jfx-tool-bar-button");
btnRefresh.setOnAction(e -> onRefresh());
JFXButton btnSearch = new JFXButton(i18n("search"));
btnSearch.getStyleClass().add("jfx-tool-bar-button");
btnSearch.setGraphic(wrap(SVG.SEARCH.createIcon(Theme.blackFill(), -1)));
searchBar = new HBox();
{
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 0));
JFXTextField searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
JFXButton closeSearchBar = new JFXButton();
closeSearchBar.getStyleClass().add("jfx-tool-bar-button");
closeSearchBar.setGraphic(wrap(SVG.CLOSE.createIcon(Theme.blackFill(), -1)));
closeSearchBar.setOnAction(e -> {
searchField.clear();
rightToolbarPane.setContent(refreshPane, ContainerAnimations.FADE);
});
onEscPressed(searchField, closeSearchBar::fire);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> queryString.set(searchField.getText()));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
});
searchBar.getChildren().setAll(searchField, closeSearchBar);
btnSearch.setOnAction(e -> {
rightToolbarPane.setContent(searchBar, ContainerAnimations.FADE);
searchField.requestFocus();
});
}
refreshPane.getChildren().setAll(new HBox(btnSearch, btnRefresh));
rightToolbarPane.setContent(refreshPane, ContainerAnimations.NONE);
}
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
toolbarPane.setLeft(checkPane);
toolbarPane.setRight(rightToolbarPane);
centrePane.getContent().setAll(toolbarPane, list);
}
center.getChildren().setAll(centrePane);
}
failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, this::onRefresh);
failedPane.getChildren().setAll(label);
}
emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, this::onBack);
emptyPane.getChildren().setAll(label);
}
}
this.setCenter(root);
versionList = downloadProvider.getVersionListById(libraryId);
boolean hasType = versionList.hasType();
chkRelease.setManaged(hasType);
chkRelease.setVisible(hasType);
chkSnapshot.setManaged(hasType);
chkSnapshot.setVisible(hasType);
chkOld.setManaged(hasType);
chkOld.setVisible(hasType);
if (hasType) {
centrePane.getContent().setAll(toolbarPane, list);
} else {
centrePane.getContent().setAll(list);
}
ComponentList.setVgrow(list, Priority.ALWAYS);
InvalidationListener listener = o -> {
List<RemoteVersion> versions = loadVersions();
String query = queryString.get();
if (!StringUtils.isBlank(query)) {
Predicate<RemoteVersion> predicate;
if (query.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(query.substring("regex:".length()));
predicate = it -> pattern.matcher(it.getSelfVersion()).find();
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
} else {
String lowerQueryString = query.toLowerCase(Locale.ROOT);
predicate = it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString);
}
versions = versions.stream().filter(predicate).collect(Collectors.toList());
}
list.getItems().setAll(versions);
};
chkRelease.selectedProperty().addListener(listener);
chkSnapshot.selectedProperty().addListener(listener);
chkOld.selectedProperty().addListener(listener);
queryString.addListener(listener);
btnRefresh.setGraphic(wrap(SVG.REFRESH.createIcon(Theme.blackFill(), -1)));
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
navigation.getSettings().put(libraryId, list.getSelectionModel().getSelectedItem());
callback.run();
});
this.downloadProvider = downloadProvider;
this.versionList = downloadProvider.getVersionListById(libraryId);
this.callback = callback;
refresh();
}
private List<RemoteVersion> loadVersions() {
return versionList.getVersions(gameVersion).stream()
.filter(it -> {
switch (it.getVersionType()) {
case RELEASE:
return chkRelease.isSelected();
case PENDING:
case SNAPSHOT:
return chkSnapshot.isSelected();
case OLD:
return chkOld.isSelected();
default:
return true;
}
})
.sorted().collect(Collectors.toList());
@Override
protected Skin<?> createDefaultSkin() {
return new VersionsPageSkin(this);
}
@Override
public void refresh() {
VersionList<?> currentVersionList = versionList;
root.setContent(spinner, ContainerAnimations.FADE);
executor = currentVersionList.refreshAsync(gameVersion).whenComplete(Schedulers.defaultScheduler(), (result, exception) -> {
if (exception == null) {
List<RemoteVersion> items = loadVersions();
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
if (currentVersionList.getVersions(gameVersion).isEmpty()) {
root.setContent(emptyPane, ContainerAnimations.FADE);
status.set(Status.LOADING);
Task<?> task = versionList.refreshAsync(gameVersion)
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
.whenComplete(Schedulers.javafx(), (items, exception) -> {
if (exception == null) {
versions.setAll(items);
status.set(Status.SUCCESS);
} else {
if (items.isEmpty()) {
chkRelease.setSelected(true);
chkSnapshot.setSelected(true);
chkOld.setSelected(true);
} else {
list.getItems().setAll(items);
}
root.setContent(center, ContainerAnimations.FADE);
LOG.warning("Failed to fetch versions list", exception);
status.set(Status.FAILED);
}
});
} else {
LOG.warning("Failed to fetch versions list", exception);
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
root.setContent(failedPane, ContainerAnimations.FADE);
});
}
// https://github.com/HMCL-dev/HMCL/issues/938
System.gc();
});
executor.start();
task.start();
}
@Override
@@ -348,11 +131,8 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
settings.remove(libraryId);
// fixme
// if (executor != null)
// executor.cancel(true);
}
private void onRefresh() {
@@ -363,27 +143,79 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
navigation.onPrev(true);
}
private void onSponsor() {
FXUtils.openLink("https://bmclapidoc.bangbang93.com");
private enum Status {
LOADING,
FAILED,
SUCCESS,
}
private enum VersionTypeFilter {
ALL,
RELEASE,
SNAPSHOTS,
APRIL_FOOLS,
OLD
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
final IconedTwoLineListItem content = new IconedTwoLineListItem();
final RipplerContainer ripplerContainer = new RipplerContainer(content);
final StackPane pane = new StackPane();
private final VersionsPage control;
private final TwoLineListItem twoLineListItem = new TwoLineListItem();
private final ImageView imageView = new ImageView();
private final StackPane pane = new StackPane();
private final Holder<RemoteVersionListCell> lastCell;
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, String libraryId) {
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, VersionsPage control) {
this.lastCell = lastCell;
if ("game".equals(libraryId)) {
content.getExternalLinkButton().setGraphic(SVG.GLOBE_BOOK.createIcon(Theme.blackFill(), -1));
FXUtils.installFastTooltip(content.getExternalLinkButton(), i18n("wiki.tooltip"));
this.control = control;
HBox hbox = new HBox(16);
HBox.setHgrow(twoLineListItem, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
HBox actions = new HBox(8);
actions.setAlignment(Pos.CENTER);
{
if ("game".equals(control.libraryId)) {
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> {
onOpenWiki();
FXUtils.clearFocus(wikiButton);
});
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
actions.getChildren().add(wikiButton);
}
JFXButton actionButton = newToggleButton4(SVG.ARROW_FORWARD);
actionButton.setOnAction(e -> onAction());
actions.getChildren().add(actionButton);
}
hbox.getChildren().setAll(imageView, twoLineListItem, actions);
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(content, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(ripplerContainer);
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(new RipplerContainer(hbox));
FXUtils.onClicked(this, this::onAction);
}
private void onAction() {
RemoteVersion item = getItem();
if (item == null)
return;
control.navigation.getSettings().put(control.libraryId, item);
control.callback.run();
}
private void onOpenWiki() {
RemoteVersion item = getItem();
if (!(item instanceof GameRemoteVersion))
return;
FXUtils.openLink(I18n.getWikiLink((GameRemoteVersion) item));
}
@Override
@@ -401,31 +233,42 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
}
setGraphic(pane);
content.setTitle(remoteVersion.getSelfVersion());
twoLineListItem.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
if (remoteVersion.getReleaseDate() != null) {
content.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
} else {
content.setSubtitle(null);
twoLineListItem.setSubtitle(null);
}
twoLineListItem.getTags().clear();
if (remoteVersion instanceof GameRemoteVersion) {
switch (remoteVersion.getVersionType()) {
case RELEASE:
content.getTags().setAll(i18n("version.game.release"));
content.setImage(VersionIconType.GRASS.getIcon());
content.setExternalLink(i18n("wiki.version.game.release", remoteVersion.getGameVersion()));
break;
case PENDING:
case SNAPSHOT:
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
content.setExternalLink(i18n("wiki.version.game.snapshot", remoteVersion.getGameVersion()));
break;
default:
content.getTags().setAll(i18n("version.game.old"));
content.setImage(VersionIconType.CRAFT_TABLE.getIcon());
content.setExternalLink(null);
break;
RemoteVersion.Type versionType = remoteVersion.getVersionType();
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
switch (versionType) {
case RELEASE -> {
twoLineListItem.addTag(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
}
case PENDING, SNAPSHOT -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("version.game.april_fools"));
imageView.setImage(VersionIconType.APRIL_FOOLS.getIcon());
} else {
twoLineListItem.addTag(i18n("version.game.snapshot"));
imageView.setImage(VersionIconType.COMMAND.getIcon());
}
}
default -> {
twoLineListItem.addTag(i18n("version.game.old"));
imageView.setImage(VersionIconType.CRAFT_TABLE.getIcon());
}
}
switch (NativePatcher.checkSupportedStatus(gameVersion, Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION)) {
case UNTESTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.untested"));
case UNSUPPORTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.unsupported"));
}
} else {
VersionIconType iconType;
@@ -435,6 +278,8 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
iconType = VersionIconType.OPTIFINE;
else if (remoteVersion instanceof ForgeRemoteVersion)
iconType = VersionIconType.FORGE;
else if (remoteVersion instanceof CleanroomRemoteVersion)
iconType = VersionIconType.CLEANROOM;
else if (remoteVersion instanceof NeoForgeRemoteVersion)
iconType = VersionIconType.NEO_FORGE;
else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion)
@@ -442,15 +287,201 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = VersionIconType.QUILT;
else
iconType = null;
iconType = VersionIconType.COMMAND;
content.setImage(iconType != null ? iconType.getIcon() : null);
if (content.getSubtitle() == null)
content.setSubtitle(remoteVersion.getGameVersion());
imageView.setImage(iconType.getIcon());
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(remoteVersion.getGameVersion());
else
content.getTags().setAll(remoteVersion.getGameVersion());
content.setExternalLink(null);
twoLineListItem.addTag(remoteVersion.getGameVersion());
}
}
}
private static final class VersionsPageSkin extends SkinBase<VersionsPage> {
private final JFXListView<RemoteVersion> list;
private final TransitionPane transitionPane;
private final JFXSpinner spinner;
private final JFXTextField nameField;
private final JFXComboBox<VersionTypeFilter> categoryField = new JFXComboBox<>();
VersionsPageSkin(VersionsPage control) {
super(control);
BorderPane root = new BorderPane();
GridPane searchPane = new GridPane();
root.setTop(searchPane);
searchPane.getStyleClass().addAll("card");
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
ColumnConstraints nameColumn = new ColumnConstraints();
nameColumn.setMinWidth(USE_PREF_SIZE);
ColumnConstraints column1 = new ColumnConstraints();
column1.setHgrow(Priority.ALWAYS);
ColumnConstraints column2 = new ColumnConstraints();
column2.setMaxWidth(150);
ColumnConstraints column3 = new ColumnConstraints();
if (control.versionList.hasType())
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
else
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
searchPane.setHgap(16);
searchPane.setVgap(10);
{
int rowIndex = 0;
{
nameField = new JFXTextField();
nameField.setPromptText(i18n("version.search.prompt"));
nameField.textProperty().addListener(o -> updateList());
if ("game".equals(control.libraryId)) {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS,
VersionTypeFilter.APRIL_FOOLS,
VersionTypeFilter.OLD
);
categoryField.getSelectionModel().select(VersionTypeFilter.RELEASE);
} else {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS
);
categoryField.getSelectionModel().select(VersionTypeFilter.ALL);
}
categoryField.setConverter(stringConverter(type -> i18n("version.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.getSelectionModel().selectedItemProperty().addListener(o -> updateList());
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
refreshButton.setOnAction(event -> control.onRefresh());
if (control.versionList.hasType()) {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("version.game.type")), categoryField,
refreshButton
);
} else {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
refreshButton
);
}
}
}
{
SpinnerPane spinnerPane = new SpinnerPane();
root.setCenter(spinnerPane);
transitionPane = new TransitionPane();
spinner = new JFXSpinner();
StackPane centerWrapper = new StackPane();
centerWrapper.setStyle("-fx-padding: 10;");
{
ComponentList centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
control.versions.addListener((InvalidationListener) o -> updateList());
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control));
ComponentList.setVgrow(list, Priority.ALWAYS);
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
centrePane.getContent().setAll(list);
}
centerWrapper.getChildren().setAll(centrePane);
}
StackPane failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, control::onRefresh);
failedPane.getChildren().setAll(label);
}
StackPane emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, control::onBack);
emptyPane.getChildren().setAll(label);
}
FXUtils.onChangeAndOperate(control.status, status -> {
if (status == Status.LOADING)
transitionPane.setContent(spinner, ContainerAnimations.FADE);
else if (status == Status.SUCCESS)
transitionPane.setContent(centerWrapper, ContainerAnimations.FADE);
else // if (status == Status.FAILED)
transitionPane.setContent(failedPane, ContainerAnimations.FADE);
});
root.setCenter(transitionPane);
}
this.getChildren().setAll(root);
}
private void updateList() {
Stream<RemoteVersion> versions = getSkinnable().versions.stream();
VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem();
if (filter != null)
versions = versions.filter(it -> {
RemoteVersion.Type versionType = it.getVersionType();
return switch (filter) {
case RELEASE -> versionType == RemoteVersion.Type.RELEASE;
case SNAPSHOTS -> versionType == RemoteVersion.Type.SNAPSHOT
|| versionType == RemoteVersion.Type.PENDING;
case APRIL_FOOLS -> versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
case OLD -> versionType == RemoteVersion.Type.OLD;
// case ALL,
default -> true;
};
});
String nameQuery = nameField.getText();
if (!StringUtils.isBlank(nameQuery)) {
if (nameQuery.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(nameQuery.substring("regex:".length()));
versions = versions.filter(it -> pattern.matcher(it.getSelfVersion()).find());
} catch (Throwable e) {
LOG.warning("Illegal regular expression: " + nameQuery, e);
}
} else {
String lowerQueryString = nameQuery.toLowerCase(Locale.ROOT);
versions = versions.filter(it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString));
}
}
//noinspection DataFlowIssue
list.getItems().setAll(versions.collect(Collectors.toList()));
list.scrollTo(0);
}
}
}
@@ -35,10 +35,10 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -55,28 +55,31 @@ public final class ExportWizardProvider implements WizardProvider {
}
@Override
public void start(Map<String, Object> settings) {
public void start(SettingsMap settings) {
}
@Override
public Object finish(Map<String, Object> settings) {
@SuppressWarnings("unchecked")
List<String> whitelist = (List<String>) settings.get(ModpackFileSelectionPage.MODPACK_FILE_SELECTION);
File modpackFile = (File) settings.get(ModpackInfoPage.MODPACK_FILE);
ModpackExportInfo exportInfo = (ModpackExportInfo) settings.get(ModpackInfoPage.MODPACK_INFO);
public Object finish(SettingsMap settings) {
List<String> whitelist = settings.get(ModpackFileSelectionPage.MODPACK_FILE_SELECTION);
Path modpackFile = settings.get(ModpackInfoPage.MODPACK_FILE);
ModpackExportInfo exportInfo = settings.get(ModpackInfoPage.MODPACK_INFO);
exportInfo.setWhitelist(whitelist);
String modpackType = (String) settings.get(ModpackTypeSelectionPage.MODPACK_TYPE);
String modpackType = settings.get(ModpackTypeSelectionPage.MODPACK_TYPE);
return exportWithLauncher(modpackType, exportInfo, modpackFile);
}
private Task<?> exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, File modpackFile) {
private Task<?> exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, Path modpackFile) {
Path launcherJar = JarUtils.thisJarPath();
boolean packWithLauncher = exportInfo.isPackWithLauncher() && launcherJar != null;
return new Task<Object>() {
File tempModpack;
return new Task<>() {
Path tempModpack;
Task<?> exportTask;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public boolean doPreExecute() {
return true;
@@ -84,9 +87,9 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void preExecute() throws Exception {
File dest;
Path dest;
if (packWithLauncher) {
dest = tempModpack = Files.createTempFile("hmcl", ".zip").toFile();
dest = tempModpack = Files.createTempFile("hmcl", ".zip");
} else {
dest = modpackFile;
}
@@ -118,7 +121,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() throws Exception {
if (!packWithLauncher) return;
try (Zipper zip = new Zipper(modpackFile.toPath())) {
try (Zipper zip = new Zipper(modpackFile)) {
Config exported = new Config();
exported.setBackgroundImageType(config().getBackgroundImageType());
@@ -161,10 +164,14 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsMcbbs(ModpackExportInfo exportInfo, File modpackFile) {
private Task<?> exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency = null;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new McbbsModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
@@ -177,10 +184,14 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, File modpackFile) {
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
VersionSetting vs = profile.getVersionSetting(version);
@@ -221,10 +232,14 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsServer(ModpackExportInfo exportInfo, File modpackFile) {
private Task<?> exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ServerModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
@@ -237,10 +252,14 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, File modpackFile) {
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
@@ -259,17 +278,13 @@ public final class ExportWizardProvider implements WizardProvider {
}
@Override
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
case 0:
return new ModpackTypeSelectionPage(controller);
case 1:
return new ModpackInfoPage(controller, profile.getRepository(), version);
case 2:
return new ModpackFileSelectionPage(controller, profile, version, ModAdviser::suggestMod);
default:
throw new IllegalArgumentException("step");
}
public Node createPage(WizardController controller, int step, SettingsMap settings) {
return switch (step) {
case 0 -> new ModpackTypeSelectionPage(controller);
case 1 -> new ModpackInfoPage(controller, profile.getRepository(), version);
case 2 -> new ModpackFileSelectionPage(controller, profile, version, ModAdviser::suggestMod);
default -> throw new IllegalArgumentException("step");
};
}
@Override
@@ -33,10 +33,13 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -45,6 +48,7 @@ import java.util.Objects;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author huangyuhui
@@ -80,16 +84,18 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
this.setBottom(nextPane);
}
private CheckBoxTreeItem<String> getTreeItem(File file, String basePath) {
if (!file.exists())
private CheckBoxTreeItem<String> getTreeItem(Path file, String basePath) {
if (Files.notExists(file))
return null;
boolean isDirectory = Files.isDirectory(file);
ModAdviser.ModSuggestion state = ModAdviser.ModSuggestion.SUGGESTED;
if (basePath.length() > "minecraft/".length()) {
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (file.isDirectory() ? "/" : ""), file.isDirectory());
if (file.isFile() && Objects.equals(FileUtils.getNameWithoutExtension(file), version)) // Ignore <version>.json, <version>.jar
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (isDirectory ? "/" : ""), isDirectory);
if (!isDirectory && Objects.equals(FileUtils.getNameWithoutExtension(file), version))
state = ModAdviser.ModSuggestion.HIDDEN;
if (file.isDirectory() && Objects.equals(file.getName(), version + "-natives")) // Ignore <version>-natives
if (isDirectory && Objects.equals(FileUtils.getName(file), version + "-natives")) // Ignore <version>-natives
state = ModAdviser.ModSuggestion.HIDDEN;
if (state == ModAdviser.ModSuggestion.HIDDEN)
return null;
@@ -99,11 +105,10 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
if (state == ModAdviser.ModSuggestion.SUGGESTED)
node.setSelected(true);
if (file.isDirectory()) {
File[] files = file.listFiles();
if (files != null) {
for (File it : files) {
CheckBoxTreeItem<String> subNode = getTreeItem(it, basePath + "/" + it.getName());
if (isDirectory) {
try (var stream = Files.list(file)) {
stream.forEach(it -> {
CheckBoxTreeItem<String> subNode = getTreeItem(it, basePath + "/" + FileUtils.getName(it));
if (subNode != null) {
node.setSelected(subNode.isSelected() || node.isSelected());
if (!subNode.isSelected()) {
@@ -111,8 +116,11 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
node.getChildren().add(subNode);
}
}
});
} catch (IOException e) {
LOG.warning("Failed to list contents of " + file, e);
}
if (!node.isSelected()) node.setIndeterminate(false);
// Empty folder need not to be displayed.
@@ -155,7 +163,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
controller.getSettings().remove(MODPACK_FILE_SELECTION);
}
@@ -171,7 +179,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
return i18n("modpack.wizard.step.2.title");
}
public static final String MODPACK_FILE_SELECTION = "modpack.accepted";
public static final SettingsMap.Key<List<String>> MODPACK_FILE_SELECTION = new SettingsMap.Key<>("modpack.accepted");
private static final Map<String, String> TRANSLATION = mapOf(
pair("minecraft/hmclversion.cfg", i18n("modpack.files.hmclversion_cfg")),
pair("minecraft/servers.dat", i18n("modpack.files.servers_dat")),
@@ -41,11 +41,13 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import java.io.File;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -56,7 +58,6 @@ import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_SERVER;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackInfoPage extends Control implements WizardPage {
@@ -87,10 +88,12 @@ public final class ModpackInfoPage extends Control implements WizardPage {
public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepository, String version) {
this.controller = controller;
this.gameRepository = gameRepository;
this.options = tryCast(controller.getSettings().get(MODPACK_INFO_OPTION), ModpackExportInfo.Options.class)
.orElseThrow(() -> new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required"));
this.options = controller.getSettings().get(MODPACK_INFO_OPTION);
this.versionName = version;
if (this.options == null)
throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required");
name.set(version);
author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getUsername).orElse(""));
@@ -110,9 +113,9 @@ public final class ModpackInfoPage extends Control implements WizardPage {
fileChooser.setInitialFileName(name.get() + ".mrpack");
} else {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
fileChooser.setInitialFileName(name.get() + ".zip");
fileChooser.setInitialFileName(name.get() + ".zip");
}
File file = fileChooser.showSaveDialog(Controllers.getStage());
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
if (file == null) {
controller.onEnd();
return;
@@ -146,7 +149,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
controller.getSettings().remove(MODPACK_INFO);
}
@@ -160,9 +163,9 @@ public final class ModpackInfoPage extends Control implements WizardPage {
return new ModpackInfoPageSkin(this);
}
public static final String MODPACK_INFO = "modpack.info";
public static final String MODPACK_FILE = "modpack.file";
public static final String MODPACK_INFO_OPTION = "modpack.info.option";
public static final SettingsMap.Key<ModpackExportInfo> MODPACK_INFO = new SettingsMap.Key<>("modpack.info");
public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("modpack.file");
public static final SettingsMap.Key<ModpackExportInfo.Options> MODPACK_INFO_OPTION = new SettingsMap.Key<>("modpack.info.option");
public static class ModpackInfoPageSkin extends SkinBase<ModpackInfoPage> {
private ObservableList<Node> originList;
@@ -189,7 +192,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
Hyperlink hyperlink = new Hyperlink(i18n("modpack.wizard.step.initialization.server"));
hyperlink.setOnAction(e -> FXUtils.openLink(Metadata.DOCS_URL + "/modpack/serverpack.html"));
borderPane.setTop(hyperlink);
} if (skinnable.controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
}
if (skinnable.controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.modrinth.info"));
borderPane.setTop(pane);
@@ -381,7 +385,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
button.setMaxHeight(16);
pane.setRight(button);
}
if (skinnable.options.isRequireNoCreateRemoteFiles()) {
BorderPane noCreateRemoteFiles = new BorderPane();
noCreateRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.no_create_remote_files")));
@@ -33,8 +33,7 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import java.util.Map;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.ui.export.ModpackInfoPage.MODPACK_INFO_OPTION;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -89,7 +88,7 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
}
@Override
public void cleanup(Map<String, Object> settings) {
public void cleanup(SettingsMap settings) {
}
@Override
@@ -97,7 +96,7 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
return i18n("modpack.wizard.step.3.title");
}
public static final String MODPACK_TYPE = "modpack.type";
public static final SettingsMap.Key<String> MODPACK_TYPE = new SettingsMap.Key<>("modpack.type");
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
public static final String MODPACK_TYPE_MULTIMC = "multimc";
@@ -287,7 +287,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
* the mark position becomes invalid.
* @see InputStream#reset()
*/
public synchronized void mark(int readlimit) {
public void mark(int readlimit) {
}
/**
@@ -302,7 +302,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
* @see InputStream#mark(int)
* @see IOException
*/
public synchronized void reset() throws IOException {
public void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
}
@@ -52,8 +52,9 @@ import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
@@ -340,8 +341,8 @@ public final class JavaDownloadDialog extends StackPane {
if (StringUtils.isBlank(fileInfo.getDirectDownloadUri()))
throw new IOException("Missing download URI: " + json);
File targetFile = File.createTempFile("hmcl-java-", "." + version.getArchiveType());
targetFile.deleteOnExit();
Path targetFile = Files.createTempFile("hmcl-java-", "." + version.getArchiveType());
targetFile.toFile().deleteOnExit();
Task<FileDownloadTask.IntegrityCheck> getIntegrityCheck;
if (StringUtils.isNotBlank(fileInfo.getChecksum()))
@@ -363,8 +364,8 @@ public final class JavaDownloadDialog extends StackPane {
return getIntegrityCheck
.thenComposeAsync(integrityCheck ->
new FileDownloadTask(downloadProvider.injectURLWithCandidates(fileInfo.getDirectDownloadUri()),
targetFile.toPath(), integrityCheck).setName(fileInfo.getFileName()))
.thenSupplyAsync(targetFile::toPath);
targetFile, integrityCheck).setName(fileInfo.getFileName()))
.thenSupplyAsync(() -> targetFile);
})
.whenComplete(Schedulers.javafx(), ((result, exception) -> {
if (exception == null) {
@@ -180,8 +180,9 @@ public final class JavaInstallPage extends WizardSinglePage {
pane.setLeft(new Label(name));
Label valueLabel = FXUtils.truncatedLabel(value, 60);
Label valueLabel = FXUtils.newSafeTruncatedLabel(value);
FXUtils.copyOnDoubleClick(valueLabel);
BorderPane.setMargin(valueLabel, new Insets(0, 0, 0, 16));
BorderPane.setAlignment(valueLabel, Pos.CENTER_RIGHT);
pane.setCenter(valueLabel);
@@ -45,13 +45,14 @@ import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import java.io.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -79,19 +80,19 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
}
FXUtils.applyDragListener(this, it -> {
String name = it.getName();
return it.isDirectory() || name.endsWith(".zip") || name.endsWith(".tar.gz") || name.equals(OperatingSystem.CURRENT_OS.getJavaExecutable());
String name = FileUtils.getName(it);
return Files.isDirectory(it) || name.endsWith(".zip") || name.endsWith(".tar.gz") || name.equals(OperatingSystem.CURRENT_OS.getJavaExecutable());
}, files -> {
for (File file : files) {
if (file.isDirectory()) {
onAddJavaHome(file.toPath());
for (Path file : files) {
if (Files.isDirectory(file)) {
onAddJavaHome(file);
} else {
String fileName = file.getName();
String fileName = FileUtils.getName(file);
if (fileName.equals(OperatingSystem.CURRENT_OS.getJavaExecutable())) {
onAddJavaBinary(file.toPath());
onAddJavaBinary(file);
} else if (fileName.endsWith(".zip") || fileName.endsWith(".tar.gz")) {
onInstallArchive(file.toPath());
onInstallArchive(file);
} else {
throw new AssertionError("Unreachable code");
}
@@ -110,9 +111,9 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Java", "java.exe"));
chooser.setTitle(i18n("settings.game.java_directory.choose"));
File file = chooser.showOpenDialog(Controllers.getStage());
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (file != null) {
JavaManager.getAddJavaTask(file.toPath()).whenComplete(Schedulers.javafx(), exception -> {
JavaManager.getAddJavaTask(file).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
LOG.warning("Failed to add java", exception);
Controllers.dialog(i18n("java.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
@@ -250,9 +251,9 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
TwoLineListItem item = new TwoLineListItem();
item.setTitle((java.isJDK() ? "JDK" : "JRE") + " " + java.getVersion());
item.setSubtitle(java.getBinary().toString());
item.getTags().add(i18n("java.info.architecture") + ": " + java.getArchitecture().getDisplayName());
item.addTag(i18n("java.info.architecture") + ": " + java.getArchitecture().getDisplayName());
if (vendor != null)
item.getTags().add(i18n("java.info.vendor") + ": " + vendor);
item.addTag(i18n("java.info.vendor") + ": " + vendor);
BorderPane.setAlignment(item, Pos.CENTER);
center.getChildren().setAll(item);
root.setCenter(center);
@@ -31,6 +31,7 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
@@ -42,10 +43,16 @@ import javafx.scene.shape.Rectangle;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.VersionList;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -61,16 +68,25 @@ import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.download.RemoteVersion.Type.RELEASE;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.SINE;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MainPage extends StackPane implements DecoratorPage {
private static final String ANNOUNCEMENT = "announcement";
@@ -100,6 +116,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
ImageView titleIcon = new ImageView(FXUtils.newBuiltinImage("/assets/img/icon-title.png"));
Label titleLabel = new Label(Metadata.FULL_TITLE);
if (I18n.isUpsideDown()) {
titleIcon.setRotate(180);
titleLabel.setRotate(180);
}
titleLabel.getStyleClass().add("jfx-decorator-title");
titleNode.getChildren().setAll(titleIcon, titleLabel);
@@ -193,25 +213,17 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchPane.getStyleClass().add("launch-pane");
launchPane.setMaxWidth(230);
launchPane.setMaxHeight(55);
launchPane.setOnScroll(event -> {
int index = IntStream.range(0, versions.size())
.filter(i -> versions.get(i).getId().equals(getCurrentGame()))
.findFirst().orElse(-1);
if (index < 0) return;
if (event.getDeltaY() > 0) {
index--;
} else {
index++;
}
profile.setSelectedVersion(versions.get((index + versions.size()) % versions.size()).getId());
});
FXUtils.onScroll(launchPane, versions, list -> {
String currentId = getCurrentGame();
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> profile.setSelectedVersion(it.getId()));
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
JFXButton launchButton = new JFXButton();
launchButton.setPrefWidth(230);
launchButton.setPrefHeight(55);
//launchButton.setButtonType(JFXButton.ButtonType.RAISED);
launchButton.setOnAction(e -> launch());
launchButton.setDefaultButton(true);
launchButton.setClip(new Rectangle(-100, -100, 310, 200));
{
@@ -219,18 +231,34 @@ public final class MainPage extends StackPane implements DecoratorPage {
graphic.setAlignment(Pos.CENTER);
graphic.setTranslateX(-7);
graphic.setMaxWidth(200);
Label launchLabel = new Label(i18n("version.launch"));
Label launchLabel = new Label();
launchLabel.setStyle("-fx-font-size: 16px;");
Label currentLabel = new Label();
currentLabel.setStyle("-fx-font-size: 12px;");
currentLabel.textProperty().bind(Bindings.createStringBinding(() -> {
if (getCurrentGame() == null) {
return i18n("version.empty");
} else {
return getCurrentGame();
FXUtils.onChangeAndOperate(currentGameProperty(), new Consumer<>() {
private Tooltip tooltip;
@Override
public void accept(String currentGame) {
if (currentGame == null) {
launchLabel.setText(i18n("version.launch.empty"));
currentLabel.setText(null);
graphic.getChildren().setAll(launchLabel);
launchButton.setOnAction(e -> MainPage.this.launchNoGame());
if (tooltip == null)
tooltip = new Tooltip(i18n("version.launch.empty.tooltip"));
FXUtils.installFastTooltip(launchButton, tooltip);
} else {
launchLabel.setText(i18n("version.launch"));
currentLabel.setText(currentGame);
graphic.getChildren().setAll(launchLabel, currentLabel);
launchButton.setOnAction(e -> MainPage.this.launch());
if (tooltip != null)
Tooltip.uninstall(launchButton, tooltip);
}
}
}, currentGameProperty()));
graphic.getChildren().setAll(launchLabel, currentLabel);
});
launchButton.setGraphic(graphic);
}
@@ -319,7 +347,46 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
private void launch() {
Versions.launch(Profiles.getSelectedProfile());
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), null);
}
private void launchNoGame() {
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
VersionList<?> versionList = downloadProvider.getVersionListById("game");
Holder<String> gameVersionHolder = new Holder<>();
Task<?> task = versionList.refreshAsync("")
.thenSupplyAsync(() -> versionList.getVersions("").stream()
.filter(it -> it.getVersionType() == RELEASE)
.sorted()
.findFirst()
.orElseThrow(() -> new IOException("No versions found")))
.thenComposeAsync(version -> {
Profile profile = Profiles.getSelectedProfile();
DefaultDependencyManager dependency = profile.getDependency();
String gameVersion = gameVersionHolder.value = version.getGameVersion();
return dependency.gameBuilder()
.name(gameVersion)
.gameVersion(gameVersion)
.buildAsync();
})
.whenComplete(any -> profile.getRepository().refreshVersions())
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
profile.setSelectedVersion(gameVersionHolder.value);
launch();
} else if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else {
LOG.warning("Failed to install game", exception);
Controllers.dialog(StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.WARNING);
}
});
Controllers.taskDialog(task, i18n("version.launch.empty.installing"), TaskCancellationAction.NORMAL);
}
private void onMenu() {
@@ -364,6 +431,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
return state;
}
public Profile getProfile() {
return profile;
}
public String getCurrentGame() {
return currentGame.get();
}
@@ -376,6 +447,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
this.currentGame.set(currentGame);
}
public ObservableList<Version> getVersions() {
return versions;
}
public boolean isShowUpdate() {
return showUpdate.get();
}
Loaded 100 of 411 files, more files were not shown because too many files have changed in this diff. Show more