Compare commits

...
151 Commits
Author SHA1 Message Date
Glavo 7d76fea48d 重构 WorldListPage 以使用 ListCell 展示元素 (#5224) 2026-01-14 20:33:55 +08:00
Glavo 600bd6e9ee 修复 CurseForge 源无法搜索到内容的问题 (#5220) 2026-01-14 20:15:37 +08:00
Glavo ccc64db805 重构 JavaManagementPage 以使用 ListCell 展示元素 (#5223) 2026-01-13 21:35:34 +08:00
Glavo ea407be5f0 更新 ToolbarListPageSkin 以使用 JFXListView 展示节点 (#5221) 2026-01-13 21:08:36 +08:00
Glavo 9b9471da49 惰性初始化 JFXRippler 属性 (#5208) 2026-01-13 21:03:49 +08:00
Glavo b1402d3821 优化下载页面图标缓存功能 (#5211) 2026-01-13 20:30:14 +08:00
Xirrenandxirren 62f0d0a1c0 修复实例列表不显示实例名的问题 (#5203)
fix #5202

Co-authored-by: xirren <xirren@outlook.com>
2026-01-12 22:13:00 +08:00
Glavo 2865f2ffca 将 InstallerItem.State 迁移至 sealed 接口 (#5209) 2026-01-12 21:52:16 +08:00
Burning_TNT 3860e68caa Feature: 下载并发控制 (#5026) 2026-01-11 21:45:31 +08:00
3gf8jv4dv 74d0a89820 fix(i18n): update button text for check mod updates (#5159)
resolves #5154
2026-01-11 21:30:00 +08:00
91e4c85a28 优化 SSL 异常处理 (#5168)
fix #5115 

Co-authored-by: xirren <xirren@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-11 21:28:54 +08:00
mineDiamond a05b24cfd7 fix: 解决数据包页面在加载超多数据包时卡顿的问题 (#5162) 2026-01-11 21:18:04 +08:00
辞庐 72e3925696 修复默认窗口大小 进入高级版本设置会向下滚动一段距离的问题 (#5197) 2026-01-11 21:14:52 +08:00
Glavo 9ea419c05b 修复未下载陶瓦联机时打开陶瓦联机页面抛出异常的问题 (#5166) 2026-01-11 21:00:35 +08:00
Glavo 3dc1631207 使用 JFXListView 显示实例列表 (#5174) 2026-01-11 20:55:17 +08:00
Glavo ab5ef6e0ff 将 FontReference 转换为 record (#5187) 2026-01-11 01:27:44 +08:00
Glavo 61113d5c08 仅在左键点击时触发 JFXRippler 波纹 (#5188) 2026-01-10 22:18:22 +08:00
辞庐 d73d7330eb 支持重置日志字体 (#5182) 2026-01-10 22:12:24 +08:00
辞庐andGlavo 45a85e006b 修复重置字体后没有遵循 hmcl.font.override HMCL_FONT 等的问题 (#5183)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-10 21:44:01 +08:00
ENC_Euphony 06cce5259b imp: use display categories for modrinth tag display (#5160) 2026-01-10 21:41:41 +08:00
辞庐andGlavo 3e393982e0 修复 InstallerItem 的 Rippler 没有正常显示的问题 (#5184)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-10 21:21:48 +08:00
辞庐 db2e2d89ee 优化 Popup 阴影 & 修复 Windows 高分屏模糊严重的问题 (#5186) 2026-01-10 21:19:08 +08:00
Glavo a3d9e29350 在资源包下载页面中隐藏不必要的标签 (#5180) 2026-01-10 20:39:15 +08:00
ENC_Euphony 682d88df02 imp: add padding for java selection list item (#5167) 2026-01-10 16:47:00 +08:00
3gf8jv4dv b5defd31ed fix: add "neo-forge" alias for modrinth modpack parsing (#5170)
resolves #5169
2026-01-10 16:46:24 +08:00
Glavo 553e67bda7 修复 JFXCheckBox 意外地显示阴影的问题 (#5179) 2026-01-10 16:17:41 +08:00
Glavo 95bb669ffc 修复 JFXButton 意外地显示阴影的问题 (#5178) 2026-01-10 16:09:53 +08:00
Glavo 505752476c 更新 README.md (#5156) 2026-01-07 22:33:03 +08:00
mineDiamond c723acfe30 feat: 允许重命名实例时使用原名 (#5137) 2026-01-07 22:32:43 +08:00
Wulian233and3gf8jv4dv 2fd7076ccf 修复第三方构建下禁用账户刷新的 tooltip 展示不支持的超链接标签 (#5133)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:32:10 +08:00
0705465ba4 优化账号SSL异常处理 (#5143)
Co-authored-by: xirren <xirren@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:30:53 +08:00
mineDiamondand3gf8jv4dv 7a4649f0cc feat: 优化世界管理界面和世界信息界面 (#4823)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:26:17 +08:00
Glavo 8b8a62ccf1 Fix #5067: 在 MinecraftInstanceTask 中使用 kala-compress 解压文件 (#5151) 2026-01-06 22:35:33 +08:00
Glavo 243f3e7371 修复窗口重新获取焦点时 JFXRadioButton 显示阴影的问题 (#5153) 2026-01-06 22:25:59 +08:00
Glavo 4ad368560c 调整平滑滚动的截止速度 (#5150) 2026-01-06 22:15:04 +08:00
Glavo c6c302e3d6 fix: update title label to use display name directly (#5152) 2026-01-06 22:14:30 +08:00
Glavo 686f59e21b 将 Unzipper 迁移至 kala-compress (#5148) 2026-01-06 21:19:21 +08:00
Glavo f1bd4bffb7 修复无法正确识别模组的问题 (#5149) 2026-01-06 21:08:49 +08:00
ZZZank 18a8556997 feat: 更新选中 mod (#4832) 2026-01-06 21:05:46 +08:00
Glavo f10c842aa4 使用 kala-compress 读取模组元数据 (#5146) 2026-01-05 21:55:52 +08:00
Glavo 74d379a09c 修复 FetchTask 未捕获部分异常的问题 (#5144) 2026-01-05 21:51:02 +08:00
NoClassDefFoundError 3dd1d5bd62 Fix #5123: 修复字体问题导致的空行 (#5124) 2026-01-05 20:55:46 +08:00
Burning_TNT 3247b77410 重构陶瓦下载功能 并 在加入联机房间时显示 p2p 难度 (#4929) 2026-01-04 22:06:44 +08:00
neveler ce9194057a 优化组件显示效果 (#5114)
- 为字体重置操作按钮添加 tooltip
- 为游戏图标重置操作按钮添加 tooltip 并将按钮图标切换为 SVG.RESTORE
2026-01-03 20:51:46 +08:00
Glavo 04cf5eca59 使用 ByteArray 简化 MurmurHash2 (#4486) 2026-01-03 20:45:45 +08:00
Glavo 86d65e96ec Fix #4774: 安装 Modrinth 整合包时下载图标 (#5119) 2026-01-03 20:41:16 +08:00
辞庐 1262a760b8 Fix #5121 : NeoForge alpha 版本未正确归类的问题 (#5125) 2026-01-03 20:31:07 +08:00
acd41e5885 在添加游戏目录页面选择目录时自动填写名称 (#4289)
Co-authored-by: Ciilu <109708109+Ciilu@users.noreply.github.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-03 20:19:08 +08:00
Wulian233 f22568d3e8 Fix #5126: BMCLAPI 选择 Liteloader 不支持的版本时报错 (#5128) 2026-01-03 16:44:07 +08:00
辞庐andGlavo 11d182f907 优化输入框校验 (#5129)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-03 16:41:40 +08:00
辞庐 e25e58cfad 修复缓存目录设置进入时默认未勾选的问题 (#5131) 2026-01-03 16:02:42 +08:00
2e4cc76d6a 优化微软登录页面 (#4794)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-02 22:06:51 +08:00
NoClassDefFoundError 02e9d807cd Fix #4938: 修复 OfflineAccountSkinPane 页面提示栏和按钮重叠的问题 (#5117) 2026-01-02 21:57:36 +08:00
Wulian233 8923be50ec 修复下载光影时对话框的标题文字错误 (#5034) 2026-01-02 21:06:59 +08:00
辞庐 a07c0d9746 Fix #5097 : 部分平台 Popup 无法通过点击侧边栏收起 (#5118) 2026-01-02 21:00:16 +08:00
3gf8jv4dv e56c4643de fix(l10n): unify term WORLD in chinese (#5058) 2026-01-02 20:48:26 +08:00
辞庐 fb942d1099 优化独立窗口弹窗 (#5060)
resolves #4942
2026-01-02 16:22:54 +08:00
neveler 3842b6df32 为不同类型的 Dialog 指定相应的默认标题 (#5079) 2026-01-02 16:15:27 +08:00
辞庐 fadd9580a2 优化打包日志逻辑 (#5108)
Fix #4587
2026-01-02 16:13:06 +08:00
辞庐 110a58b3cd 修复添加实例目录页面有一个空 Tooltip 的问题 (#5112) 2026-01-02 16:09:19 +08:00
辞庐 5a25ba6663 更新 NeoForge 版本号规则 (#5110)
Resolves #5109
2026-01-02 16:02:15 +08:00
辞庐 0013a52a5c 优化 OptionToggleButton (#5107) 2026-01-02 15:27:08 +08:00
辞庐 05d37e3911 Fix #4861 : 复制游戏实例相关问题 (#5111) 2026-01-02 15:23:32 +08:00
Glavo bc55da0720 Fix #5105: 修复按钮字体大小异常的问题 (#5106) 2026-01-01 23:04:30 +08:00
mineDiamond 57a582e2cb fix: 修复部分版本无法正确归一化的问题 (#5091) 2026-01-01 21:47:22 +08:00
neveler 7c0faf2143 更新启动按钮分隔符颜色 (#5087) 2026-01-01 21:40:28 +08:00
Glavo fe0515ed28 Fix #5068: 修复无法读取 AppliedE 模组信息的问题 (#5081) 2026-01-01 21:32:14 +08:00
Glavo 0c657ef382 修复 AccountDetailsInputPane 部分情况下未正确验证用户名的问题 (#5080) 2026-01-01 21:29:57 +08:00
辞庐 4ce6588f0c 更新 about.copyright.statement 到 2026 (#5082) 2026-01-01 20:42:01 +08:00
辞庐 d083ff9048 修复安装游戏时选择加载器版本后返回动画方向不正确的问题 (#5086) 2026-01-01 20:36:25 +08:00
neveler 54f9902849 首页切换实例菜单仅在菜单高度大于最大高度时显示垂直滚动条 (#5088) 2026-01-01 20:28:38 +08:00
辞庐 2e572d2b02 JFXSpinner 添加圆角 (#5101) 2026-01-01 20:12:00 +08:00
Glavo ac9f337d4e Fix #5048: 修复 JFXTextField 上下文菜单未使用 MD3 色彩系统的问题 (#5102) 2026-01-01 20:06:31 +08:00
neveler 078c2ad98b 优化 SettingsPage 内边距 (#5077) 2025-12-26 21:06:46 +08:00
neveler 830d9a05e7 优化不透明度滑块显示效果 (#5076) 2025-12-26 20:54:35 +08:00
Glavo 7dfeebff39 更新 placeholder 标签的颜色 (#5057) 2025-12-24 21:13:26 +08:00
mineDiamond 05a414c93f feat: 更新 NBT 图标 (#5062) 2025-12-24 21:10:59 +08:00
3gf8jv4dv 16a432125b fix: reorder items in explore menu on edit instance page (#5059)
resolves #4940
2025-12-24 21:05:59 +08:00
Glavo 573e25ecdf 修复 JFXPopup 在 HiDPI 显示器上播放动画过程中内容 Y 轴方向缩放异常的问题 (#5064) 2025-12-24 20:38:20 +08:00
Glavo 821cc6d768 简化 JFXListViewSkin (#5063) 2025-12-24 01:24:44 +08:00
Glavo c13b5e15d4 修复 JFXListViewSkin 大量触发 ListCell#updateItem 的问题 (#5056) 2025-12-24 00:41:23 +08:00
3gf8jv4dv 3eea76784d fix: correct the MB to MiB in the game crash window (#5061) 2025-12-24 00:28:28 +08:00
Calboot 22c66b2790 [Bugfix] 修复导出整合包有关以及NBT文件查看有关的界面样式及深色模式问题 (#5043)
Fixes #5042
2025-12-23 22:25:21 +08:00
mineDiamond fcabe9ca36 [Feature] [UI] 统一账户选择弹窗在列表为空时的表现 (#5054) 2025-12-23 21:52:43 +08:00
3gf8jv4dv 2af3c8f714 fix(l10n): simplify the text for settings.memory in simpchinese (#5047)
resolves https://github.com/HMCL-dev/HMCL/issues/5041
2025-12-23 21:33:34 +08:00
Glavo 253440844e 优化 JFXListView 的平滑滚动效果 (#5052) 2025-12-23 20:20:52 +08:00
Glavo cc87f6cdd1 禁用 LogWindow 的平滑滚动效果 (#5046) 2025-12-23 20:11:46 +08:00
辞庐 a77ef95ce4 取消 FXUtils#onClicked 的连点限制 (#4882) 2025-12-22 21:56:01 +08:00
Glavo 7fb7b00836 简化 GlobalConfig (#4781) 2025-12-22 21:09:56 +08:00
Glavo 3252de2f3f Update PLATFORM.md (#5045) 2025-12-22 20:48:30 +08:00
Calboot 9b0b975490 [Enhancement] 导出整合包时隐藏系统资源文件 (#4897)
Resolves #4785
2025-12-21 21:49:19 +08:00
mineDiamond 449acbc7dc feat: 优化InputDialogPane体验 (#5032) 2025-12-21 21:41:20 +08:00
神奇的赵二 (OrzMiku) 1c82e4e1e1 ModListPage 刷新时不滚动到顶部 (#5016) 2025-12-21 21:40:47 +08:00
辞庐 77cdc2108f 在非 Windows 平台也将 : 视为非法字符 (#5035) 2025-12-21 21:20:54 +08:00
neveler cc092f535b 使用 subtitle 显示重启生效提示 (#5024)
close #4956
2025-12-21 21:09:42 +08:00
Glavo 13930f8dcc Bump version root to 3.10 (#5028) 2025-12-21 20:22:22 +08:00
neveler ebad1f67ce 优化同步工作流 (#5036) 2025-12-21 16:25:45 +08:00
Glavo d3d46f6a29 Fix #5039: 修复陶瓦联机页面部分文本在深色模式下颜色异常的问题 (#5040) 2025-12-21 15:13:58 +08:00
Glavo 6f1db67e9a 优化反馈页面 (#5008)
1. 将主页左下角“官方群组”按钮调整为跳转到“反馈”页面;
2. 将反馈页面分为“官方群组”和“提交反馈”两部分
2025-12-20 20:35:41 +08:00
mineDiamond 0d7ece819d feat: 添加快速进入世界功能 (#4872)
close #2563
2025-12-20 20:33:52 +08:00
Damon Lu 7f852080cd 在 macOS 上添加启动脚本的后缀 .command (#4927) 2025-12-20 20:32:34 +08:00
neveler e0360c2b38 使用 subtitle 显示预览更新提示 (#5019) 2025-12-20 16:04:20 +08:00
Glavo 3bf0eb2a16 Fix #5014: 修复 GameCrashWindow 中 feedbackTextFlow 样式错误的问题 (#5020) 2025-12-20 16:00:42 +08:00
Glavo 84b428b57c 在 JFXCustomColorPickerDialog 中使用 HEX 作为默认 Tab (#5011) 2025-12-20 15:33:03 +08:00
neveler 41895e97e1 修复 subtitle 颜色丢失的问题 (#5018)
fix #5017
2025-12-20 15:32:34 +08:00
zimzaza4 f839dc192c 修改AccountAdvancedListItem 控件, 在 RootPage 中添加快速选择账户 (#4932) 2025-12-19 22:18:10 +08:00
Glavo 24250a6239 更新 CNB Issue 模板 (#5010) 2025-12-19 22:03:43 +08:00
Glavo a3ab2b52b6 添加 CNB Issue 模板 (#5009) 2025-12-19 21:39:00 +08:00
Glavo c4d4c6fcf3 优化镜像仓库同步功能 (#5006) 2025-12-19 16:47:12 +08:00
Glavo f77f005f88 mirror.yml 中直接传递用户名 (#5005) 2025-12-19 16:10:02 +08:00
Glavo 9ec494664c 简化镜像仓库同步 workflow (#5004) 2025-12-19 16:02:02 +08:00
Glavo dc7c883962 在低于 JavaFX 23 的情况下隐藏主题模式跟随系统选项 (#4985) 2025-12-18 21:25:07 +08:00
Glavo 567bd15997 在陶瓦联机界面启动游戏时禁用离线皮肤功能 (#4916) 2025-12-18 21:18:03 +08:00
Glavo 25c9ef0f27 修复无法解析类似 "26.1 Snapshot 1" 的版本号的问题 (#5002) 2025-12-18 21:09:07 +08:00
Glavo d45d994307 将 Release 同步至 CNB (#5000) 2025-12-18 21:08:30 +08:00
Glavo 01b7a6b95a Revert "修复 TransitionPane 动画被打断时节点状态异常的问题" (#4986)
Reverts HMCL-dev/HMCL#4957
2025-12-18 20:47:48 +08:00
神奇的赵二 (OrzMiku) a0593c03c6 支持下载 Java25 (#4990) 2025-12-18 20:46:52 +08:00
Glavo 91f05315aa Add CNB badge (#4999) 2025-12-18 20:11:06 +08:00
Burning_TNT 84b8fff37b Bump authlib-injector to 1.2.7: fix compatibility with 26.1-snapshot-1 (#4992)
Fix #4989
2025-12-17 17:05:07 -05:00
Glavo 3a9cc2b9bb 修复 CNB 同步功能 (#4995) 2025-12-18 05:57:14 +08:00
Glavo 056f30f6f5 将代码同步至 CNB (#4994) 2025-12-18 05:47:29 +08:00
辞庐 2d6e01fcf9 添加输出游戏调试日志选项 (#4144) 2025-12-14 20:49:23 +08:00
Calboot b6d95c3ebb [Enhancement] 给 TableView 添加样式 (#4854) 2025-12-12 22:00:55 +08:00
Glavo 29e40ec1a5 在 DownloadListPage 中添加图标缓存 (#4866) 2025-12-12 21:12:51 +08:00
Glavo 497789603a 在日志中记录显示器信息 (#4581) 2025-12-11 20:47:10 +08:00
Glavo 8d3cb66d17 将 TableView 中的 CheckBox 替换为 JFXCheckBox (#4964) 2025-12-11 20:41:26 +08:00
mineDiamond 0613b342f0 feat: 添加1.21.11_unobfuscated支持 (#4963) 2025-12-11 20:29:55 +08:00
Glavo 18000fae30 修复 TransitionPane 动画被打断时节点状态异常的问题 (#4957) 2025-12-09 15:32:17 +08:00
Glavo d2c985491a 合并 SettingsPage 与 SettingsView (#4955) 2025-12-08 16:45:29 +08:00
Glavo f7f0d55504 更新 RipplerContainer 的涟漪颜色 (#4953) 2025-12-08 15:17:10 +08:00
Glavo 210e127ac7 Fix #4950: 修复部分按钮波纹颜色错误的问题 (#4952) 2025-12-07 21:46:42 +08:00
Glavo 36748a3a07 Fix #4941: 更新 JFXComboBox 下拉菜单滚动条样式 (#4951) 2025-12-07 21:43:47 +08:00
Burning_TNT cf667b2b8d Fix #4759: 部分 LF/NF 模组无法正确识别 (#4945) 2025-12-07 21:13:49 +08:00
Burning_TNT a3f7a230e7 Fix #4597: 绕过 OptiFine HD U G6 pre1 与其他模组 Fingerprint 冲突的问题 (#4946) 2025-12-07 21:12:08 +08:00
辞庐 9dd8084be2 修复本地安装新版本 Forge 时未移除版本号中 forge- 前缀的问题 (#4935) 2025-12-07 21:04:18 +08:00
Glavo 9cf5a5e5db 修复从 BMCLAPI 下载部分旧文件时校验失败的问题 (#4949) 2025-12-07 20:45:02 +08:00
Glavo 8efa54dd11 更新超链接被聚焦时的边框样式 (#4948) 2025-12-07 20:44:32 +08:00
Wulian233 7902020300 Fix #4939: 修复深色模式超链接的已访问样式配色问题 (#4943) 2025-12-07 16:35:46 +08:00
Glavo e72c0bbb50 修复部分控件 Tooltip 文字颜色异常的问题 (#4947) 2025-12-07 16:15:22 +08:00
ef3df92592 feat: 资源包管理 (#4475)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-12-06 22:17:04 +08:00
Glavo 04b300f453 简化 MessageDialogPane (#4918) 2025-12-06 21:18:58 +08:00
mineDiamondandGlavo e455ce4c84 feat: 支持1.21.11-pre4/5/rc1/2_unobfuscated (#4911)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-12-06 21:03:27 +08:00
Glavo 456d13959e 更新 GameVersionNumber 解析规则 (#4917)
1. 支持 [Minecraft
新版本号方案](https://www.minecraft.net/en-us/article/minecraft-new-version-numbering-system)
2. 支持对版本号进行归一化处理。
2025-12-06 20:55:11 +08:00
Glavo 7ac22ad567 Windows 11 上日志窗口标题栏颜色应当跟随主题模式设置 (#4910) 2025-12-04 01:24:36 +08:00
neveler ed4fec6c42 在 HintPane 提示框中为图标和文本添加间距 (#4915) 2025-12-03 16:45:44 +08:00
Glavo c321453399 Fix #4900: 修复 ManagedProcess#getLines 完成后不会解除锁定的问题 (#4913) 2025-12-03 16:19:17 +08:00
Calboot aae02bef50 [Bugfix] 修复模组更新列表界面按 esc 无法正确退出的问题 (#4895)
Fixes #4894
2025-12-03 16:17:46 +08:00
Glavo 89c9a93aab 优化深色模式下日志窗口筛选按钮的颜色 (#4912) 2025-12-03 16:04:10 +08:00
neveler 74ec4aaae8 优化检查更新工作流 (#4879) 2025-12-02 21:15:43 +08:00
Glavo 669a9dfc57 优化更新弹窗的颜色 (#4903) 2025-12-02 21:14:10 +08:00
Glavo a1e75e48da 修复部分控件在深色模式下颜色异常的问题 (#4899) 2025-12-02 20:29:46 +08:00
245 changed files with 8428 additions and 4729 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: QQ 群
url: https://docs.hmcl.net/groups.html
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
- name: Discord 服务器
url: https://discord.gg/jVvC7HfM6U
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
-79
View File
@@ -1,79 +0,0 @@
name: Check Update
on:
workflow_dispatch:
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
jobs:
check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- channel: dev
task: checkUpdateDev
- channel: stable
task: checkUpdateStable
runs-on: ubuntu-latest
name: check-update-${{ matrix.channel }}
steps:
- 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: Fetch last version
run: ./gradlew ${{ matrix.task }} --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 "$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"
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
if [ "${{ matrix.channel }}" = "stable" ]; then
echo "**This version is a stable version.**" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
fi
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/${{ matrix.channel }}.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> 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
if: ${{ env.continue == 'true' }}
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTE
files: |
HMCL-${{ env.HMCL_VERSION }}.exe
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.sh
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
-21
View File
@@ -1,21 +0,0 @@
name: Sync to Gitee
on:
push
jobs:
run:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- name: Mirror GitHub to Gitee
uses: Yikun/hub-mirror-action@v1.4
with:
src: github/HMCL-dev
dst: gitee/huanghongxun
static_list: 'HMCL'
force_update: true
debug: true
dst_key: ${{ secrets.GITEE_SYNC_BOT_PRIVATE_KEY }}
dst_token: ${{ secrets.GITEE_SYNC_BOT_TOKEN }}
cache_path: /github/workspace/hub-mirror-cache
+33
View File
@@ -0,0 +1,33 @@
name: Mirror Repository
on:
workflow_dispatch:
push:
concurrency:
group: mirror-repository
cancel-in-progress: true
jobs:
mirror:
strategy:
fail-fast: false
matrix:
include:
- name: Gitee
repo: gitee.com/huanghongxun/HMCL
user: 'hmcl-sync'
token: 'GITEE_SYNC_TOKEN'
- name: CNB
repo: cnb.cool/HMCL-dev/HMCL
user: 'cnb'
token: 'CNB_SYNC_TOKEN'
name: Mirror to ${{ matrix.name }}
if: ${{ github.repository == 'HMCL-dev/HMCL' }}
runs-on: ubuntu-latest
steps:
- name: Mirror GitHub to ${{ matrix.name }}
run: |
git clone --mirror "https://github.com/${{ github.repository }}.git" -- repo
cd repo
git push -f --prune "https://${{ matrix.user }}:${{ secrets[matrix.token] }}@${{ matrix.repo }}.git" "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*"
+125
View File
@@ -0,0 +1,125 @@
name: Create Release
on:
workflow_dispatch:
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
jobs:
check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- channel: dev
task: checkUpdateDev
- channel: stable
task: checkUpdateStable
runs-on: ubuntu-latest
name: check-update-${{ matrix.channel }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch last version
run: ./gradlew ${{ matrix.task }} --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 "$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: Verify artifacts
if: ${{ env.continue == 'true' }}
run: |
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
# GitHub Release Note
echo "&nbsp;**[Changelog](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)** (Chinese)" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> 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
# CNB Release Note
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
echo "" >> CNB_RELEASE_NOTE
echo "| 文件 | SHA-256 校验码 |" >> CNB_RELEASE_NOTE
echo "| :--- | --- |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
- name: Create GitHub release
if: ${{ env.continue == 'true' }}
run: |
gh release create "${{ env.HMCL_TAG_NAME }}" \
"HMCL-${{ env.HMCL_VERSION }}.exe" \
"HMCL-${{ env.HMCL_VERSION }}.jar" \
"HMCL-${{ env.HMCL_VERSION }}.sh" \
--target "${{ env.HMCL_COMMIT_SHA }}" \
--title "${{ env.HMCL_TAG_NAME }}" \
--notes-file RELEASE_NOTE \
--prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install git-cnb
if: ${{ env.continue == 'true' }}
run: go install "cnb.cool/looc/git-cnb@$GIT_CNB_VERSION"
env:
GIT_CNB_VERSION: '1.1.2'
- name: Create CNB release
if: ${{ env.continue == 'true' }}
run: |
echo "Uploading tags to CNB"
git fetch --tags
git push "https://cnb:${{ secrets.CNB_SYNC_TOKEN }}@cnb.cool/$CNB_REPO.git" "$HMCL_TAG_NAME"
echo "Creating CNB release"
~/go/bin/git-cnb release create \
--repo "$CNB_REPO" \
--tag "$HMCL_TAG_NAME" \
--name "HMCL $HMCL_VERSION" \
--body "$(cat CNB_RELEASE_NOTE)" \
--prerelease true
echo "Uploading HMCL-$HMCL_VERSION.jar"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.jar"
echo "Uploading HMCL-$HMCL_VERSION.exe"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.exe"
echo "Uploading HMCL-$HMCL_VERSION.sh"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
env:
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
CNB_REPO: HMCL-dev/HMCL
+28
View File
@@ -1,3 +1,4 @@
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
@@ -128,11 +129,13 @@ val addOpens = listOf(
"javafx.base/javafx.beans.property",
"javafx.graphics/javafx.css",
"javafx.graphics/javafx.stage",
"javafx.graphics/com.sun.glass.ui",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.javafx.util",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.graphics/com.sun.javafx.tk.quantum",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
@@ -238,6 +241,11 @@ tasks.processResources {
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
inputs.property("terracotta_version", libs.versions.terracotta)
doLast {
upgradeTerracottaConfig.get().checkValid()
}
}
val makeExecutables by tasks.registering {
@@ -360,6 +368,26 @@ tasks.register<JavaExec>("run") {
}
}
// terracotta
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
))
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
templateFile.set(source)
outputFile.set(destination)
}
// Check Translations
tasks.register<CheckTranslations>("checkTranslations") {
@@ -0,0 +1,129 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.controls;
import com.jfoenix.converters.ButtonTypeConverter;
import com.jfoenix.skins.JFXButtonSkin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableObjectProperty;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.css.StyleableProperty;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
import javafx.scene.control.Labeled;
import javafx.scene.control.Skin;
import javafx.scene.paint.Paint;
public class JFXButton extends Button {
private static final String DEFAULT_STYLE_CLASS = "jfx-button";
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
public JFXButton() {
this.initialize();
}
public JFXButton(String text) {
super(text);
this.initialize();
}
public JFXButton(String text, Node graphic) {
super(text, graphic);
this.initialize();
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
protected Skin<?> createDefaultSkin() {
return new JFXButtonSkin(this);
}
private final ObjectProperty<Paint> ripplerFill = new SimpleObjectProperty<>(this, "ripplerFill", null);
public final ObjectProperty<Paint> ripplerFillProperty() {
return this.ripplerFill;
}
public final Paint getRipplerFill() {
return this.ripplerFillProperty().get();
}
public final void setRipplerFill(Paint ripplerFill) {
this.ripplerFillProperty().set(ripplerFill);
}
private final StyleableObjectProperty<ButtonType> buttonType = new SimpleStyleableObjectProperty<>(
JFXButton.StyleableProperties.BUTTON_TYPE, this, "buttonType", JFXButton.ButtonType.FLAT);
public ButtonType getButtonType() {
return this.buttonType == null ? JFXButton.ButtonType.FLAT : this.buttonType.get();
}
public StyleableObjectProperty<ButtonType> buttonTypeProperty() {
return this.buttonType;
}
public void setButtonType(ButtonType type) {
this.buttonType.set(type);
}
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (this.STYLEABLES == null) {
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(Labeled.getClassCssMetaData());
this.STYLEABLES = List.copyOf(styleables);
}
return this.STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return JFXButton.StyleableProperties.CHILD_STYLEABLES;
}
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
}
public enum ButtonType {
FLAT,
RAISED;
}
private static final class StyleableProperties {
private static final CssMetaData<JFXButton, ButtonType> BUTTON_TYPE;
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
BUTTON_TYPE = new CssMetaData<>("-jfx-button-type", ButtonTypeConverter.getInstance(), JFXButton.ButtonType.FLAT) {
public boolean isSettable(JFXButton control) {
return control.buttonType == null || !control.buttonType.isBound();
}
public StyleableProperty<ButtonType> getStyleableProperty(JFXButton control) {
return control.buttonTypeProperty();
}
};
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, BUTTON_TYPE);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
}
@@ -0,0 +1,171 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXPopupSkin;
import javafx.application.Platform;
import javafx.beans.DefaultProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.PopupControl;
import javafx.scene.control.Skin;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.stage.Window;
/// JFXPopup is the material design implementation of a popup.
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-03-01
@DefaultProperty(value = "popupContent")
public class JFXPopup extends PopupControl {
public enum PopupHPosition {
RIGHT, LEFT
}
public enum PopupVPosition {
TOP, BOTTOM
}
/// Creates empty popup.
public JFXPopup() {
this(null);
}
/// creates popup with a specified container and content
///
/// @param content the node that will be shown in the popup
public JFXPopup(Region content) {
setPopupContent(content);
initialize();
}
private void initialize() {
this.setAutoFix(false);
this.setAutoHide(true);
this.setHideOnEscape(true);
this.setConsumeAutoHidingEvents(false);
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXPopupSkin(this);
}
/***************************************************************************
* *
* Setters / Getters *
* *
**************************************************************************/
private final ObjectProperty<Region> popupContent = new SimpleObjectProperty<>(new Pane());
public final ObjectProperty<Region> popupContentProperty() {
return this.popupContent;
}
public final Region getPopupContent() {
return this.popupContentProperty().get();
}
public final void setPopupContent(final Region popupContent) {
this.popupContentProperty().set(popupContent);
}
/***************************************************************************
* *
* Public API *
* *
**************************************************************************/
/// show the popup using the default position
public void show(Node node) {
this.show(node, PopupVPosition.TOP, PopupHPosition.LEFT, 0, 0);
}
/// show the popup according to the specified position
///
/// @param vAlign can be TOP/BOTTOM
/// @param hAlign can be LEFT/RIGHT
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign) {
this.show(node, vAlign, hAlign, 0, 0);
}
/// show the popup according to the specified position with a certain offset
///
/// @param vAlign can be TOP/BOTTOM
/// @param hAlign can be LEFT/RIGHT
/// @param initOffsetX on the x-axis
/// @param initOffsetY on the y-axis
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
if (!isShowing()) {
if (node.getScene() == null || node.getScene().getWindow() == null) {
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
}
Window parent = node.getScene().getWindow();
final Point2D origin = node.localToScene(0, 0);
final double anchorX = parent.getX() + origin.getX()
+ node.getScene().getX() + (hAlign == PopupHPosition.RIGHT ? ((Region) node).getWidth() : 0);
final double anchorY = parent.getY() + origin.getY()
+ node.getScene()
.getY() + (vAlign == PopupVPosition.BOTTOM ? ((Region) node).getHeight() : 0);
this.show(parent, anchorX, anchorY);
((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY);
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
}
}
public void show(Window window, double x, double y, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
if (!isShowing()) {
if (window == null) {
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
}
Window parent = window;
final double anchorX = parent.getX() + x + initOffsetX;
final double anchorY = parent.getY() + y + initOffsetY;
this.show(parent, anchorX, anchorY);
((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY);
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
}
}
@Override
public void hide() {
super.hide();
((JFXPopupSkin) getSkin()).init();
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-popup'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-popup";
}
@@ -34,6 +34,7 @@ import javafx.geometry.Bounds;
import javafx.scene.CacheHint;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
@@ -77,14 +78,13 @@ public class JFXRippler extends StackPane {
protected Node control;
protected static final double RIPPLE_MAX_RADIUS = 300;
private boolean enabled = true;
private boolean forceOverlay = false;
private final Interpolator rippleInterpolator = Interpolator.SPLINE(0.0825,
private static final Interpolator RIPPLE_INTERPOLATOR = Interpolator.SPLINE(0.0825,
0.3025,
0.0875,
0.9975); //0.1, 0.54, 0.28, 0.95);
private boolean forceOverlay = false;
/// creates empty rippler node
public JFXRippler() {
this(null, RipplerMask.RECT, RipplerPos.FRONT);
@@ -176,10 +176,6 @@ public class JFXRippler extends StackPane {
return control;
}
public void setEnabled(boolean enable) {
this.enabled = enable;
}
// methods that can be changed by extending the rippler class
/// generate the clipping mask
@@ -255,10 +251,15 @@ public class JFXRippler extends StackPane {
if (getChildren().contains(control)) {
control.boundsInParentProperty().addListener(observable -> resetRippler());
}
control.addEventHandler(MouseEvent.MOUSE_PRESSED,
(event) -> createRipple(event.getX(), event.getY()));
control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
createRipple(event.getX(), event.getY());
});
// create fade out transition for the ripple
control.addEventHandler(MouseEvent.MOUSE_RELEASED, e -> releaseRipple());
control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
releaseRipple();
});
}
/**
@@ -363,25 +364,23 @@ public class JFXRippler extends StackPane {
}
void createRipple() {
if (enabled) {
if (!generating.getAndSet(true)) {
// create overlay once then change its color later
createOverlay();
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
this.setClip(getMask());
}
this.resetClip = false;
// create the ripple effect
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
getChildren().add(ripple);
ripplesQueue.add(ripple);
// animate the ripple
overlayRect.outAnimation.stop();
overlayRect.inAnimation.play();
ripple.inAnimation.play();
if (!generating.getAndSet(true)) {
// create overlay once then change its color later
createOverlay();
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
this.setClip(getMask());
}
this.resetClip = false;
// create the ripple effect
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
getChildren().add(ripple);
ripplesQueue.add(ripple);
// animate the ripple
overlayRect.outAnimation.stop();
overlayRect.inAnimation.play();
ripple.inAnimation.play();
}
}
@@ -415,15 +414,15 @@ public class JFXRippler extends StackPane {
overlayRect.setClip(getMask());
getChildren().add(0, overlayRect);
overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> {
if (ripplerFill.get() instanceof Color) {
return new Color(((Color) ripplerFill.get()).getRed(),
((Color) ripplerFill.get()).getGreen(),
((Color) ripplerFill.get()).getBlue(),
if (getRipplerFill() instanceof Color fill) {
return new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.2);
} else {
return Color.TRANSPARENT;
}
}, ripplerFill));
}, ripplerFillProperty()));
}
}
@@ -473,8 +472,8 @@ public class JFXRippler extends StackPane {
private Ripple(double centerX, double centerY) {
super(centerX,
centerY,
ripplerRadius.get().doubleValue() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius() : ripplerRadius.get().doubleValue(), null);
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius() : getRipplerRadius(), null);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
@@ -484,12 +483,12 @@ public class JFXRippler extends StackPane {
KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2];
outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3];
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, rippleInterpolator);
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, rippleInterpolator);
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR);
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR);
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, rippleInterpolator);
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, rippleInterpolator);
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, rippleInterpolator);
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR);
if (isRipplerRecenter()) {
double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55;
@@ -497,42 +496,42 @@ public class JFXRippler extends StackPane {
inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(),
Math.signum(dx) * Math.min(Math.abs(dx),
this.getRadius() / 2),
rippleInterpolator);
RIPPLE_INTERPOLATOR);
inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(),
Math.signum(dy) * Math.min(Math.abs(dy),
this.getRadius() / 2),
rippleInterpolator);
RIPPLE_INTERPOLATOR);
}
inAnimation = new Timeline(new KeyFrame(Duration.ZERO,
new KeyValue(scaleXProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(scaleYProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(translateXProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(translateYProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(opacityProperty(),
1,
rippleInterpolator)
RIPPLE_INTERPOLATOR)
), new KeyFrame(Duration.millis(900), inKeyValues));
setScaleX(0);
setScaleY(0);
if (ripplerFill.get() instanceof Color) {
Color circleColor = new Color(((Color) ripplerFill.get()).getRed(),
((Color) ripplerFill.get()).getGreen(),
((Color) ripplerFill.get()).getBlue(),
if (getRipplerFill() instanceof Color fill) {
Color circleColor = new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.3);
setStroke(circleColor);
setFill(circleColor);
} else {
setStroke(ripplerFill.get());
setFill(ripplerFill.get());
setStroke(getRipplerFill());
setFill(getRipplerFill());
}
}
}
@@ -585,110 +584,125 @@ public class JFXRippler extends StackPane {
* the ripple recenter property, by default it's false.
* if true the ripple effect will show gravitational pull to the center of its control
*/
private final StyleableObjectProperty<Boolean> ripplerRecenter = new SimpleStyleableObjectProperty<>(
StyleableProperties.RIPPLER_RECENTER,
JFXRippler.this,
"ripplerRecenter",
false);
private StyleableBooleanProperty ripplerRecenter;
public Boolean isRipplerRecenter() {
public boolean isRipplerRecenter() {
return ripplerRecenter != null && ripplerRecenter.get();
}
public StyleableObjectProperty<Boolean> ripplerRecenterProperty() {
public StyleableBooleanProperty ripplerRecenterProperty() {
if (this.ripplerRecenter == null) {
this.ripplerRecenter = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_RECENTER,
JFXRippler.this,
"ripplerRecenter",
false);
}
return this.ripplerRecenter;
}
public void setRipplerRecenter(Boolean radius) {
this.ripplerRecenter.set(radius);
public void setRipplerRecenter(boolean recenter) {
ripplerRecenterProperty().set(recenter);
}
/**
* the ripple radius size, by default it will be automatically computed.
*/
private final StyleableObjectProperty<Number> ripplerRadius = new SimpleStyleableObjectProperty<>(
StyleableProperties.RIPPLER_RADIUS,
JFXRippler.this,
"ripplerRadius",
Region.USE_COMPUTED_SIZE);
private StyleableDoubleProperty ripplerRadius;
public Number getRipplerRadius() {
public double getRipplerRadius() {
return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get();
}
public StyleableObjectProperty<Number> ripplerRadiusProperty() {
public StyleableDoubleProperty ripplerRadiusProperty() {
if (this.ripplerRadius == null) {
this.ripplerRadius = new SimpleStyleableDoubleProperty(
StyleableProperties.RIPPLER_RADIUS,
JFXRippler.this,
"ripplerRadius",
Region.USE_COMPUTED_SIZE);
}
return this.ripplerRadius;
}
public void setRipplerRadius(Number radius) {
this.ripplerRadius.set(radius);
public void setRipplerRadius(double radius) {
ripplerRadiusProperty().set(radius);
}
private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255);
/**
* the default color of the ripple effect
*/
private final StyleableObjectProperty<Paint> ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
JFXRippler.this,
"ripplerFill",
Color.rgb(0,
200,
255));
private StyleableObjectProperty<Paint> ripplerFill;
public Paint getRipplerFill() {
return ripplerFill == null ? Color.rgb(0, 200, 255) : ripplerFill.get();
return ripplerFill == null ? DEFAULT_RIPPLER_FILL : ripplerFill.get();
}
public StyleableObjectProperty<Paint> ripplerFillProperty() {
if (this.ripplerFill == null) {
this.ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
JFXRippler.this,
"ripplerFill",
DEFAULT_RIPPLER_FILL);
}
return this.ripplerFill;
}
public void setRipplerFill(Paint color) {
this.ripplerFill.set(color);
ripplerFillProperty().set(color);
}
/// mask property used for clipping the rippler.
/// can be either CIRCLE/RECT
private final StyleableObjectProperty<RipplerMask> maskType = new SimpleStyleableObjectProperty<>(
StyleableProperties.MASK_TYPE,
JFXRippler.this,
"maskType",
RipplerMask.RECT);
private StyleableObjectProperty<RipplerMask> maskType;
public RipplerMask getMaskType() {
return maskType == null ? RipplerMask.RECT : maskType.get();
}
public StyleableObjectProperty<RipplerMask> maskTypeProperty() {
if (this.maskType == null) {
this.maskType = new SimpleStyleableObjectProperty<>(
StyleableProperties.MASK_TYPE,
JFXRippler.this,
"maskType",
RipplerMask.RECT);
}
return this.maskType;
}
public void setMaskType(RipplerMask type) {
this.maskType.set(type);
if (this.maskType != null || type != RipplerMask.RECT)
maskTypeProperty().set(type);
}
/**
* the ripple disable, by default it's false.
* if true the ripple effect will be hidden
*/
private final StyleableBooleanProperty ripplerDisabled = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_DISABLED,
JFXRippler.this,
"ripplerDisabled",
false);
private StyleableBooleanProperty ripplerDisabled;
public Boolean isRipplerDisabled() {
public boolean isRipplerDisabled() {
return ripplerDisabled != null && ripplerDisabled.get();
}
public StyleableBooleanProperty ripplerDisabledProperty() {
if (this.ripplerDisabled == null) {
this.ripplerDisabled = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_DISABLED,
JFXRippler.this,
"ripplerDisabled",
false);
}
return this.ripplerDisabled;
}
public void setRipplerDisabled(Boolean disabled) {
this.ripplerDisabled.set(disabled);
public void setRipplerDisabled(boolean disabled) {
ripplerDisabledProperty().set(disabled);
}
/**
* indicates whether the ripple effect is infront of or behind the node
*/
@@ -735,7 +749,7 @@ public class JFXRippler extends StackPane {
};
private static final CssMetaData<JFXRippler, Paint> RIPPLER_FILL =
new CssMetaData<>("-jfx-rippler-fill",
PaintConverter.getInstance(), Color.rgb(0, 200, 255)) {
PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerFill == null || !control.ripplerFill.isBound();
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2016 JFoenix
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.jfoenix.effects;
import javafx.scene.Node;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
/**
* it will create a shadow effect for a given node and a specified depth level.
* depth levels are {0,1,2,3,4,5}
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public final class JFXDepthManager {
private JFXDepthManager() {
throw new AssertionError();
}
private static final DropShadow[] depth = new DropShadow[] {
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 1),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 6, 0, 0, 2),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 10, 0, 0, 3),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 14, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 6)
};
/**
* this method is used to add shadow effect to the node,
* however the shadow is not real ( gets affected with node transformations)
* <p>
* use {@link #createMaterialNode(Node, int)} instead to generate a real shadow
*/
public static void setDepth(Node control, int level) {
level = level < 0 ? 0 : level;
level = level > 5 ? 5 : level;
control.setEffect(new DropShadow(BlurType.GAUSSIAN,
depth[level].getColor(),
depth[level].getRadius(),
depth[level].getSpread(),
depth[level].getOffsetX(),
depth[level].getOffsetY()));
}
public static int getLevels() {
return depth.length;
}
public static DropShadow getShadowAt(int level) {
return depth[level];
}
/**
* this method will generate a new container node that prevent
* control transformation to be applied to the shadow effect
* (which makes it looks as a real shadow)
*/
public static Node createMaterialNode(Node control, int level) {
Node container = new Pane(control) {
@Override
protected double computeMaxWidth(double height) {
return computePrefWidth(height);
}
@Override
protected double computeMaxHeight(double width) {
return computePrefHeight(width);
}
@Override
protected double computePrefWidth(double height) {
return control.prefWidth(height);
}
@Override
protected double computePrefHeight(double width) {
return control.prefHeight(width);
}
};
container.getStyleClass().add("depth-container");
container.setPickOnBounds(false);
level = level < 0 ? 0 : level;
level = level > 5 ? 5 : level;
container.setEffect(new DropShadow(BlurType.GAUSSIAN,
depth[level].getColor(),
depth[level].getRadius(),
depth[level].getSpread(),
depth[level].getOffsetX(),
depth[level].getOffsetY()));
return container;
}
public static void pop(Node control) {
control.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.26), 5, 0.05, 0, 1));
}
}
@@ -0,0 +1,222 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ButtonSkin;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
public class JFXButtonSkin extends ButtonSkin {
private final StackPane buttonContainer = new StackPane();
private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) {
protected Node getMask() {
StackPane mask = new StackPane();
mask.shapeProperty().bind(JFXButtonSkin.this.buttonContainer.shapeProperty());
mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background(
new BackgroundFill(Color.WHITE,
JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty()
? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getRadii()
: JFXButtonSkin.this.defaultRadii, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)),
JFXButtonSkin.this.buttonContainer.backgroundProperty()));
mask.resize(JFXButtonSkin.this.buttonContainer.getWidth() - JFXButtonSkin.this.buttonContainer.snappedRightInset() - JFXButtonSkin.this.buttonContainer.snappedLeftInset(), JFXButtonSkin.this.buttonContainer.getHeight() - JFXButtonSkin.this.buttonContainer.snappedBottomInset() - JFXButtonSkin.this.buttonContainer.snappedTopInset());
return mask;
}
private void initListeners() {
this.ripplerPane.setOnMousePressed((event) -> {
if (JFXButtonSkin.this.releaseManualRippler != null) {
JFXButtonSkin.this.releaseManualRippler.run();
}
JFXButtonSkin.this.releaseManualRippler = null;
this.createRipple(event.getX(), event.getY());
});
}
};
private Transition clickedAnimation;
private final CornerRadii defaultRadii = new CornerRadii(3.0);
private boolean invalid = true;
private Runnable releaseManualRippler = null;
public JFXButtonSkin(JFXButton button) {
super(button);
this.getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.releaseManualRippler = this.buttonRippler.createManualRipple();
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(1.0);
this.clickedAnimation.play();
}
} else {
if (this.releaseManualRippler != null) {
this.releaseManualRippler.run();
}
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(-1.0);
this.clickedAnimation.play();
}
}
});
this.buttonContainer.getChildren().add(this.buttonRippler);
button.buttonTypeProperty().addListener((o, oldVal, newVal) -> this.updateButtonType(newVal));
button.setOnMousePressed((e) -> {
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(1.0F);
this.clickedAnimation.play();
}
});
button.setOnMouseReleased((e) -> {
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(-1.0F);
this.clickedAnimation.play();
}
});
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(button);
if (focusVisibleProperty == null) {
focusVisibleProperty = button.focusedProperty();
}
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.buttonRippler.showOverlay();
}
} else {
this.buttonRippler.hideOverlay();
}
});
button.pressedProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.hideOverlay());
button.setPickOnBounds(false);
this.buttonContainer.setPickOnBounds(false);
this.buttonContainer.shapeProperty().bind(this.getSkinnable().shapeProperty());
this.buttonContainer.borderProperty().bind(this.getSkinnable().borderProperty());
this.buttonContainer.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground()) || this.isJavaDefaultClickedBackground(button.getBackground())) {
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
}
try {
return new Background(new BackgroundFill(this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getFill() : Color.TRANSPARENT, this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getRadii() : this.defaultRadii, Insets.EMPTY));
} catch (Exception var3) {
return this.getSkinnable().getBackground();
}
}, this.getSkinnable().backgroundProperty()));
button.ripplerFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground())) {
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
}
this.updateButtonType(button.getButtonType());
this.updateChildren();
}
protected void updateChildren() {
super.updateChildren();
if (this.buttonContainer != null) {
this.getChildren().add(0, this.buttonContainer);
}
for (int i = 1; i < this.getChildren().size(); ++i) {
this.getChildren().get(i).setMouseTransparent(true);
}
}
protected void layoutChildren(double x, double y, double w, double h) {
if (this.invalid) {
if (((JFXButton) this.getSkinnable()).getRipplerFill() == null) {
for (int i = this.getChildren().size() - 1; i >= 1; --i) {
if (this.getChildren().get(i) instanceof Shape shape) {
this.buttonRippler.setRipplerFill(shape.getFill());
shape.fillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
break;
}
if (this.getChildren().get(i) instanceof Label label) {
this.buttonRippler.setRipplerFill(label.getTextFill());
label.textFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
break;
}
}
} else {
this.buttonRippler.setRipplerFill(((JFXButton) this.getSkinnable()).getRipplerFill());
}
this.invalid = false;
}
double shift = 1.0F;
this.buttonContainer.resizeRelocate(this.getSkinnable().getLayoutBounds().getMinX() - shift, this.getSkinnable().getLayoutBounds().getMinY() - shift, this.getSkinnable().getWidth() + (double) 2.0F * shift, this.getSkinnable().getHeight() + (double) 2.0F * shift);
this.layoutLabelInArea(x, y, w, h);
}
private boolean isJavaDefaultBackground(Background background) {
try {
String firstFill = background.getFills().get(0).getFill().toString();
return "0xffffffba".equals(firstFill) || "0xffffffbf".equals(firstFill) || "0xffffff12".equals(firstFill) || "0xffffffbd".equals(firstFill);
} catch (Exception var3) {
return false;
}
}
private boolean isJavaDefaultClickedBackground(Background background) {
try {
return "0x039ed3ff".equals(background.getFills().get(0).getFill().toString());
} catch (Exception var3) {
return false;
}
}
private void updateButtonType(JFXButton.ButtonType type) {
switch (type) {
case RAISED -> {
JFXDepthManager.setDepth(this.buttonContainer, 2);
this.clickedAnimation = new ButtonClickTransition();
}
case FLAT -> this.buttonContainer.setEffect(null);
}
}
private class ButtonClickTransition extends CachedTransition {
public ButtonClickTransition() {
super(JFXButtonSkin.this.buttonContainer, new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), Interpolator.EASE_BOTH)),
new KeyFrame(Duration.millis(1000.0F),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), Interpolator.EASE_BOTH))));
this.setCycleDuration(Duration.seconds(0.2));
this.setDelay(Duration.seconds(0.0F));
}
}
}
@@ -17,6 +17,7 @@ import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
@@ -35,6 +36,7 @@ import javafx.scene.paint.Color;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
public class JFXCheckBoxSkin extends CheckBoxSkin {
private final StackPane box = new StackPane();
@@ -75,7 +77,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
this.updateRippleColor();
this.playSelectAnimation(newVal);
});
control.focusedProperty().addListener((o, oldVal, newVal) -> {
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null)
focusVisibleProperty = control.focusedProperty();
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.rippler.showOverlay();
@@ -28,6 +28,8 @@ import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.geometry.Insets;
@@ -60,6 +62,9 @@ public final class JFXColorPickerSkin extends JFXGenericPickerSkin<Color> {
"colorLabelVisible",
true);
private final ObjectProperty<Color> textFill = new SimpleObjectProperty<>(Color.BLACK);
private final ObjectProperty<Background> colorBoxBackground = new SimpleObjectProperty<>(null);
public JFXColorPickerSkin(final ColorPicker colorPicker) {
super(colorPicker);
@@ -67,14 +72,16 @@ public final class JFXColorPickerSkin extends JFXGenericPickerSkin<Color> {
displayNode = new Label("");
displayNode.getStyleClass().add("color-label");
displayNode.setMouseTransparent(true);
displayNode.textFillProperty().bind(textFill);
// label graphic
colorBox = new JFXClippedPane(displayNode);
colorBox.getStyleClass().add("color-box");
colorBox.setManaged(false);
colorBox.backgroundProperty().bind(colorBoxBackground);
initColor();
final JFXRippler rippler = new JFXRippler(colorBox, JFXRippler.RipplerMask.FIT);
rippler.ripplerFillProperty().bind(displayNode.textFillProperty());
rippler.ripplerFillProperty().bind(textFill);
getChildren().setAll(rippler);
JFXDepthManager.setDepth(getSkinnable(), 1);
getSkinnable().setPickOnBounds(false);
@@ -168,13 +175,13 @@ public final class JFXColorPickerSkin extends JFXGenericPickerSkin<Color> {
200,
Interpolator.EASE_BOTH)));
animateColor.setOnFinished((finish) -> {
JFXNodeUtils.updateBackground(colorBox.getBackground(), colorBox, colorCircle.getFill());
colorBoxBackground.set(new Background(new BackgroundFill(colorCircle.getFill(), new CornerRadii(3), Insets.EMPTY)));
colorBox.getChildren().remove(colorCircle);
});
animateColor.play();
}
// update label color
displayNode.setTextFill(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf(
textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf(
"rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)"));
if (colorLabelVisible.get()) {
displayNode.setText(JFXNodeUtils.colorToHex(circleColor));
@@ -188,9 +195,9 @@ public final class JFXColorPickerSkin extends JFXGenericPickerSkin<Color> {
Color color = colorPicker.getValue();
Color circleColor = color == null ? Color.WHITE : color;
// update picker box color
colorBox.setBackground(new Background(new BackgroundFill(circleColor, new CornerRadii(3), Insets.EMPTY)));
colorBoxBackground.set(new Background(new BackgroundFill(circleColor, new CornerRadii(3), Insets.EMPTY)));
// update label color
displayNode.setTextFill(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf(
textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf(
"rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)"));
if (colorLabelVisible.get()) {
displayNode.setText(JFXNodeUtils.colorToHex(circleColor));
@@ -32,9 +32,7 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
@@ -118,9 +116,9 @@ public class JFXCustomColorPickerDialog extends StackPane {
Tab hexTab = new Tab("HEX");
hexTab.setContent(hexField);
tabs.getTabs().add(hexTab);
tabs.getTabs().add(rgbTab);
tabs.getTabs().add(hsbTab);
tabs.getTabs().add(hexTab);
curvedColorPicker.selectedPath.addListener((o, oldVal, newVal) -> {
if (paraTransition != null) {
@@ -156,15 +154,15 @@ public class JFXCustomColorPickerDialog extends StackPane {
Color fontColor = ((Color) newVal.getFills().get(0).getFill()).grayscale()
.getRed() > 0.5 ? Color.valueOf(
"rgba(40, 40, 40, 0.87)") : Color.valueOf("rgba(255, 255, 255, 0.87)");
for (Node tabNode : tabs.lookupAll(".tab")) {
for (Node node : tabNode.lookupAll(".tab-label")) {
((Label) node).setTextFill(fontColor);
}
for (Node node : tabNode.lookupAll(".jfx-rippler")) {
((JFXRippler) node).setRipplerFill(fontColor);
}
}
((Pane) tabs.lookup(".tab-selected-line")).setBackground(new Background(new BackgroundFill(fontColor, CornerRadii.EMPTY, Insets.EMPTY)));
// for (Node tabNode : tabs.lookupAll(".tab")) {
// for (Node node : tabNode.lookupAll(".tab-label")) {
// ((Label) node).setTextFill(fontColor);
// }
// for (Node node : tabNode.lookupAll(".jfx-rippler")) {
// ((JFXRippler) node).setRipplerFill(fontColor);
// }
// }
// ((Pane) tabs.lookup(".tab-selected-line")).setBackground(new Background(new BackgroundFill(fontColor, CornerRadii.EMPTY, Insets.EMPTY)));
pickerDecorator.lookupAll(".jfx-decorator-button").forEach(button -> {
((JFXButton) button).setRipplerFill(fontColor);
((SVGGlyph) ((JFXButton) button).getGraphic()).setFill(fontColor);
@@ -20,26 +20,24 @@
*/
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ListViewSkin;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.effects.JFXDepthManager;
import javafx.scene.control.ListCell;
import javafx.scene.control.skin.ListViewSkin;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.layout.Region;
import org.jackhuang.hmcl.ui.FXUtils;
// https://github.com/HMCL-dev/HMCL/issues/4720
public class JFXListViewSkin<T> extends ListViewSkin<T> {
private final VirtualFlow<ListCell<T>> flow;
@SuppressWarnings("unchecked")
public JFXListViewSkin(final JFXListView<T> listView) {
super(listView);
flow = (VirtualFlow<ListCell<T>>) getChildren().get(0);
VirtualFlow<ListCell<T>> flow = getVirtualFlow();
JFXDepthManager.setDepth(flow, listView.depthProperty().get());
listView.depthProperty().addListener((o, oldVal, newVal) -> JFXDepthManager.setDepth(flow, newVal));
FXUtils.smoothScrolling(flow);
if (!Boolean.TRUE.equals(listView.getProperties().get("no-smooth-scrolling"))) {
FXUtils.smoothScrolling(flow);
}
}
@Override
@@ -47,47 +45,4 @@ public class JFXListViewSkin<T> extends ListViewSkin<T> {
return 200;
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
final int itemsCount = getSkinnable().getItems().size();
if (getSkinnable().maxHeightProperty().isBound() || itemsCount <= 0) {
return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
}
final double fixedCellSize = getSkinnable().getFixedCellSize();
double computedHeight = fixedCellSize != Region.USE_COMPUTED_SIZE ?
fixedCellSize * itemsCount + snapVerticalInsets() : estimateHeight();
double height = super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
if (height > computedHeight) {
height = computedHeight;
}
if (getSkinnable().getMaxHeight() > 0 && computedHeight > getSkinnable().getMaxHeight()) {
return getSkinnable().getMaxHeight();
}
return height;
}
private double estimateHeight() {
// compute the border/padding for the list
double borderWidth = snapVerticalInsets();
// compute the gap between list cells
JFXListView<T> listview = (JFXListView<T>) getSkinnable();
double gap = listview.isExpanded() ? ((JFXListView<T>) getSkinnable()).getVerticalGap() * (getSkinnable().getItems()
.size()) : 0;
// compute the height of each list cell
double cellsHeight = 0;
for (int i = 0; i < flow.getCellCount(); i++) {
ListCell<T> cell = flow.getCell(i);
cellsHeight += cell.getHeight();
}
return cellsHeight + gap + borderWidth;
}
private double snapVerticalInsets() {
return getSkinnable().snappedBottomInset() + getSkinnable().snappedTopInset();
}
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2016 JFoenix
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXPopup.PopupHPosition;
import com.jfoenix.controls.JFXPopup.PopupVPosition;
import com.jfoenix.effects.JFXDepthManager;
import javafx.animation.*;
import javafx.animation.Animation.Status;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import javafx.scene.layout.*;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
/// # Material Design Popup Skin
/// TODO: REWORK
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-03-01
public class JFXPopupSkin implements Skin<JFXPopup> {
protected JFXPopup control;
protected StackPane container = new StackPane();
protected Region popupContent;
protected Node root;
private Animation animation;
protected Scale scale;
public JFXPopupSkin(JFXPopup control) {
this.control = control;
// set scale y to 0.01 instead of 0 to allow layout of the content,
// otherwise it will cause exception in traverse engine, when focusing the 1st node
scale = new Scale(1.0, 0.01, 0, 0);
popupContent = control.getPopupContent();
container.getStyleClass().add("jfx-popup-container");
container.getChildren().add(popupContent);
container.getTransforms().add(scale);
container.setOpacity(0);
root = JFXDepthManager.createMaterialNode(container, 4);
animation = AnimationUtils.isAnimationEnabled() ? getAnimation() : null;
}
public void reset(PopupVPosition vAlign, PopupHPosition hAlign, double offsetX, double offsetY) {
// postion the popup according to its animation
scale.setPivotX(hAlign == PopupHPosition.RIGHT ? container.getWidth() : 0);
scale.setPivotY(vAlign == PopupVPosition.BOTTOM ? container.getHeight() : 0);
control.setX(control.getX() + (hAlign == PopupHPosition.RIGHT ? -container.getWidth() + offsetX : offsetX));
control.setY(control.getY() + (vAlign == PopupVPosition.BOTTOM ? -container.getHeight() + offsetY : offsetY));
}
public final void animate() {
if (animation != null) {
if (animation.getStatus() == Status.STOPPED) {
container.setOpacity(1);
animation.playFromStart();
}
} else {
container.setOpacity(1);
popupContent.setOpacity(1);
scale.setX(1.0);
scale.setY(1.0);
}
}
@Override
public JFXPopup getSkinnable() {
return control;
}
@Override
public Node getNode() {
return root;
}
@Override
public void dispose() {
if (animation != null) {
animation.stop();
animation = null;
}
container = null;
control = null;
popupContent = null;
root = null;
}
protected Animation getAnimation() {
Interpolator interpolator = Motion.EASE;
return new Timeline(
new KeyFrame(
Duration.ZERO,
new KeyValue(popupContent.opacityProperty(), 0, interpolator),
new KeyValue(scale.xProperty(), 0, interpolator),
new KeyValue(scale.yProperty(), 0, interpolator)
),
new KeyFrame(Motion.SHORT4,
new KeyValue(popupContent.opacityProperty(), 0, interpolator),
new KeyValue(scale.xProperty(), 1, interpolator)
),
new KeyFrame(Motion.MEDIUM2,
new KeyValue(popupContent.opacityProperty(), 1, interpolator),
new KeyValue(scale.yProperty(), 1, interpolator)
)
);
}
public void init() {
if (animation != null)
animation.stop();
container.setOpacity(0);
scale.setX(1.0);
scale.setY(0.01);
}
}
@@ -12,6 +12,7 @@ import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
@@ -23,6 +24,7 @@ import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.text.Text;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
public class JFXRadioButtonSkin extends RadioButtonSkin {
@@ -58,7 +60,12 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
this.updateChildren();
control.focusedProperty().addListener((o, oldVal, newVal) -> {
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null) {
focusVisibleProperty = control.focusedProperty();
}
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.rippler.showOverlay();
@@ -66,7 +73,6 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
} else {
this.rippler.hideOverlay();
}
});
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
this.registerChangeListener(control.selectedColorProperty(), ignored -> updateColors());
@@ -20,11 +20,13 @@ package org.jackhuang.hmcl;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ObservableBooleanValue;
import javafx.geometry.Rectangle2D;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Screen;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
@@ -54,6 +56,7 @@ import java.net.CookieManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Objects;
@@ -79,6 +82,18 @@ public final class Launcher extends Application {
LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false));
LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false));
if (Screen.getScreens().isEmpty()) {
LOG.info("No screen");
} else {
StringBuilder builder = new StringBuilder("Screens:");
int count = 0;
for (Screen screen : Screen.getScreens()) {
builder.append("\n - Screen ").append(++count).append(": ");
appendScreen(builder, screen);
}
LOG.info(builder.toString());
}
try {
try {
ConfigHolder.init();
@@ -131,6 +146,38 @@ public final class Launcher extends Application {
}
}
private static void appendScreen(StringBuilder builder, Screen screen) {
Rectangle2D bounds = screen.getBounds();
double scale = screen.getOutputScaleX();
builder.append(Math.round(bounds.getWidth() * scale));
builder.append('x');
builder.append(Math.round(bounds.getHeight() * scale));
DecimalFormat decimalFormat = new DecimalFormat("#.##");
if (scale != 1.0) {
builder.append(" @ ");
builder.append(decimalFormat.format(scale));
builder.append('x');
}
double dpi = screen.getDpi();
builder.append(' ');
builder.append(decimalFormat.format(dpi));
builder.append("dpi");
builder.append(" in ")
.append(Math.round(Math.sqrt(bounds.getWidth() * bounds.getWidth() + bounds.getHeight() * bounds.getHeight()) / dpi))
.append('"');
builder.append(" (").append(decimalFormat.format(bounds.getMinX()))
.append(", ").append(decimalFormat.format(bounds.getMinY()))
.append(", ").append(decimalFormat.format(bounds.getMaxX()))
.append(", ").append(decimalFormat.format(bounds.getMaxY()))
.append(")");
}
private static ButtonType showAlert(AlertType alertType, String contentText, ButtonType... buttons) {
return new Alert(alertType, contentText, buttons).showAndWait().orElse(null);
}
@@ -25,20 +25,20 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.mod.ModAdviser;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.VersionSetting;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -55,8 +55,8 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
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;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLGameRepository extends DefaultGameRepository {
private final Profile profile;
@@ -174,7 +174,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
Files.copy(fromJson, toJson);
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
@@ -338,7 +338,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion);
if (versionNumber.isAprilFools()) {
return VersionIconType.APRIL_FOOLS.getIcon();
} else if (versionNumber instanceof GameVersionNumber.Snapshot) {
} else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) {
return VersionIconType.COMMAND.getIcon();
} else if (versionNumber instanceof GameVersionNumber.Old) {
return VersionIconType.CRAFT_TABLE.getIcon();
@@ -387,7 +387,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
vs.setUsesGlobal(true);
}
public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
public LaunchOptions.Builder 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()
@@ -418,7 +418,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.isFullscreen())
.setServerIp(vs.getServerIp())
.setWrapper(vs.getWrapper())
.setPreLaunchCommand(vs.getPreLaunchCommand())
.setPostExitCommand(vs.getPostExitCommand())
@@ -428,12 +427,17 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setNativesDir(vs.getNativesDir())
.setProcessPriority(vs.getProcessPriority())
.setRenderer(vs.getRenderer())
.setEnableDebugLogOutput(vs.isEnableDebugLogOutput())
.setUseNativeGLFW(vs.isUseNativeGLFW())
.setUseNativeOpenAL(vs.isUseNativeOpenAL())
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
if (StringUtils.isNotBlank(vs.getServerIp())) {
builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp()));
}
if (config().hasProxy()) {
builder.setProxyType(config().getProxyType());
builder.setProxyHost(config().getProxyHost());
@@ -460,7 +464,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder.create();
return builder;
}
@Override
@@ -79,7 +79,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
}
}
@@ -22,6 +22,7 @@ import javafx.stage.Stage;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
@@ -51,6 +52,7 @@ import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
@@ -60,7 +62,6 @@ import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.lang.ref.WeakReference;
import static javafx.application.Platform.runLater;
import static javafx.application.Platform.setImplicitExit;
@@ -81,6 +82,8 @@ public final class LauncherHelper {
private final VersionSetting setting;
private LauncherVisibility launcherVisibility;
private boolean showLogs;
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
this.profile = Objects.requireNonNull(profile);
@@ -111,6 +114,14 @@ public final class LauncherHelper {
launcherVisibility = LauncherVisibility.KEEP;
}
public void setQuickPlayOption(QuickPlayOption quickPlayOption) {
this.quickPlayOption = quickPlayOption;
}
public void setDisableOfflineSkin() {
disableOfflineSkin = true;
}
public void launch() {
FXUtils.checkFxUserThread();
@@ -188,8 +199,15 @@ public final class LauncherHelper {
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions launchOptions = repository.getLaunchOptions(
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
if (quickPlayOption != null) {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
@@ -639,10 +657,13 @@ public final class LauncherHelper {
return future;
}
private static Task<AuthInfo> logIn(Account account) {
private Task<AuthInfo> logIn(Account account) {
return Task.composeAsync(() -> {
try {
return Task.completed(account.logIn());
if (disableOfflineSkin && account instanceof OfflineAccount offlineAccount)
return Task.completed(offlineAccount.logInWithoutSkin());
else
return Task.completed(account.logIn());
} catch (CredentialExpiredException e) {
LOG.info("Credential has expired", e);
@@ -17,21 +17,19 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.management.ManagementFactory;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -43,7 +41,9 @@ public final class LogExporter {
private LogExporter() {
}
public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
PathMatcher logMatcher) {
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
@@ -65,10 +65,10 @@ public final class LogExporter {
return CompletableFuture.runAsync(() -> {
try (Zipper zipper = new Zipper(zipFile)) {
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper);
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper);
processLogs(runDirectory, "*.log", "runDirectory", zipper);
processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper);
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper, logMatcher);
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper, logMatcher);
processLogs(runDirectory, "*.log", "runDirectory", zipper, logMatcher);
processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper, logMatcher);
zipper.putTextFile(LOG.getLogs(), "hmcl.log");
zipper.putTextFile(logs, "minecraft.log");
@@ -86,14 +86,11 @@ public final class LogExporter {
});
}
private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper) {
private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper, PathMatcher logMatcher) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, fileExtension)) {
long processStartTime = ManagementFactory.getRuntimeMXBean().getStartTime();
for (Path file : stream) {
if (Files.isRegularFile(file)) {
FileTime time = Files.readAttributes(file, BasicFileAttributes.class).lastModifiedTime();
if (time.toMillis() >= processStartTime) {
if (logMatcher == null || logMatcher.matches(file)) {
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
} catch (IOException e) {
@@ -188,7 +188,7 @@ public final class ModpackHelper {
});
}
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) {
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
profile.getRepository().markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
@@ -208,17 +208,17 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.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)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
else
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
}
@@ -152,6 +152,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
@Override
public void openBrowser(String url) throws IOException {
lastlyOpenedURL = url;
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
}
FXUtils.openLink(url);
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
@@ -414,6 +414,12 @@ public final class Accounts {
return i18n("account.failed.no_character");
} else if (exception instanceof ServerDisconnectException) {
if (exception.getCause() instanceof SSLException) {
if (exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated")) {
return i18n("account.failed.connect_authentication_server");
}
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("account.failed.dns");
}
return i18n("account.failed.ssl");
} else {
return i18n("account.failed.connect_authentication_server");
@@ -465,7 +471,7 @@ public final class Accounts {
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
return i18n("account.methods.microsoft.error.no_character");
} else if (exception instanceof MicrosoftService.NoXuiException) {
return i18n("account.methods.microsoft.error.add_family_probably");
return i18n("account.methods.microsoft.error.add_family");
} else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) {
return i18n("account.methods.microsoft.snapshot");
} else if (exception instanceof OAuthAccount.WrongAccountException) {
@@ -134,7 +134,10 @@ public final class DownloadProviders {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException) {
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.text.Font;
import org.jackhuang.hmcl.Metadata;
@@ -94,31 +95,26 @@ public final class FontManager {
return null;
});
private static final ObjectProperty<FontReference> fontProperty;
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
static {
updateFont();
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
}
private static void updateFont() {
String fontFamily = config().getLauncherFontFamily();
if (fontFamily == null)
fontFamily = System.getProperty("hmcl.font.override");
if (fontFamily == null)
fontFamily = System.getenv("HMCL_FONT");
FontReference fontReference;
if (fontFamily == null) {
Font defaultFont = DEFAULT_FONT.get();
fontReference = defaultFont != null ? new FontReference(defaultFont) : null;
} else
fontReference = new FontReference(fontFamily);
fontProperty = new SimpleObjectProperty<>(fontReference);
LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System"));
fontProperty.addListener((obs, oldValue, newValue) -> {
if (newValue != null)
config().setLauncherFontFamily(newValue.getFamily());
else
config().setLauncherFontFamily(null);
});
font.set(defaultFont != null ? new FontReference(defaultFont) : null);
} else {
font.set(new FontReference(fontFamily));
}
}
private static Font tryLoadDefaultFont(Path dir) {
@@ -231,64 +227,35 @@ public final class FontManager {
}
}
public static ObjectProperty<FontReference> fontProperty() {
return fontProperty;
public static ReadOnlyObjectProperty<FontReference> fontProperty() {
return font;
}
public static FontReference getFont() {
return fontProperty.get();
}
public static void setFont(FontReference font) {
fontProperty.set(font);
return font.get();
}
public static void setFontFamily(String fontFamily) {
setFont(fontFamily != null ? new FontReference(fontFamily) : null);
config().setLauncherFontFamily(fontFamily);
updateFont();
}
// https://github.com/HMCL-dev/HMCL/issues/4072
public static final class FontReference {
private final @NotNull String family;
private final @Nullable String style;
public record FontReference(@NotNull String family, @Nullable String style) {
public FontReference {
Objects.requireNonNull(family);
}
public FontReference(@NotNull String family) {
this.family = Objects.requireNonNull(family);
this.style = null;
this(family, null);
}
public FontReference(@NotNull Font font) {
this.family = font.getFamily();
this.style = font.getStyle();
}
public @NotNull String getFamily() {
return family;
}
public @Nullable String getStyle() {
return style;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof FontReference))
return false;
FontReference that = (FontReference) o;
return Objects.equals(family, that.family) && Objects.equals(style, that.style);
}
@Override
public int hashCode() {
return Objects.hash(family, style);
}
@Override
public String toString() {
return String.format("FontReference[family='%s', style='%s']", family, style);
this(font.getFamily(), font.getStyle());
}
}
private FontManager() {
}
}
@@ -19,131 +19,109 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jackhuang.hmcl.util.javafx.PropertyUtils;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
import java.util.*;
@JsonAdapter(GlobalConfig.Serializer.class)
public final class GlobalConfig implements Observable {
@JsonAdapter(GlobalConfig.Adapter.class)
public final class GlobalConfig extends ObservableSetting {
@Nullable
public static GlobalConfig fromJson(String json) throws JsonParseException {
GlobalConfig loaded = Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
if (loaded == null) {
return null;
}
GlobalConfig instance = new GlobalConfig();
PropertyUtils.copyProperties(loaded, instance);
instance.unknownFields.putAll(loaded.unknownFields);
return instance;
return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
}
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
private final IntegerProperty logRetention = new SimpleIntegerProperty();
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
private final Map<String, Object> unknownFields = new HashMap<>();
private final transient ObservableHelper helper = new ObservableHelper(this);
public GlobalConfig() {
PropertyUtils.attachListener(this, helper);
}
@Override
public void addListener(InvalidationListener listener) {
helper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
helper.removeListener(listener);
register();
}
public String toJson() {
return Config.CONFIG_GSON.toJson(this);
}
public int getAgreementVersion() {
return agreementVersion.get();
}
@SerializedName("agreementVersion")
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
public IntegerProperty agreementVersionProperty() {
return agreementVersion;
}
public int getAgreementVersion() {
return agreementVersion.get();
}
public void setAgreementVersion(int agreementVersion) {
this.agreementVersion.set(agreementVersion);
}
public int getTerracottaAgreementVersion() {
return terracottaAgreementVersion.get();
}
@SerializedName("terracottaAgreementVersion")
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
public IntegerProperty terracottaAgreementVersionProperty() {
return terracottaAgreementVersion;
}
public int getTerracottaAgreementVersion() {
return terracottaAgreementVersion.get();
}
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
@SerializedName("platformPromptVersion")
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
public IntegerProperty platformPromptVersionProperty() {
return platformPromptVersion;
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
public void setPlatformPromptVersion(int platformPromptVersion) {
this.platformPromptVersion.set(platformPromptVersion);
}
public int getLogRetention() {
return logRetention.get();
}
@SerializedName("logRetention")
private final IntegerProperty logRetention = new SimpleIntegerProperty(20);
public IntegerProperty logRetentionProperty() {
return logRetention;
}
public int getLogRetention() {
return logRetention.get();
}
public void setLogRetention(int logRetention) {
this.logRetention.set(logRetention);
}
public boolean isEnableOfflineAccount() {
return enableOfflineAccount.get();
}
@SerializedName("enableOfflineAccount")
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
public BooleanProperty enableOfflineAccountProperty() {
return enableOfflineAccount;
}
public boolean isEnableOfflineAccount() {
return enableOfflineAccount.get();
}
public void setEnableOfflineAccount(boolean value) {
enableOfflineAccount.set(value);
}
@SerializedName("fontAntiAliasing")
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
public StringProperty fontAntiAliasingProperty() {
return fontAntiAliasing;
}
@@ -156,89 +134,24 @@ public final class GlobalConfig implements Observable {
this.fontAntiAliasing.set(value);
}
@SerializedName("userJava")
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
public ObservableSet<String> getUserJava() {
return userJava;
}
@SerializedName("disabledJava")
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
public ObservableSet<String> getDisabledJava() {
return disabledJava;
}
public static final class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
"agreementVersion",
"terracottaAgreementVersion",
"platformPromptVersion",
"logRetention",
"userJava",
"disabledJava",
"enableOfflineAccount",
"fontAntiAliasing"
));
static final class Adapter extends ObservableSetting.Adapter<GlobalConfig> {
@Override
public JsonElement serialize(GlobalConfig src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null) {
return JsonNull.INSTANCE;
}
JsonObject jsonObject = new JsonObject();
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("terracottaAgreementVersion", context.serialize(src.getTerracottaAgreementVersion()));
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
if (src.enableOfflineAccount.get())
jsonObject.addProperty("enableOfflineAccount", true);
if (!src.getUserJava().isEmpty())
jsonObject.add("userJava", context.serialize(src.getUserJava()));
if (!src.getDisabledJava().isEmpty())
jsonObject.add("disabledJava", context.serialize(src.getDisabledJava()));
for (Map.Entry<String, Object> entry : src.unknownFields.entrySet()) {
jsonObject.add(entry.getKey(), context.serialize(entry.getValue()));
}
return jsonObject;
}
@Override
public GlobalConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject)) return null;
JsonObject obj = (JsonObject) json;
GlobalConfig config = new GlobalConfig();
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setTerracottaAgreementVersion(Optional.ofNullable(obj.get("terracottaAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null));
JsonElement userJava = obj.get("userJava");
if (userJava != null && userJava.isJsonArray()) {
for (JsonElement element : userJava.getAsJsonArray()) {
config.userJava.add(element.getAsString());
}
}
JsonElement disabledJava = obj.get("disabledJava");
if (disabledJava != null && disabledJava.isJsonArray()) {
for (JsonElement element : disabledJava.getAsJsonArray()) {
config.disabledJava.add(element.getAsString());
}
}
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
if (!knownFields.contains(entry.getKey())) {
config.unknownFields.put(entry.getKey(), context.deserialize(entry.getValue(), Object.class));
}
}
return config;
protected GlobalConfig createInstance() {
return new GlobalConfig();
}
}
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
@@ -114,6 +115,8 @@ public final class ProxyManager {
config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator);
FetchTask.notifyInitialized();
}
private static abstract class AbstractProxySelector extends ProxySelector {
@@ -47,6 +47,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class StyleSheets {
private static final int FONT_STYLE_SHEET_INDEX = 0;
private static final int THEME_STYLE_SHEET_INDEX = 1;
private static final int BRIGHTNESS_SHEET_INDEX = 2;
private static final ObservableList<String> stylesheets;
@@ -54,12 +55,16 @@ public final class StyleSheets {
String[] array = new String[]{
getFontStyleSheet(),
getThemeStyleSheet(),
getBrightnessStyleSheet(),
"/assets/css/root.css"
};
stylesheets = FXCollections.observableList(Arrays.asList(array));
FontManager.fontProperty().addListener(o -> stylesheets.set(FONT_STYLE_SHEET_INDEX, getFontStyleSheet()));
Themes.colorSchemeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()));
Themes.colorSchemeProperty().addListener(o -> {
stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet());
stylesheets.set(BRIGHTNESS_SHEET_INDEX, getBrightnessStyleSheet());
});
}
private static String toStyleSheetUri(String styleSheet, String fallback) {
@@ -85,11 +90,11 @@ public final class StyleSheets {
final String defaultCss = "/assets/css/font.css";
final FontManager.FontReference font = FontManager.getFont();
if (font == null || "System".equals(font.getFamily()))
if (font == null || "System".equals(font.family()))
return defaultCss;
String fontFamily = font.getFamily();
String style = font.getStyle();
String fontFamily = font.family();
String style = font.style();
String weight = null;
String posture = null;
@@ -131,6 +136,12 @@ public final class StyleSheets {
return toStyleSheetUri(builder.toString(), defaultCss);
}
private static String getBrightnessStyleSheet() {
return Themes.getColorScheme().getBrightness() == Brightness.LIGHT
? "/assets/css/brightness-light.css"
: "/assets/css/brightness-dark.css";
}
private static void addColor(StringBuilder builder, String name, Color color) {
builder.append(" ").append(name)
.append(": ").append(ThemeColor.getColorDisplayName(color)).append(";\n");
@@ -170,13 +181,7 @@ public final class StyleSheets {
addColor(builder, scheme, ColorRole.ON_SURFACE_VARIANT, 0.38);
addColor(builder, scheme, ColorRole.SURFACE_CONTAINER_LOW, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.8);
// If we use -monet-error-container as the tag color, there may be a conflict between the tag and background colors on the mod management page
if (scheme.getBrightness() == Brightness.LIGHT) {
addColor(builder, "-warning-tag-background", Color.web("#f1aeb5"));
} else {
addColor(builder, "-warning-tag-background", Color.web("#2c0b0e"));
}
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
builder.append("}\n");
return toStyleSheetUri(builder.toString(), blueCss);
@@ -459,6 +459,20 @@ public final class VersionSetting implements Cloneable, Observable {
showLogsProperty.set(showLogs);
}
private final BooleanProperty enableDebugLogOutputProperty = new SimpleBooleanProperty(this, "enableDebugLogOutput", false);
public BooleanProperty enableDebugLogOutputProperty() {
return enableDebugLogOutputProperty;
}
public boolean isEnableDebugLogOutput() {
return enableDebugLogOutputProperty.get();
}
public void setEnableDebugLogOutput(boolean u) {
this.enableDebugLogOutputProperty.set(u);
}
// Minecraft settings.
private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", "");
@@ -776,6 +790,7 @@ public final class VersionSetting implements Cloneable, Observable {
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
obj.addProperty("showLogs", src.isShowLogs());
obj.addProperty("enableDebugLogOutput", src.isEnableDebugLogOutput());
obj.addProperty("gameDir", src.getGameDir());
obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal());
obj.addProperty("processPriority", src.getProcessPriority().ordinal());
@@ -847,6 +862,7 @@ public final class VersionSetting implements Cloneable, Observable {
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setEnableDebugLogOutput(Optional.ofNullable(obj.get("enableDebugLogOutput")).map(JsonElement::getAsBoolean).orElse(false));
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
@@ -0,0 +1,191 @@
/*
* 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.download.ArtifactMalformedException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
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.DigestInputStream;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
public final class TerracottaBundle {
private final Path root;
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck hash;
private final Map<String, FileDownloadTask.IntegrityCheck> files;
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
this.root = root;
this.links = links;
this.hash = hash;
this.files = files;
}
public Task<Path> download(AbstractTerracottaProvider.DownloadContext context) {
return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz"))
.thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
FetchTask.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 {
context.checkCancellation();
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess()) {
context.checkCancellation();
}
delegate.close();
}
};
}
};
context.bindProgress(download.progressProperty());
return download.thenSupplyAsync(() -> pkg);
});
}
public Task<?> install(Path pkg) {
return Task.runAsync(() -> {
Files.createDirectories(root);
FileUtils.cleanDirectory(root);
try (TarFileTree tree = TarFileTree.open(pkg)) {
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
String file = entry.getKey();
FileDownloadTask.IntegrityCheck check = entry.getValue();
Path path = root.resolve(file);
TarArchiveEntry archive = tree.getEntry("/" + file);
if (archive == null) {
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (
InputStream is = tree.getInputStream(archive);
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
) {
is.transferTo(os);
}
String hash = HexFormat.of().formatHex(digest.digest());
if (!check.getChecksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
}
switch (OperatingSystem.CURRENT_OS) {
case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode()));
}
}
}
}).whenComplete(exception -> {
if (exception != null) {
FileUtils.deleteDirectory(root);
}
});
}
public Path locate(String file) {
FileDownloadTask.IntegrityCheck check = files.get(file);
if (check == null) {
throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file));
}
return root.resolve(file).toAbsolutePath();
}
public AbstractTerracottaProvider.Status status() throws IOException {
if (Files.exists(root) && isLocalBundleValid()) {
return AbstractTerracottaProvider.Status.READY;
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.", e);
}
return AbstractTerracottaProvider.Status.NOT_EXIST;
}
private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer.
long total = 0;
byte[] buffer = new byte[8192];
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
Path path = root.resolve(entry.getKey());
FileDownloadTask.IntegrityCheck check = entry.getValue();
if (!Files.isReadable(path)) {
return false;
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
int n;
while ((n = is.read(buffer)) >= 0) {
total += n;
if (total >= 50 * 1024 * 1024) { // >=50MB
return false;
}
}
}
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
return false;
}
}
return true;
}
}
@@ -18,7 +18,6 @@
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;
@@ -29,24 +28,26 @@ 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.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FXThread;
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.HttpRequest;
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.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
@@ -70,9 +71,10 @@ public final class TerracottaManager {
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()));
case READY -> launch(setState(new TerracottaState.Launching()), false);
}
} catch (Exception e) {
LOG.warning("Cannot initialize Terracotta.", e);
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
});
@@ -82,114 +84,145 @@ public final class TerracottaManager {
return STATE.getReadOnlyProperty();
}
static {
Lang.thread(() -> {
while (true) {
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
LockSupport.parkNanos(500_000);
private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true);
@FXThread // Written in FXThread, read-only on background daemon
private static volatile boolean daemonRunning = false;
private static void runBackground() {
final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500);
final long BACKGROUND = TimeUnit.SECONDS.toMillis(15);
while (true) {
if (daemonRunning) {
LockSupport.parkNanos(ACTIVE);
} else {
long deadline = System.currentTimeMillis() + BACKGROUND;
do {
LockSupport.parkUntil(deadline);
} while (!daemonRunning && System.currentTimeMillis() < deadline - 100);
}
if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) {
continue;
}
int port = state.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port))
.retry(5)
.getJson(TerracottaState.Ready.class);
if (object.index <= index) {
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);
object.port = port;
next = object;
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
}, "Terracotta Background Daemon", true);
compareAndSet(state, next);
}
}
public static boolean validate(Path file) {
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
public static TerracottaState.Preparing install(@Nullable Path file) {
@FXThread
public static void switchDaemon(boolean active) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
Task.supplyAsync(Schedulers.io(), () -> {
return file != null ? TarFileTree.open(file) : null;
}).thenComposeAsync(Schedulers.javafx(), tree -> {
return getProvider().install(preparing, tree).whenComplete(exception -> {
if (tree != null) {
tree.close();
}
if (exception != null) {
throw exception;
}
});
}).whenComplete(exception -> {
if (exception == null) {
try {
TerracottaMetadata.removeLegacyVersionFiles();
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching);
}
} else if (exception instanceof CancellationException) {
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
LOG.warning("Cannot install terracotta from local package.", exception);
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
boolean dr = daemonRunning;
if (dr != active) {
daemonRunning = active;
if (active) {
LockSupport.unpark(DAEMON);
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
}
private static ITerracottaProvider getProvider() {
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
private static AbstractTerracottaProvider getProvider() {
AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static TerracottaState recover(@Nullable Path file) {
public static boolean isInvalidBundle(Path file) {
return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
@FXThread
public static TerracottaState.Preparing download() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true);
Task.composeAsync(() -> getProvider().download(preparing))
.thenComposeAsync(pkg -> {
if (!preparing.requestInstallFence()) {
return null;
}
return getProvider().install(pkg).thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
});
}).whenComplete(exception -> {
if (exception instanceof CancellationException) {
// no-op
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState.Preparing install(Path bundle) {
FXUtils.checkFxUserThread();
if (isInvalidBundle(bundle)) {
return null;
}
TerracottaState state = STATE_V.get();
TerracottaState.Preparing preparing;
if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) {
preparing = previousPreparing;
} else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) {
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false);
} else {
return null;
}
Task.composeAsync(() -> getProvider().install(bundle))
.thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
})
.whenComplete(exception -> {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}).start();
return state != preparing && compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState recover() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
@@ -198,21 +231,23 @@ public final class TerracottaManager {
}
try {
// FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making
// hash check accept 50MB at most. Calling it on JavaFX should be safe.
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> install(file);
case NOT_EXIST, LEGACY_VERSION -> download();
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching);
launch(launching, false);
yield launching;
}
};
} catch (NullPointerException | IOException e) {
} catch (RuntimeException | 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) {
private static void launch(TerracottaState.Launching state, boolean removeLegacy) {
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)));
@@ -230,7 +265,7 @@ public final class TerracottaManager {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException("Process has exited for 10s.");
throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode()));
}
}
}
@@ -238,6 +273,9 @@ public final class TerracottaManager {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
if (removeLegacy) {
TerracottaMetadata.removeLegacyVersionFiles();
}
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
@@ -272,23 +310,27 @@ public final class TerracottaManager {
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();
String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/scanning", portSpecific.port), Map.of(
"player", getPlayerName()
));
new GetTask(uri).setSignificance(Task.TaskSignificance.MINOR).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
public static Task<TerracottaState.GuestConnecting> 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()
)))
String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/guesting", portSpecific.port), Map.of(
"room", room,
"player", getPlayerName()
));
return new GetTask(uri)
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
} else {
return null;
@@ -20,10 +20,10 @@ 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.AbstractTerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
@@ -32,6 +32,8 @@ 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.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -40,11 +42,11 @@ import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -52,46 +54,60 @@ public final class TerracottaMetadata {
private TerracottaMetadata() {
}
public record Link(@SerializedName("desc") LocalizedText description, String link) {
private record Options(String version, String classifier) {
public String replace(String value) {
return value.replace("${version}", version).replace("${classifier}", classifier);
}
}
@JsonSerializable
public record Link(
@SerializedName("desc") LocalizedText description,
@SerializedName("link") String link
) {
}
@JsonSerializable
private record Package(
@SerializedName("hash") String hash,
@SerializedName("files") Map<String, String> files
) {
}
@JsonSerializable
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("packages") Map<String, Package> pkgs,
@SerializedName("downloads") List<String> downloads,
@SerializedName("downloads_CN") List<String> downloadsCN,
@SerializedName("links") List<Link> links
) {
private @Nullable TerracottaNative of(String classifier) {
String hash = this.classifiers.get(classifier);
if (hash == null)
private @Nullable TerracottaBundle resolve(Options options) {
Package pkg = pkgs.get(options.classifier);
if (pkg == null) {
return null;
if (!hash.startsWith("sha256:"))
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
hash = hash.substring("sha256:".length());
List<URI> links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size());
for (String download : LocaleUtils.IS_CHINA_MAINLAND
? Lang.merge(this.downloadsCN, this.downloads)
: Lang.merge(this.downloads, this.downloadsCN)) {
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
}
return new TerracottaNative(
Collections.unmodifiableList(links),
Metadata.DEPENDENCIES_DIRECTORY.resolve(
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
).toAbsolutePath(),
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> URI.create(options.replace(link)))
.toList();
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue())
));
return new TerracottaBundle(
Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(),
links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash),
files
);
}
}
public static final ITerracottaProvider PROVIDER;
public static final AbstractTerracottaProvider 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(
@@ -99,8 +115,6 @@ public final class TerracottaMetadata {
"launcher_version", Metadata.VERSION
));
private static final Pattern LEGACY;
private static final List<String> RECENT;
private static final String LATEST;
static {
@@ -111,95 +125,80 @@ public final class TerracottaMetadata {
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)
));
}
Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName());
TerracottaBundle bundle = config.resolve(options);
AbstractTerracottaProvider provider;
if (bundle == null || (provider = locateProvider(bundle, options)) == null) {
PROVIDER = null;
PACKAGE_NAME = null;
PACKAGE_LINKS = null;
} else {
PROVIDER = provider;
PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz");
List<Link> packageLinks = config.links.stream()
.map(link -> new Link(link.description, options.replace(link.link)))
.collect(Collectors.toList());
Collections.shuffle(packageLinks);
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
} else {
PACKAGE_LINKS = null;
}
}
private record ProviderContext(ITerracottaProvider provider, String branch) {
ProviderContext(ITerracottaProvider provider, String system, String arch) {
this(provider, system + "-" + arch);
}
}
@Nullable
private static ProviderContext locateProvider(Config config) {
String arch = Architecture.SYSTEM_ARCH.getCheckedName();
private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) {
String prefix = options.replace("terracotta-${version}-${classifier}");
// FIXME: As HMCL is a cross-platform application, developers may mistakenly locate
// non-existent files in non-native platform logic without assertion errors during debugging.
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
yield null;
TerracottaNative target = config.of("windows-%s.exe".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "windows", arch)
: null;
}
case LINUX -> {
TerracottaNative target = config.of("linux-%s".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "linux", arch)
: null;
}
case MACOS -> {
TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch));
TerracottaNative binary = config.of("macos-%s".formatted(arch));
yield installer != null && binary != null
? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch)
: null;
yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe"));
}
case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix));
case MACOS -> new MacOSProvider(
bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg")
);
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;
}
public static void removeLegacyVersionFiles() {
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
if (terracotta == null)
return;
for (Path path : terracotta) {
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", 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;
}
}
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
return terracotta != null && terracotta.iterator().hasNext();
}
}
return false;
private static @Nullable DirectoryStream<Path> collectLegacyVersionFiles() throws IOException {
Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta");
if (Files.notExists(terracottaDir))
return null;
VersionRange<VersionNumber> range = VersionNumber.atMost(LATEST);
return Files.newDirectoryStream(terracottaDir, path -> {
String name = FileUtils.getName(path);
return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name));
});
}
}
@@ -1,147 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.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;
}
}
@@ -21,15 +21,16 @@ 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 javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
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.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
@@ -63,32 +64,38 @@ public abstract sealed class TerracottaState {
}
}
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean installFence = new AtomicBoolean(false);
private final AtomicBoolean hasInstallFence;
Preparing(ReadOnlyDoubleWrapper progress) {
Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) {
this.progress = progress;
this.hasInstallFence = new AtomicBoolean(hasInstallFence);
}
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);
return hasInstallFence.compareAndSet(true, false);
}
public boolean hasInstallFence() {
return hasInstallFence.get();
}
@Override
public boolean hasInstallFence() {
return !installFence.get();
public void bindProgress(ObservableDoubleValue progress) {
this.progress.bind(progress);
}
@Override
public void checkCancellation() {
if (!hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
}
}
@@ -112,7 +119,7 @@ public abstract sealed class TerracottaState {
@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 = GuestConnecting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
@@ -202,12 +209,34 @@ public abstract sealed class TerracottaState {
}
}
public static final class GuestStarting extends Ready {
GuestStarting(int port, int index, String state) {
public static final class GuestConnecting extends Ready {
GuestConnecting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestStarting extends Ready {
public enum Difficulty {
UNKNOWN,
EASIEST,
SIMPLE,
MEDIUM,
TOUGH
}
@SerializedName("difficulty")
private final Difficulty difficulty;
GuestStarting(int port, int index, String state, Difficulty difficulty) {
super(port, index, state);
this.difficulty = difficulty;
}
public Difficulty getDifficulty() {
return difficulty;
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@@ -0,0 +1,63 @@
/*
* 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.ObservableDoubleValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.FXThread;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
public abstract class AbstractTerracottaProvider {
public enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
public interface DownloadContext {
@FXThread
void bindProgress(ObservableDoubleValue progress);
void checkCancellation() throws CancellationException;
}
protected final TerracottaBundle bundle;
protected AbstractTerracottaProvider(TerracottaBundle bundle) {
this.bundle = bundle;
}
public Status status() throws IOException {
return bundle.status();
}
public final Task<Path> download(DownloadContext progress) {
return bundle.download(progress);
}
public Task<?> install(Path pkg) throws IOException {
return bundle.install(pkg);
}
public abstract List<String> ofCommandLine(Path portTransfer);
}
@@ -17,51 +17,21 @@
*/
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 org.jackhuang.hmcl.terracotta.TerracottaBundle;
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 final class GeneralProvider extends AbstractTerracottaProvider {
private final Path executable;
public GeneralProvider(TerracottaNative target) {
this.target = target;
public GeneralProvider(TerracottaBundle bundle, Path executable) {
super(bundle);
this.executable = executable;
}
@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());
public List<String> ofCommandLine(Path portTransfer) {
return List.of(executable.toString(), "--hmcl", portTransfer.toString());
}
}
@@ -1,77 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.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);
}
@@ -19,103 +19,77 @@ package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MacOSProvider implements ITerracottaProvider {
public final TerracottaNative installer, binary;
public final class MacOSProvider extends AbstractTerracottaProvider {
private final Path executable, installer;
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) {
super(bundle);
this.executable = executable;
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();
return bundle.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
assert installer != null && binary != null;
public Task<?> install(Path pkg) throws IOException {
return super.install(pkg).thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
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
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer));
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
return Task.allOf(
installerTask.thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
movedInstaller, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer.getPath()));
Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(movedInstaller.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
pkg, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(pkg.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d",
pkg,
process.getExitCode()
));
}
});
}),
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)))
);
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode()
));
}
});
});
}
@Override
public List<String> ofCommandLine(Path path) {
assert binary != null;
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
return List.of(executable.toString(), "--hmcl", path.toString());
}
}
@@ -113,8 +113,16 @@ public final class Themes {
return colorScheme.get();
}
private static final ObjectBinding<Color> titleFill = Bindings.createObjectBinding(
() -> config().isTitleTransparent()
? getColorScheme().getOnSurface()
: getColorScheme().getOnPrimaryContainer(),
colorSchemeProperty(),
config().titleTransparentProperty()
);
public static ObservableValue<Color> titleFillProperty() {
return colorSchemeProperty().getOnPrimaryContainer();
return titleFill;
}
public static BooleanBinding darkModeProperty() {
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
@@ -72,9 +73,10 @@ import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.setting.ConfigHolder.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class Controllers {
public static final String JAVA_VERSION_TIP = "javaVersion";
@@ -505,8 +507,8 @@ public final class Controllers {
return prompt(title, onResult, "");
}
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult, String initialValue) {
InputDialogPane pane = new InputDialogPane(title, initialValue, onResult);
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult, String initialValue, ValidatorBase... validators) {
InputDialogPane pane = new InputDialogPane(title, initialValue, onResult, validators);
dialog(pane);
return pane.getCompletableFuture();
}
@@ -0,0 +1,155 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXDialog;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jetbrains.annotations.Nullable;
import java.util.Optional;
import java.util.function.Consumer;
public final class DialogUtils {
private DialogUtils() {
}
public static final String PROPERTY_DIALOG_INSTANCE = DialogUtils.class.getName() + ".dialog.instance";
public static final String PROPERTY_DIALOG_PANE_INSTANCE = DialogUtils.class.getName() + ".dialog.pane.instance";
public static final String PROPERTY_DIALOG_CLOSE_HANDLER = DialogUtils.class.getName() + ".dialog.closeListener";
public static final String PROPERTY_PARENT_PANE_REF = DialogUtils.class.getName() + ".dialog.parentPaneRef";
public static final String PROPERTY_PARENT_DIALOG_REF = DialogUtils.class.getName() + ".dialog.parentDialogRef";
public static void show(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> show(decorator, content));
return;
}
show(decorator.getDrawerWrapper(), content, (dialog) -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingWindow(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(decorator.getDrawerWrapper());
});
}
public static void show(StackPane container, Node content) {
show(container, content, null);
}
public static void show(StackPane container, Node content, @Nullable Consumer<JFXDialog> onDialogCreated) {
FXUtils.checkFxUserThread();
JFXDialog dialog = (JFXDialog) container.getProperties().get(PROPERTY_DIALOG_INSTANCE);
JFXDialogPane dialogPane = (JFXDialogPane) container.getProperties().get(PROPERTY_DIALOG_PANE_INSTANCE);
if (dialog == null) {
dialog = new JFXDialog(AnimationUtils.isAnimationEnabled()
? JFXDialog.DialogTransition.CENTER
: JFXDialog.DialogTransition.NONE);
dialogPane = new JFXDialogPane();
dialog.setContent(dialogPane);
dialog.setDialogContainer(container);
dialog.setOverlayClose(false);
container.getProperties().put(PROPERTY_DIALOG_INSTANCE, dialog);
container.getProperties().put(PROPERTY_DIALOG_PANE_INSTANCE, dialogPane);
if (onDialogCreated != null) {
onDialogCreated.accept(dialog);
}
dialog.show();
}
content.getProperties().put(PROPERTY_PARENT_PANE_REF, dialogPane);
content.getProperties().put(PROPERTY_PARENT_DIALOG_REF, dialog);
dialogPane.push(content);
EventHandler<DialogCloseEvent> handler = event -> close(content);
content.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler);
content.addEventHandler(DialogCloseEvent.CLOSE, handler);
handleDialogShown(dialog, content);
}
private static void handleDialogShown(JFXDialog dialog, Node node) {
if (dialog.isVisible()) {
dialog.requestFocus();
if (node instanceof DialogAware dialogAware)
dialogAware.onDialogShown();
} else {
dialog.visibleProperty().addListener(new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
if (newValue) {
dialog.requestFocus();
if (node instanceof DialogAware dialogAware)
dialogAware.onDialogShown();
observable.removeListener(this);
}
}
});
}
}
@SuppressWarnings("unchecked")
public static void close(Node content) {
FXUtils.checkFxUserThread();
Optional.ofNullable(content.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER))
.ifPresent(handler -> content.removeEventHandler(DialogCloseEvent.CLOSE, (EventHandler<DialogCloseEvent>) handler));
JFXDialogPane pane = (JFXDialogPane) content.getProperties().get(PROPERTY_PARENT_PANE_REF);
JFXDialog dialog = (JFXDialog) content.getProperties().get(PROPERTY_PARENT_DIALOG_REF);
if (dialog != null && pane != null) {
if (pane.size() == 1 && pane.peek().orElse(null) == content) {
dialog.setOnDialogClosed(e -> pane.pop(content));
dialog.close();
StackPane container = dialog.getDialogContainer();
if (container != null) {
container.getProperties().remove(PROPERTY_DIALOG_INSTANCE);
container.getProperties().remove(PROPERTY_DIALOG_PANE_INSTANCE);
container.getProperties().remove(PROPERTY_PARENT_DIALOG_REF);
container.getProperties().remove(PROPERTY_PARENT_PANE_REF);
}
} else {
pane.pop(content);
}
if (content instanceof DialogAware dialogAware) {
dialogAware.onDialogClosed();
}
}
}
}
@@ -137,6 +137,7 @@ public final class FXUtils {
public static final @Nullable ReadOnlyObjectProperty<Color> ACCENT_COLOR;
public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY;
public static final @Nullable MethodHandle FOCUS_VISIBLE_PROPERTY;
static {
String jfxVersion = System.getProperty("javafx.version");
@@ -203,6 +204,20 @@ public final class FXUtils {
}
}
TEXT_TRUNCATED_PROPERTY = textTruncatedProperty;
MethodHandle focusVisibleProperty = null;
if (JAVAFX_MAJOR_VERSION >= 19) {
try {
focusVisibleProperty = MethodHandles.publicLookup().findVirtual(
Node.class,
"focusVisibleProperty",
MethodType.methodType(ReadOnlyBooleanProperty.class)
);
} catch (Throwable e) {
LOG.warning("Failed to lookup focusVisibleProperty", e);
}
}
FOCUS_VISIBLE_PROPERTY = focusVisibleProperty;
}
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
@@ -423,6 +438,20 @@ public final class FXUtils {
}
}
public static @Nullable ReadOnlyBooleanProperty focusVisibleProperty(Node node) {
if (FOCUS_VISIBLE_PROPERTY != null) {
try {
return (ReadOnlyBooleanProperty) FOCUS_VISIBLE_PROPERTY.invokeExact(node);
} 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);
@@ -1188,7 +1217,16 @@ public final class FXUtils {
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth));
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
}
public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uri)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
}
public static ObservableValue<Image> newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
@@ -1201,6 +1239,7 @@ public final class FXUtils {
LOG.warning("An exception encountered while loading remote image: " + url, exception);
}
})
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return image;
}
@@ -1302,17 +1341,11 @@ public final class FXUtils {
}
public static <T> Callback<ListView<T>, ListCell<T>> jfxListCellFactory(Function<T, Node> graphicBuilder) {
Holder<Object> lastCell = new Holder<>();
return view -> new JFXListCell<T>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
if (!empty) {
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
setGraphic(graphicBuilder.apply(item));
@@ -1357,7 +1390,7 @@ public final class FXUtils {
public static void onClicked(Node node, Runnable action) {
node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 1) {
if (e.getButton() == MouseButton.PRIMARY) {
action.run();
e.consume();
}
@@ -1403,16 +1436,6 @@ public final class FXUtils {
});
}
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) {
@@ -1468,7 +1491,6 @@ public final class FXUtils {
hyperlinkAction.accept(link);
});
text.setCursor(Cursor.HAND);
text.setFill(Color.web("#0070E0"));
text.setUnderline(true);
texts.add(text);
} else if ("b".equals(element.getTagName())) {
@@ -24,11 +24,11 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
@@ -40,6 +40,7 @@ import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
@@ -50,9 +51,12 @@ import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@@ -81,6 +85,7 @@ public class GameCrashWindow extends Stage {
private final ProcessListener.ExitType exitType;
private final LaunchOptions launchOptions;
private final View view;
private final StackPane stackPane;
private final List<Log> logs;
@@ -93,7 +98,7 @@ public class GameCrashWindow extends Stage {
this.logs = logs;
this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " MB").orElse("-");
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-");
total_memory = MEGABYTES.formatBytes(SystemInfo.getTotalMemorySize());
@@ -103,9 +108,10 @@ public class GameCrashWindow extends Stage {
this.view = new View();
this.stackPane = new StackPane(view);
this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction));
setScene(new Scene(view, 800, 480));
setScene(new Scene(stackPane, 800, 480));
StyleSheets.init(getScene());
setTitle(i18n("game.crash.title"));
FXUtils.setIcon(this);
@@ -269,22 +275,41 @@ public class GameCrashWindow extends Stage {
CompletableFuture.supplyAsync(() ->
logs.stream().map(Log::getLog).collect(Collectors.joining("\n")))
.thenComposeAsync(logs ->
LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString()))
.handleAsync((result, exception) -> {
Alert alert;
.thenComposeAsync(logs -> {
long processStartTime = managedProcess.getProcess().info()
.startInstant()
.map(Instant::toEpochMilli).orElseGet(() -> {
try {
return ManagementFactory.getRuntimeMXBean().getStartTime();
} catch (Throwable e) {
LOG.warning("Failed to get process start time", e);
return 0L;
}
});
return LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs,
new CommandBuilder().addAll(managedProcess.getCommands()).toString(),
path -> {
try {
FileTime lastModifiedTime = Files.getLastModifiedTime(path);
return lastModifiedTime.toMillis() >= processStartTime;
} catch (Throwable e) {
LOG.warning("Failed to read file attributes", e);
return false;
}
});
})
.handleAsync((result, exception) -> {
if (exception == null) {
FXUtils.showFileInExplorer(logFile);
alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile));
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build();
DialogUtils.show(stackPane, dialog);
} else {
LOG.warning("Failed to export game crash info", exception);
alert = new Alert(Alert.AlertType.WARNING, i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception));
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build();
DialogUtils.show(stackPane, dialog);
}
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
return null;
}, Schedulers.javafx());
}
@@ -400,6 +425,8 @@ public class GameCrashWindow extends Stage {
reasonPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
reasonPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
feedbackTextFlow.getStyleClass().add("crash-reason-text-flow");
gameDirPane.setPadding(new Insets(8));
VBox.setVgrow(gameDirPane, Priority.ALWAYS);
FXUtils.onChangeAndOperate(feedbackTextFlow.visibleProperty(), visible -> {
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRippler;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
@@ -62,7 +63,7 @@ public class InstallerItem extends Control {
private final ObjectProperty<Runnable> onInstall = new SimpleObjectProperty<>(this, "onInstall");
private final ObjectProperty<Runnable> onRemove = new SimpleObjectProperty<>(this, "onRemove");
public interface State {
public sealed interface State {
}
public static final class InstallableState implements State {
@@ -72,46 +73,10 @@ public class InstallerItem extends Control {
}
}
public static final class IncompatibleState implements State {
private final String incompatibleItemName;
private final String incompatibleItemVersion;
public IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) {
this.incompatibleItemName = incompatibleItemName;
this.incompatibleItemVersion = incompatibleItemVersion;
}
public String getIncompatibleItemName() {
return incompatibleItemName;
}
public String getIncompatibleItemVersion() {
return incompatibleItemVersion;
}
public record IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) implements State {
}
public static final class InstalledState implements State {
private final String version;
private final boolean external;
private final boolean incompatibleWithGame;
public InstalledState(String version, boolean external, boolean incompatibleWithGame) {
this.version = version;
this.external = external;
this.incompatibleWithGame = incompatibleWithGame;
}
public String getVersion() {
return version;
}
public boolean isExternal() {
return external;
}
public boolean isIncompatibleWithGame() {
return incompatibleWithGame;
}
public record InstalledState(String version, boolean external, boolean incompatibleWithGame) implements State {
}
public enum Style {
@@ -335,6 +300,7 @@ public class InstallerItem extends Control {
}
pane.getStyleClass().add("installer-item");
RipplerContainer container = new RipplerContainer(pane);
container.setPosition(JFXRippler.RipplerPos.FRONT);
getChildren().setAll(container);
pane.pseudoClassStateChanged(LIST_ITEM, control.style == Style.LIST_ITEM);
@@ -367,21 +333,20 @@ public class InstallerItem extends Control {
statusLabel.textProperty().bind(Bindings.createStringBinding(() -> {
State state = control.resolvedStateProperty.get();
if (state instanceof InstalledState) {
InstalledState s = (InstalledState) state;
if (s.incompatibleWithGame) {
return i18n("install.installer.change_version", s.version);
if (state instanceof InstalledState installedState) {
if (installedState.incompatibleWithGame) {
return i18n("install.installer.change_version", installedState.version);
}
if (s.external) {
return i18n("install.installer.external_version", s.version);
if (installedState.external) {
return i18n("install.installer.external_version", installedState.version);
}
return i18n("install.installer.version", s.version);
return i18n("install.installer.version", installedState.version);
} else if (state instanceof InstallableState) {
return control.style == Style.CARD
? i18n("install.installer.do_not_install")
: i18n("install.installer.not_installed");
} else if (state instanceof IncompatibleState) {
return i18n("install.installer.incompatible", i18n("install.installer." + ((IncompatibleState) state).incompatibleItemName));
} else if (state instanceof IncompatibleState incompatibleState) {
return i18n("install.installer.incompatible", i18n("install.installer." + incompatibleState.incompatibleItemName));
} else {
throw new AssertionError("Unknown state type: " + state.getClass());
}
@@ -402,7 +367,7 @@ public class InstallerItem extends Control {
} else {
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
State state = control.resolvedStateProperty.get();
return state instanceof InstalledState && !((InstalledState) state).external;
return state instanceof InstalledState installedState && !installedState.external;
}, control.resolvedStateProperty));
}
removeButton.managedProperty().bind(removeButton.visibleProperty());
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXListView;
import com.sun.jna.Pointer;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
@@ -28,26 +29,28 @@ import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
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;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.Dwmapi;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import org.jackhuang.hmcl.util.platform.windows.WinTypes;
import java.io.IOException;
import java.nio.file.Files;
@@ -60,8 +63,8 @@ import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author huangyuhui
@@ -84,6 +87,36 @@ public final class LogWindow extends Stage {
private final LogWindowImpl impl;
private final ManagedProcess gameProcess;
@SuppressWarnings("unused")
private Object windowsDarkModeListenerHolder;
{
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
this.addEventFilter(WindowEvent.WINDOW_SHOWN, new EventHandler<>() {
@Override
public void handle(WindowEvent event) {
LogWindow.this.removeEventFilter(WindowEvent.WINDOW_SHOWN, this);
windowsDarkModeListenerHolder = FXUtils.onWeakChangeAndOperate(Themes.darkModeProperty(), darkMode -> {
if (LogWindow.this.isShowing()) {
WindowsNativeUtils.getWindowHandle(LogWindow.this).ifPresent(handle -> {
if (handle == WinTypes.HANDLE.INVALID_VALUE)
return;
Dwmapi.INSTANCE.DwmSetWindowAttribute(
new WinTypes.HANDLE(Pointer.createConstant(handle)),
WinConstants.DWMWA_USE_IMMERSIVE_DARK_MODE,
new WinTypes.BOOLByReference(new WinTypes.BOOL(darkMode)),
WinTypes.BOOL.SIZE
);
});
}
});
}
});
}
}
public LogWindow(ManagedProcess gameProcess) {
this(gameProcess, new CircularArrayList<>());
}
@@ -164,10 +197,12 @@ public final class LogWindow extends Stage {
private final StringProperty[] buttonText = new StringProperty[LEVELS.length];
private final BooleanProperty[] showLevel = new BooleanProperty[LEVELS.length];
private final JFXComboBox<Integer> cboLines = new JFXComboBox<>();
private final StackPane stackPane = new StackPane();
LogWindowImpl() {
getStyleClass().add("log-window");
listView.getProperties().put("no-smooth-scrolling", true);
listView.setItems(FXCollections.observableList(new CircularArrayList<>(logs.size())));
for (int i = 0; i < LEVELS.length; i++) {
@@ -205,9 +240,8 @@ public final class LogWindow extends Stage {
}
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile));
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build();
DialogUtils.show(stackPane, dialog);
});
FXUtils.showFileInExplorer(logFile);
@@ -231,9 +265,8 @@ public final class LogWindow extends Stage {
LOG.warning("Failed to create minecraft jstack dump", e);
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR, i18n("logwindow.export_dump"));
alert.setTitle(i18n("message.error"));
alert.showAndWait();
var dialog = new MessageDialogPane.Builder(i18n("logwindow.export_dump") + "\n" + StringUtils.getStackTrace(e), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build();
DialogUtils.show(stackPane, dialog);
});
}
@@ -264,8 +297,9 @@ public final class LogWindow extends Stage {
VBox vbox = new VBox(3);
vbox.setPadding(new Insets(3, 0, 3, 0));
vbox.setStyle("-fx-background-color: white");
getChildren().setAll(vbox);
getSkinnable().stackPane.getChildren().setAll(vbox);
getChildren().setAll(getSkinnable().stackPane);
{
BorderPane borderPane = new BorderPane();
@@ -286,8 +320,7 @@ public final class LogWindow extends Stage {
HBox hBox = new HBox(3);
for (int i = 0; i < LEVELS.length; i++) {
ToggleButton button = new ToggleButton();
button.setStyle("-fx-background-color: " + FXUtils.toWeb(LEVELS[i].getColor()) + ";");
button.getStyleClass().add("log-toggle");
button.getStyleClass().addAll("log-toggle", LEVELS[i].name().toLowerCase(Locale.ROOT));
button.textProperty().bind(control.buttonText[i]);
button.setSelected(true);
control.showLevel[i].bind(button.selectedProperty());
@@ -309,8 +342,7 @@ public final class LogWindow extends Stage {
listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT)
+ "\"; -fx-font-size: " + config().getFontSize() + "px;");
Holder<Object> lastCell = new Holder<>();
listView.setCellFactory(x -> new ListCell<Log>() {
listView.setCellFactory(x -> new ListCell<>() {
{
x.setSelectionModel(new NoneMultipleSelectionModel<>());
getStyleClass().add("log-window-list-cell");
@@ -354,11 +386,6 @@ public final class LogWindow extends Stage {
protected void updateItem(Log item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
pseudoClassStateChanged(EMPTY, empty);
pseudoClassStateChanged(FATAL, !empty && item.getLevel() == Log4jLevel.FATAL);
pseudoClassStateChanged(ERROR, !empty && item.getLevel() == Log4jLevel.ERROR);
@@ -25,7 +25,6 @@ import javafx.animation.Animation.Status;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.event.EventHandler;
import javafx.scene.control.IndexedCell;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.input.MouseEvent;
@@ -55,6 +54,8 @@ final class ScrollUtils {
private static final double DEFAULT_SPEED = 1.0;
private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0;
private static final double CUTOFF_DELTA = 0.01;
/**
* Determines if the given ScrollEvent comes from a trackpad.
* <p></p>
@@ -211,7 +212,7 @@ final class ScrollUtils {
break;
}
if (Math.abs(dy) < 0.001) {
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
@@ -226,7 +227,6 @@ final class ScrollUtils {
final double[] derivatives = new double[FRICTIONS.length];
Timeline timeline = new Timeline();
Holder<ScrollDirection> scrollDirectionHolder = new Holder<>();
final EventHandler<MouseEvent> mouseHandler = event -> timeline.stop();
final EventHandler<ScrollEvent> scrollHandler = event -> {
if (event.getEventType() == ScrollEvent.SCROLL) {
@@ -234,7 +234,6 @@ final class ScrollUtils {
if (scrollDirection == ScrollDirection.LEFT || scrollDirection == ScrollDirection.RIGHT) {
return;
}
scrollDirectionHolder.value = scrollDirection;
double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed;
derivatives[0] += scrollDirection.intDirection * currentSpeed;
@@ -256,17 +255,9 @@ final class ScrollUtils {
}
double dy = derivatives[derivatives.length - 1];
virtualFlow.scrollPixels(dy);
int cellCount = virtualFlow.getCellCount();
IndexedCell<?> firstVisibleCell = virtualFlow.getFirstVisibleCell();
double height = firstVisibleCell != null ? firstVisibleCell.getHeight() * cellCount : 0.0;
double delta = height > 0.0
? dy / height
: (scrollDirectionHolder.value == ScrollDirection.DOWN ? 0.001 : -0.001);
virtualFlow.setPosition(Math.min(Math.max(virtualFlow.getPosition() + delta, 0), 1));
if (Math.abs(dy) < 0.001) {
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
@@ -18,24 +18,26 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ListCell;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import java.util.List;
public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>> extends SkinBase<T> {
public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends SkinBase<P> {
public ToolbarListPageSkin(T skinnable) {
protected final JFXListView<E> listView;
public ToolbarListPageSkin(P skinnable) {
super(skinnable);
SpinnerPane spinnerPane = new SpinnerPane();
@@ -58,18 +60,12 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
}
{
ScrollPane scrollPane = new ScrollPane();
ComponentList.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
VBox content = new VBox();
Bindings.bindContent(content.getChildren(), skinnable.itemsProperty());
scrollPane.setContent(content);
FXUtils.smoothScrolling(scrollPane);
root.getContent().add(scrollPane);
this.listView = new JFXListView<>();
this.listView.setPadding(Insets.EMPTY);
this.listView.setCellFactory(listView -> createListCell((JFXListView<E>) listView));
ComponentList.setVgrow(listView, Priority.ALWAYS);
Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty());
root.getContent().add(listView);
}
spinnerPane.setContent(root);
@@ -89,10 +85,7 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
ret.getStyleClass().add("jfx-tool-bar-button");
ret.setGraphic(wrap(svg.createIcon()));
ret.setText(text);
ret.setOnAction(e -> {
onClick.run();
FXUtils.clearFocus(ret);
});
ret.setOnAction(e -> onClick.run());
return ret;
}
@@ -105,5 +98,21 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
return ret;
}
protected abstract List<Node> initializeToolbar(T skinnable);
protected abstract List<Node> initializeToolbar(P skinnable);
protected ListCell<E> createListCell(JFXListView<E> listView) {
return new ListCell<>() {
@Override
protected void updateItem(E item, boolean empty) {
super.updateItem(item, empty);
if (!empty && item instanceof Node node) {
setGraphic(node);
setText(null);
} else {
setGraphic(null);
setText(null);
}
}
};
}
}
@@ -19,8 +19,8 @@ package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
@@ -48,7 +48,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7));
setHeading(new Label(i18n("update.changelog")));
setBody(new ProgressIndicator());
setBody(new JFXSpinner());
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
@@ -55,4 +55,8 @@ public class WeakListenerHolder {
public boolean remove(Object obj) {
return refs.remove(obj);
}
public void clear() {
refs.clear();
}
}
@@ -0,0 +1,60 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui;
import javafx.stage.Stage;
import javafx.stage.Window;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.OptionalLong;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class WindowsNativeUtils {
public static OptionalLong getWindowHandle(Stage stage) {
try {
Class<?> windowStageClass = Class.forName("com.sun.javafx.tk.quantum.WindowStage");
Class<?> glassWindowClass = Class.forName("com.sun.glass.ui.Window");
Class<?> tkStageClass = Class.forName("com.sun.javafx.tk.TKStage");
Object tkStage = MethodHandles.privateLookupIn(Window.class, MethodHandles.lookup())
.findVirtual(Window.class, "getPeer", MethodType.methodType(tkStageClass))
.invoke(stage);
MethodHandles.Lookup windowStageLookup = MethodHandles.privateLookupIn(windowStageClass, MethodHandles.lookup());
MethodHandle getPlatformWindow = windowStageLookup.findVirtual(windowStageClass, "getPlatformWindow", MethodType.methodType(glassWindowClass));
Object platformWindow = getPlatformWindow.invoke(tkStage);
long handle = (long) MethodHandles.privateLookupIn(glassWindowClass, MethodHandles.lookup())
.findVirtual(glassWindowClass, "getNativeWindow", MethodType.methodType(long.class))
.invoke(platformWindow);
return OptionalLong.of(handle);
} catch (Throwable ex) {
LOG.warning("Failed to get window handle", ex);
return OptionalLong.empty();
}
}
private WindowsNativeUtils() {
}
}
@@ -68,6 +68,10 @@ public class AccountAdvancedListItem extends AdvancedListItem {
};
public AccountAdvancedListItem() {
this(null);
}
public AccountAdvancedListItem(Account account) {
tooltip = new Tooltip();
FXUtils.installFastTooltip(this, tooltip);
@@ -76,9 +80,13 @@ public class AccountAdvancedListItem extends AdvancedListItem {
setActionButtonVisible(false);
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(account.get()),
Accounts::setSelectedAccount);
if (account != null) {
this.accountProperty().set(account);
} else {
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(accountProperty().get()),
Accounts::setSelectedAccount);
}
}
public ObjectProperty<Account> accountProperty() {
@@ -130,7 +130,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
btnRefresh.setDisable(true);
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot.tooltip"));
}
btnRefresh.setOnAction(e -> {
spinnerRefresh.showSpinner();
@@ -25,11 +25,10 @@ import javafx.scene.layout.*;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Lang;
import javax.net.ssl.SSLException;
@@ -88,6 +87,10 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
addServerPane.setBody(txtServerUrl);
addServerPane.setActions(lblCreationWarning, actions);
txtServerUrl.getValidators().addAll(new RequiredValidator(), new URLValidator());
FXUtils.setValidateWhileTextChanged(txtServerUrl, true);
btnAddNext.disableProperty().bind(txtServerUrl.activeValidatorProperty().isNotNull());
}
confirmServerPane = new JFXDialogLayout();
@@ -149,7 +152,6 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
this.setContent(addServerPane, ContainerAnimations.NONE);
lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty());
btnAddNext.disableProperty().bind(txtServerUrl.textProperty().isEmpty());
nextPane.hideSpinner();
onEscPressed(this, this::onAddCancel);
@@ -162,6 +164,12 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
private String resolveFetchExceptionMessage(Throwable exception) {
if (exception instanceof SSLException) {
if (exception.getMessage() != null && exception.getMessage().contains("Remote host terminated")) {
return i18n("account.failed.connect_injector_server");
}
if (exception.getMessage() != null && (exception.getMessage().contains("No name matching") || exception.getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("account.failed.dns");
}
return i18n("account.failed.ssl");
} else if (exception instanceof IOException) {
return i18n("account.failed.connect_injector_server");
@@ -35,8 +35,6 @@ import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TextInputControl;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.NoSelectedCharacterException;
@@ -64,11 +62,7 @@ import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Pattern;
@@ -286,72 +280,95 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
btnAccept.disableProperty().unbind();
detailsContainer.getChildren().remove(detailsPane);
lblErrorMessage.setText("");
lblErrorMessage.setVisible(true);
}
if (factory == Accounts.FACTORY_MICROSOFT) {
VBox vbox = new VBox(8);
if (!Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
} else {
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
}
});
FXUtils.onClicked(hintPane, () -> {
if (deviceCode.get() != null) {
FXUtils.copyText(deviceCode.get().getUserCode());
}
});
detailsPane = vbox;
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> {
runInFX(() -> deviceCode.set(value));
}));
FlowPane box = new FlowPane();
box.setHgap(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
birthLink.setExternalLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a");
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A");
JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password"));
forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx");
JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings"));
createProfileLink.setExternalLink("https://www.minecraft.net/msaprofile/mygames/editprofile");
JFXHyperlink bannedQueryLink = new JFXHyperlink(i18n("account.methods.ban_query"));
bannedQueryLink.setExternalLink("https://enforcement.xbox.com/enforcement/showenforcementhistory");
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink, bannedQueryLink);
GridPane.setColumnSpan(box, 2);
if (!IntegrityChecker.isOfficial()) {
HintPane unofficialHint = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHint.setText(i18n("unofficial.hint"));
vbox.getChildren().add(unofficialHint);
}
vbox.getChildren().addAll(hintPane, box);
btnAccept.setDisable(false);
} else {
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("account.methods.microsoft.snapshot"));
JFXHyperlink officialWebsite = new JFXHyperlink(i18n("account.methods.microsoft.snapshot.website"));
officialWebsite.setExternalLink(Metadata.PUBLISH_URL);
vbox.getChildren().setAll(hintPane, officialWebsite);
btnAccept.setDisable(true);
vbox.getChildren().add(hintPane);
return;
}
detailsPane = vbox;
if (!IntegrityChecker.isOfficial()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("unofficial.hint"));
vbox.getChildren().add(hintPane);
}
VBox codeBox = new VBox(8);
Label hint = new Label(i18n("account.methods.microsoft.code"));
Label code = new Label();
code.setMouseTransparent(true);
code.setStyle("-fx-font-size: 24");
codeBox.getChildren().addAll(hint, code);
codeBox.setAlignment(Pos.CENTER);
vbox.getChildren().add(codeBox);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR);
errHintPane.setVisible(false);
errHintPane.setManaged(false);
codeBox.setVisible(false);
codeBox.setManaged(false);
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
code.setText(deviceCode.getUserCode());
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getVerificationUri()));
codeBox.setVisible(true);
codeBox.setManaged(true);
} else {
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
codeBox.setVisible(false);
codeBox.setManaged(false);
}
});
lblErrorMessage.setVisible(false);
lblErrorMessage.textProperty().addListener((obs, oldVal, newVal) -> {
boolean hasError = !newVal.isEmpty();
errHintPane.setSegment(newVal);
errHintPane.setVisible(hasError);
errHintPane.setManaged(hasError);
hintPane.setVisible(!hasError);
hintPane.setManaged(!hasError);
codeBox.setVisible(!hasError && deviceCode.get() != null);
codeBox.setManaged(!hasError && deviceCode.get() != null);
});
FXUtils.onClicked(codeBox, () -> {
if (deviceCode.get() != null) FXUtils.copyText(deviceCode.get().getUserCode());
});
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value ->
runInFX(() -> deviceCode.set(value))
));
HBox linkBox = new HBox();
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A");
JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password"));
forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx");
linkBox.getChildren().setAll(profileLink, purchaseLink, deauthorizeLink, forgotpasswordLink);
vbox.getChildren().addAll(hintPane, errHintPane, linkBox);
btnAccept.setDisable(false);
} else {
detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire);
btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not());
}
detailsContainer.getChildren().add(detailsPane);
}
@@ -460,6 +477,9 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
onChangeAndOperate(cboServers.valueProperty(), server -> {
this.server = server;
linksContainer.getChildren().setAll(createHyperlinks(server));
if (txtUsername != null)
txtUsername.validate();
});
linksContainer.setMinWidth(USE_PREF_SIZE);
@@ -590,6 +610,9 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
if ((factory instanceof AuthlibInjectorAccountFactory) && this.server != null) {
return !server.isNonEmailLogin();
}
if (factory instanceof BoundAuthlibInjectorAccountFactory bound) {
return !bound.getServer().isNonEmailLogin();
}
return false;
}
@@ -66,11 +66,11 @@ public class OAuthAccountLoginDialog extends DialogPane {
HBox box = new HBox(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
birthLink.setOnAction(e -> FXUtils.openLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a"));
birthLink.setExternalLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a");
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setOnAction(e -> FXUtils.openLink("https://account.live.com/editprof.aspx"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
box.getChildren().setAll(profileLink, birthLink, purchaseLink);
GridPane.setColumnSpan(box, 2);
@@ -24,6 +24,7 @@ import com.jfoenix.controls.JFXTextField;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.Label;
import javafx.scene.input.DragEvent;
import javafx.scene.input.TransferMode;
@@ -159,7 +160,9 @@ public class OfflineAccountSkinPane extends StackPane {
FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> {
GridPane gridPane = new GridPane();
gridPane.setPadding(new Insets(0, 0, 0, 10));
// Increase bottom padding to prevent the prompt from overlapping with the dialog action area
gridPane.setPadding(new Insets(0, 0, 45, 10));
gridPane.setHgap(16);
gridPane.setVgap(8);
gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing());
@@ -172,9 +175,23 @@ public class OfflineAccountSkinPane extends StackPane {
case LITTLE_SKIN:
HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO);
hint.setText(i18n("account.skin.type.little_skin.hint"));
// Spanning two columns and expanding horizontally
GridPane.setColumnSpan(hint, 2);
GridPane.setHgrow(hint, Priority.ALWAYS);
hint.setMaxWidth(Double.MAX_VALUE);
// Force top alignment within cells (to avoid vertical offset caused by the baseline)
GridPane.setValignment(hint, VPos.TOP);
// Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space.
hint.setMaxHeight(Region.USE_PREF_SIZE);
hint.setMinHeight(Region.USE_PREF_SIZE);
gridPane.addRow(0, hint);
break;
case LOCAL_FILE:
gridPane.setPadding(new Insets(0, 0, 0, 10));
gridPane.addRow(0, new Label(i18n("account.skin.model")), modelCombobox);
gridPane.addRow(1, new Label(i18n("account.skin")), skinSelector);
gridPane.addRow(2, new Label(i18n("account.cape")), capeSelector);
@@ -23,7 +23,6 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.DirectoryChooser;
@@ -45,7 +44,6 @@ public class FileItem extends BorderPane {
private final SimpleStringProperty name = new SimpleStringProperty(this, "name");
private final SimpleStringProperty title = new SimpleStringProperty(this, "title");
private final SimpleStringProperty tooltip = new SimpleStringProperty(this, "tooltip");
private final SimpleStringProperty path = new SimpleStringProperty(this, "path");
private final SimpleBooleanProperty convertToRelativePath = new SimpleBooleanProperty(this, "convertToRelativePath");
@@ -65,10 +63,6 @@ public class FileItem extends BorderPane {
FXUtils.installFastTooltip(right, i18n("button.edit"));
setRight(right);
Tooltip tip = new Tooltip();
tip.textProperty().bind(tooltipProperty());
Tooltip.install(this, tip);
convertToRelativePath.addListener(onInvalidating(() -> path.set(processPath(path.get()))));
}
@@ -142,18 +136,6 @@ public class FileItem extends BorderPane {
this.title.set(title);
}
public String getTooltip() {
return tooltip.get();
}
public StringProperty tooltipProperty() {
return tooltip;
}
public void setTooltip(String tooltip) {
this.tooltip.set(tooltip);
}
public String getPath() {
return path.get();
}
@@ -28,7 +28,6 @@ import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import java.util.Locale;
@@ -45,29 +44,9 @@ public class HintPane extends VBox {
setFillWidth(true);
getStyleClass().addAll("hint", type.name().toLowerCase(Locale.ROOT));
SVG svg;
switch (type) {
case INFO:
svg = SVG.INFO;
break;
case ERROR:
svg = SVG.ERROR;
break;
case SUCCESS:
svg = SVG.CHECK_CIRCLE;
break;
case WARNING:
svg = SVG.WARNING;
break;
case QUESTION:
svg = SVG.HELP;
break;
default:
throw new IllegalArgumentException("Unrecognized message box message type " + type);
}
HBox hbox = new HBox(svg.createIcon(16), new Text(type.getDisplayName()));
HBox hbox = new HBox(type.getIcon().createIcon(16), new Text(type.getDisplayName()));
hbox.setAlignment(Pos.CENTER_LEFT);
hbox.setSpacing(2);
flow.getChildren().setAll(label);
getChildren().setAll(hbox, flow);
label.textProperty().bind(text);
@@ -24,7 +24,7 @@ import org.jackhuang.hmcl.ui.SVG;
public final class IconedMenuItem extends IconedItem {
public IconedMenuItem(SVG icon, String text, Runnable action, JFXPopup popup) {
super(icon != null ? icon.createIcon(14) : null, text);
super(icon != null ? FXUtils.limitingSize(icon.createIcon(14), 14, 14) : null, text);
getStyleClass().setAll("iconed-menu-item");
@@ -58,11 +58,12 @@ public final class ImagePickerItem extends BorderPane {
selectButton.getStyleClass().add("toggle-icon4");
JFXButton deleteButton = new JFXButton();
deleteButton.setGraphic(SVG.CLOSE.createIcon(20));
deleteButton.setGraphic(SVG.RESTORE.createIcon(20));
deleteButton.onActionProperty().bind(onDeleteButtonClicked);
deleteButton.getStyleClass().add("toggle-icon4");
FXUtils.installFastTooltip(selectButton, i18n("button.edit"));
FXUtils.installFastTooltip(deleteButton, i18n("button.reset"));
HBox hBox = new HBox();
hBox.getChildren().setAll(imageView, selectButton, deleteButton);
@@ -20,9 +20,11 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FutureCallback;
import java.util.concurrent.CompletableFuture;
@@ -30,12 +32,22 @@ import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InputDialogPane extends JFXDialogLayout {
public class InputDialogPane extends JFXDialogLayout implements DialogAware {
private final CompletableFuture<String> future = new CompletableFuture<>();
private final JFXTextField textField;
private final Label lblCreationWarning;
private final SpinnerPane acceptPane;
private final JFXButton acceptButton;
public InputDialogPane(String text, String initialValue, FutureCallback<String> onResult, ValidatorBase... validators) {
this(text, initialValue, onResult);
if (validators != null && validators.length > 0) {
textField.getValidators().addAll(validators);
FXUtils.setValidateWhileTextChanged(textField, true);
acceptButton.disableProperty().bind(textField.activeValidatorProperty().isNotNull());
}
}
public InputDialogPane(String text, String initialValue, FutureCallback<String> onResult) {
textField = new JFXTextField(initialValue);
@@ -47,7 +59,7 @@ public class InputDialogPane extends JFXDialogLayout {
acceptPane = new SpinnerPane();
acceptPane.getStyleClass().add("small-spinner-pane");
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
acceptButton = new JFXButton(i18n("button.ok"));
acceptButton.getStyleClass().add("dialog-accept");
acceptPane.setContent(acceptButton);
@@ -69,10 +81,15 @@ public class InputDialogPane extends JFXDialogLayout {
lblCreationWarning.setText(msg);
});
});
textField.setOnAction(event -> acceptButton.fire());
onEscPressed(this, cancelButton::fire);
}
@Override
public void onDialogShown() {
textField.requestFocus();
}
public CompletableFuture<String> getCompletableFuture() {
return future;
}
@@ -0,0 +1,68 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.util.Callback;
/// @author Glavo
public final class JFXCheckBoxTableCell<S, T> extends TableCell<S, T> {
public static <S> Callback<TableColumn<S, Boolean>, TableCell<S, Boolean>> forTableColumn(
final TableColumn<S, Boolean> column) {
return list -> new JFXCheckBoxTableCell<>();
}
private final JFXCheckBox checkBox = new JFXCheckBox();
private BooleanProperty booleanProperty;
public JFXCheckBoxTableCell() {
this.getStyleClass().add("jfx-checkbox-table-cell");
}
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setGraphic(null);
checkBox.disableProperty().unbind();
} else {
setGraphic(checkBox);
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
if (getTableColumn().getCellObservableValue(getIndex()) instanceof BooleanProperty obsValue) {
booleanProperty = obsValue;
checkBox.selectedProperty().bindBidirectional(booleanProperty);
}
checkBox.disableProperty().bind(Bindings.not(
getTableView().editableProperty().and(
getTableColumn().editableProperty()).and(
editableProperty())
));
}
}
}
@@ -24,17 +24,14 @@ import javafx.scene.control.ListCell;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Holder;
public abstract class MDListCell<T> extends ListCell<T> {
private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private final StackPane container = new StackPane();
private final StackPane root = new StackPane();
private final Holder<Object> lastCell;
public MDListCell(JFXListView<T> listView, Holder<Object> lastCell) {
this.lastCell = lastCell;
public MDListCell(JFXListView<T> listView) {
setText(null);
setGraphic(null);
@@ -58,13 +55,6 @@ public abstract class MDListCell<T> extends ListCell<T> {
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (lastCell != null) {
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
}
updateControl(item, empty);
if (empty) {
setGraphic(null);
@@ -46,11 +46,21 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MessageDialogPane extends HBox {
public enum MessageType {
ERROR,
INFO,
WARNING,
QUESTION,
SUCCESS;
ERROR(SVG.ERROR),
INFO(SVG.INFO),
WARNING(SVG.WARNING),
QUESTION(SVG.HELP),
SUCCESS(SVG.CHECK_CIRCLE);
private final SVG icon;
MessageType(SVG icon) {
this.icon = icon;
}
public SVG getIcon() {
return icon;
}
public String getDisplayName() {
return i18n("message." + name().toLowerCase(Locale.ROOT));
@@ -70,34 +80,14 @@ public final class MessageDialogPane extends HBox {
graphic.setTranslateY(10);
graphic.setMinSize(40, 40);
graphic.setMaxSize(40, 40);
SVG svg;
switch (type) {
case INFO:
svg = SVG.INFO;
break;
case ERROR:
svg = SVG.ERROR;
break;
case SUCCESS:
svg = SVG.CHECK_CIRCLE;
break;
case WARNING:
svg = SVG.WARNING;
break;
case QUESTION:
svg = SVG.HELP;
break;
default:
throw new IllegalArgumentException("Unrecognized message box message type " + type);
}
graphic.setGraphic(svg.createIcon(40));
graphic.setGraphic(type.getIcon().createIcon(40));
VBox vbox = new VBox();
HBox.setHgrow(vbox, Priority.ALWAYS);
{
StackPane titlePane = new StackPane();
titlePane.getStyleClass().addAll("jfx-layout-heading", "title");
titlePane.getChildren().setAll(new Label(title != null ? title : i18n("message.info")));
titlePane.getChildren().setAll(new Label(title != null ? title : type.getDisplayName()));
StackPane content = new StackPane();
content.getStyleClass().add("jfx-layout-body");
@@ -200,6 +190,7 @@ public final class MessageDialogPane extends HBox {
public Builder addCancel(String cancelText, @Nullable Runnable cancel) {
JFXButton btnCancel = new JFXButton(cancelText);
btnCancel.setButtonType(JFXButton.ButtonType.FLAT);
btnCancel.getStyleClass().add("dialog-cancel");
if (cancel != null) {
btnCancel.setOnAction(e -> cancel.run());
@@ -181,6 +181,7 @@ public final class MultiFileItem<T> extends VBox {
center.setWrapText(true);
center.getStyleClass().add("subtitle-label");
center.setStyle("-fx-font-size: 10;");
center.setPadding(new Insets(0, 0, 0, 15));
pane.setCenter(center);
}
@@ -25,8 +25,6 @@ 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;
@@ -51,24 +49,22 @@ public class OptionToggleButton extends StackPane {
Label titleLabel = new Label();
titleLabel.textProperty().bind(title);
Label subtitleLabel = new Label();
subtitleLabel.setMouseTransparent(true);
subtitleLabel.setWrapText(true);
subtitleLabel.setMouseTransparent(true);
subtitleLabel.getStyleClass().add("subtitle");
subtitleLabel.textProperty().bind(subtitle);
pane.setCenter(left);
left.setAlignment(Pos.CENTER_LEFT);
JFXToggleButton toggleButton = new JFXToggleButton();
pane.setRight(toggleButton);
StackPane right = new StackPane(toggleButton);
right.setAlignment(Pos.CENTER);
pane.setRight(right);
toggleButton.selectedProperty().bindBidirectional(selected);
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
container.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY) {
toggleButton.setSelected(!toggleButton.isSelected());
e.consume();
}
});
FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected()));
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
if (StringUtils.isNotBlank(subtitle)) {
@@ -144,12 +144,20 @@ public class RipplerContainer extends StackPane {
}
protected void updateChildren() {
getChildren().addAll(buttonContainer, getContainer());
if (buttonRippler.getPosition() == JFXRippler.RipplerPos.BACK)
getChildren().setAll(buttonContainer, getContainer());
else
getChildren().setAll(getContainer(), buttonContainer);
for (int i = 1; i < getChildren().size(); ++i)
getChildren().get(i).setPickOnBounds(false);
}
public void setPosition(JFXRippler.RipplerPos pos) {
buttonRippler.setPosition(pos);
updateChildren();
}
public Node getContainer() {
return container.get();
}
@@ -92,8 +92,6 @@ public final class TaskListPane extends StackPane {
private final ObjectProperty<Insets> progressNodePadding = new SimpleObjectProperty<>(Insets.EMPTY);
private final DoubleProperty cellWidth = new SimpleDoubleProperty();
private Cell lastCell;
public TaskListPane() {
listView.setPadding(new Insets(12, 0, 0, 0));
listView.setCellFactory(l -> new Cell());
@@ -316,11 +314,6 @@ public final class TaskListPane extends StackPane {
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();
@@ -17,18 +17,13 @@
*/
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXDialog;
import com.jfoenix.controls.JFXSnackbar;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.image.Image;
@@ -48,14 +43,15 @@ import org.jackhuang.hmcl.setting.EnumBackgroundImage;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogUtils;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
import org.jackhuang.hmcl.ui.animation.*;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane.AnimationProducer;
import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.Lang;
@@ -66,7 +62,9 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.stream.Stream;
import static java.util.stream.Collectors.toList;
@@ -77,14 +75,9 @@ import static org.jackhuang.hmcl.util.io.FileUtils.getExtension;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DecoratorController {
private static final String PROPERTY_DIALOG_CLOSE_HANDLER = DecoratorController.class.getName() + ".dialog.closeListener";
private final Decorator decorator;
private final Navigator navigator;
private JFXDialog dialog;
private JFXDialogPane dialogPane;
public DecoratorController(Stage stage, Node mainPage) {
decorator = new Decorator(stage);
decorator.setOnCloseButtonAction(() -> {
@@ -134,19 +127,24 @@ public class DecoratorController {
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
if (!(e.getTarget() instanceof Node)) {
return; // event source can't be determined
if (!(e.getTarget() instanceof Node t)) {
return;
}
Node newTarget;
if (dialogPane != null && dialogPane.peek().isPresent()) {
newTarget = dialogPane.peek().get(); // current dialog
} else {
newTarget = navigator.getCurrentPage(); // current page
JFXDialogPane currentDialogPane = null;
if (decorator.getDrawerWrapper() != null) {
currentDialogPane = (JFXDialogPane) decorator.getDrawerWrapper().getProperties().get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
}
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
newTarget = currentDialogPane.peek().get();
} else {
newTarget = navigator.getCurrentPage();
}
boolean needsRedirect = true;
Node t = (Node) e.getTarget();
while (t != null) {
if (t == newTarget) {
// current event target is in newTarget
@@ -373,9 +371,8 @@ public class DecoratorController {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
// FIXME: Get WorldPage working first, and revisit this later
page.closePage();
if (page.isPageCloseable()) {
page.closePage();
return;
}
}
@@ -446,81 +443,12 @@ public class DecoratorController {
}
// ==== Dialog ====
public void showDialog(Node node) {
FXUtils.checkFxUserThread();
if (dialog == null) {
if (decorator.getDrawerWrapper() == null) {
// Sometimes showDialog will be invoked before decorator was initialized.
// Keep trying again.
Platform.runLater(() -> showDialog(node));
return;
}
dialog = new JFXDialog(AnimationUtils.isAnimationEnabled()
? JFXDialog.DialogTransition.CENTER
: JFXDialog.DialogTransition.NONE);
dialogPane = new JFXDialogPane();
dialog.setContent(dialogPane);
decorator.capableDraggingWindow(dialog);
decorator.forbidDraggingWindow(dialogPane);
dialog.setDialogContainer(decorator.getDrawerWrapper());
dialog.setOverlayClose(false);
dialog.show();
navigator.setDisable(true);
}
dialogPane.push(node);
EventHandler<DialogCloseEvent> handler = event -> closeDialog(node);
node.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler);
node.addEventHandler(DialogCloseEvent.CLOSE, handler);
if (dialog.isVisible()) {
dialog.requestFocus();
if (node instanceof DialogAware)
((DialogAware) node).onDialogShown();
} else {
dialog.visibleProperty().addListener(new ChangeListener<Boolean>() {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
if (newValue) {
dialog.requestFocus();
if (node instanceof DialogAware)
((DialogAware) node).onDialogShown();
observable.removeListener(this);
}
}
});
}
DialogUtils.show(decorator, node);
}
@SuppressWarnings("unchecked")
private void closeDialog(Node node) {
FXUtils.checkFxUserThread();
Optional.ofNullable(node.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER))
.ifPresent(handler -> node.removeEventHandler(DialogCloseEvent.CLOSE, (EventHandler<DialogCloseEvent>) handler));
if (dialog != null) {
JFXDialogPane pane = dialogPane;
if (pane.size() == 1 && pane.peek().orElse(null) == node) {
dialog.setOnDialogClosed(e -> pane.pop(node));
dialog.close();
dialog = null;
dialogPane = null;
navigator.setDisable(false);
} else {
pane.pop(node);
}
if (node instanceof DialogAware) {
((DialogAware) node).onDialogClosed();
}
}
DialogUtils.close(node);
}
// ==== Toast ====
@@ -304,6 +304,7 @@ public class DecoratorSkin extends SkinBase<Decorator> {
BorderPane center = new BorderPane();
if (title != null) {
Label titleLabel = new Label();
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT);
titleLabel.getStyleClass().add("jfx-decorator-title");
if (titleNode == null) {
@@ -68,7 +68,7 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
gameVersion,
downloadProvider,
libraryId,
() -> controller.onPrev(false, Navigation.NavigationDirection.NEXT)
() -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS)
), Navigation.NavigationDirection.NEXT
);
});
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
@@ -40,6 +39,7 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TabHeader;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.DownloadListPage;
@@ -84,8 +84,8 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
"game", versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((profile, __, file) -> {
Versions.downloadModpackImpl(profile, uploadVersion, file);
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((profile, __, mod, file) -> {
Versions.downloadModpackImpl(profile, uploadVersion, mod, file);
}, false);
JFXButton installLocalModpackButton = FXUtils.newRaisedButton(i18n("install.modpack"));
@@ -94,9 +94,9 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
page.getActions().add(installLocalModpackButton);
return page;
}));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((profile, version, file) -> download(profile, version, file, "mods"), true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((profile, version, file) -> download(profile, version, file, "resourcepacks"), true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(ModrinthRemoteModRepository.SHADER_PACKS, (profile, version, file) -> download(profile, version, file, "shaderpacks"), true)));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((profile, version, mod, file) -> download(profile, version, file, "mods"), true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((profile, version, mod, file) -> download(profile, version, file, "resourcepacks"), true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((profile, version, mod, file) -> download(profile, version, file, "shaderpacks"), true)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteModRepository.WORLDS)));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
@@ -135,10 +135,6 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory();
Controllers.prompt(i18n("archive.file.name"), (result, resolve, reject) -> {
if (!FileUtils.isNameValid(result)) {
reject.accept(i18n("install.new_game.malformed"));
return;
}
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
@@ -156,9 +152,8 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Controllers.showToast(i18n("install.success"));
}
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
resolve.run();
}, file.getFile().getFilename());
}, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid));
}
@@ -200,6 +195,10 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
tab.select(modpackTab, false);
}
public void showResourcepackDownloads() {
tab.select(resourcePackTab, false);
}
public DownloadListPage showModDownloads() {
tab.select(modTab, false);
return modTab.getNode();
@@ -50,7 +50,7 @@ public class InstallersPage extends AbstractInstallersPage {
@Override
public String getTitle() {
return group.getGame().versionProperty().get().getVersion();
return group.getGame().versionProperty().get().version();
}
private String getVersion(String id) {
@@ -111,7 +111,7 @@ public class InstallersPage extends AbstractInstallersPage {
}
private void setTxtNameWithLoaders() {
StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().getVersion());
StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().version());
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), "");
@@ -180,4 +180,5 @@ public final class LocalModpackPage extends ModpackPage {
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
public static final SettingsMap.Key<String> MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL");
}
@@ -33,6 +33,7 @@ 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 org.jackhuang.hmcl.util.StringUtils;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -45,6 +46,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
private final Profile profile;
private final Path file;
private final String updateVersion;
private String iconUrl;
public ModpackInstallWizardProvider(Profile profile) {
this(profile, null, null);
@@ -64,12 +66,18 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
this.updateVersion = updateVersion;
}
public void setIconUrl(String iconUrl) {
this.iconUrl = iconUrl;
}
@Override
public void start(SettingsMap settings) {
if (file != null)
settings.put(LocalModpackPage.MODPACK_FILE, file);
if (updateVersion != null)
settings.put(LocalModpackPage.MODPACK_NAME, updateVersion);
if (StringUtils.isNotBlank(iconUrl))
settings.put(LocalModpackPage.MODPACK_ICON_URL, iconUrl);
settings.put(ModpackPage.PROFILE, profile);
}
@@ -78,6 +86,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
ServerModpackManifest serverModpackManifest = settings.get(RemoteModpackPage.MODPACK_SERVER_MANIFEST);
Modpack modpack = settings.get(LocalModpackPage.MODPACK_MANIFEST);
String name = settings.get(LocalModpackPage.MODPACK_NAME);
String iconUrl = settings.get(LocalModpackPage.MODPACK_ICON_URL);
Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET);
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);
@@ -111,7 +120,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
return ModpackHelper.getInstallTask(profile, serverModpackManifest, name, modpack)
.thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name));
} else {
return ModpackHelper.getInstallTask(profile, selected, name, modpack)
return ModpackHelper.getInstallTask(profile, selected, name, modpack, iconUrl)
.thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name));
}
}
@@ -29,11 +29,14 @@ import javafx.scene.shape.SVGPath;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.construct.URLValidator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
@@ -161,7 +164,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
} catch (IOException e) {
reject.accept(e.getMessage());
}
});
}, "", new URLValidator());
}
public void onChooseRepository() {
@@ -57,7 +57,6 @@ 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;
@@ -164,10 +163,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
private final ImageView imageView = new ImageView();
private final StackPane pane = new StackPane();
private final Holder<RemoteVersionListCell> lastCell;
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, VersionsPage control) {
this.lastCell = lastCell;
RemoteVersionListCell(VersionsPage control) {
this.control = control;
HBox hbox = new HBox(16);
@@ -179,10 +175,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
{
if ("game".equals(control.libraryId)) {
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> {
onOpenWiki();
FXUtils.clearFocus(wikiButton);
});
wikiButton.setOnAction(event -> onOpenWiki());
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
actions.getChildren().add(wikiButton);
}
@@ -222,18 +215,13 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
public void updateItem(RemoteVersion remoteVersion, boolean empty) {
super.updateItem(remoteVersion, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
if (empty) {
setGraphic(null);
return;
}
setGraphic(pane);
twoLineListItem.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
twoLineListItem.setTitle(I18n.getDisplayVersion(remoteVersion));
if (remoteVersion.getReleaseDate() != null) {
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
} else {
@@ -398,8 +386,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
control.versions.addListener((InvalidationListener) o -> updateList());
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control));
list.setCellFactory(listView -> new RemoteVersionListCell(control));
ComponentList.setVgrow(list, Priority.ALWAYS);
@@ -18,10 +18,10 @@
package org.jackhuang.hmcl.ui.export;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXTreeView;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.CheckBox;
import javafx.scene.control.CheckBoxTreeItem;
import javafx.scene.control.Label;
import javafx.scene.control.TreeItem;
@@ -43,8 +43,8 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
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;
@@ -68,6 +68,8 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
rootNode = getTreeItem(profile.getRepository().getRunDirectory(version), "minecraft");
treeView.setRoot(rootNode);
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
onEscPressed(treeView, () -> controller.onPrev(true));
setMargin(treeView, new Insets(10, 10, 5, 10));
this.setCenter(treeView);
HBox nextPane = new HBox();
@@ -93,9 +95,22 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
ModAdviser.ModSuggestion state = ModAdviser.ModSuggestion.SUGGESTED;
if (basePath.length() > "minecraft/".length()) {
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (isDirectory ? "/" : ""), isDirectory);
if (!isDirectory && Objects.equals(FileUtils.getNameWithoutExtension(file), version))
state = ModAdviser.ModSuggestion.HIDDEN;
if (isDirectory && Objects.equals(FileUtils.getName(file), version + "-natives")) // Ignore <version>-natives
String fileName = FileUtils.getName(file);
if (!isDirectory) {
switch (fileName) {
case ".DS_Store", // macOS system file
"desktop.ini", "Thumbs.db" // Windows system files
-> state = ModAdviser.ModSuggestion.HIDDEN;
}
if (fileName.startsWith("._")) // macOS system file
state = ModAdviser.ModSuggestion.HIDDEN;
if (FileUtils.getNameWithoutExtension(file).equals(version))
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (isDirectory && fileName.equals(version + "-natives")) // Ignore <version>-natives
state = ModAdviser.ModSuggestion.HIDDEN;
if (state == ModAdviser.ModSuggestion.HIDDEN)
return null;
@@ -130,15 +145,14 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
HBox graphic = new HBox();
CheckBox checkBox = new CheckBox();
JFXCheckBox checkBox = new JFXCheckBox();
checkBox.selectedProperty().bindBidirectional(node.selectedProperty());
checkBox.indeterminateProperty().bindBidirectional(node.indeterminateProperty());
graphic.getChildren().add(checkBox);
if (TRANSLATION.containsKey(basePath)) {
Label comment = new Label();
comment.setText(TRANSLATION.get(basePath));
comment.setStyle("-fx-text-fill: gray;");
Label comment = new Label(TRANSLATION.get(basePath));
comment.setStyle("-fx-text-fill: -monet-on-surface-variant;");
comment.setMouseTransparent(true);
graphic.getChildren().add(comment);
}
@@ -20,10 +20,10 @@ package org.jackhuang.hmcl.ui.export;
import com.jfoenix.controls.JFXButton;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.SVGPath;
import org.jackhuang.hmcl.mod.ModpackExportInfo;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
@@ -77,8 +77,7 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
graphic.setMouseTransparent(true);
graphic.setLeft(new TwoLineListItem(i18n("modpack.type." + type), i18n("modpack.type." + type + ".export")));
SVGPath arrow = new SVGPath();
arrow.setContent(SVG.ARROW_FORWARD.getPath());
Node arrow = SVG.ARROW_FORWARD.createIcon();
BorderPane.setAlignment(arrow, Pos.CENTER);
graphic.setRight(arrow);
@@ -43,34 +43,41 @@ public class FeedbackPage extends SpinnerPane {
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
ComponentList community = new ComponentList();
ComponentList groups = new ComponentList();
{
IconedTwoLineListItem users = new IconedTwoLineListItem();
users.setImage(FXUtils.newBuiltinImage("/assets/img/icon.png"));
users.setTitle(i18n("feedback.qq_group"));
users.setSubtitle(i18n("feedback.qq_group.statement"));
users.setTitle(i18n("contact.chat.qq_group"));
users.setSubtitle(i18n("contact.chat.qq_group.statement"));
users.setExternalLink(Metadata.GROUPS_URL);
IconedTwoLineListItem discord = new IconedTwoLineListItem();
discord.setImage(FXUtils.newBuiltinImage("/assets/img/discord.png"));
discord.setTitle(i18n("contact.chat.discord"));
discord.setSubtitle(i18n("contact.chat.discord.statement"));
discord.setExternalLink("https://discord.gg/jVvC7HfM6U");
groups.getContent().setAll(users, discord);
}
ComponentList feedback = new ComponentList();
{
IconedTwoLineListItem github = new IconedTwoLineListItem();
github.imageProperty().bind(Bindings.when(Themes.darkModeProperty())
.then(FXUtils.newBuiltinImage("/assets/img/github-white.png"))
.otherwise(FXUtils.newBuiltinImage("/assets/img/github.png")));
github.setTitle(i18n("feedback.github"));
github.setSubtitle(i18n("feedback.github.statement"));
github.setTitle(i18n("contact.feedback.github"));
github.setSubtitle(i18n("contact.feedback.github.statement"));
github.setExternalLink("https://github.com/HMCL-dev/HMCL/issues/new/choose");
IconedTwoLineListItem discord = new IconedTwoLineListItem();
discord.setImage(FXUtils.newBuiltinImage("/assets/img/discord.png"));
discord.setTitle(i18n("feedback.discord"));
discord.setSubtitle(i18n("feedback.discord.statement"));
discord.setExternalLink("https://discord.gg/jVvC7HfM6U");
community.getContent().setAll(users, github, discord);
feedback.getContent().setAll(github);
}
content.getChildren().addAll(
ComponentList.createComponentListTitle(i18n("feedback.channel")),
community
ComponentList.createComponentListTitle(i18n("contact.chat")),
groups,
ComponentList.createComponentListTitle(i18n("contact.feedback")),
feedback
);
this.setContent(content);
@@ -18,17 +18,19 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.java.JavaInfo;
import org.jackhuang.hmcl.java.JavaManager;
@@ -42,6 +44,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -62,7 +65,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class JavaManagementPage extends ListPageBase<JavaManagementPage.JavaItem> {
public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
@SuppressWarnings("FieldCanBeLocal")
private final ChangeListener<Collection<JavaRuntime>> listener;
@@ -172,130 +175,17 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
}).start();
}
// FXThread
@FXThread
private void loadJava(Collection<JavaRuntime> javaRuntimes) {
if (javaRuntimes != null) {
List<JavaItem> items = new ArrayList<>();
for (JavaRuntime java : javaRuntimes) {
items.add(new JavaItem(java));
}
this.setItems(FXCollections.observableList(items));
this.setItems(FXCollections.observableArrayList(javaRuntimes));
this.setLoading(false);
} else
} else {
this.setLoading(true);
}
static final class JavaItem extends Control {
private final JavaRuntime java;
public JavaItem(JavaRuntime java) {
this.java = java;
}
public JavaRuntime getJava() {
return java;
}
public void onReveal() {
Path target;
Path parent = java.getBinary().getParent();
if (parent != null
&& parent.getParent() != null
&& parent.getFileName() != null
&& parent.getFileName().toString().equals("bin")
&& Files.exists(parent.getParent().resolve("release"))) {
target = parent.getParent();
} else {
target = java.getBinary();
}
FXUtils.showFileInExplorer(target);
}
public void onRemove() {
if (java.isManaged()) {
Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL);
} else {
String path = java.getBinary().toString();
ConfigHolder.globalConfig().getUserJava().remove(path);
ConfigHolder.globalConfig().getDisabledJava().add(path);
try {
JavaManager.removeJava(java);
} catch (InterruptedException ignored) {
}
}
}
@Override
protected Skin<?> createDefaultSkin() {
return new JavaRuntimeItemSkin(this);
}
}
private static final class JavaRuntimeItemSkin extends SkinBase<JavaItem> {
JavaRuntimeItemSkin(JavaItem control) {
super(control);
JavaRuntime java = control.getJava();
String vendor = JavaInfo.normalizeVendor(java.getVendor());
BorderPane root = new BorderPane();
HBox center = new HBox();
center.setMouseTransparent(true);
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
TwoLineListItem item = new TwoLineListItem();
item.setTitle((java.isJDK() ? "JDK" : "JRE") + " " + java.getVersion());
item.setSubtitle(java.getBinary().toString());
item.addTag(i18n("java.info.architecture") + ": " + java.getArchitecture().getDisplayName());
if (vendor != null)
item.addTag(i18n("java.info.vendor") + ": " + vendor);
BorderPane.setAlignment(item, Pos.CENTER);
center.getChildren().setAll(item);
root.setCenter(center);
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
{
JFXButton revealButton = new JFXButton();
revealButton.getStyleClass().add("toggle-icon4");
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
revealButton.setOnAction(e -> control.onReveal());
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
JFXButton removeButton = new JFXButton();
removeButton.getStyleClass().add("toggle-icon4");
removeButton.setOnAction(e -> Controllers.confirm(
java.isManaged() ? i18n("java.uninstall.confirm") : i18n("java.disable.confirm"),
i18n("message.warning"),
control::onRemove,
null
));
if (java.isManaged()) {
removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE_FOREVER.createIcon(24), 24, 24));
FXUtils.installFastTooltip(removeButton, i18n("java.uninstall"));
if (JavaRuntime.CURRENT_JAVA != null && java.getBinary().equals(JavaRuntime.CURRENT_JAVA.getBinary()))
removeButton.setDisable(true);
} else {
removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE.createIcon(24), 24, 24));
FXUtils.installFastTooltip(removeButton, i18n("java.disable"));
}
right.getChildren().setAll(revealButton, removeButton);
}
root.setRight(right);
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
getChildren().setAll(new RipplerContainer(root));
}
}
private static final class JavaPageSkin extends ToolbarListPageSkin<JavaManagementPage> {
private static final class JavaPageSkin extends ToolbarListPageSkin<JavaRuntime, JavaManagementPage> {
JavaPageSkin(JavaManagementPage skinnable) {
super(skinnable);
@@ -317,5 +207,139 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
return res;
}
@Override
protected ListCell<JavaRuntime> createListCell(JFXListView<JavaRuntime> listView) {
return new JavaItemCell();
}
}
private static final class JavaItemCell extends ListCell<JavaRuntime> {
private final Node graphic;
private final TwoLineListItem content;
private SVG removeIcon;
private final StackPane removeIconPane;
private final Tooltip removeTooltip = new Tooltip();
JavaItemCell() {
BorderPane root = new BorderPane();
HBox center = new HBox();
center.setMouseTransparent(true);
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
this.content = new TwoLineListItem();
BorderPane.setAlignment(content, Pos.CENTER);
center.getChildren().setAll(content);
root.setCenter(center);
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
{
JFXButton revealButton = new JFXButton();
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
revealButton.getStyleClass().add("toggle-icon4");
revealButton.setOnAction(e -> {
JavaRuntime java = getItem();
if (java != null)
onReveal(java);
});
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
JFXButton removeButton = new JFXButton();
removeButton.getStyleClass().add("toggle-icon4");
removeButton.setOnAction(e -> {
JavaRuntime java = getItem();
if (java != null)
onRemove(java);
});
FXUtils.installFastTooltip(removeButton, removeTooltip);
this.removeIconPane = new StackPane();
removeIconPane.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(removeIconPane, 24);
FXUtils.setLimitHeight(removeIconPane, 24);
removeButton.setGraphic(removeIconPane);
right.getChildren().setAll(revealButton, removeButton);
}
root.setRight(right);
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
this.graphic = new RipplerContainer(root);
}
@Override
protected void updateItem(JavaRuntime item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setGraphic(null);
} else {
content.setTitle((item.isJDK() ? "JDK" : "JRE") + " " + item.getVersion());
content.setSubtitle(item.getBinary().toString());
content.getTags().clear();
content.addTag(i18n("java.info.architecture") + ": " + item.getArchitecture().getDisplayName());
String vendor = JavaInfo.normalizeVendor(item.getVendor());
if (vendor != null)
content.addTag(i18n("java.info.vendor") + ": " + vendor);
SVG newRemoveIcon = item.isManaged() ? SVG.DELETE_FOREVER : SVG.DELETE;
if (removeIcon != newRemoveIcon) {
removeIcon = newRemoveIcon;
removeIconPane.getChildren().setAll(removeIcon.createIcon(24));
removeTooltip.setText(item.isManaged() ? i18n("java.uninstall") : i18n("java.disable"));
}
setGraphic(graphic);
}
}
private void onReveal(JavaRuntime java) {
Path target;
Path parent = java.getBinary().getParent();
if (parent != null
&& parent.getParent() != null
&& parent.getFileName() != null
&& parent.getFileName().toString().equals("bin")
&& Files.exists(parent.getParent().resolve("release"))) {
target = parent.getParent();
} else {
target = java.getBinary();
}
FXUtils.showFileInExplorer(target);
}
private void onRemove(JavaRuntime java) {
if (java.isManaged()) {
Controllers.confirm(
i18n("java.uninstall.confirm"),
i18n("message.warning"),
() -> Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL),
null
);
} else {
Controllers.confirm(
i18n("java.disable.confirm"),
i18n("message.warning"),
() -> {
String path = java.getBinary().toString();
ConfigHolder.globalConfig().getUserJava().remove(path);
ConfigHolder.globalConfig().getDisabledJava().add(path);
try {
JavaManager.removeJava(java);
} catch (InterruptedException ignored) {
}
},
null
);
}
}
}
}
@@ -192,7 +192,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
}
}
private static final class JavaRestorePageSkin extends ToolbarListPageSkin<JavaRestorePage> {
private static final class JavaRestorePageSkin extends ToolbarListPageSkin<DisabledJavaItem, JavaRestorePage> {
JavaRestorePageSkin(JavaRestorePage skinnable) {
super(skinnable);
}
@@ -69,7 +69,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
.addNavigationDrawerTab(tab, downloadTab, i18n("download"), SVG.DOWNLOAD)
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tab, helpTab, i18n("help"), SVG.HELP, SVG.HELP_FILL)
.addNavigationDrawerTab(tab, feedbackTab, i18n("feedback"), SVG.FEEDBACK, SVG.FEEDBACK_FILL)
.addNavigationDrawerTab(tab, feedbackTab, i18n("contact"), SVG.FEEDBACK, SVG.FEEDBACK_FILL)
.addNavigationDrawerTab(tab, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
FXUtils.setLimitWidth(sideBar, 200);
setLeft(sideBar);
@@ -22,14 +22,12 @@ import com.jfoenix.controls.JFXPopup;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
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;
@@ -39,7 +37,6 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
@@ -52,6 +49,7 @@ import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -59,10 +57,10 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.GameItem;
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
@@ -73,7 +71,6 @@ 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;
@@ -92,16 +89,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final PopupMenu menu = new PopupMenu();
private final StackPane popupWrapper = new StackPane(menu);
private final JFXPopup popup = new JFXPopup(popupWrapper);
private final StringProperty currentGame = new SimpleStringProperty(this, "currentGame");
private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
private final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>(this, "latestVersion");
private final ObservableList<Version> versions = FXCollections.observableArrayList();
private final ObservableList<Node> versionNodes;
private Profile profile;
private TransitionPane announcementPane;
@@ -120,6 +111,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
titleLabel.setRotate(180);
}
titleLabel.getStyleClass().add("jfx-decorator-title");
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
titleNode.getChildren().setAll(titleIcon, titleLabel);
state.setValue(new State(null, titleNode, false, false, true));
@@ -186,16 +178,13 @@ public final class MainPage extends StackPane implements DecoratorPage {
StackPane.setAlignment(hBox, Pos.CENTER_LEFT);
StackPane.setMargin(hBox, new Insets(9, 12, 9, 16));
{
Label lblIcon = new Label();
lblIcon.setGraphic(SVG.UPDATE.createIcon(20));
TwoLineListItem prompt = new TwoLineListItem();
prompt.setSubtitle(i18n("update.bubble.subtitle"));
prompt.setPickOnBounds(false);
prompt.titleProperty().bind(BindingMapping.of(latestVersionProperty()).map(latestVersion ->
latestVersion == null ? "" : i18n("update.bubble.title", latestVersion.getVersion())));
hBox.getChildren().setAll(lblIcon, prompt);
hBox.getChildren().setAll(SVG.UPDATE.createIcon(20), prompt);
}
JFXButton closeUpdateButton = new JFXButton();
@@ -208,10 +197,8 @@ public final class MainPage extends StackPane implements DecoratorPage {
updatePane.getChildren().setAll(hBox, closeUpdateButton);
}
StackPane launchPane = new StackPane();
HBox launchPane = new HBox();
launchPane.getStyleClass().add("launch-pane");
launchPane.setMaxWidth(230);
launchPane.setMaxHeight(55);
FXUtils.onScroll(launchPane, versions, list -> {
String currentId = getCurrentGame();
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
@@ -220,16 +207,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
JFXButton launchButton = new JFXButton();
launchButton.setPrefWidth(230);
launchButton.setPrefHeight(55);
//launchButton.setButtonType(JFXButton.ButtonType.RAISED);
launchButton.getStyleClass().add("launch-button");
launchButton.setDefaultButton(true);
launchButton.setClip(new Rectangle(-100, -100, 310, 200));
{
VBox graphic = new VBox();
graphic.setAlignment(Pos.CENTER);
graphic.setTranslateX(-7);
graphic.setMaxWidth(200);
Label launchLabel = new Label();
launchLabel.setStyle("-fx-font-size: 16px;");
Label currentLabel = new Label();
@@ -262,26 +244,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchButton.setGraphic(graphic);
}
Rectangle separator = new Rectangle();
separator.setWidth(1);
separator.setHeight(57);
separator.setTranslateX(95);
separator.setMouseTransparent(true);
menuButton = new JFXButton();
menuButton.setPrefHeight(55);
menuButton.setPrefWidth(230);
//menuButton.setButtonType(JFXButton.ButtonType.RAISED);
menuButton.setStyle("-fx-font-size: 15px;");
menuButton.getStyleClass().add("menu-button");
menuButton.setOnAction(e -> onMenu());
menuButton.setClip(new Rectangle(211, -100, 100, 200));
StackPane graphic = new StackPane();
Node svg = SVG.ARROW_DROP_UP.createIcon(30);
StackPane.setAlignment(svg, Pos.CENTER_RIGHT);
graphic.getChildren().setAll(svg);
graphic.setTranslateX(6);
FXUtils.installFastTooltip(menuButton, i18n("version.switch"));
menuButton.setGraphic(graphic);
menuButton.setGraphic(SVG.ARROW_DROP_UP.createIcon(30));
EventHandler<MouseEvent> secondaryClickHandle = event -> {
if (event.getButton() == MouseButton.SECONDARY && event.getClickCount() == 1) {
@@ -292,24 +259,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
menuButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
launchPane.getChildren().setAll(launchButton, separator, menuButton);
launchPane.getChildren().setAll(launchButton, menuButton);
}
getChildren().addAll(updatePane, launchPane);
menu.setMaxHeight(365);
menu.setMaxWidth(545);
menu.setAlwaysShowingVBar(true);
FXUtils.onClicked(menu, popup::hide);
versionNodes = MappedObservableList.create(versions, version -> {
Node node = PopupMenu.wrapPopupMenuItem(new GameItem(profile, version.getId()));
FXUtils.onClicked(node, () -> {
profile.setSelectedVersion(version.getId());
popup.hide();
});
return node;
});
Bindings.bindContent(menu.getContent(), versionNodes);
}
private void showUpdate(boolean show) {
@@ -347,7 +301,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
private void launch() {
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), null);
Versions.launch(profile, profile.getSelectedVersion());
}
private void launchNoGame() {
@@ -389,20 +343,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
private void onMenu() {
Node contentNode;
if (menu.getContent().isEmpty()) {
Label placeholder = new Label(i18n("version.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: gray; -fx-font-style: italic;");
contentNode = placeholder;
} else {
contentNode = menu;
}
popupWrapper.getChildren().setAll(contentNode);
if (popup.isShowing()) {
popup.hide();
}
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
@@ -44,6 +44,7 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@@ -86,8 +87,11 @@ public class PersonalizationPage extends StackPane {
brightnessPane.setLeft(left);
JFXComboBox<String> cboBrightness = new JFXComboBox<>(
FXCollections.observableArrayList("auto", "light", "dark"));
JFXComboBox<String> cboBrightness = new JFXComboBox<>(FXCollections.observableArrayList(
FXUtils.DARK_MODE != null
? List.of("auto", "light", "dark")
: List.of("light", "dark")
));
cboBrightness.setConverter(FXUtils.stringConverter(name -> i18n("settings.launcher.brightness." + name)));
cboBrightness.valueProperty().bindBidirectional(config().themeBrightnessProperty());
brightnessPane.setRight(cboBrightness);
@@ -122,6 +126,7 @@ public class PersonalizationPage extends StackPane {
themeList.getContent().add(animationButton);
animationButton.selectedProperty().bindBidirectional(config().animationDisabledProperty());
animationButton.setTitle(i18n("settings.launcher.turn_off_animations"));
animationButton.setSubtitle(i18n("settings.take_effect_after_restart"));
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.appearance")), themeList);
@@ -168,6 +173,7 @@ public class PersonalizationPage extends StackPane {
slider.setMinorTickCount(1);
slider.setBlockIncrement(5);
slider.setSnapToTicks(true);
slider.setPadding(new Insets(9, 0, 0, 0));
HBox.setHgrow(slider, Priority.ALWAYS);
if (config().getBackgroundImageType() == EnumBackgroundImage.TRANSLUCENT) {
@@ -232,7 +238,14 @@ public class PersonalizationPage extends StackPane {
.fallbackTo(12.0)
.asPredicate(Validator.addTo(txtLogFontSize)));
hBox.getChildren().setAll(cboLogFont, txtLogFontSize);
JFXButton clearButton = new JFXButton();
clearButton.getStyleClass().add("toggle-icon4");
clearButton.setGraphic(SVG.RESTORE.createIcon());
clearButton.setOnAction(e -> cboLogFont.setValue(null));
FXUtils.installFastTooltip(clearButton, i18n("button.reset"));
hBox.getChildren().setAll(cboLogFont, txtLogFontSize, clearButton);
borderPane.setRight(hBox);
}
@@ -281,6 +294,8 @@ public class PersonalizationPage extends StackPane {
clearButton.setGraphic(SVG.RESTORE.createIcon());
clearButton.setOnAction(e -> cboFont.setValue(null));
FXUtils.installFastTooltip(clearButton, i18n("button.reset"));
hBox.getChildren().setAll(cboFont, clearButton);
borderPane.setRight(hBox);
@@ -295,8 +310,12 @@ public class PersonalizationPage extends StackPane {
{
BorderPane fontAntiAliasingPane = new BorderPane();
{
Label left = new Label(i18n("settings.launcher.font.anti_aliasing"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
VBox left = new VBox();
Label title = new Label(i18n("settings.launcher.font.anti_aliasing"));
title.getStyleClass().add("title");
Label subtitle = new Label(i18n("settings.take_effect_after_restart"));
subtitle.getStyleClass().add("subtitle");
left.getChildren().setAll(title, subtitle);
fontAntiAliasingPane.setLeft(left);
}
@@ -17,9 +17,12 @@
*/
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.game.HMCLGameRepository;
@@ -39,6 +42,7 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
@@ -64,8 +68,8 @@ import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
private MainPage mainPage = null;
@@ -144,6 +148,12 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
// first item in left sidebar
AccountAdvancedListItem accountListItem = new AccountAdvancedListItem();
accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage()));
accountListItem.setOnMouseClicked(e -> {
if (e.getButton() == MouseButton.SECONDARY) {
showAccountListPopupMenu(accountListItem);
e.consume();
}
});
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
// second item in left sidebar
@@ -234,13 +244,45 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
.add(launcherSettingsItem)
.add(terracottaItem)
.addNavigationDrawerItem(i18n("chat"), SVG.CHAT, () -> FXUtils.openLink(Metadata.GROUPS_URL));
.addNavigationDrawerItem(i18n("contact.chat"), SVG.CHAT, () -> {
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
});
// the root page, with the sidebar in left, navigator in center.
setLeft(sideBar);
setCenter(getSkinnable().getMainPage());
}
public void showAccountListPopupMenu(
AccountAdvancedListItem accountListItem
) {
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
AdvancedListBox scrollPane = new AdvancedListBox();
scrollPane.getStyleClass().add("no-padding");
scrollPane.setPrefWidth(220);
scrollPane.setPrefHeight(-1);
scrollPane.setMaxHeight(260);
if (Accounts.getAccounts().isEmpty()) {
Label placeholder = new Label(i18n("account.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
scrollPane.add(placeholder);
} else {
for (Account account : Accounts.getAccounts()) {
AccountAdvancedListItem item = new AccountAdvancedListItem(account);
item.setOnAction(e -> {
Accounts.setSelectedAccount(account);
popup.hide();
});
scrollPane.add(item);
}
}
popupMenu.getContent().add(scrollPane);
popup.show(accountListItem, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0);
}
}
private boolean checkedModpack = false;
@@ -257,7 +299,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
.thenApplyAsync(
encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding))
.thenApplyAsync(modpack -> ModpackHelper
.getInstallTask(repository.getProfile(), modpackFile, modpack.getName(), modpack)
.getInstallTask(repository.getProfile(), modpackFile, modpack.getName(), modpack, null)
.executor())
.thenAcceptAsync(Schedulers.javafx(), executor -> {
Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL);
@@ -17,22 +17,40 @@
*/
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXRadioButton;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.*;
import javafx.scene.text.TextAlignment;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.setting.Settings;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
@@ -45,6 +63,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.zip.GZIPInputStream;
@@ -52,79 +71,258 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class SettingsPage extends SettingsView {
public final class SettingsPage extends ScrollPane {
private InvalidationListener updateListener;
@SuppressWarnings("FieldCanBeLocal")
private final ToggleGroup updateChannelGroup;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener updateListener;
public SettingsPage() {
FXUtils.smoothScrolling(scroll);
this.setFitToWidth(true);
// ==== Languages ====
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
VBox rootPane = new VBox();
rootPane.setPadding(new Insets(10));
this.setContent(rootPane);
FXUtils.smoothScrolling(this);
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
// ====
ComponentList settingsPane = new ComponentList();
{
{
StackPane sponsorPane = new StackPane();
sponsorPane.setCursor(Cursor.HAND);
FXUtils.onClicked(sponsorPane, this::onSponsor);
sponsorPane.setPadding(new Insets(8, 0, 8, 0));
fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty());
fileCommonLocationSublist.subtitleProperty().bind(
Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory())
.orElse(i18n("launcher.cache_directory.disabled")),
config().commonDirectoryProperty(), config().commonDirTypeProperty()));
GridPane gridPane = new GridPane();
// ==== Update ====
FXUtils.installFastTooltip(btnUpdate, i18n("update.tooltip"));
updateListener = any -> {
btnUpdate.setVisible(UpdateChecker.isOutdated());
ColumnConstraints col = new ColumnConstraints();
col.setHgrow(Priority.SOMETIMES);
col.setMaxWidth(Double.POSITIVE_INFINITY);
if (UpdateChecker.isOutdated()) {
lblUpdateSub.setText(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion()));
lblUpdateSub.getStyleClass().setAll("update-label");
gridPane.getColumnConstraints().setAll(col);
lblUpdate.setText(i18n("update.found"));
lblUpdate.getStyleClass().setAll("update-label");
} else if (UpdateChecker.isCheckingUpdate()) {
lblUpdateSub.setText(i18n("update.checking"));
lblUpdateSub.getStyleClass().setAll("subtitle-label");
RowConstraints row = new RowConstraints();
row.setMinHeight(Double.NEGATIVE_INFINITY);
row.setValignment(VPos.TOP);
row.setVgrow(Priority.SOMETIMES);
gridPane.getRowConstraints().setAll(row);
lblUpdate.setText(i18n("update"));
lblUpdate.getStyleClass().setAll("title-label");
} else {
lblUpdateSub.setText(i18n("update.latest"));
lblUpdateSub.getStyleClass().setAll("subtitle-label");
{
Label label = new Label(i18n("sponsor.hmcl"));
label.setWrapText(true);
label.setTextAlignment(TextAlignment.JUSTIFY);
GridPane.setRowIndex(label, 0);
GridPane.setColumnIndex(label, 0);
gridPane.getChildren().add(label);
}
lblUpdate.setText(i18n("update"));
lblUpdate.getStyleClass().setAll("title-label");
sponsorPane.getChildren().setAll(gridPane);
settingsPane.getContent().add(sponsorPane);
}
};
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
updateListener.invalidated(null);
ToggleGroup updateChannelGroup = new ToggleGroup();
chkUpdateDev.setToggleGroup(updateChannelGroup);
chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT);
chkUpdateStable.setToggleGroup(updateChannelGroup);
chkUpdateStable.setUserData(UpdateChannel.STABLE);
ObjectProperty<UpdateChannel> updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class);
updateChannel.set(UpdateChannel.getChannel());
{
ComponentSublist updatePane = new ComponentSublist();
updatePane.setTitle(i18n("update"));
updatePane.setHasSubtitle(true);
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
updateChannel.addListener(checkUpdateListener);
previewPane.selectedProperty().addListener(checkUpdateListener);
// ====
final Label lblUpdate;
final Label lblUpdateSub;
{
VBox headerLeft = new VBox();
lblUpdate = new Label(i18n("update"));
lblUpdate.getStyleClass().add("title-label");
lblUpdateSub = new Label();
lblUpdateSub.getStyleClass().add("subtitle-label");
headerLeft.getChildren().setAll(lblUpdate, lblUpdateSub);
updatePane.setHeaderLeft(headerLeft);
}
{
JFXButton btnUpdate = new JFXButton();
btnUpdate.setOnAction(e -> onUpdate());
btnUpdate.getStyleClass().add("toggle-icon4");
btnUpdate.setGraphic(SVG.UPDATE.createIcon(20));
FXUtils.installFastTooltip(btnUpdate, i18n("update.tooltip"));
updateListener = any -> {
btnUpdate.setVisible(UpdateChecker.isOutdated());
if (UpdateChecker.isOutdated()) {
lblUpdateSub.setText(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion()));
lblUpdateSub.getStyleClass().setAll("update-label");
lblUpdate.setText(i18n("update.found"));
lblUpdate.getStyleClass().setAll("update-label");
} else if (UpdateChecker.isCheckingUpdate()) {
lblUpdateSub.setText(i18n("update.checking"));
lblUpdateSub.getStyleClass().setAll("subtitle-label");
lblUpdate.setText(i18n("update"));
lblUpdate.getStyleClass().setAll("title-label");
} else {
lblUpdateSub.setText(i18n("update.latest"));
lblUpdateSub.getStyleClass().setAll("subtitle-label");
lblUpdate.setText(i18n("update"));
lblUpdate.getStyleClass().setAll("title-label");
}
};
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
updateListener.invalidated(null);
updatePane.setHeaderRight(btnUpdate);
}
{
VBox content = new VBox();
content.setSpacing(8);
JFXRadioButton chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable"));
JFXRadioButton chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev"));
updateChannelGroup = new ToggleGroup();
chkUpdateDev.setToggleGroup(updateChannelGroup);
chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT);
chkUpdateStable.setToggleGroup(updateChannelGroup);
chkUpdateStable.setUserData(UpdateChannel.STABLE);
Label noteWrapper = new Label(i18n("update.note"));
VBox.setMargin(noteWrapper, new Insets(10, 0, 0, 0));
content.getChildren().setAll(chkUpdateStable, chkUpdateDev, noteWrapper);
updatePane.getContent().add(content);
}
settingsPane.getContent().add(updatePane);
}
{
OptionToggleButton previewPane = new OptionToggleButton();
previewPane.setTitle(i18n("update.preview"));
previewPane.setSubtitle(i18n("update.preview.subtitle"));
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
ObjectProperty<UpdateChannel> updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class);
updateChannel.set(UpdateChannel.getChannel());
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
updateChannel.addListener(checkUpdateListener);
previewPane.selectedProperty().addListener(checkUpdateListener);
settingsPane.getContent().add(previewPane);
}
{
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
fileCommonLocation.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
.setChooserTitle(i18n("launcher.cache_directory.choose"))
.setDirectory(true)
.bindBidirectional(config().commonDirectoryProperty())
));
fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty());
ComponentSublist fileCommonLocationSublist = new ComponentSublist();
fileCommonLocationSublist.getContent().add(fileCommonLocation);
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
fileCommonLocationSublist.setHasSubtitle(true);
fileCommonLocationSublist.subtitleProperty().bind(
Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory())
.orElse(i18n("launcher.cache_directory.disabled")),
config().commonDirectoryProperty(), config().commonDirTypeProperty()));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory());
fileCommonLocationSublist.setHeaderRight(cleanButton);
settingsPane.getContent().add(fileCommonLocationSublist);
}
{
BorderPane languagePane = new BorderPane();
VBox left = new VBox();
Label title = new Label(i18n("settings.launcher.language"));
title.getStyleClass().add("title");
Label subtitle = new Label(i18n("settings.take_effect_after_restart"));
subtitle.getStyleClass().add("subtitle");
left.getChildren().setAll(title, subtitle);
languagePane.setLeft(left);
SupportedLocale currentLocale = I18n.getLocale();
JFXComboBox<SupportedLocale> cboLanguage = new JFXComboBox<>();
cboLanguage.setConverter(stringConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
}));
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
FXUtils.setLimitWidth(cboLanguage, 300);
languagePane.setRight(cboLanguage);
settingsPane.getContent().add(languagePane);
}
{
OptionToggleButton disableAutoGameOptionsPane = new OptionToggleButton();
disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options"));
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
settingsPane.getContent().add(disableAutoGameOptionsPane);
}
{
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
logButton.setOnAction(e -> onExportLogs());
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, logButton);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
settingsPane.getContent().add(debugPane);
}
rootPane.getChildren().add(settingsPane);
}
}
@Override
protected void onUpdate() {
private void openLogFolder() {
FXUtils.openFolder(LOG.getLogFile().getParent());
}
private void onUpdate() {
RemoteVersion target = UpdateChecker.getLatestVersion();
if (target == null) {
return;
@@ -168,8 +366,7 @@ public final class SettingsPage extends SettingsView {
}
}
@Override
protected void onExportLogs() {
private void onExportLogs() {
thread(() -> {
String nameBase = "hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"));
List<Path> recentLogFiles = LOG.findRecentLogFiles(5);
@@ -249,13 +446,11 @@ public final class SettingsPage extends SettingsView {
});
}
@Override
protected void onSponsor() {
private void onSponsor() {
FXUtils.openLink("https://github.com/HMCL-dev/HMCL");
}
@Override
protected void clearCacheDirectory() {
private void clearCacheDirectory() {
String commonDirectory = Settings.instance().getCommonDirectory();
if (commonDirectory != null) {
FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache"));
@@ -1,255 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXRadioButton;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.*;
import javafx.scene.text.TextAlignment;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import java.util.Arrays;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class SettingsView extends StackPane {
protected final JFXComboBox<SupportedLocale> cboLanguage;
protected final OptionToggleButton disableAutoGameOptionsPane;
protected final MultiFileItem<EnumCommonDirectory> fileCommonLocation;
protected final ComponentSublist fileCommonLocationSublist;
protected final Label lblUpdate;
protected final Label lblUpdateSub;
protected final JFXRadioButton chkUpdateStable;
protected final JFXRadioButton chkUpdateDev;
protected final JFXButton btnUpdate;
protected final OptionToggleButton previewPane;
protected final ScrollPane scroll;
public SettingsView() {
scroll = new ScrollPane();
getChildren().setAll(scroll);
scroll.setStyle("-fx-font-size: 14;");
scroll.setFitToWidth(true);
{
VBox rootPane = new VBox();
rootPane.setPadding(new Insets(32, 10, 32, 10));
{
ComponentList settingsPane = new ComponentList();
{
{
StackPane sponsorPane = new StackPane();
sponsorPane.setCursor(Cursor.HAND);
FXUtils.onClicked(sponsorPane, this::onSponsor);
sponsorPane.setPadding(new Insets(8, 0, 8, 0));
GridPane gridPane = new GridPane();
ColumnConstraints col = new ColumnConstraints();
col.setHgrow(Priority.SOMETIMES);
col.setMaxWidth(Double.POSITIVE_INFINITY);
gridPane.getColumnConstraints().setAll(col);
RowConstraints row = new RowConstraints();
row.setMinHeight(Double.NEGATIVE_INFINITY);
row.setValignment(VPos.TOP);
row.setVgrow(Priority.SOMETIMES);
gridPane.getRowConstraints().setAll(row);
{
Label label = new Label(i18n("sponsor.hmcl"));
label.setWrapText(true);
label.setTextAlignment(TextAlignment.JUSTIFY);
GridPane.setRowIndex(label, 0);
GridPane.setColumnIndex(label, 0);
gridPane.getChildren().add(label);
}
sponsorPane.getChildren().setAll(gridPane);
settingsPane.getContent().add(sponsorPane);
}
}
{
ComponentSublist updatePane = new ComponentSublist();
updatePane.setTitle(i18n("update"));
updatePane.setHasSubtitle(true);
{
VBox headerLeft = new VBox();
lblUpdate = new Label(i18n("update"));
lblUpdate.getStyleClass().add("title-label");
lblUpdateSub = new Label();
lblUpdateSub.getStyleClass().add("subtitle-label");
headerLeft.getChildren().setAll(lblUpdate, lblUpdateSub);
updatePane.setHeaderLeft(headerLeft);
}
{
btnUpdate = new JFXButton();
btnUpdate.setOnAction(e -> onUpdate());
btnUpdate.getStyleClass().add("toggle-icon4");
btnUpdate.setGraphic(SVG.UPDATE.createIcon(20));
updatePane.setHeaderRight(btnUpdate);
}
{
VBox content = new VBox();
content.setSpacing(8);
chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable"));
chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev"));
Label noteWrapper = new Label(i18n("update.note"));
VBox.setMargin(noteWrapper, new Insets(10, 0, 0, 0));
content.getChildren().setAll(chkUpdateStable, chkUpdateDev, noteWrapper);
updatePane.getContent().add(content);
}
settingsPane.getContent().add(updatePane);
}
{
previewPane = new OptionToggleButton();
previewPane.setTitle(i18n("update.preview"));
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
FXUtils.installFastTooltip(previewPane, i18n("update.preview.tooltip"));
settingsPane.getContent().add(previewPane);
}
{
fileCommonLocation = new MultiFileItem<>();
fileCommonLocationSublist = new ComponentSublist();
fileCommonLocationSublist.getContent().add(fileCommonLocation);
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
fileCommonLocationSublist.setHasSubtitle(true);
fileCommonLocation.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
.setChooserTitle(i18n("launcher.cache_directory.choose"))
.setDirectory(true)
.bindBidirectional(config().commonDirectoryProperty())
));
{
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory());
fileCommonLocationSublist.setHeaderRight(cleanButton);
}
settingsPane.getContent().add(fileCommonLocationSublist);
}
{
BorderPane languagePane = new BorderPane();
Label left = new Label(i18n("settings.launcher.language"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
languagePane.setLeft(left);
SupportedLocale currentLocale = I18n.getLocale();
cboLanguage = new JFXComboBox<>();
cboLanguage.setConverter(stringConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
}));
FXUtils.setLimitWidth(cboLanguage, 300);
languagePane.setRight(cboLanguage);
settingsPane.getContent().add(languagePane);
}
{
disableAutoGameOptionsPane = new OptionToggleButton();
disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options"));
settingsPane.getContent().add(disableAutoGameOptionsPane);
}
{
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
logButton.setOnAction(e -> onExportLogs());
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, logButton);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
settingsPane.getContent().add(debugPane);
}
rootPane.getChildren().add(settingsPane);
}
scroll.setContent(rootPane);
}
}
public void openLogFolder() {
FXUtils.openFolder(LOG.getLogFile().getParent());
}
protected abstract void onUpdate();
protected abstract void onExportLogs();
protected abstract void onSponsor();
protected abstract void clearCacheDirectory();
}
@@ -89,7 +89,10 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
setLoading(false);
root.setCenter(new NBTTreeView(result));
NBTTreeView view = new NBTTreeView(result);
BorderPane.setMargin(view, new Insets(10));
onEscPressed(view, cancelButton::fire);
root.setCenter(view);
} else {
LOG.warning("Fail to open nbt file", exception);
Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(exception), null, MessageDialogPane.MessageType.WARNING, cancelButton::fire);
@@ -20,11 +20,13 @@ package org.jackhuang.hmcl.ui.nbt;
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
import com.github.steveice10.opennbt.tag.builtin.ListTag;
import com.github.steveice10.opennbt.tag.builtin.Tag;
import javafx.scene.control.*;
import com.jfoenix.controls.JFXTreeView;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.util.Callback;
import org.jackhuang.hmcl.util.Holder;
import java.lang.reflect.Array;
import java.util.EnumMap;
@@ -34,7 +36,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Glavo
*/
public final class NBTTreeView extends TreeView<Tag> {
public final class NBTTreeView extends JFXTreeView<Tag> {
public NBTTreeView(NBTTreeView.Item tree) {
this.setRoot(tree);
@@ -42,10 +44,9 @@ public final class NBTTreeView extends TreeView<Tag> {
}
private static Callback<TreeView<Tag>, TreeCell<Tag>> cellFactory() {
Holder<Object> lastCell = new Holder<>();
EnumMap<NBTTagType, Image> icons = new EnumMap<>(NBTTagType.class);
return view -> new TreeCell<Tag>() {
return view -> new TreeCell<>() {
private void setTagText(String text) {
String name = ((Item) getTreeItem()).getName();
@@ -66,11 +67,6 @@ public final class NBTTreeView extends TreeView<Tag> {
public void updateItem(Tag item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
ImageView imageView = (ImageView) this.getGraphic();
if (imageView == null) {
imageView = new ImageView();
@@ -85,6 +81,8 @@ public final class NBTTreeView extends TreeView<Tag> {
NBTTagType tagType = NBTTagType.typeOf(item);
imageView.setImage(icons.computeIfAbsent(tagType, type -> new Image(type.getIconUrl())));
imageView.setFitHeight(16);
imageView.setFitWidth(16);
if (((Item) getTreeItem()).getText() != null) {
setText(((Item) getTreeItem()).getText());
@@ -22,10 +22,9 @@ import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.RequiredFieldValidator;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
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;
@@ -44,6 +43,7 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Optional;
@@ -53,7 +53,6 @@ public final class ProfilePage extends BorderPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final StringProperty location;
private final Profile profile;
private final JFXTextField txtProfileName;
private final FileItem gameDir;
private final OptionToggleButton toggleUseRelativePath;
@@ -147,6 +146,38 @@ public final class ProfilePage extends BorderPane implements DecoratorPage {
() -> !txtProfileName.validate() || StringUtils.isBlank(getLocation()),
txtProfileName.textProperty(), location));
}
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
Path newPath;
try {
newPath = FileUtils.toAbsolute(Path.of(newValue));
} catch (InvalidPathException ignored) {
return;
}
if (!".minecraft".equals(FileUtils.getName(newPath)))
return;
Path parent = newPath.getParent();
if (parent == null)
return;
String suggestedName = FileUtils.getName(parent);
if (!suggestedName.isBlank()) {
txtProfileName.setText(suggestedName);
}
};
locationProperty().addListener(locationChangeListener);
txtProfileName.textProperty().addListener(new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (txtProfileName.isFocused()) {
txtProfileName.textProperty().removeListener(this);
locationProperty().removeListener(locationChangeListener);
}
}
});
}
private void onSave() {
@@ -40,7 +40,6 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.game.LauncherHelper;
@@ -58,7 +57,13 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
@@ -97,6 +102,11 @@ public class TerracottaControllerPage extends StackPane {
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
Consider moving UI into multiple files for each state respectively. */
public TerracottaControllerPage() {
holder.add(FXUtils.observeWeak(() -> {
// Run daemon process only if HMCL is focused and is displaying current node.
TerracottaManager.switchDaemon(getScene() != null && Controllers.getStage().isFocused());
}, this.sceneProperty(), Controllers.getStage().focusedProperty()));
TransitionPane transition = new TransitionPane();
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
@@ -108,7 +118,7 @@ public class TerracottaControllerPage extends StackPane {
if (state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK
state instanceof TerracottaState.Fatal fatal && fatal.isRecoverable()
) {
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
} else {
@@ -117,7 +127,7 @@ public class TerracottaControllerPage extends StackPane {
}, files -> {
Path path = files.get(0);
if (!TerracottaManager.validate(path)) {
if (TerracottaManager.isInvalidBundle(path)) {
Controllers.dialog(
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
i18n("message.error"),
@@ -126,25 +136,7 @@ public class TerracottaControllerPage extends StackPane {
return;
}
TerracottaState state = UI_STATE.get(), next;
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5,
MessageDialogPane.MessageType.INFO, () -> {
TerracottaState.Preparing s = TerracottaManager.install(path);
if (s != null) {
UI_STATE.set(s);
}
}, null);
return;
}
next = TerracottaManager.install(path);
} else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
next = TerracottaManager.recover(path);
} else {
return;
}
TerracottaState.Preparing next = TerracottaManager.install(path);
if (next != null) {
UI_STATE.set(next);
}
@@ -168,6 +160,7 @@ public class TerracottaControllerPage extends StackPane {
progressProperty.set(0);
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
body.getStyleClass().add("terracotta-hint");
body.setLineSpacing(4);
LineButton download = LineButton.of();
@@ -176,7 +169,7 @@ public class TerracottaControllerPage extends StackPane {
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
download.setRightIcon(SVG.ARROW_FORWARD);
FXUtils.onClicked(download, () -> {
TerracottaState.Preparing s = TerracottaManager.install(null);
TerracottaState.Preparing s = TerracottaManager.download();
if (s != null) {
UI_STATE.set(s);
}
@@ -211,6 +204,7 @@ public class TerracottaControllerPage extends StackPane {
progressProperty.set(1);
TextFlow flow = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
flow.getStyleClass().add("terracotta-hint");
flow.setLineSpacing(4);
LineButton host = LineButton.of();
@@ -231,7 +225,10 @@ public class TerracottaControllerPage extends StackPane {
MessageDialogPane.MessageType.QUESTION
).addAction(i18n("version.launch"), () -> {
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
Versions.launch(profile, profile.getSelectedVersion(), launcherHelper -> {
launcherHelper.setKeep();
launcherHelper.setDisableOfflineSkin();
});
}).addCancel(i18n("terracotta.status.waiting.host.launch.skip"), () -> {
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
if (s1 != null) {
@@ -249,7 +246,7 @@ public class TerracottaControllerPage extends StackPane {
guest.setRightIcon(SVG.ARROW_FORWARD);
FXUtils.onClicked(guest, () -> {
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> {
Task<TerracottaState.GuestStarting> task = TerracottaManager.setGuesting(code);
Task<TerracottaState.GuestConnecting> task = TerracottaManager.setGuesting(code);
if (task != null) {
task.whenComplete(Schedulers.javafx(), (s, e) -> {
if (e != null) {
@@ -282,6 +279,7 @@ public class TerracottaControllerPage extends StackPane {
progressProperty.set(-1);
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.status.scanning.desc"), Controllers::onHyperlinkAction);
body.getStyleClass().add("terracotta-hint");
body.setLineSpacing(4);
LineButton room = LineButton.of();
@@ -367,7 +365,7 @@ public class TerracottaControllerPage extends StackPane {
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
}
}
} else if (state instanceof TerracottaState.GuestStarting) {
} else if (state instanceof TerracottaState.GuestConnecting || state instanceof TerracottaState.GuestStarting) {
statusProperty.set(i18n("terracotta.status.guest_starting"));
progressProperty.set(-1);
@@ -382,7 +380,26 @@ public class TerracottaControllerPage extends StackPane {
}
});
nodesProperty.setAll(room);
nodesProperty.clear();
if (state instanceof TerracottaState.GuestStarting) {
TerracottaState.GuestStarting.Difficulty difficulty = ((TerracottaState.GuestStarting) state).getDifficulty();
if (difficulty != null && difficulty != TerracottaState.GuestStarting.Difficulty.UNKNOWN) {
LineButton info = LineButton.of();
info.setLeftIcon(switch (difficulty) {
case UNKNOWN -> throw new AssertionError();
case EASIEST, SIMPLE -> SVG.INFO;
case MEDIUM, TOUGH -> SVG.WARNING;
});
String difficultyID = difficulty.name().toLowerCase(Locale.ROOT);
info.setTitle(i18n(String.format("terracotta.difficulty.%s", difficultyID)));
info.setSubtitle(i18n("terracotta.difficulty.estimate_only"));
nodesProperty.add(info);
}
}
nodesProperty.add(room);
} else if (state instanceof TerracottaState.GuestOK guestOK) {
if (guestOK.isForkOf(legacyState)) {
if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) {
@@ -481,7 +498,7 @@ public class TerracottaControllerPage extends StackPane {
retry.setTitle(i18n("terracotta.status.fatal.retry"));
retry.setSubtitle(message);
FXUtils.onClicked(retry, () -> {
TerracottaState s = TerracottaManager.recover(null);
TerracottaState s = TerracottaManager.recover();
if (s != null) {
UI_STATE.set(s);
}
@@ -626,9 +643,9 @@ public class TerracottaControllerPage extends StackPane {
}
HBox secondLine = new HBox();
secondLine.getStyleClass().add("second-line");
{
Text text = new Text(button.subTitle.get());
text.setFill(new Color(0, 0, 0, 0.5));
TextFlow lblSubtitle = new TextFlow(text);
lblSubtitle.getStyleClass().add("subtitle");
@@ -24,7 +24,6 @@ import javafx.geometry.Insets;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
@@ -71,7 +70,10 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
AdvancedListBox toolbar = new AdvancedListBox()
.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> {
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
Versions.launch(profile, profile.getSelectedVersion(), launcherHelper -> {
launcherHelper.setKeep();
launcherHelper.setDisableOfflineSkin();
});
}, item -> {
instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(Profiles.selectedVersionProperty(),
instanceName -> item.setSubtitle(StringUtils.isNotBlank(instanceName) ? instanceName : i18n("version.empty"))
@@ -83,8 +85,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> mainPage.getProfile().setSelectedVersion(it.getId()));
})
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK))
.addNavigationDrawerItem(i18n("terracotta.easytier"), SVG.HOST, () -> FXUtils.openLink("https://easytier.cn/"));
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
left.setBottom(toolbar);
@@ -73,6 +73,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
getChildren().setAll(scrollPane);
VBox rootPane = new VBox();
rootPane.setFocusTraversable(true);
rootPane.setFillWidth(true);
scrollPane.setContent(rootPane);
FXUtils.smoothScrolling(scrollPane);
@@ -48,14 +48,12 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
public DatapackListPage(WorldManagePage worldManagePage) {
this.worldDir = worldManagePage.getWorld().getFile();
datapack = new Datapack(worldDir.resolve("datapacks"));
datapack.loadFromDir();
setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new));
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
mods -> mods.forEach(this::installSingleDatapack), this::refresh);
refresh();
}
private void installSingleDatapack(Path datapack) {
@@ -74,9 +72,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
public void refresh() {
setLoading(true);
Task.runAsync(datapack::loadFromDir)
.withRunAsync(Schedulers.javafx(), () -> {
setLoading(false);
})
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
.start();
}
@@ -54,7 +54,6 @@ import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.MDListCell;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.Nullable;
@@ -180,8 +179,7 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
center.getStyleClass().add("large-spinner-pane");
center.loadingProperty().bind(skinnable.loadingProperty());
Holder<Object> lastCell = new Holder<>();
listView.setCellFactory(x -> new DatapackInfoListCell(listView, lastCell));
listView.setCellFactory(x -> new DatapackInfoListCell(listView));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
this.listView.setItems(filteredList);
@@ -304,8 +302,8 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
final TwoLineListItem content = new TwoLineListItem();
BooleanProperty booleanProperty;
DatapackInfoListCell(JFXListView<DatapackInfoObject> listView, Holder<Object> lastCell) {
super(listView, lastCell);
DatapackInfoListCell(JFXListView<DatapackInfoObject> listView) {
super(listView);
HBox container = new HBox(8);
container.setPickOnBounds(false);
@@ -35,6 +35,7 @@ import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
@@ -55,14 +56,13 @@ import org.jackhuang.hmcl.ui.construct.FloatListCell;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.AggregatedObservableList;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import java.net.URI;
import java.util.*;
import java.util.stream.Collectors;
@@ -199,6 +199,10 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
: i18n("curse.category." + category);
}
protected boolean shouldDisplayCategory(String category) {
return !"minecraft".equals(category);
}
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
return StringUtils.repeats(' ', category.indent * 4) +
(category.getCategory() == null
@@ -234,6 +238,12 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteMod> listView = new JFXListView<>();
private final RemoteImageLoader iconLoader = new RemoteImageLoader() {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull URI uri) {
return FXUtils.getRemoteImageTask(uri, 80, 80, true, true);
}
};
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
@@ -364,6 +374,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
EventHandler<ActionEvent> searchAction = e -> {
iconLoader.clearInvalidCache();
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
control.pageOffset.set(0);
}
@@ -523,6 +534,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.setCellFactory(x -> new FloatListCell<>(listView) {
private final TwoLineListItem content = new TwoLineListItem();
private final ImageView imageView = new ImageView();
@@ -546,13 +558,13 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : dataItem.getTitle());
content.setSubtitle(dataItem.getDescription());
content.getTags().clear();
dataItem.getCategories().stream()
.map(category -> getSkinnable().getLocalizedCategory(category))
.forEach(content::addTag);
if (StringUtils.isNotBlank(dataItem.getIconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(dataItem.getIconUrl(), 80, 80, true, true));
for (String category : dataItem.getCategories()) {
if (getSkinnable().shouldDisplayCategory(category))
content.addTag(getSkinnable().getLocalizedCategory(category));
}
iconLoader.load(imageView.imageProperty(), dataItem.getIconUrl());
}
});
}
@@ -157,15 +157,15 @@ public class DownloadPage extends Control implements DecoratorPage {
this.failed.set(failed);
}
public void download(RemoteMod.Version file) {
public void download(RemoteMod mod, RemoteMod.Version file) {
if (this.callback == null) {
saveAs(file);
saveAs(mod, file);
} else {
this.callback.download(version.getProfile(), version.getVersion(), file);
this.callback.download(version.getProfile(), version.getVersion(), mod, file);
}
}
public void saveAs(RemoteMod.Version file) {
public void saveAs(RemoteMod mod, RemoteMod.Version file) {
String extension = StringUtils.substringAfterLast(file.getFile().getFilename(), '.');
FileChooser fileChooser = new FileChooser();
@@ -287,7 +287,7 @@ public class DownloadPage extends Control implements DecoratorPage {
if (targetLoaders.contains(loader)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
new ModItem(modVersion, control)
new ModItem(control.addon, modVersion, control)
);
break resolve;
}
@@ -308,7 +308,7 @@ public class DownloadPage extends Control implements DecoratorPage {
ComponentList sublist = new ComponentList(() -> {
ArrayList<ModItem> items = new ArrayList<>(versions.size());
for (RemoteMod.Version v : versions) {
items.add(new ModItem(v, control));
items.add(new ModItem(control.addon, v, control));
}
return items;
});
@@ -357,9 +357,10 @@ public class DownloadPage extends Control implements DecoratorPage {
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(page.repository.getType()).getModByCurseForgeId(addon.getSlug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.getTitle());
content.setSubtitle(addon.getDescription());
addon.getCategories().stream()
.map(page::getLocalizedCategory)
.forEach(content::addTag);
for (String category : addon.getCategories()) {
if (page.shouldDisplayCategory(category))
content.addTag(page.getLocalizedCategory(category));
}
if (StringUtils.isNotBlank(addon.getIconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 80, 80, true, true));
}
@@ -373,7 +374,7 @@ public class DownloadPage extends Control implements DecoratorPage {
private static final class ModItem extends StackPane {
ModItem(RemoteMod.Version dataItem, DownloadPage selfPage) {
ModItem(RemoteMod mod, RemoteMod.Version dataItem, DownloadPage selfPage) {
VBox pane = new VBox(8);
pane.setPadding(new Insets(8, 0, 8, 0));
@@ -435,7 +436,7 @@ public class DownloadPage extends Control implements DecoratorPage {
}
RipplerContainer container = new RipplerContainer(pane);
FXUtils.onClicked(container, () -> Controllers.dialog(new ModVersion(dataItem, selfPage)));
FXUtils.onClicked(container, () -> Controllers.dialog(new ModVersion(mod, dataItem, selfPage)));
getChildren().setAll(container);
// Workaround for https://github.com/HMCL-dev/HMCL/issues/2129
@@ -444,30 +445,21 @@ public class DownloadPage extends Control implements DecoratorPage {
}
private static final class ModVersion extends JFXDialogLayout {
public ModVersion(RemoteMod.Version version, DownloadPage selfPage) {
public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) {
RemoteModRepository.Type type = selfPage.repository.getType();
String title;
switch (type) {
case WORLD:
title = "world.download.title";
break;
case MODPACK:
title = "modpack.download.title";
break;
case RESOURCE_PACK:
title = "resourcepack.download.title";
break;
case MOD:
default:
title = "mods.download.title";
break;
}
String title = switch (type) {
case WORLD -> "world.download.title";
case MODPACK -> "modpack.download.title";
case RESOURCE_PACK -> "resourcepack.download.title";
case SHADER_PACK -> "shaderpack.download.title";
default -> "mods.download.title";
};
this.setHeading(new HBox(new Label(i18n(title, version.getName()))));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
ModItem modItem = new ModItem(version, selfPage);
ModItem modItem = new ModItem(mod, version, selfPage);
modItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again
box.getChildren().setAll(modItem);
SpinnerPane spinnerPane = new SpinnerPane();
@@ -493,7 +485,7 @@ public class DownloadPage extends Control implements DecoratorPage {
if (type == RemoteModRepository.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.download(version);
selfPage.download(mod, version);
});
}
@@ -503,7 +495,7 @@ public class DownloadPage extends Control implements DecoratorPage {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.saveAs(version);
selfPage.saveAs(mod, version);
});
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
@@ -557,6 +549,6 @@ public class DownloadPage extends Control implements DecoratorPage {
}
public interface DownloadCallback {
void download(Profile profile, @Nullable String version, RemoteMod.Version file);
void download(Profile profile, @Nullable String version, RemoteMod mod, RemoteMod.Version file);
}
}
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff. Show more