Compare commits

..
137 Commits
Author SHA1 Message Date
Glavo a7108f318a Adjust JVM arguments priority (#1898)
* Adjust JVM arguments priority

* #1889: Use the default JVM arguments consistent with the official launcher

* Follow -Xmx settings in modpack info

* Do not force generation of -Xmx
2022-12-06 13:34:32 +08:00
zkitefly a3eaccb45b exe and sh download java link update (#1903)
* Update main.cpp

* exe and sh download java link update

* fix
2022-12-06 13:33:00 +08:00
zkitefly e0c97da9fd Update main.cpp (#1900) 2022-12-03 23:22:15 +08:00
zkitefly 3843002113 (c)->© (#1899)
* Update I18N_zh_CN.properties

* Update I18N.properties

* Update I18N_es.properties

* Update I18N_ja.properties

* Update I18N_ru.properties

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* Update I18N.properties

* `(c)`->`©`

* fix

* Update AboutPage.java

* Update AboutPage.java
2022-12-03 15:05:42 +08:00
Glavo 1ec9e41459 fix #1885: filter letter color code (#1890) 2022-12-02 17:28:25 +08:00
Glavo a6231ac593 修复内存泄漏 (#1888)
* Fix memory leaks

* Cache icon in cell

* Lazy acquisition of availableCharsets
2022-12-02 17:27:52 +08:00
Glavo ea91ee91ec close #1199: Reimplement Zipper (#1891) 2022-12-02 17:26:00 +08:00
Glavo d4c0c30283 修复在精简 JRE 上崩溃的问题 (#1893)
* Do not load javafx.web on unsupported platforms

* No longer requires jdk.zipfs to exist
2022-12-02 17:23:08 +08:00
Glavo 9e2c46a6f8 Gradle 7.6 (#1895) 2022-12-02 17:22:09 +08:00
Glavo ce31bcc6e0 Catch the exception that the OperatingSystemMXBean does not exist 2022-12-01 03:51:21 +08:00
Glavo d11e22026c 修复遗留问题 (#1875)
* Check the sha1 file of java in the minecraft runtime dir

* Fix download Java on macOS

* misc

* Verify Java at startup

* Find the Java installed by official launcher on osx-arm64

* Extract the downloaded file to a temporary file

* Rollback JavaRepository.getSystemJavaPlatform
2022-11-26 21:43:35 +08:00
Glavo a96fe298b8 fix #1108 (#1882)
close #1791
close #1868
2022-11-26 21:43:03 +08:00
Glavo 8e28c2487f Load gif background (#1877) 2022-11-26 20:33:46 +08:00
Glavo fda6f75333 fix #1553: Hide popup before rolling back mod (#1881) 2022-11-26 20:33:02 +08:00
zkitefly e414450f3c 优化日志分析器 (#1813)
* 优化日志分析器

* 修复翻译错误

* 修复翻译错误

* 修复翻译错误

* Update CrashReportAnalyzer.java

* Update I18N_zh_CN.properties

* 逐步修复问题

* 逐步修复问题

* 尝试修复

* 修复错误

* 修复错误

* 尝试

* 尝试

* 修复

* 按照要求修改

* 依据 Check Style 修复问题

* 按提示修改

* 修复错误

* Update CrashReportAnalyzer.java

* 删除

* Update I18N_zh_CN.properties

* Update CrashReportAnalyzer.java
2022-11-25 22:45:00 +08:00
Glavo 95e15d7df4 Replace ObservableMap with MapProperty (#1870) 2022-11-25 22:40:16 +08:00
Glavo 7e826a5952 close #1803: Add new rule to CrashReportAnalyzer (#1872) 2022-11-25 22:37:41 +08:00
Glavo 43e2644f0e fix NPE (#1871) 2022-11-25 22:37:05 +08:00
Glavo a0ef16bef4 close #1781: Add suggestions for naming (#1873) 2022-11-25 22:35:01 +08:00
Glavo f5d83095cc 修复 Java 下载相关问题 (#1874)
* fix #1561: Replace existing files when downloading Java
close #1756

* close #1755: Download Java using mirror

* Skip existing files
2022-11-25 22:34:26 +08:00
Glavo a94bb407e5 Submit the modification of JFXTextField after editing (#1869) 2022-11-25 17:20:16 +08:00
Glavo 6611774aa9 Fix the problem of garbled logs on Java 18+ (#1866) 2022-11-25 17:17:31 +08:00
Glavo e1eb40c129 Optimization HMCLProcessListener::onLog (#1867) 2022-11-25 17:16:23 +08:00
Glavo 2e532be34c 将 Quilt 图标添加至 VersionIconDialog (#1864)
* Safely deserialize VersionSetting

* Add quilt to VersionIconDialog
2022-11-24 12:54:56 +08:00
Glavo b9d0cfc12a Update GitHub Action (#1863) 2022-11-24 12:54:48 +08:00
Glavo 3d88bc35bb fix memory leak (#1865) 2022-11-24 12:53:16 +08:00
Glavo 8c9c8fd28f 修复 macOS Dock 中的图标 (#1862)
* High resolution logo

* Load original icon on Java 8
2022-11-24 12:52:54 +08:00
Glavo 5e685b7f26 Modify the version number for unofficial builds (#1861) 2022-11-23 22:54:28 +08:00
Glavo c76b562705 Remove log4j-patch (#1860)
* Remove log4j-patch

* Update I18N
2022-11-23 17:20:15 +08:00
Glavo 4c9f8d3dca Complete the missing translation (#1859) 2022-11-23 17:19:45 +08:00
Glavo 0f26ae5cfc 代码清理与修复 UI 卡顿资源占用过高的问题 (#1849)
* Lazy initialization of Swing

* Load ISRG Root X1 certificate only on Java 8

* Replace JOptionPane with JavaFX Alert

* Avoid using java.awt.Desktop

* Rewrite TexturesLoader

* Optimization SelfDependencyPatcher

* fix typo

* close #968: Use computeIfAbsent to ensure thread safety

* Optimization GameVersion::minecraftVersion

* code cleanup

* Set the initial capacity of readFullyWithoutClosing

* code cleanup

* Mark inner classes as static if possible

* Cache version icon

* Code cleanup

* Fix ListView scrolling performance issues

* DatapackListPage::items

* Replace OutputStream with FileChannel::write
2022-11-23 16:33:14 +08:00
Glavo 035469240d close #1855: Add 1.19 to DEFAULT_GAME_VERSIONS (#1857) 2022-11-23 16:32:20 +08:00
Glavo 7ee81f7425 Deny access to CurseForge without api key (#1858) 2022-11-23 16:32:07 +08:00
zkitefly 024c90d688 在HMCL显示changelog时自动定位该更新内容 (#1850)
* 在HMCL显示changelog时自动定位该更新内容

* 尝试修复错误

* updata

* fix
2022-11-23 14:07:13 +08:00
Glavo 1253272626 fix #1852: Lookup manifest in this jar (#1853) 2022-11-23 14:06:35 +08:00
zkitefly 73b938cfd9 修复微软账户登录弹窗不显示“忘记密码”的问题 (#1829) 2022-11-13 23:44:01 +08:00
Glavo ee843543bf 性能与内存优化 (#1848)
* optimization

* Make ManagedProcess::getLines thread safe
2022-11-13 23:43:45 +08:00
skbeh fa8a479aac Bump Gradle Shadow to 7.1.2 (#1831)
This allows compilation with `sourceCompatibility = 17`.
2022-11-13 23:40:10 +08:00
Glavo bac14db432 修复无法正确处理带有 Quilt 的 MultiMC 整合包的问题 (#1841)
* fix #1760: typo

* export quilt info in mmc modpack
2022-11-13 23:39:56 +08:00
zkitefly f6242ea868 添加提示 (#1844) 2022-11-13 23:39:40 +08:00
zkitefly 16db35655a 修改 2022-11-11 21:50:10 +08:00
Glavo 53336aedc9 Remove HiPer Support 2022-11-11 18:30:27 +08:00
zkitefly eaddb67932 Update HelpPage.java (#1827) 2022-11-05 23:21:47 +08:00
Glavo c7b7dd9500 fix crash (#1826) 2022-11-05 22:51:10 +08:00
Glavo 9901bf738d fix crash (#1825) 2022-11-05 22:12:44 +08:00
huanghongxun a3d8c6b3b3 feat(skin): introduce new official skins. 2022-11-05 20:22:02 +08:00
huanghongxun 948928f740 feat: update HMCLauncher.exe 2022-11-05 20:11:22 +08:00
Rei Hatada 71e98c035e change (#1824) 2022-11-05 19:51:39 +08:00
Glavo af107b15c0 完善 HiPer 支持 (#1821)
* Display hiper token expiration time

* Automatically download missing files when starting HiPer

* Disable start HiPer when token is empty
2022-11-05 19:36:17 +08:00
Glavo 8687bb9487 修复 MessageDialogPane 总是折叠内容的问题 (#1815)
* fix #1751: Use ScrollPane in message dialog only when needed
close #1777

* Parse segment only if it contains xml tag
2022-11-05 19:28:24 +08:00
huanghongxun a70e53364d fix(multiplayer): force using authlib-injector in multiplayer mode. 2022-10-30 13:45:34 +08:00
zkitefly 11a1c37765 将日志更换至docs.hmcl.net (#1812)
* 将日志更换至docs.hmcl.net,以便更新日志

* 修改链接

* 更换目录
2022-10-30 01:29:51 +08:00
Glavo 68a8944810 改进 HiPer 支持 (#1763)
* Disable start hiper button when token is empty

* Use gsudo start hiper

* Automatically request administrator privileges on Windows and Linux

* Automatically start hiper with sudo on mac

* Update I18N

* Prompt user to chown unwritable files

* Update HiPer file verification failed prompt

* Log other hiper exceptions

* temporarily disable gsudo

* Detect admin rights with 'net session'

* Hide the hint pane when running with administrator privileges

* Prompt user when starting HMCL with sudo

* Fix ClassNotFoundException when no JavaFX

* Update the prompt when the permission cannot be obtained

* Add support for saving multiple HiPer configurations

* update link
close Glavo/HMCL#3

* Detect RISC-V 32

* Save original hiper authorization information

* Add support for importing and exporting license files

* Set initial license file name

* Complement the missing message

* Handling gsudo failure to obtain permission
2022-10-25 21:55:12 +08:00
zkitefly b280b238df 尝试完成 #1780 (#1789) 2022-10-25 21:52:23 +08:00
huanghongxun 0f39aad429 fix(auth): legacy user type for OfflineAccount. 2022-10-25 21:48:55 +08:00
Glavo ff78297ee5 fix IntegrityCheck::performCheck (#1792) 2022-10-23 23:28:22 +08:00
Glavo f75bbdcecc Log HMCL jar path (#1794) 2022-10-23 23:27:42 +08:00
Glavo 694e31a7fe Cache manifest in JarUtils (#1797) 2022-10-23 23:27:21 +08:00
Glavo c113670cda Migrate HMCL_DIRECTORY to the user directory (#1785) 2022-10-09 13:21:02 +08:00
zkitefly 1b3c399335 添加“测试能否联机”按钮 (#1757)
* add

* 修改图标(其实我不知道该弄啥图标好……)

* fix
2022-10-09 11:18:00 +08:00
huanghongxun 45cbe20449 fix: crash when there is an incorrect authlib-injector server url. Closes #1774. 2022-10-08 17:39:04 +08:00
zkiteflyandYuhui Huang 4d606510f6 修改 登录失败 弹窗 (#1782)
* 修改 登录失败 弹窗

* fix

* Update I18N.properties

* Update I18N_zh_CN.properties

* Update I18N_zh.properties

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* Edit

Co-authored-by: Yuhui Huang <i@huangyuhui.net>
2022-10-08 17:33:07 +08:00
huanghongxun a6639552cf fix: lblCreationWarning not wrapping text. Closes #1783. 2022-10-08 17:31:28 +08:00
zkitefly 2a088ec555 更新多人联机页面 (#1758)
* 更新 multiplayer.error.file_not_found 翻译

* 修改翻译

* 移除标题

* add

* try fix

* try fix

* try fix

* try fix

* try fix

* try

* try

* try

* fix

* fix

* add

* fix

* fix

* add

* fix

* edit
2022-10-08 17:13:55 +08:00
zkitefly 1c46d63367 try (#1768) 2022-10-08 17:12:13 +08:00
zkitefly f2b7fae694 添加“忘记密码”按钮 (#1765) 2022-10-08 17:11:56 +08:00
Glavo 43986676d8 修复中文路径下启动 <= 1.12.2 的版本没有声音的问题 (#1754)
* Use native encoding by default

* Fix the problem that LWJGL fails to load OpenAL under Java 18
2022-10-08 17:10:27 +08:00
Glavo 2649e79d47 fix build fails with Java 19 (#1784) 2022-10-08 17:09:12 +08:00
zkitefly f83d3cd769 Fix https://github.com/huanghongxun/HMCL/issues/1703 (#1753) 2022-09-28 19:14:15 +08:00
zkitefly f124910f94 修改多人联机帮助与提示 (#1733)
* 修改多人联机提示

* text

* 撤回修改

* 1

* 修改帮助

* fix

* try

* Delete try

* Update I18N.properties

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* Update I18N_zh_CN.properties

* Update I18N_zh.properties

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* 修改提示框样式

* 修改提示框样式

* fix

* 修改样式

* 修改样式

* 修改提示框样式

* 修改翻译

* 修改翻译

* add

* return

* add

* add

* 去掉图标

* 试图让左边图标为空

* Update MultiplayerPageSkin.java

* ruturn

* add

* add

* Update MultiplayerPageSkin.java

* Update I18N_zh.properties

* Update I18N_zh_CN.properties
2022-09-25 14:25:05 +08:00
zkitefly c1712c3e1e 修改翻译 (#1742) 2022-09-25 14:23:39 +08:00
huanghongxun d817c80629 fix: checkstyle. 2022-09-25 00:31:51 +08:00
huanghongxun 8d830dbf96 fix(microsoft): ok cannot be clicked. Closes #1729. 2022-09-24 23:54:36 +08:00
huanghongxun 0396e96334 feat(multiplayer): update agreement. Closes #1724. 2022-09-24 23:50:48 +08:00
huanghongxun 7b23c7bc79 feat(multiplayer): link to video tutorial. 2022-09-24 23:36:48 +08:00
huanghongxun d3ca50259a fix(modrinth): quilt is already supported. Closes #1746. 2022-09-24 23:16:11 +08:00
huanghongxun 499a65d5fb fix(multiplayer): connection. 2022-09-24 23:03:07 +08:00
huanghongxun 0b61f9856c fix(multiplayer): ui incorrect 2022-09-24 21:55:53 +08:00
huanghongxun c8409dd258 build: update gradle. 2022-09-24 18:38:04 +08:00
huanghongxun e5ea4cd79f feat(multiplayer): broadcast address to local network. 2022-09-24 18:31:22 +08:00
huanghongxun 4d54c7f6ba feat(multiplayer): show token expire time. 2022-09-24 18:31:22 +08:00
Cato : Love the world and myselfandzkitefly 0f4a5c26df 远端配置无法读取,使用本地缓存 (#1740)
* 远端配置无法读取,使用本地缓存

* 远程配置&本地配置确认

* fix err

* fix-err

* fix-err

* fix-err

* fix-err

* restore

* code clean

* fix-err

* add

* fix

* 远端读取异常日志

Co-authored-by: zkitefly <64117916+zkitefly@users.noreply.github.com>
2022-09-24 18:17:34 +08:00
Akira Yamazaki a8b09e2195 修复执行 PreLaunchCommand 时会被阻塞的问题 (#1737) 2022-09-24 16:57:19 +08:00
Cato : Love the world and myself 3ed6e38ad3 新增有效期getValidAt #1727 (#1732)
* 界面标题描述纠正

* 新增有效期getValidAt #1727

* fix err

* fix-err-again

* fix-err

* fix

* fix-4

* rename

* fix

* fix

* code clean
2022-09-24 16:56:19 +08:00
huanghongxun 2028b0ef9c fix(multiplayer): restore LocalServerBroadcaster and impl port forwarding. 2022-09-17 18:36:12 +08:00
zkitefly aa7f9b91c7 修改微软账户重新登录页面提示 (#1710)
* 修改微软账户重新登录页面提示

* 添加忘记添加的

* 修改翻译

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* Update I18N_zh_CN.properties

* Update I18N_zh.properties
2022-09-17 12:15:56 +08:00
Cato : Love the world and myself d6598310b2 界面标题描述纠正 (#1728) 2022-09-17 12:11:00 +08:00
zkitefly ce6c06987c 修改多人联机提示 (#1725)
* 修改多人联机提示

* 修改

* fix
2022-09-17 12:10:46 +08:00
huanghongxun 90955d7916 fix(ui): ComponentList prefheight wrong. 2022-09-16 02:34:23 +08:00
huanghongxun b3bbd21f1f chore(multiplayer): remove JSTUN. 2022-09-16 01:41:23 +08:00
Cato : Love the world and myselfandYuhui Huang 735a407f0e 缺少字符矫正 / NAT 检测移除 (#1718)
* 缺少字符矫正 / NAT 检测移除

* 错误修复

* 修正nat移除不彻底导致的编译错误

* 授权协议发布

* code-clean

* fix-err

* fix-err-2

* update-in18

* url-fix

Co-authored-by: Yuhui Huang <i@huangyuhui.net>
2022-09-16 01:18:26 +08:00
Glavo 8461b0418c close #1534: open 'java.com' when need to download Java 8 (#1716) 2022-09-15 21:28:02 +08:00
Glavo e46ab8bf9e fix the build errors and checkstyle (#1717) 2022-09-15 21:27:43 +08:00
zkitefly 330548b24e 添加游戏档案创建链接 (#1696)
* Create  获得加入Cato社区网络身份凭证的方法.txt

* Delete 获得加入Cato社区网络身份凭证的方法.txt

* Update CreateMultiplayerRoomDialog.java

* Update CreateMultiplayerRoomDialog.java

* Update MultiplayerPageSkin.java

* add makegameidsettings

* fix

* fix

* fix

* Update CreateAccountPane.java

* Update CreateAccountPane.java

* 听取意见,修改变量名为createProfileLink
2022-09-14 21:46:38 +08:00
Cato : Love the world and myselfandYuhui Huang cf5ccca42d tap/tun二选一,目前优先tun先看效果。 (#1713)
* tap/tun二选一,目前优先tun先看效果。

* 描述修正

* 修正故障描述

Co-authored-by: Yuhui Huang <i@huangyuhui.net>
2022-09-14 21:45:55 +08:00
Cato : Love the world and myself a18bd0729b 配置文件读取失败及证书失效判断 (#1715)
* 配置文件读取失败及证书失效判断

* 描述修正
2022-09-14 21:43:44 +08:00
huanghongxun 662b42c40f fix(multiplayer): get ip from hiper log. 2022-09-12 23:05:13 +08:00
huanghongxun b69c400a08 fix(multiplayer): get ip from hiper log. 2022-09-12 22:45:38 +08:00
huanghongxun f1eb2da57c feat(multiplayer): hiper. 2022-09-12 22:38:36 +08:00
huanghongxun 99be810b5e feat(multiplayer): wip: hiper 2022-09-12 22:38:36 +08:00
zkitefly 03541a6f85 修改 response.errorDescription.contains() 参数 (#1708)
* 添加两个 response.errorDescription.contains()

* 再添加一个qwq

* 删除重复项

* 恢复删除的项

* 修改

* 修改
2022-09-10 21:50:08 +08:00
Chikage0o0 5fe1ef899d fix modrinth modpack install (#1702)
* fix modrinth modpack install

* fix NullPointerException
2022-09-10 21:49:31 +08:00
CNCloud_Casey 6a81f4d240 更新整合包安装的文本介绍 (#1695)
* Update I18N_zh_CN.properties

* Update I18N_zh.properties

* Update I18N_ru.properties

* Update I18N_ja.properties

* Update I18N_es.properties

* Update I18N.properties
2022-09-05 20:11:10 +08:00
zkitefly 3c6aa489e8 添加sh支持 (#1697) 2022-09-05 20:10:49 +08:00
zkitefly ba7043fa92 修改一些提示 (#1699) 2022-09-05 20:10:37 +08:00
huanghongxun bfb6c99484 fix: checkstyle. 2022-09-04 00:26:14 +08:00
huanghongxun a6b26a0cd9 fix(microsoft): show relogin dialog when grant expired. Closes #1691. 2022-09-03 23:35:09 +08:00
huanghongxun 0c7b0f285e fix(microsoft): retry 5 times when socket timeout. Closes #1691. 2022-09-03 23:33:11 +08:00
huanghongxun 5e1ae56740 fix(ui): error dialog overflow. Closes #1677. 2022-09-03 22:44:38 +08:00
huanghongxun b17bac050b fix(ui): not parsing xml. Closes #1678. 2022-09-03 22:28:58 +08:00
huanghongxun b1952a7b37 fix(ui): not wrapping text. Closes #1673. 2022-09-03 22:26:05 +08:00
huanghongxun cc1fc1e4be fix(ui): some text overflow. Closes #1694. 2022-09-03 22:21:38 +08:00
Glavo 6c07924d9b 完善 osx-arm64 原生支持 (#1689)
* Add 1.6~1.18.2 support for osx-arm64

* fix crash on 1.12

* Update I18N
2022-09-03 16:55:27 +08:00
Glavo 835668d781 Update purchase link (#1692) 2022-09-03 16:53:55 +08:00
Glavo 8f7c09d2cc 添加 Linux MIPS64el 平台支持 (#1686)
* Add support for linux-mips64el

* Update PLATFORM.md
2022-09-02 11:37:09 +08:00
Chikage0o0 837939e27c Fix #1684 (#1685)
* 修复生成option.txt与YOSBR模组的冲突

* only search 1 layer
2022-09-02 11:36:58 +08:00
Glavo 058a8e2335 Update JavaVersionConstraint (#1683) 2022-09-01 22:20:16 +08:00
CNCloud_Casey a5af5265be Update README (#1682) 2022-08-31 15:25:29 +08:00
Gamom 8a724587ca 更新错误的翻译键 world.info.player.game_type.adventure (#1674)
* 更新错误的翻译键 world.info.player.game_type.adventure

我不理解 adventure 冒险 为什么会 翻译成极限 ...

* Update I18N_zh.properties
2022-08-31 14:20:56 +08:00
zkitefly bef9c0ad88 Fix #1675 (#1676) 2022-08-31 14:20:39 +08:00
Glavo 8d29775410 添加 Windows on Arm 平台的支持 (#1679)
* Add Minecraft 1.6~1.18.2 support for windows-arm64

* Prompt at startup on non-x86 platforms

* Automatically download JavaFX on windows-arm64

* Update PLATFORM.md
2022-08-31 14:20:27 +08:00
Glavo 620c0e4c73 Automatically download JavaFX on linux-loongarch64_ow (#1670) 2022-08-29 19:28:20 +08:00
Glavo b5c92c1564 Fix stuck on startup when no suitable Java is found (#1671) 2022-08-29 19:27:51 +08:00
huanghongxun 3c3c28aaa5 fix(launch): not escape spaces in exported shell script. Closes #1541. 2022-08-29 00:19:32 +08:00
huanghongxun a80fc34ef1 fix(launch): should not create file when throwing CommandTooLongException. Closes #1606. 2022-08-29 00:03:35 +08:00
huanghongxun 08d7ff138b fix(login): prompt to retry logging in. Closes #1658 #1591 #1544 1608. 2022-08-28 23:57:47 +08:00
huanghongxun 0c172e5d08 fix(crash): incorrect layout for feedbackLabel. Closes #1667. 2022-08-28 21:45:05 +08:00
Glavo bce2d1ea5c Revert ExitWaiter (#1668) 2022-08-28 21:05:43 +08:00
huanghongxun cbc1e26b6f feat(log): hyperlink for log analyzer feedback. Closes #1652. 2022-08-28 17:12:03 +08:00
Yuhui Huang 3b5ddcafa6 fix(quilt): Quilt Hash not enabled. Closes #1657. 2022-08-28 17:03:54 +08:00
Glavo c6a5624708 引导用户下载 Java (#1659)
* Direct users to download Java

* Read JVM options from environment variables
2022-08-28 17:00:15 +08:00
Glavo 23cc568bac Support Microsoft login in dev version (#1651) 2022-08-28 16:59:03 +08:00
Glavo 7ff679c3c8 Revert AdvancedListBox (#1647)
fix #1258, fix #1397, fix #1559, fix #1589, fix #1646
2022-08-28 16:56:52 +08:00
Glavo c873352ee9 更多平台的原生适配 (#1654)
* Add Minecraft 1.13+ support for linux-loongarch64_ow

* Add Minecraft 1.6~1.12.2 support for linux-arm32

* Update documents
2022-08-28 16:56:33 +08:00
Glavo a4164a86af Support path containing unicode characters on Mac OS (#1661) 2022-08-28 16:54:22 +08:00
Glavo c21671e86b 修复通过自动安装更新游戏时的崩溃 (#1662)
* fix game upgrade

* fix #1459: Invalidate the version number cache before saving the version
close #1566
2022-08-28 16:52:26 +08:00
Glavo d9bdc2b1f7 fix #1579: Hide tokens in the exported Minecraft logs (#1665) 2022-08-28 16:51:49 +08:00
214 changed files with 4083 additions and 4866 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[*]
charset = utf-8
end_of_line = crlf
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = false
+3 -5
View File
@@ -8,15 +8,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout submodules
run: git submodule update --init --recursive
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v1
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
java-package: jdk+fx
java-package: 'jdk+fx'
- name: Check style main
run: ./gradlew checkstyleMain
- name: Check style test
+3 -5
View File
@@ -8,14 +8,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout submodules
run: git submodule update --init --recursive
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v1
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
java-package: jdk+fx
java-package: 'jdk+fx'
- name: Check style test
run: ./gradlew checkTranslations
+6 -2
View File
@@ -10,7 +10,7 @@ jobs:
if: ${{ github.repository_owner == 'huanghongxun' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
@@ -39,6 +39,8 @@ jobs:
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha1"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha1"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha1"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
- name: Generate release note
@@ -46,7 +48,7 @@ jobs:
run: |
echo "HMCL v$HMCL_VERSION" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "The full changelogs can be found on the website: https://hmcl.huangyuhui.net/changelog/dev.html" >> RELEASE_NOTE
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/dev.html" >> RELEASE_NOTE
echo "Notice: changelogs are written in Chinese." >> RELEASE_NOTE
- name: Create release
if: ${{ env.continue == 'true' }}
@@ -58,6 +60,8 @@ jobs:
HMCL-${{ env.HMCL_VERSION }}.exe.sha1
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.jar.sha1
HMCL-${{ env.HMCL_VERSION }}.sh
HMCL-${{ env.HMCL_VERSION }}.sh.sha1
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
+8 -6
View File
@@ -8,21 +8,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout submodules
run: git submodule update --init --recursive
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v2
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
java-package: jdk+fx
java-package: 'jdk+fx'
- name: Build with Gradle
run: ./gradlew build
env:
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload Artifacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: HMCL-${{ env.SHORT_SHA }}
path: HMCL/build/libs
+1 -3
View File
@@ -12,6 +12,7 @@ hs_err_pid*
.mine*
/externalgames
NVIDIA
minecraft-exported-crash-info*
# gradle build
/build/
@@ -19,7 +20,6 @@ NVIDIA
/HMCLCore/build/
/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/log4j-patch/build/
# idea
.idea
@@ -27,14 +27,12 @@ NVIDIA
/HMCL/out/
/HMCLCore/out/
/minecraft/libraries/HMCLTransformerDiscoveryService/out/
/minecraft/libraries/log4j-patch/out/
# eclipse
/bin/
/HMCL/bin/
/HMCLCore/bin/
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
/minecraft/libraries/log4j-patch/bin/
.classpath
.project
.settings
+3 -4
View File
@@ -28,7 +28,7 @@ buildscript {
}
plugins {
id("com.github.johnrengelman.shadow") version "7.0.0"
id("com.github.johnrengelman.shadow") version "7.1.2"
}
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
@@ -37,7 +37,8 @@ val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
(number - offset).toString()
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.toLowerCase()?.substring(0, 7)
if (!shortCommit.isNullOrEmpty()) "dev-$shortCommit" else "SNAPSHOT"
val prefix = if (System.getenv("GITHUB_REPOSITORY_OWNER") == "huanghongxun") "dev" else "unofficial"
if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT"
}
}
val versionRoot = System.getenv("VERSION_ROOT") ?: "3.5"
@@ -52,8 +53,6 @@ version = "$versionRoot.$buildNumber"
dependencies {
implementation(project(":HMCLCore"))
implementation("libs:JFoenix")
implementation("de.javawi.jstun:jstun:0.7.4")
}
fun digest(algorithm: String, bytes: ByteArray) = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -16,8 +16,8 @@ import org.jetbrains.annotations.Nullable;
public class SkinCanvas extends Group {
public static final Image ALEX = new Image(SkinCanvas.class.getResourceAsStream("/assets/img/alex.png"));
public static final Image STEVE = new Image(SkinCanvas.class.getResourceAsStream("/assets/img/steve.png"));
public static final Image ALEX = new Image(SkinCanvas.class.getResourceAsStream("/assets/img/skin/alex.png"));
public static final Image STEVE = new Image(SkinCanvas.class.getResourceAsStream("/assets/img/skin/steve.png"));
public static final SkinCube ALEX_LARM = new SkinCube(3, 12, 4, 14F / 64F, 16F / 64F, 32F / 64F, 48F / 64F, 0F, true);
public static final SkinCube ALEX_RARM = new SkinCube(3, 12, 4, 14F / 64F, 16F / 64F, 40F / 64F, 16F / 64F, 0F, true);
@@ -19,26 +19,35 @@ package org.jackhuang.hmcl;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.ui.AwtUtils;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.CrashReporter;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.awt.*;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.net.*;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
@@ -62,19 +71,57 @@ public final class Launcher extends Application {
Main.showWarningAndContinue(i18n("fatal.samba"));
} catch (IOException e) {
LOG.log(Level.SEVERE, "Failed to load config", e);
Main.showErrorAndExit(i18n("fatal.config_loading_failure", Paths.get("").toAbsolutePath().normalize()));
try {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS) {
String owner = Files.getOwner(ConfigHolder.configLocation()).getName();
String userName = System.getProperty("user.name");
if (!Files.isWritable(ConfigHolder.configLocation())
&& !userName.equals("root")
&& !userName.equals(owner)) {
ArrayList<String> files = new ArrayList<>();
{
files.add(ConfigHolder.configLocation().toString());
if (Files.exists(Metadata.HMCL_DIRECTORY))
files.add(Metadata.HMCL_DIRECTORY.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
}
String command = new CommandBuilder().add("sudo", "chown", "-R", userName).addAll(files).toString();
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
ButtonType res = new Alert(Alert.AlertType.ERROR, i18n("fatal.config_loading_failure.unix", owner, command), copyAndExit, ButtonType.CLOSE)
.showAndWait()
.orElse(null);
if (res == copyAndExit) {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
System.exit(1);
}
}
} catch (IOException ioe) {
LOG.log(Level.WARNING, "Failed to get file owner", ioe);
}
Main.showErrorAndExit(i18n("fatal.config_loading_failure", Paths.get("").toAbsolutePath().normalize().toString()));
}
if (ConfigHolder.isOwnerChanged()) {
ButtonType res = new Alert(Alert.AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO)
.showAndWait()
.orElse(null);
if (res == ButtonType.NO)
return;
}
if (Metadata.HMCL_DIRECTORY.toAbsolutePath().toString().indexOf('=') >= 0) {
Main.showWarningAndContinue(i18n("fatal.illegal_char"));
}
if (!Architecture.CURRENT_ARCH.isX86()
&& !(OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.CURRENT_ARCH == Architecture.ARM64)
&& ConfigHolder.globalConfig().getAgreementVersion() < 1) {
Main.showWarningAndContinue(i18n("fatal.unsupported_platform"));
}
// runLater to ensure ConfigHolder.init() finished initialization
Platform.runLater(() -> {
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
@@ -82,8 +129,6 @@ public final class Launcher extends Application {
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
initIcon();
UpdateChecker.init();
primaryStage.show();
@@ -99,12 +144,6 @@ public final class Launcher extends Application {
Controllers.onApplicationStop();
}
private void initIcon() {
Toolkit toolkit = Toolkit.getDefaultToolkit();
Image image = toolkit.getImage(Launcher.class.getResource("/assets/img/icon.png"));
AwtUtils.setAppleIcon(image);
}
public static void main(String[] args) {
if (UpdateHandler.processArguments(args)) {
return;
@@ -123,6 +162,7 @@ public final class Launcher extends Application {
LOG.info("Java Home: " + System.getProperty("java.home"));
LOG.info("Current Directory: " + Paths.get("").toAbsolutePath());
LOG.info("HMCL Directory: " + Metadata.HMCL_DIRECTORY);
LOG.info("HMCL Jar Path: " + JarUtils.thisJar().map(it -> it.toAbsolutePath().toString()).orElse("Not Found"));
LOG.info("Memory: " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "MB");
ManagementFactory.getMemoryPoolMXBeans().stream().filter(bean -> bean.getName().equals("Metaspace")).findAny()
.ifPresent(bean -> LOG.info("Metaspace: " + bean.getUsage().getUsed() / 1024 / 1024 + "MB"));
+37 -17
View File
@@ -17,14 +17,19 @@
*/
package org.jackhuang.hmcl;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import org.jackhuang.hmcl.ui.AwtUtils;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.ui.SwingUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.JavaVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import javax.swing.*;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@@ -56,29 +61,25 @@ public final class Main {
// Fix title bar not displaying in GTK systems
System.setProperty("jdk.gtk.version", "2");
// Use System look and feel
initLookAndFeel();
checkDirectoryPath();
// This environment check will take ~300ms
thread(Main::fixLetsEncrypt, "CA Certificate Check", true);
if (JavaVersion.CURRENT_JAVA.getParsedVersion() < 9)
// This environment check will take ~300ms
thread(Main::fixLetsEncrypt, "CA Certificate Check", true);
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
initIcon();
Logging.start(Metadata.HMCL_DIRECTORY.resolve("logs"));
checkJavaFX();
Launcher.main(args);
}
private static void initLookAndFeel() {
if (System.getProperty("swing.defaultlaf") == null) {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Throwable ignored) {
}
}
private static void initIcon() {
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(Main.class.getResource("/assets/img/icon@8x.png"));
AwtUtils.setAppleIcon(image);
}
private static void checkDirectoryPath() {
@@ -116,7 +117,16 @@ public final class Main {
static void showErrorAndExit(String message) {
System.err.println(message);
System.err.println("A fatal error has occurred, forcibly exiting.");
JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.ERROR, message).showAndWait();
System.exit(1);
}
} catch (Throwable ignored) {
}
SwingUtils.showErrorDialog(message);
System.exit(1);
}
@@ -126,7 +136,16 @@ public final class Main {
static void showWarningAndContinue(String message) {
System.err.println(message);
System.err.println("Potential issues have been detected.");
JOptionPane.showMessageDialog(null, message, "Warning", JOptionPane.WARNING_MESSAGE);
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.WARNING, message).showAndWait();
return;
}
} catch (Throwable ignored) {
}
SwingUtils.showWarningDialog(message);
}
static void fixLetsEncrypt() {
@@ -156,7 +175,8 @@ public final class Main {
tls.init(null, instance.getTrustManagers(), null);
HttpsURLConnection.setDefaultSSLSocketFactory(tls.getSocketFactory());
LOG.info("Added Lets Encrypt root certificates as additional trust");
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException | KeyManagementException e) {
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException |
KeyManagementException e) {
LOG.log(Level.SEVERE, "Failed to load lets encrypt certificate. Expect problems", e);
}
}
@@ -18,11 +18,9 @@
package org.jackhuang.hmcl;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Stores metadata about this application.
@@ -32,7 +30,7 @@ public final class Metadata {
public static final String NAME = "HMCL";
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.thisJar().flatMap(JarUtils::getImplementationVersion).orElse("@develop@"));
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getManifestAttribute("Implementation-Version", "@develop@"));
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
@@ -40,27 +38,14 @@ public final class Metadata {
public static final String UPDATE_URL = System.getProperty("hmcl.update_source.override", "https://hmcl.huangyuhui.net/api/update_link");
public static final String CONTACT_URL = "https://github.com/huanghongxun/HMCL/issues";
public static final String HELP_URL = "https://hmcl.huangyuhui.net/help";
public static final String CHANGELOG_URL = "https://hmcl.huangyuhui.net/changelog/";
public static final String PUBLISH_URL = "http://www.mcbbs.net/thread-142335-1-1.html";
public static final String CHANGELOG_URL = "https://docs.hmcl.net/changelog/";
public static final String PUBLISH_URL = "https://www.mcbbs.net/thread-142335-1-1.html";
public static final String EULA_URL = "https://hmcl.huangyuhui.net/eula";
public static final String BUILD_CHANNEL = JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Build-Channel")).orElse("nightly");
public static final String BUILD_CHANNEL = JarUtils.getManifestAttribute("Build-Channel", "nightly");
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
public static final Path HMCL_DIRECTORY = getHMCLDirectory();
private static Path getHMCLDirectory() {
String home = System.getProperty("user.home", ".");
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) {
// to fulfill XDG standard.
String xdgCache = System.getenv("XDG_CACHE_HOME");
if (StringUtils.isNotBlank(xdgCache)) {
return Paths.get(xdgCache, "hmcl");
}
return Paths.get(home, ".cache", "hmcl");
}
return OperatingSystem.getWorkingDirectory("hmcl");
}
public static final Path HMCL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
public static boolean isStable() {
return "stable".equals(BUILD_CHANNEL);
@@ -59,8 +59,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
private void generateOptionsTxt() {
File optionsFile = new File(repository.getRunDirectory(version.getId()), "options.txt");
File configFolder = new File(repository.getRunDirectory(version.getId()), "config");
if (optionsFile.exists())
return;
if (configFolder.isDirectory())
if (findFiles(configFolder, "options.txt"))
return;
try {
// TODO: Dirty implementation here
if (I18n.getCurrentLocale().getLocale() == Locale.CHINA)
@@ -70,6 +75,20 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
}
private boolean findFiles(File folder, String fileName) {
File[] fs = folder.listFiles();
if (fs != null) {
for (File f : fs) {
if (f.isDirectory())
if (f.listFiles((dir, name) -> name.equals(fileName)) != null)
return true;
if (f.getName().equals(fileName))
return true;
}
}
return false;
}
@Override
public ManagedProcess launch() throws IOException, InterruptedException {
generateOptionsTxt();
@@ -337,7 +337,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
.setJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
.setMaxMemory((int)(getAllocatedMemory(
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int)(getAllocatedMemory(
vs.getMaxMemory() * 1024L * 1024L,
OperatingSystem.getPhysicalMemoryStatus().orElse(OperatingSystem.PhysicalMemoryStatus.INVALID).getAvailable(),
vs.isAutoMemory()
@@ -380,6 +380,9 @@ public class HMCLGameRepository extends DefaultGameRepository {
}
}
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder.create();
}
@@ -17,10 +17,10 @@
*/
package org.jackhuang.hmcl.game;
import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
@@ -46,13 +46,9 @@ import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
@@ -62,6 +58,7 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
@@ -70,7 +67,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class LauncherHelper {
private final Profile profile;
private final Account account;
private Account account;
private final String selectedVersion;
private File scriptFile;
private final VersionSetting setting;
@@ -89,6 +86,14 @@ public final class LauncherHelper {
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
public Account getAccount() {
return account;
}
public void setAccount(Account account) {
this.account = account;
}
public void setTestMode() {
launcherVisibility = LauncherVisibility.KEEP;
showLogs = true;
@@ -124,8 +129,6 @@ public final class LauncherHelper {
AtomicReference<JavaVersion> javaVersionRef = new AtomicReference<>();
getLog4jPatch(version.get()).ifPresent(javaAgents::add);
TaskExecutor executor = checkGameState(profile, setting, version.get())
.thenComposeAsync(javaVersion -> {
javaVersionRef.set(Objects.requireNonNull(javaVersion));
@@ -171,17 +174,7 @@ public final class LauncherHelper {
.thenComposeAsync(() -> {
return gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null);
})
.thenComposeAsync(Task.supplyAsync(() -> {
try {
return account.logIn();
} catch (CredentialExpiredException e) {
LOG.info("Credential has expired: " + e);
return DialogController.logIn(account);
} catch (AuthenticationException e) {
LOG.warning("Authentication failed, try playing offline: " + e);
return account.playOffline().orElseThrow(() -> e);
}
}).withStage("launch.state.logging_in"))
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions launchOptions = repository.getLaunchOptions(selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, scriptFile != null);
return new HMCLGameLauncher(
@@ -375,7 +368,11 @@ public final class LauncherHelper {
}
}
if (targetJavaVersion != null) {
if (targetJavaVersion == null) {
Controllers.confirm(i18n("launch.failed.no_accepted_java"), i18n("message.warning"), MessageType.WARNING, continueAction, () -> {
future.completeExceptionally(new CancellationException("No accepted java"));
});
} else {
downloadJava(gameVersion.toString(), targetJavaVersion, profile)
.thenAcceptAsync(downloadedJavaVersion -> {
future.complete(downloadedJavaVersion);
@@ -564,7 +561,11 @@ public final class LauncherHelper {
JFXHyperlink link = new JFXHyperlink(i18n("download.external_link"));
link.setOnAction(e -> {
FXUtils.openLink(OPENJDK_DOWNLOAD_LINK);
if (javaVersion.getMajorVersion() == JavaVersion.JAVA_8) {
FXUtils.openLink(ORACLEJDK_DOWNLOAD_LINK);
} else {
FXUtils.openLink(OPENJDK_DOWNLOAD_LINK);
}
future.completeExceptionally(new CancellationException());
});
@@ -618,45 +619,41 @@ public final class LauncherHelper {
return future;
}
private static Optional<String> getLog4jPatch(Version version) {
Optional<String> log4jVersion = version.getLibraries().stream()
.filter(it -> it.is("org.apache.logging.log4j", "log4j-core")
&& (VersionNumber.VERSION_COMPARATOR.compare(it.getVersion(), "2.17") < 0 || "2.0-beta9".equals(it.getVersion())))
.map(Library::getVersion)
.findFirst();
private static Task<AuthInfo> logIn(Account account) {
return Task.composeAsync(() -> {
try {
return Task.completed(account.logIn());
} catch (CredentialExpiredException e) {
LOG.log(Level.INFO, "Credential has expired", e);
if (log4jVersion.isPresent()) {
final String agentFileName = "log4j-patch-agent-1.0.jar";
return Task.completed(DialogController.logIn(account));
} catch (AuthenticationException e) {
LOG.log(Level.WARNING, "Authentication failed, try skipping refresh", e);
Path agentFile = Metadata.HMCL_DIRECTORY.resolve(agentFileName).toAbsolutePath();
String agentFilePath = agentFile.toString();
if (agentFilePath.indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the HMCL directory path, unable to attach log4j-patch");
return Optional.empty();
CompletableFuture<Task<AuthInfo>> future = new CompletableFuture<>();
runInFX(() -> {
JFXButton loginOfflineButton = new JFXButton(i18n("account.login.skip"));
loginOfflineButton.setOnAction(event -> {
try {
future.complete(Task.completed(account.playOffline()));
} catch (AuthenticationException e2) {
future.completeExceptionally(e2);
}
});
JFXButton retryButton = new JFXButton(i18n("account.login.retry"));
retryButton.setOnAction(event -> {
future.complete(logIn(account));
});
Controllers.dialog(new MessageDialogPane.Builder(i18n("account.failed.server_disconnected"), i18n("account.failed"), MessageType.ERROR)
.addAction(loginOfflineButton)
.addAction(retryButton)
.addCancel(() ->
future.completeExceptionally(new CancellationException()))
.build());
});
return Task.fromCompletableFuture(future).thenComposeAsync(task -> task);
}
if (Files.notExists(agentFile)) {
try (InputStream input = DefaultLauncher.class.getResourceAsStream("/assets/game/" + agentFileName)) {
LOG.info("Extract log4j patch to " + agentFilePath);
Files.createDirectories(agentFile.getParent());
Files.copy(input, agentFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to extract log4j patch");
try {
Files.deleteIfExists(agentFile);
} catch (IOException ex) {
LOG.log(Level.WARNING, "Failed to delete incomplete log4j patch", ex);
}
return Optional.empty();
}
}
boolean isBeta = log4jVersion.get().startsWith("2.0-beta");
return Optional.of(agentFilePath + "=" + isBeta);
} else {
LOG.info("No log4j with security vulnerabilities found");
return Optional.empty();
}
});
}
private void checkExit() {
@@ -688,7 +685,7 @@ public final class LauncherHelper {
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
* Because every time we launched a game, we generates a new [HMCLProcessListener]
*/
class HMCLProcessListener implements ProcessListener {
private final class HMCLProcessListener implements ProcessListener {
private final HMCLGameRepository repository;
private final Version version;
@@ -697,9 +694,11 @@ public final class LauncherHelper {
private boolean lwjgl;
private LogWindow logWindow;
private final boolean detectWindow;
private final LinkedList<Pair<String, Log4jLevel>> logs;
private final ArrayDeque<String> logs;
private final ArrayDeque</*Log4jLevel*/Object> levels;
private final CountDownLatch logWindowLatch = new CountDownLatch(1);
private final CountDownLatch launchingLatch;
private final String forbiddenAccessToken;
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.repository = repository;
@@ -707,8 +706,11 @@ public final class LauncherHelper {
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
this.forbiddenAccessToken = authInfo != null ? authInfo.getAccessToken() : null;
logs = new LinkedList<>();
final int numLogs = config().getLogLines() + 1;
this.logs = new ArrayDeque<>(numLogs);
this.levels = new ArrayDeque<>(numLogs);
}
@Override
@@ -759,6 +761,7 @@ public final class LauncherHelper {
Controllers.getStage().close();
Controllers.shutdown();
Schedulers.shutdown();
System.gc();
}
});
break;
@@ -766,17 +769,28 @@ public final class LauncherHelper {
}
@Override
public synchronized void onLog(String log, Log4jLevel level) {
String filteredLog = Logging.filterForbiddenToken(log);
public void onLog(String log, boolean isErrorStream) {
String filteredLog = forbiddenAccessToken == null ? log : log.replace(forbiddenAccessToken, "<access token>");
if (level.lessOrEqual(Log4jLevel.ERROR))
if (isErrorStream)
System.err.println(filteredLog);
else
System.out.println(filteredLog);
logs.add(pair(filteredLog, level));
if (logs.size() > config().getLogLines())
logs.removeFirst();
Log4jLevel level;
if (isErrorStream)
level = Log4jLevel.ERROR;
else
level = showLogs ? Optional.ofNullable(Log4jLevel.guessLevel(filteredLog)).orElse(Log4jLevel.INFO) : null;
synchronized (this) {
logs.add(filteredLog);
levels.add(level != null ? level : Optional.empty()); // Use 'Optional.empty()' as hole
if (logs.size() > config().getLogLines()) {
logs.removeFirst();
levels.removeFirst();
}
}
if (showLogs) {
try {
@@ -789,7 +803,7 @@ public final class LauncherHelper {
Platform.runLater(() -> logWindow.logLine(filteredLog, level));
}
if (!lwjgl && (filteredLog.toLowerCase().contains("lwjgl version") || filteredLog.toLowerCase().contains("lwjgl openal") || !detectWindow)) {
if (!lwjgl && (!detectWindow || filteredLog.toLowerCase().contains("lwjgl version") || filteredLog.toLowerCase().contains("lwjgl openal"))) {
lwjgl = true;
finishLaunch();
}
@@ -806,8 +820,11 @@ public final class LauncherHelper {
if (!lwjgl) finishLaunch();
if (exitType != ExitType.NORMAL) {
ArrayList<Pair<String, Log4jLevel>> pairs = new ArrayList<>(logs.size());
Lang.forEachZipped(logs, levels,
(log, l) -> pairs.add(pair(log, l instanceof Log4jLevel ? ((Log4jLevel) l) : Optional.ofNullable(Log4jLevel.guessLevel(log)).orElse(Log4jLevel.INFO))));
repository.markVersionLaunchedAbnormally(version.getId());
Platform.runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
Platform.runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, pairs).show());
}
checkExit();
@@ -815,6 +832,7 @@ public final class LauncherHelper {
}
private static final String ORACLEJDK_DOWNLOAD_LINK = "https://www.java.com/download";
private static final String OPENJDK_DOWNLOAD_LINK = "https://docs.microsoft.com/java/openjdk/download";
public static final Queue<ManagedProcess> PROCESSES = new ConcurrentLinkedQueue<>();
@@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -104,7 +103,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"), StandardCharsets.UTF_8)
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%close-page%", i18n("account.methods.microsoft.close_page"));
} catch (IOException e) {
Logging.LOG.log(Level.SEVERE, "Failed to load html");
@@ -160,13 +159,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
@Override
public String getClientId() {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Id")).orElse(""));
JarUtils.getManifestAttribute("Microsoft-Auth-Id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Secret")).orElse(""));
JarUtils.getManifestAttribute("Microsoft-Auth-Secret", ""));
}
@Override
@@ -17,9 +17,13 @@
*/
package org.jackhuang.hmcl.game;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelWriter;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
@@ -31,18 +35,15 @@ import org.jackhuang.hmcl.util.ResourceNotFoundError;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@@ -64,15 +65,15 @@ public final class TexturesLoader {
// ==== Texture Loading ====
public static class LoadedTexture {
private final BufferedImage image;
private final Image image;
private final Map<String, String> metadata;
public LoadedTexture(BufferedImage image, Map<String, String> metadata) {
public LoadedTexture(Image image, Map<String, String> metadata) {
this.image = requireNonNull(image);
this.metadata = requireNonNull(metadata);
}
public BufferedImage getImage() {
public Image getImage() {
return image;
}
@@ -96,7 +97,7 @@ public final class TexturesLoader {
return TEXTURES_DIR.resolve(prefix).resolve(hash);
}
public static LoadedTexture loadTexture(Texture texture) throws IOException {
public static LoadedTexture loadTexture(Texture texture) throws Throwable {
if (StringUtils.isBlank(texture.getUrl())) {
throw new IOException("Texture url is empty");
}
@@ -117,12 +118,13 @@ public final class TexturesLoader {
}
}
BufferedImage img;
Image img;
try (InputStream in = Files.newInputStream(file)) {
img = ImageIO.read(in);
img = new Image(in);
}
if (img == null)
throw new IOException("Texture is malformed");
if (img.isError())
throw img.getException();
Map<String, String> metadata = texture.getMetadata();
if (metadata == null) {
@@ -136,16 +138,21 @@ public final class TexturesLoader {
private final static Map<TextureModel, LoadedTexture> DEFAULT_SKINS = new EnumMap<>(TextureModel.class);
static {
loadDefaultSkin("/assets/img/steve.png", TextureModel.STEVE);
loadDefaultSkin("/assets/img/alex.png", TextureModel.ALEX);
loadDefaultSkin("/assets/img/skin/steve.png", TextureModel.STEVE);
loadDefaultSkin("/assets/img/skin/alex.png", TextureModel.ALEX);
}
private static void loadDefaultSkin(String path, TextureModel model) {
try (InputStream in = ResourceNotFoundError.getResourceAsStream(path)) {
DEFAULT_SKINS.put(model, new LoadedTexture(ImageIO.read(in), singletonMap("model", model.modelName)));
Image skin;
try {
skin = new Image(path);
if (skin.isError())
throw skin.getException();
} catch (Throwable e) {
throw new ResourceNotFoundError("Cannoot load default skin from " + path, e);
throw new ResourceNotFoundError("Cannot load default skin from " + path, e);
}
DEFAULT_SKINS.put(model, new LoadedTexture(skin, singletonMap("model", model.modelName)));
}
public static LoadedTexture getDefaultSkin(TextureModel model) {
@@ -172,7 +179,7 @@ public final class TexturesLoader {
return CompletableFuture.supplyAsync(() -> {
try {
return loadTexture(texture);
} catch (IOException e) {
} catch (Throwable e) {
LOG.log(Level.WARNING, "Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
return uuidFallback;
}
@@ -195,7 +202,7 @@ public final class TexturesLoader {
return CompletableFuture.supplyAsync(() -> {
try {
return loadTexture(texture);
} catch (IOException e) {
} catch (Throwable e) {
LOG.log(Level.WARNING, "Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
return uuidFallback;
}
@@ -209,38 +216,109 @@ public final class TexturesLoader {
// ====
// ==== Avatar ====
public static BufferedImage toAvatar(BufferedImage skin, int size) {
BufferedImage avatar = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = avatar.createGraphics();
public static void drawAvatar(Canvas canvas, Image skin) {
canvas.getGraphicsContext2D().clearRect(0, 0, canvas.getWidth(), canvas.getHeight());
int scale = skin.getWidth() / 64;
int size = (int) canvas.getWidth();
int scale = (int) skin.getWidth() / 64;
int faceOffset = (int) Math.round(size / 18.0);
GraphicsContext g = canvas.getGraphicsContext2D();
try {
g.setImageSmoothing(false);
drawAvatarFX(g, skin, size, scale, faceOffset);
} catch (NoSuchMethodError ignored) {
// Earlier JavaFX did not support GraphicsContext::setImageSmoothing, fallback to Java 2D
drawAvatarJ2D(g, skin, size, scale, faceOffset);
}
}
private static void drawAvatarFX(GraphicsContext g, Image skin, int size, int scale, int faceOffset) {
g.drawImage(skin,
8 * scale, 8 * scale, 8 * scale, 8 * scale,
faceOffset, faceOffset, size - 2 * faceOffset, size - 2 * faceOffset);
g.drawImage(skin,
40 * scale, 8 * scale, 8 * scale, 8 * scale,
0, 0, size, size);
}
private static void drawAvatarJ2D(GraphicsContext g, Image skin, int size, int scale, int faceOffset) {
BufferedImage bi = FXUtils.fromFXImage(skin);
BufferedImage avatar = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = avatar.createGraphics();
g2d.drawImage(bi,
faceOffset, faceOffset, size - faceOffset, size - faceOffset,
8 * scale, 8 * scale, 16 * scale, 16 * scale,
null);
g.drawImage(skin,
g2d.drawImage(bi,
0, 0, size, size,
40 * scale, 8 * scale, 48 * scale, 16 * scale, null);
g.dispose();
return avatar;
g2d.dispose();
PixelWriter pw = g.getPixelWriter();
for (int x = 0; x < size; x++) {
for (int y = 0; y < size; y++) {
pw.setArgb(x, y, avatar.getRGB(x, y));
}
}
}
public static ObjectBinding<Image> fxAvatarBinding(YggdrasilService service, UUID uuid, int size) {
return BindingMapping.of(skinBinding(service, uuid))
.map(it -> toAvatar(it.image, size))
.map(FXUtils::toFXImage);
private static final class SkinBindingChangeListener implements ChangeListener<LoadedTexture> {
static final WeakHashMap<Canvas, SkinBindingChangeListener> hole = new WeakHashMap<>();
final WeakReference<Canvas> canvasRef;
final ObjectBinding<LoadedTexture> binding;
SkinBindingChangeListener(Canvas canvas, ObjectBinding<LoadedTexture> binding) {
this.canvasRef = new WeakReference<>(canvas);
this.binding = binding;
}
@Override
public void changed(ObservableValue<? extends LoadedTexture> observable,
LoadedTexture oldValue, LoadedTexture loadedTexture) {
Canvas canvas = canvasRef.get();
if (canvas != null)
drawAvatar(canvas, loadedTexture.image);
}
}
public static ObjectBinding<Image> fxAvatarBinding(Account account, int size) {
if (account instanceof YggdrasilAccount || account instanceof MicrosoftAccount) {
return BindingMapping.of(skinBinding(account))
.map(it -> toAvatar(it.image, size))
.map(FXUtils::toFXImage);
} else {
return Bindings.createObjectBinding(
() -> FXUtils.toFXImage(toAvatar(getDefaultSkin(TextureModel.detectUUID(account.getUUID())).image, size)));
public static void fxAvatarBinding(Canvas canvas, ObjectBinding<LoadedTexture> skinBinding) {
synchronized (SkinBindingChangeListener.hole) {
SkinBindingChangeListener oldListener = SkinBindingChangeListener.hole.remove(canvas);
if (oldListener != null)
oldListener.binding.removeListener(oldListener);
SkinBindingChangeListener listener = new SkinBindingChangeListener(canvas, skinBinding);
listener.changed(skinBinding, null, skinBinding.get());
skinBinding.addListener(listener);
SkinBindingChangeListener.hole.put(canvas, listener);
}
}
public static void bindAvatar(Canvas canvas, YggdrasilService service, UUID uuid) {
fxAvatarBinding(canvas, skinBinding(service, uuid));
}
public static void bindAvatar(Canvas canvas, Account account) {
if (account instanceof YggdrasilAccount || account instanceof MicrosoftAccount)
fxAvatarBinding(canvas, skinBinding(account));
else {
unbindAvatar(canvas);
drawAvatar(canvas, getDefaultSkin(TextureModel.detectUUID(account.getUUID())).image);
}
}
public static void unbindAvatar(Canvas canvas) {
synchronized (SkinBindingChangeListener.hole) {
SkinBindingChangeListener oldListener = SkinBindingChangeListener.hole.remove(canvas);
if (oldListener != null)
oldListener.binding.removeListener(oldListener);
}
}
// ====
@@ -141,7 +141,7 @@ public final class Config implements Cloneable, Observable {
private StringProperty versionListSource = new SimpleStringProperty("balanced");
@SerializedName("configurations")
private ObservableMap<String, Profile> configurations = FXCollections.observableMap(new TreeMap<>());
private SimpleMapProperty<String, Profile> configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>()));
@SerializedName("accounts")
private ObservableList<Map<Object, Object>> accountStorages = FXCollections.observableArrayList();
@@ -476,7 +476,7 @@ public final class Config implements Cloneable, Observable {
return versionListSource;
}
public ObservableMap<String, Profile> getConfigurations() {
public MapProperty<String, Profile> getConfigurations() {
return configurations;
}
@@ -22,6 +22,7 @@ import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -45,6 +46,7 @@ public final class ConfigHolder {
private static Config configInstance;
private static GlobalConfig globalConfigInstance;
private static boolean newlyCreated;
private static boolean ownerChanged = false;
public static Config config() {
if (configInstance == null) {
@@ -60,10 +62,18 @@ public final class ConfigHolder {
return globalConfigInstance;
}
public static Path configLocation() {
return configLocation;
}
public static boolean isNewlyCreated() {
return newlyCreated;
}
public static boolean isOwnerChanged() {
return ownerChanged;
}
public synchronized static void init() throws IOException {
if (configInstance != null) {
throw new IllegalStateException("Configuration is already loaded");
@@ -109,7 +119,7 @@ public final class ConfigHolder {
}
private static Path locateConfig() {
Path exePath = Paths.get("");
Path exePath = Paths.get("").toAbsolutePath();
try {
Path jarPath = Paths.get(ConfigHolder.class.getProtectionDomain().getCodeSource().getLocation()
.toURI()).toAbsolutePath();
@@ -143,6 +153,15 @@ public final class ConfigHolder {
private static Config loadConfig() throws IOException {
if (Files.exists(configLocation)) {
try {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
&& "root".equals(System.getProperty("user.name"))
&& !"root".equals(Files.getOwner(configLocation).getName())) {
ownerChanged = true;
}
} catch (IOException e1) {
LOG.log(Level.WARNING, "Failed to get owner");
}
try {
String content = FileUtils.readText(configLocation);
Config deserialized = Config.fromJson(content);
@@ -189,6 +208,28 @@ public final class ConfigHolder {
// Global Config
private static GlobalConfig loadGlobalConfig() throws IOException {
// Migrate from old directory
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Files.notExists(GLOBAL_CONFIG_PATH)) {
Path oldHome;
String xdgCache = System.getenv("XDG_CACHE_HOME");
if (StringUtils.isNotBlank(xdgCache)) {
oldHome = Paths.get(xdgCache, "hmcl");
} else {
oldHome = Paths.get(System.getProperty("user.home", "."), ".cache", "hmcl");
}
if (Files.exists(oldHome)) {
Path oldConfigPath = oldHome.resolve("config.json");
if (Files.isRegularFile(oldConfigPath)) {
try {
Files.copy(oldConfigPath, GLOBAL_CONFIG_PATH);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to migrate global config", e);
}
}
}
}
if (Files.exists(GLOBAL_CONFIG_PATH)) {
try {
String content = FileUtils.readText(GLOBAL_CONFIG_PATH);
@@ -68,7 +68,7 @@ public class GlobalConfig implements Cloneable, Observable {
private IntegerProperty agreementVersion = new SimpleIntegerProperty();
private StringProperty multiplayerToken = new SimpleStringProperty();
private IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
private BooleanProperty multiplayerRelay = new SimpleBooleanProperty();
@@ -113,6 +113,18 @@ public class GlobalConfig implements Cloneable, Observable {
this.agreementVersion.set(agreementVersion);
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
public IntegerProperty platformPromptVersionProperty() {
return platformPromptVersion;
}
public void setPlatformPromptVersion(int platformPromptVersion) {
this.platformPromptVersion.set(platformPromptVersion);
}
public boolean isMultiplayerRelay() {
return multiplayerRelay.get();
}
@@ -137,21 +149,10 @@ public class GlobalConfig implements Cloneable, Observable {
this.multiplayerAgreementVersion.set(multiplayerAgreementVersion);
}
public String getMultiplayerToken() {
return multiplayerToken.get();
}
public StringProperty multiplayerTokenProperty() {
return multiplayerToken;
}
public void setMultiplayerToken(String multiplayerToken) {
this.multiplayerToken.set(multiplayerToken);
}
public static class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
"agreementVersion",
"platformPromptVersion",
"multiplayerToken",
"multiplayerRelay",
"multiplayerAgreementVersion"
@@ -165,7 +166,7 @@ public class GlobalConfig implements Cloneable, Observable {
JsonObject jsonObject = new JsonObject();
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("multiplayerToken", context.serialize(src.getMultiplayerToken()));
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
jsonObject.add("multiplayerRelay", context.serialize(src.isMultiplayerRelay()));
jsonObject.add("multiplayerAgreementVersion", context.serialize(src.getMultiplayerAgreementVersion()));
for (Map.Entry<String, Object> entry : src.unknownFields.entrySet()) {
@@ -183,7 +184,7 @@ public class GlobalConfig implements Cloneable, Observable {
GlobalConfig config = new GlobalConfig();
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setMultiplayerToken(Optional.ofNullable(obj.get("multiplayerToken")).map(JsonElement::getAsString).orElse(null));
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
config.setMultiplayerRelay(Optional.ofNullable(obj.get("multiplayerRelay")).map(JsonElement::getAsBoolean).orElse(false));
config.setMultiplayerAgreementVersion(Optional.ofNullable(obj.get("multiplayerAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.setting;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
@@ -29,8 +30,8 @@ import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static javafx.collections.FXCollections.observableArrayList;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
@@ -130,8 +131,11 @@ public final class Profiles {
if (!initialized)
return;
// update storage
config().getConfigurations().clear();
config().getConfigurations().putAll(profiles.stream().collect(Collectors.toMap(Profile::getName, it -> it)));
TreeMap<String, Profile> newConfigurations = new TreeMap<>();
for (Profile profile : profiles) {
newConfigurations.put(profile.getName(), profile);
}
config().getConfigurations().setValue(FXCollections.observableMap(newConfigurations));
}
/**
@@ -27,7 +27,8 @@ public enum VersionIconType {
CRAFT_TABLE("/assets/img/craft_table.png"),
FABRIC("/assets/img/fabric.png"),
FORGE("/assets/img/forge.png"),
FURNACE("/assets/img/furnace.png");
FURNACE("/assets/img/furnace.png"),
QUILT("/assets/img/quilt.png");
// Please append new items at last
@@ -24,6 +24,7 @@ import javafx.beans.property.*;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -41,8 +42,6 @@ import java.util.Optional;
import java.util.concurrent.CancellationException;
import java.util.stream.Collectors;
import static com.jfoenix.concurrency.JFXUtilities.runInFX;
/**
*
* @author huangyuhui
@@ -660,9 +659,7 @@ public final class VersionSetting implements Cloneable {
.filter(java -> java.getVersion().equals(getJava()))
.collect(Collectors.toList());
if (matchedJava.isEmpty()) {
runInFX(() -> {
setJava("Auto");
});
FXUtils.runInFX(() -> setJava("Auto"));
return JavaVersion.fromCurrentEnvironment();
} else {
return matchedJava.stream()
@@ -796,9 +793,17 @@ public final class VersionSetting implements Cloneable {
return obj;
}
private static <T> T getOrDefault(T[] values, JsonElement index, T defaultValue) {
if (index == null)
return defaultValue;
int idx = index.getAsInt();
return idx >= 0 && idx < values.length ? values[idx] : defaultValue;
}
@Override
public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == JsonNull.INSTANCE || !(json instanceof JsonObject))
if (!(json instanceof JsonObject))
return null;
JsonObject obj = (JsonObject) json;
@@ -829,15 +834,15 @@ public final class VersionSetting implements Cloneable {
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setLauncherVisibility(LauncherVisibility.values()[Optional.ofNullable(obj.get("launcherVisibility")).map(JsonElement::getAsInt).orElse(LauncherVisibility.HIDE.ordinal())]);
vs.setProcessPriority(ProcessPriority.values()[Optional.ofNullable(obj.get("processPriority")).map(JsonElement::getAsInt).orElse(ProcessPriority.NORMAL.ordinal())]);
vs.setLauncherVisibility(getOrDefault(LauncherVisibility.values(), obj.get("launcherVisibility"), LauncherVisibility.HIDE));
vs.setProcessPriority(getOrDefault(ProcessPriority.values(), obj.get("processPriority"), ProcessPriority.NORMAL));
vs.setUseSoftwareRenderer(Optional.ofNullable(obj.get("useSoftwareRenderer")).map(JsonElement::getAsBoolean).orElse(false));
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false));
vs.setGameDirType(GameDirectoryType.values()[Optional.ofNullable(obj.get("gameDirType")).map(JsonElement::getAsInt).orElse(GameDirectoryType.ROOT_FOLDER.ordinal())]);
vs.setGameDirType(getOrDefault(GameDirectoryType.values(), obj.get("gameDirType"), GameDirectoryType.ROOT_FOLDER));
vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null));
vs.setNativesDirType(NativesDirectoryType.values()[Optional.ofNullable(obj.get("nativesDirType")).map(JsonElement::getAsInt).orElse(0)]);
vs.setVersionIcon(VersionIconType.values()[Optional.ofNullable(obj.get("versionIcon")).map(JsonElement::getAsInt).orElse(0)]);
vs.setNativesDirType(getOrDefault(NativesDirectoryType.values(), obj.get("nativesDirType"), NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(getOrDefault(VersionIconType.values(), obj.get("versionIcon"), VersionIconType.DEFAULT));
return vs;
}
@@ -9,8 +9,8 @@ public final class AwtUtils {
public static void setAppleIcon(Image image) {
try {
Class<?> taskbarClass = Class.forName("java.awt.TaskBar");
Method getTaskBarMethod = taskbarClass.getDeclaredMethod("getTaskBar");
Class<?> taskbarClass = Class.forName("java.awt.Taskbar");
Method getTaskBarMethod = taskbarClass.getDeclaredMethod("getTaskbar");
Object taskBar = getTaskBarMethod.invoke(null);
Method setIconImageMethod = taskbarClass.getDeclaredMethod("setIconImage", Image.class);
setIconImageMethod.invoke(taskBar, image);
@@ -45,7 +45,6 @@ import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.multiplayer.MultiplayerPage;
import org.jackhuang.hmcl.ui.versions.GameListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.util.FutureCallback;
@@ -53,7 +52,9 @@ import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.JavaVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.File;
import java.util.List;
@@ -91,7 +92,6 @@ public final class Controllers {
accountListPage.authServersProperty().bindContentBidirectional(config().getAuthlibInjectorServers());
return accountListPage;
});
private static Lazy<MultiplayerPage> multiplayerPage = new Lazy<>(MultiplayerPage::new);
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
private Controllers() {
@@ -120,11 +120,6 @@ public final class Controllers {
return rootPage.get();
}
// FXThread
public static MultiplayerPage getMultiplayerPage() {
return multiplayerPage.get();
}
// FXThread
public static LauncherSettingsPage getSettingsPage() {
return settingsPage.get();
@@ -146,10 +141,16 @@ public final class Controllers {
}
public static void onApplicationStop() {
config().setHeight(stageHeight.get());
config().setWidth(stageWidth.get());
stageHeight = null;
stageWidth = null;
if (stageHeight != null) {
config().setHeight(stageHeight.get());
stageHeight.unbind();
stageHeight = null;
}
if (stageWidth != null) {
config().setWidth(stageWidth.get());
stageWidth.unbind();
stageWidth = null;
}
}
public static void initialize(Stage stage) {
@@ -187,6 +188,19 @@ public final class Controllers {
stage.initStyle(StageStyle.TRANSPARENT);
stage.setScene(scene);
if (!Architecture.SYSTEM_ARCH.isX86() && globalConfig().getPlatformPromptVersion() < 1) {
Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1);
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
Controllers.dialog(i18n("fatal.unsupported_platform.osx_arm64"), null, MessageType.INFO, continueAction);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
Controllers.dialog(i18n("fatal.unsupported_platform.windows_arm64"), null, MessageType.INFO, continueAction);
} else {
Controllers.dialog(i18n("fatal.unsupported_platform"), null, MessageType.WARNING, continueAction);
}
}
if (globalConfig().getAgreementVersion() < 1) {
JFXDialogLayout agreementPane = new JFXDialogLayout();
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
@@ -307,9 +321,12 @@ public final class Controllers {
rootPage = null;
versionPage = null;
gameListPage = null;
downloadPage = null;
accountListPage = null;
settingsPage = null;
decorator = null;
stage = null;
scene = null;
onApplicationStop();
}
}
@@ -34,10 +34,8 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.*;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
@@ -49,6 +47,7 @@ import javafx.scene.text.TextFlow;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.util.Logging;
@@ -58,6 +57,7 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
@@ -72,6 +72,7 @@ import javax.xml.parsers.ParserConfigurationException;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.lang.ref.WeakReference;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.URI;
@@ -357,53 +358,77 @@ public final class FXUtils {
public static void openFolder(File file) {
if (!FileUtils.makeDirectory(file)) {
Logging.LOG.log(Level.SEVERE, "Unable to make directory " + file);
LOG.log(Level.SEVERE, "Unable to make directory " + file);
return;
}
String path = file.getAbsolutePath();
switch (OperatingSystem.CURRENT_OS) {
case OSX:
String openCommand;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
openCommand = "explorer.exe";
else if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
openCommand = "/usr/bin/open";
else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && new File("/usr/bin/xdg-open").exists())
openCommand = "/usr/bin/xdg-open";
else
openCommand = null;
thread(() -> {
if (openCommand != null) {
try {
Runtime.getRuntime().exec(new String[]{"/usr/bin/open", path});
} catch (IOException e) {
Logging.LOG.log(Level.SEVERE, "Unable to open " + path + " by executing /usr/bin/open", e);
int exitCode = SystemUtils.callExternalProcess(openCommand, path);
// explorer.exe always return 1
if (exitCode == 0 || (exitCode == 1 && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS))
return;
else
LOG.warning("Open " + path + " failed with code " + exitCode);
} catch (Throwable e) {
LOG.log(Level.WARNING, "Unable to open " + path + " by executing " + openCommand, e);
}
break;
default:
thread(() -> {
if (java.awt.Desktop.isDesktopSupported()) {
try {
java.awt.Desktop.getDesktop().open(file);
} catch (Throwable e) {
Logging.LOG.log(Level.SEVERE, "Unable to open " + path + " by java.awt.Desktop.getDesktop()::open", e);
}
}
});
}
}
// Fallback to java.awt.Desktop::open
try {
java.awt.Desktop.getDesktop().open(file);
} catch (Throwable e) {
LOG.log(Level.SEVERE, "Unable to open " + path + " by java.awt.Desktop.getDesktop()::open", e);
}
});
}
public static void showFileInExplorer(Path file) {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
String path = file.toAbsolutePath().toString();
String[] openCommands;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
openCommands = new String[]{"explorer.exe", "/select,", path};
else if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
openCommands = new String[]{"/usr/bin/open", "-R", path};
else
openCommands = null;
if (openCommands != null) {
thread(() -> {
try {
Runtime.getRuntime().exec(new String[]{"explorer.exe", "/select,", file.toAbsolutePath().toString()});
} catch (IOException e) {
Logging.LOG.log(Level.SEVERE, "Unable to open " + file + " by executing explorer /select," + file, e);
int exitCode = SystemUtils.callExternalProcess(openCommands);
// explorer.exe always return 1
if (exitCode == 0 || (exitCode == 1 && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS))
return;
else
LOG.warning("Show " + path + " in explorer failed with code " + exitCode);
} catch (Throwable e) {
LOG.log(Level.WARNING, "Unable to show " + path + " in explorer", e);
}
break;
case OSX:
try {
Runtime.getRuntime().exec(new String[]{"/usr/bin/open", "-R", file.toAbsolutePath().toString()});
} catch (IOException e) {
Logging.LOG.log(Level.SEVERE, "Unable to open " + file + " by executing /usr/bin/open -R " + file, e);
}
break;
default:
// We do not have an universal method to show file in file manager.
// Fallback to open folder
openFolder(file.getParent().toFile());
break;
});
} else {
// We do not have a universal method to show file in file manager.
openFolder(file.getParent().toFile());
}
}
@@ -426,34 +451,38 @@ public final class FXUtils {
if (link == null)
return;
if (java.awt.Desktop.isDesktopSupported()) {
thread(() -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) {
for (String browser : linuxBrowsers) {
try (final InputStream is = Runtime.getRuntime().exec(new String[]{"which", browser}).getInputStream()) {
if (is.read() != -1) {
Runtime.getRuntime().exec(new String[]{browser, link});
return;
}
} catch (Throwable ignored) {
}
Logging.LOG.log(Level.WARNING, "No known browser found");
}
}
thread(() -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
java.awt.Desktop.getDesktop().browse(new URI(link));
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", link});
return;
} catch (Throwable e) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
try {
Runtime.getRuntime().exec(new String[]{"/usr/bin/open", link});
} catch (IOException ex) {
Logging.LOG.log(Level.WARNING, "Unable to open link: " + link, ex);
}
Logging.LOG.log(Level.WARNING, "Failed to open link: " + link, e);
LOG.log(Level.WARNING, "An exception occurred while calling rundll32", e);
}
});
}
} if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) {
for (String browser : linuxBrowsers) {
try (final InputStream is = Runtime.getRuntime().exec(new String[]{"which", browser}).getInputStream()) {
if (is.read() != -1) {
Runtime.getRuntime().exec(new String[]{browser, link});
return;
}
} catch (Throwable ignored) {
}
Logging.LOG.log(Level.WARNING, "No known browser found");
}
}
try {
java.awt.Desktop.getDesktop().browse(new URI(link));
} catch (Throwable e) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
try {
Runtime.getRuntime().exec(new String[]{"/usr/bin/open", link});
} catch (IOException ex) {
Logging.LOG.log(Level.WARNING, "Unable to open link: " + link, ex);
}
Logging.LOG.log(Level.WARNING, "Failed to open link: " + link, e);
}
});
}
public static void showWebDialog(String title, String content) {
@@ -469,12 +498,12 @@ public final class FXUtils {
} catch (NoClassDefFoundError | UnsatisfiedLinkError e) {
LOG.log(Level.WARNING, "WebView is missing or initialization failed, use JEditorPane replaced", e);
SwingUtils.initLookAndFeel();
SwingUtilities.invokeLater(() -> {
final JFrame frame = new JFrame(title);
frame.setSize(width, height);
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
frame.setLocationByPlatform(true);
//noinspection ConstantConditions
frame.setIconImage(new ImageIcon(FXUtils.class.getResource("/assets/img/icon.png")).getImage());
frame.setLayout(new BorderLayout());
@@ -502,20 +531,85 @@ public final class FXUtils {
}
}
public static void bindInt(JFXTextField textField, Property<Number> property) {
textField.textProperty().bindBidirectional(property, SafeStringConverter.fromInteger());
public static <T> void bind(JFXTextField textField, Property<T> property, StringConverter<T> converter) {
textField.setText(converter == null ? (String) property.getValue() : converter.toString(property.getValue()));
TextFieldBindingListener<T> listener = new TextFieldBindingListener<>(textField, property, converter);
textField.focusedProperty().addListener((ChangeListener<Boolean>) listener);
property.addListener(listener);
}
public static void unbindInt(JFXTextField textField, Property<Number> property) {
textField.textProperty().unbindBidirectional(property);
public static void bindInt(JFXTextField textField, Property<Number> property) {
bind(textField, property, SafeStringConverter.fromInteger());
}
public static void bindString(JFXTextField textField, Property<String> property) {
textField.textProperty().bindBidirectional(property);
bind(textField, property, null);
}
public static void unbindString(JFXTextField textField, Property<String> property) {
textField.textProperty().unbindBidirectional(property);
public static void unbind(JFXTextField textField, Property<?> property) {
TextFieldBindingListener<?> listener = new TextFieldBindingListener<>(textField, property, null);
textField.focusedProperty().removeListener((ChangeListener<Boolean>) listener);
property.removeListener(listener);
}
private static final class TextFieldBindingListener<T> implements ChangeListener<Boolean>, InvalidationListener {
private final int hashCode;
private final WeakReference<JFXTextField> textFieldRef;
private final WeakReference<Property<T>> propertyRef;
private final StringConverter<T> converter;
TextFieldBindingListener(JFXTextField textField, Property<T> property, StringConverter<T> converter) {
this.textFieldRef = new WeakReference<>(textField);
this.propertyRef = new WeakReference<>(property);
this.converter = converter;
this.hashCode = System.identityHashCode(textField) ^ System.identityHashCode(property);
}
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean focused) { // On TextField changed
JFXTextField textField = textFieldRef.get();
Property<T> property = this.propertyRef.get();
if (textField != null && property != null && oldValue == Boolean.TRUE && focused == Boolean.FALSE) {
if (textField.validate()) {
String newText = textField.getText();
@SuppressWarnings("unchecked")
T newValue = converter == null ? (T) newText : converter.fromString(newText);
if (!Objects.equals(newValue, property.getValue()))
property.setValue(newValue);
} else {
// Rollback to old value
invalidated(null);
}
}
}
@Override
public void invalidated(Observable observable) { // On property change
JFXTextField textField = textFieldRef.get();
Property<T> property = this.propertyRef.get();
if (textField != null && property != null) {
T value = property.getValue();
textField.setText(converter == null ? (String) value : converter.toString(value));
}
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof TextFieldBindingListener))
return false;
TextFieldBindingListener<?> other = (TextFieldBindingListener<?>) obj;
return this.hashCode == other.hashCode
&& this.textFieldRef.get() == other.textFieldRef.get()
&& this.propertyRef.get() == other.propertyRef.get();
}
}
public static void bindBoolean(JFXToggleButton toggleButton, Property<Boolean> property) {
@@ -632,10 +726,17 @@ public final class FXUtils {
}
public static <T> Callback<ListView<T>, ListCell<T>> jfxListCellFactory(Function<T, Node> graphicBuilder) {
MutableObject<Object> lastCell = new MutableObject<>();
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.getValue() && !isVisible())
return;
lastCell.setValue(this);
if (!empty) {
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
setGraphic(graphicBuilder.apply(item));
@@ -688,11 +789,12 @@ public final class FXUtils {
// Based on https://stackoverflow.com/a/57552025
// Fix #874: Use it instead of SwingFXUtils.toFXImage
public static WritableImage toFXImage(BufferedImage image) {
WritableImage wr = new WritableImage(image.getWidth(), image.getHeight());
PixelWriter pw = wr.getPixelWriter();
final int iw = image.getWidth();
final int ih = image.getHeight();
WritableImage wr = new WritableImage(iw, ih);
PixelWriter pw = wr.getPixelWriter();
for (int x = 0; x < iw; x++) {
for (int y = 0; y < ih; y++) {
pw.setArgb(x, y, image.getRGB(x, y));
@@ -701,6 +803,21 @@ public final class FXUtils {
return wr;
}
public static BufferedImage fromFXImage(Image image) {
final int iw = (int) image.getWidth();
final int ih = (int) image.getHeight();
PixelReader pr = image.getPixelReader();
BufferedImage bufferedImage = new BufferedImage(iw, ih, BufferedImage.TYPE_INT_ARGB);
for (int x = 0; x < iw; x++) {
for (int y = 0; y < ih; y++) {
bufferedImage.setRGB(x, y, pr.getArgb(x, y));
}
}
return bufferedImage;
}
public static void copyText(String text) {
ClipboardContent content = new ClipboardContent();
content.putString(text);
@@ -710,6 +827,9 @@ public final class FXUtils {
}
public static List<Node> parseSegment(String segment, Consumer<String> hyperlinkAction) {
if (segment.indexOf('<') < 0)
return Collections.singletonList(new Text(segment));
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
@@ -42,13 +42,13 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.awt.*;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -77,7 +77,7 @@ public class GameCrashWindow extends Stage {
private final StringProperty arch = new SimpleStringProperty(Architecture.SYSTEM_ARCH.getDisplayName());
private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown")));
private final BooleanProperty loading = new SimpleBooleanProperty();
private final Label feedbackLabel = new Label(i18n("game.crash.feedback"));
private final TextFlow feedbackTextFlow = new TextFlow();
private final ManagedProcess managedProcess;
private final DefaultGameRepository repository;
@@ -85,9 +85,9 @@ public class GameCrashWindow extends Stage {
private final LaunchOptions launchOptions;
private final View view;
private final List<Pair<String, Log4jLevel>> logs;
private final Collection<Pair<String, Log4jLevel>> logs;
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List<Pair<String, Log4jLevel>> logs) {
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, Collection<Pair<String, Log4jLevel>> logs) {
this.managedProcess = managedProcess;
this.exitType = exitType;
this.repository = repository;
@@ -104,6 +104,8 @@ public class GameCrashWindow extends Stage {
this.view = new View();
this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction));
setScene(new Scene(view, 800, 480));
getScene().getStylesheets().addAll(config().getTheme().getStylesheets(config().getLauncherFontFamily()));
setTitle(i18n("game.crash.title"));
@@ -137,7 +139,7 @@ public class GameCrashWindow extends Stage {
if (exception != null) {
LOG.log(Level.WARNING, "Failed to analyze crash report", exception);
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.unknown")));
reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction));
} else {
List<CrashReportAnalyzer.Result> results = pair.getKey();
Set<String> keywords = pair.getValue();
@@ -177,12 +179,12 @@ public class GameCrashWindow extends Stage {
if (!keywords.isEmpty()) {
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.stacktrace", String.join(", ", keywords))));
} else {
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.unknown")));
reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction));
}
feedbackLabel.setVisible(true);
feedbackTextFlow.setVisible(true);
} else {
feedbackLabel.setVisible(false);
feedbackTextFlow.setVisible(false);
reasonTextFlow.getChildren().setAll(segments);
}
}
@@ -221,7 +223,7 @@ public class GameCrashWindow extends Stage {
private void showLogWindow() {
LogWindow logWindow = new LogWindow();
logWindow.logLine("Command: " + new CommandBuilder().addAll(managedProcess.getCommands()).toString(), Log4jLevel.INFO);
logWindow.logLine(Logging.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.getCommands())), Log4jLevel.INFO);
if (managedProcess.getClasspath() != null) logWindow.logLine("ClassPath: " + managedProcess.getClasspath(), Log4jLevel.INFO);
for (Map.Entry<String, Log4jLevel> entry : logs)
logWindow.logLine(entry.getKey(), entry.getValue());
@@ -237,15 +239,11 @@ public class GameCrashWindow extends Stage {
.thenComposeAsync(logs ->
LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString()))
.thenRunAsync(() -> {
FXUtils.showFileInExplorer(logFile);
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();
if (Desktop.isDesktopSupported()) {
try {
Desktop.getDesktop().open(logFile.toFile());
} catch (IOException | IllegalArgumentException ignored) {
}
}
}, Schedulers.javafx())
.exceptionally(e -> {
LOG.log(Level.WARNING, "Failed to export game crash info", e);
@@ -350,7 +348,7 @@ public class GameCrashWindow extends Stage {
gameDirPane.setPadding(new Insets(8));
VBox.setVgrow(gameDirPane, Priority.ALWAYS);
gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonTextFlow));
gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonTextFlow, feedbackTextFlow));
}
HBox toolBar = new HBox();
@@ -368,7 +366,7 @@ public class GameCrashWindow extends Stage {
toolBar.setPadding(new Insets(8));
toolBar.setSpacing(8);
toolBar.getStyleClass().add("jfx-tool-bar");
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, feedbackLabel);
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton);
}
getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar);
@@ -34,22 +34,18 @@ import javafx.scene.control.Label;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.swing.*;
import java.awt.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayDeque;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.stream.Collectors;
@@ -98,8 +94,8 @@ public final class LogWindow extends Stage {
levelShownMap.values().forEach(property -> property.addListener((a, b, newValue) -> shakeLogs()));
}
public void logLine(String line, Log4jLevel level) {
Log log = new Log(parseEscapeSequence(line), level);
public void logLine(String filteredLine, Log4jLevel level) {
Log log = new Log(parseEscapeSequence(filteredLine), level);
logs.add(log);
if (levelShownMap.get(level).get())
impl.listView.getItems().add(log);
@@ -154,12 +150,12 @@ public final class LogWindow extends Stage {
public class LogWindowImpl extends Control {
private ListView<Log> listView = new JFXListView<>();
private BooleanProperty autoScroll = new SimpleBooleanProperty();
private List<StringProperty> buttonText = IntStream.range(0, 5).mapToObj(x -> new SimpleStringProperty()).collect(Collectors.toList());
private List<BooleanProperty> showLevel = IntStream.range(0, 5).mapToObj(x -> new SimpleBooleanProperty(true)).collect(Collectors.toList());
private JFXComboBox<String> cboLines = new JFXComboBox<>();
private BooleanProperty showCrashReport = new SimpleBooleanProperty();
private final ListView<Log> listView = new JFXListView<>();
private final BooleanProperty autoScroll = new SimpleBooleanProperty();
private final List<StringProperty> buttonText = IntStream.range(0, 5).mapToObj(x -> new SimpleStringProperty()).collect(Collectors.toList());
private final List<BooleanProperty> showLevel = IntStream.range(0, 5).mapToObj(x -> new SimpleBooleanProperty(true)).collect(Collectors.toList());
private final JFXComboBox<String> cboLines = new JFXComboBox<>();
private final BooleanProperty showCrashReport = new SimpleBooleanProperty();
LogWindowImpl() {
getStyleClass().add("log-window");
@@ -206,13 +202,13 @@ public final class LogWindow extends Stage {
return;
}
JOptionPane.showMessageDialog(null, i18n("settings.launcher.launcher_log.export.success", logFile), i18n("settings.launcher.launcher_log.export"), JOptionPane.INFORMATION_MESSAGE);
if (Desktop.isDesktopSupported()) {
try {
Desktop.getDesktop().open(logFile.toFile());
} catch (IOException | IllegalArgumentException ignored) {
}
}
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();
});
FXUtils.showFileInExplorer(logFile);
});
}
@@ -228,13 +224,13 @@ public final class LogWindow extends Stage {
}
private static class LogWindowSkin extends SkinBase<LogWindowImpl> {
private static PseudoClass EMPTY = PseudoClass.getPseudoClass("empty");
private static PseudoClass FATAL = PseudoClass.getPseudoClass("fatal");
private static PseudoClass ERROR = PseudoClass.getPseudoClass("error");
private static PseudoClass WARN = PseudoClass.getPseudoClass("warn");
private static PseudoClass INFO = PseudoClass.getPseudoClass("info");
private static PseudoClass DEBUG = PseudoClass.getPseudoClass("debug");
private static PseudoClass TRACE = PseudoClass.getPseudoClass("trace");
private static final PseudoClass EMPTY = PseudoClass.getPseudoClass("empty");
private static final PseudoClass FATAL = PseudoClass.getPseudoClass("fatal");
private static final PseudoClass ERROR = PseudoClass.getPseudoClass("error");
private static final PseudoClass WARN = PseudoClass.getPseudoClass("warn");
private static final PseudoClass INFO = PseudoClass.getPseudoClass("info");
private static final PseudoClass DEBUG = PseudoClass.getPseudoClass("debug");
private static final PseudoClass TRACE = PseudoClass.getPseudoClass("trace");
private static ToggleButton createToggleButton(String backgroundColor, StringProperty buttonText, BooleanProperty showLevel) {
ToggleButton button = new ToggleButton();
@@ -291,6 +287,7 @@ public final class LogWindow extends Stage {
listView.scrollTo(listView.getItems().size() - 1);
});
listView.setStyle("-fx-font-family: " + config().getFontFamily() + "; -fx-font-size: " + config().getFontSize() + "px;");
MutableObject<Object> lastCell = new MutableObject<>();
listView.setCellFactory(x -> new ListCell<Log>() {
{
getStyleClass().add("log-window-list-cell");
@@ -307,6 +304,12 @@ public final class LogWindow extends Stage {
@Override
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.getValue() && !isVisible())
return;
lastCell.setValue(this);
pseudoClassStateChanged(EMPTY, empty);
pseudoClassStateChanged(FATAL, !empty && item.level == Log4jLevel.FATAL);
pseudoClassStateChanged(ERROR, !empty && item.level == Log4jLevel.ERROR);
@@ -0,0 +1,33 @@
package org.jackhuang.hmcl.ui;
import javax.swing.*;
public final class SwingUtils {
static {
if (System.getProperty("swing.defaultlaf") == null) {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Throwable ignored) {
}
}
}
private SwingUtils() {
}
public static void initLookAndFeel() {
// Make sure the static constructor is called
}
public static void showInfoDialog(Object message) {
JOptionPane.showMessageDialog(null, message, "Info", JOptionPane.INFORMATION_MESSAGE);
}
public static void showWarningDialog(Object message) {
JOptionPane.showMessageDialog(null, message, "Warning", JOptionPane.WARNING_MESSAGE);
}
public static void showErrorDialog(Object message) {
JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
}
}
@@ -41,7 +41,7 @@ public class UpgradeDialog extends JFXDialogLayout {
}
{
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html#nowchange";
try {
WebView webView = new WebView();
webView.getEngine().setUserDataDirectory(Metadata.HMCL_DIRECTORY.toFile());
@@ -24,11 +24,11 @@ import javafx.beans.value.WeakChangeListener;
import javafx.collections.ListChangeListener;
import javafx.collections.WeakListChangeListener;
import java.util.LinkedList;
import java.util.ArrayList;
import java.util.List;
public class WeakListenerHolder {
private List<Object> refs = new LinkedList<>();
private final List<Object> refs = new ArrayList<>(0);
public WeakListenerHolder() {
}
@@ -22,9 +22,8 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
@@ -34,20 +33,18 @@ import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import static javafx.beans.binding.Bindings.createStringBinding;
import static org.jackhuang.hmcl.setting.Accounts.getAccountFactory;
import static org.jackhuang.hmcl.setting.Accounts.getLocalizedLoginTypeName;
import static org.jackhuang.hmcl.ui.FXUtils.toFXImage;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class AccountAdvancedListItem extends AdvancedListItem {
private final Tooltip tooltip;
private final ImageView imageView;
private final Canvas canvas;
private ObjectProperty<Account> account = new SimpleObjectProperty<Account>() {
private final ObjectProperty<Account> account = new SimpleObjectProperty<Account>() {
@Override
protected void invalidated() {
@@ -55,17 +52,19 @@ public class AccountAdvancedListItem extends AdvancedListItem {
if (account == null) {
titleProperty().unbind();
subtitleProperty().unbind();
imageView.imageProperty().unbind();
tooltip.textProperty().unbind();
setTitle(i18n("account.missing"));
setSubtitle(i18n("account.missing.add"));
imageView.setImage(toFXImage(TexturesLoader.toAvatar(TexturesLoader.getDefaultSkin(TextureModel.STEVE).getImage(), 32)));
tooltip.setText(i18n("account.create"));
TexturesLoader.unbindAvatar(canvas);
TexturesLoader.drawAvatar(canvas, TexturesLoader.getDefaultSkin(TextureModel.STEVE).getImage());
} else {
titleProperty().bind(BindingMapping.of(account, Account::getCharacter));
subtitleProperty().bind(accountSubtitle(account));
imageView.imageProperty().bind(TexturesLoader.fxAvatarBinding(account, 32));
tooltip.textProperty().bind(accountTooltip(account));
TexturesLoader.bindAvatar(canvas, account);
}
}
};
@@ -74,9 +73,8 @@ public class AccountAdvancedListItem extends AdvancedListItem {
tooltip = new Tooltip();
FXUtils.installFastTooltip(this, tooltip);
Pair<Node, ImageView> view = createImageView(null);
setLeftGraphic(view.getKey());
imageView = view.getValue();
canvas = new Canvas(32, 32);
setLeftGraphic(canvas);
setActionButtonVisible(false);
@@ -20,14 +20,11 @@ package org.jackhuang.hmcl.ui.account;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableBooleanValue;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Skin;
import javafx.scene.image.Image;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthenticationException;
@@ -39,7 +36,6 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -70,7 +66,6 @@ public class AccountListItem extends RadioButton {
private final Account account;
private final StringProperty title = new SimpleStringProperty();
private final StringProperty subtitle = new SimpleStringProperty();
private final ObjectProperty<Image> image = new SimpleObjectProperty<>();
public AccountListItem(Account account) {
this.account = account;
@@ -95,8 +90,6 @@ public class AccountListItem extends RadioButton {
account.getUsername().isEmpty() ? characterName :
Bindings.concat(account.getUsername(), " - ", characterName));
}
image.bind(TexturesLoader.fxAvatarBinding(account, 32));
}
@Override
@@ -226,16 +219,4 @@ public class AccountListItem extends RadioButton {
public StringProperty subtitleProperty() {
return subtitle;
}
public Image getImage() {
return image.get();
}
public void setImage(Image image) {
this.image.set(image);
}
public ObjectProperty<Image> imageProperty() {
return image;
}
}
@@ -21,15 +21,16 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRadioButton;
import com.jfoenix.effects.JFXDepthManager;
import javafx.geometry.Pos;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.Schedulers;
@@ -64,9 +65,8 @@ public class AccountListItemSkin extends SkinBase<AccountListItem> {
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
ImageView imageView = new ImageView();
FXUtils.limitSize(imageView, 32, 32);
imageView.imageProperty().bind(skinnable.imageProperty());
Canvas canvas = new Canvas(32, 32);
TexturesLoader.bindAvatar(canvas, skinnable.getAccount());
Label title = new Label();
title.getStyleClass().add("title");
@@ -84,7 +84,7 @@ public class AccountListItemSkin extends SkinBase<AccountListItem> {
item.getStyleClass().add("two-line-list-item");
BorderPane.setAlignment(item, Pos.CENTER);
center.getChildren().setAll(imageView, item);
center.getChildren().setAll(canvas, item);
root.setCenter(center);
HBox right = new HBox();
@@ -45,8 +45,10 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.net.URI;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
@@ -148,7 +150,13 @@ public class AccountListPage extends DecoratorAnimatedPage implements DecoratorP
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
item.subtitleProperty().set(URI.create(server.getUrl()).getHost());
String host = "";
try {
host = URI.create(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.log(Level.WARNING, "Unparsable authlib-injector server url " + server.getUrl(), e);
}
item.subtitleProperty().set(host);
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
FXUtils.installFastTooltip(item, tooltip);
@@ -22,6 +22,7 @@ import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.task.Schedulers;
@@ -67,6 +68,7 @@ public class AddAuthlibInjectorServerPane extends StackPane implements DialogAwa
loadFXML(this, "/assets/fxml/authlib-injector-server-add.fxml");
root.setContent(addServerPane, ContainerAnimations.NONE.getAnimationProducer());
lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty());
btnAddNext.disableProperty().bind(txtServerUrl.textProperty().isEmpty());
nextPane.hideSpinner();
@@ -30,10 +30,10 @@ import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TextInputControl;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.CharacterSelector;
@@ -286,7 +286,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> {
runInFX(() -> deviceCode.set(value));
}));
HBox box = new HBox(8);
FlowPane box = new FlowPane();
box.setHgap(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
birthLink.setOnAction(e -> FXUtils.openLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"));
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
@@ -294,8 +295,12 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.yggdrasil.purchase"));
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
deauthorizeLink.setOnAction(e -> FXUtils.openLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"));
JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password"));
forgotpasswordLink.setOnAction(e -> FXUtils.openLink("https://www.minecraft.net/password/forgot"));
JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings"));
createProfileLink.setOnAction(e -> FXUtils.openLink("https://www.minecraft.net/msaprofile/mygames/editprofile"));
purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL));
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink);
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink);
GridPane.setColumnSpan(box, 2);
vbox.getChildren().setAll(hintPane, box);
@@ -503,6 +508,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
}
if (factory instanceof OfflineAccountFactory) {
txtUsername.setPromptText(i18n("account.methods.offline.name.special_characters"));
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.yggdrasil.purchase"));
purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL));
HBox linkPane = new HBox(purchaseLink);
@@ -647,12 +654,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
public GameProfile select(YggdrasilService service, List<GameProfile> profiles) throws NoSelectedCharacterException {
Platform.runLater(() -> {
for (GameProfile profile : profiles) {
ImageView portraitView = new ImageView();
portraitView.setSmooth(false);
portraitView.imageProperty().bind(TexturesLoader.fxAvatarBinding(service, profile.getId(), 32));
FXUtils.limitSize(portraitView, 32, 32);
Canvas portraitCanvas = new Canvas(32, 32);
TexturesLoader.bindAvatar(portraitCanvas, service, profile.getId());
IconedItem accountItem = new IconedItem(portraitView, profile.getName());
IconedItem accountItem = new IconedItem(portraitCanvas, profile.getName());
accountItem.setOnMouseClicked(e -> {
selectedProfile = profile;
latch.countDown();
@@ -48,9 +48,9 @@ public class OAuthAccountLoginDialog extends DialogPane {
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
hintPane.setSegment(i18n("account.login.refresh.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
} else {
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
hintPane.setSegment(i18n("account.login.refresh.microsoft.hint"));
}
});
hintPane.setOnMouseClicked(e -> {
@@ -19,26 +19,37 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.function.Consumer;
public class AdvancedListBox extends VBox {
public class AdvancedListBox extends ScrollPane {
private final VBox container = new VBox();
{
getStyleClass().add("advanced-list-box-content");
setContent(container);
FXUtils.smoothScrolling(this);
setFitToHeight(true);
setFitToWidth(true);
setHbarPolicy(ScrollBarPolicy.NEVER);
container.getStyleClass().add("advanced-list-box-content");
}
public AdvancedListBox add(Node child) {
if (child instanceof Pane || child instanceof AdvancedListItem)
getChildren().add(child);
container.getChildren().add(child);
else {
StackPane pane = new StackPane();
pane.getStyleClass().add("advanced-list-box-item");
pane.getChildren().setAll(child);
getChildren().add(pane);
container.getChildren().add(pane);
}
return this;
}
@@ -53,27 +64,27 @@ public class AdvancedListBox extends VBox {
public AdvancedListBox add(int index, Node child) {
if (child instanceof Pane || child instanceof AdvancedListItem)
getChildren().add(index, child);
container.getChildren().add(index, child);
else {
StackPane pane = new StackPane();
pane.getStyleClass().add("advanced-list-box-item");
pane.getChildren().setAll(child);
getChildren().add(index, pane);
container.getChildren().add(index, pane);
}
return this;
}
public AdvancedListBox remove(Node child) {
getChildren().remove(indexOf(child));
container.getChildren().remove(indexOf(child));
return this;
}
public int indexOf(Node child) {
if (child instanceof Pane) {
return getChildren().indexOf(child);
return container.getChildren().indexOf(child);
} else {
for (int i = 0; i < getChildren().size(); ++i) {
Node node = getChildren().get(i);
for (int i = 0; i < container.getChildren().size(); ++i) {
Node node = container.getChildren().get(i);
if (node instanceof StackPane) {
ObservableList<Node> list = ((StackPane) node).getChildren();
if (list.size() == 1 && list.get(0) == child)
@@ -88,7 +99,11 @@ public class AdvancedListBox extends VBox {
return add(new ClassTitle(category));
}
public void setSpacing(double spacing) {
container.setSpacing(spacing);
}
public void clear() {
getChildren().clear();
container.getChildren().clear();
}
}
@@ -28,11 +28,11 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
@@ -115,12 +115,17 @@ public class ComponentList extends Control {
expanded = true;
}
@Override
public Orientation getContentBias() {
return Orientation.HORIZONTAL;
}
@Override
protected javafx.scene.control.Skin<?> createDefaultSkin() {
return new Skin(this);
}
protected static class Skin extends SkinBase<ComponentList> {
protected static class Skin extends ControlSkinBase<ComponentList> {
private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first");
private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");
@@ -166,7 +171,7 @@ public class ComponentList extends Control {
VBox vbox = new VBox();
vbox.setFillWidth(true);
Bindings.bindContent(vbox.getChildren(), list);
getChildren().setAll(vbox);
node = vbox;
}
}
@@ -0,0 +1,55 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import java.util.Objects;
public abstract class ControlSkinBase<C extends Control> implements Skin<C> {
private final C control;
protected Node node;
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
protected ControlSkinBase(C control) {
this.control = control;
}
@Override
public C getSkinnable() {
return control;
}
@Override
public Node getNode() {
Objects.requireNonNull(node);
return node;
}
@Override
public void dispose() {
}
}
@@ -34,7 +34,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class DialogPane extends JFXDialogLayout {
private final StringProperty title = new SimpleStringProperty();
private final BooleanProperty valid = new SimpleBooleanProperty();
private final BooleanProperty valid = new SimpleBooleanProperty(true);
protected final SpinnerPane acceptPane = new SpinnerPane();
protected final JFXButton cancelButton = new JFXButton();
protected final Label warningLabel = new Label();
@@ -22,6 +22,7 @@ import javafx.css.PseudoClass;
import javafx.scene.control.ListCell;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.ui.FXUtils;
public abstract class MDListCell<T> extends ListCell<T> {
@@ -29,8 +30,11 @@ public abstract class MDListCell<T> extends ListCell<T> {
private final StackPane container = new StackPane();
private final StackPane root = new StackPane();
private final MutableObject<Object> lastCell;
public MDListCell(JFXListView<T> listView, MutableObject<Object> lastCell) {
this.lastCell = lastCell;
public MDListCell(JFXListView<T> listView) {
setText(null);
setGraphic(null);
@@ -50,6 +54,14 @@ public abstract class MDListCell<T> extends ListCell<T> {
@Override
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.getValue() && !isVisible())
return;
lastCell.setValue(this);
}
updateControl(item, empty);
if (empty) {
setGraphic(null);
@@ -23,8 +23,12 @@ import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.ButtonBase;
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.TextFlow;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -69,7 +73,17 @@ public final class MessageDialogPane extends StackPane {
public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull MessageType type) {
FXUtils.loadFXML(this, "/assets/fxml/message-dialog.fxml");
content.getChildren().setAll(FXUtils.segmentToTextFlow(text, Controllers::onHyperlinkAction));
EnhancedTextFlow textFlow = new EnhancedTextFlow();
textFlow.getChildren().setAll(FXUtils.parseSegment(text, Controllers::onHyperlinkAction));
if (textFlow.computePrefHeight(400.0) <= 350.0)
content.getChildren().setAll(textFlow);
else {
ScrollPane scrollPane = new ScrollPane(textFlow);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
content.getChildren().setAll(scrollPane);
}
if (title != null)
this.title.setText(title);
@@ -114,6 +128,13 @@ public final class MessageDialogPane extends StackPane {
return cancelButton;
}
private static final class EnhancedTextFlow extends TextFlow {
@Override
public double computePrefHeight(double width) {
return super.computePrefHeight(width);
}
}
public static class Builder {
private final MessageDialogPane dialog;
@@ -123,10 +144,11 @@ public final class MessageDialogPane extends StackPane {
public Builder addAction(Node actionNode) {
dialog.addButton(actionNode);
actionNode.getStyleClass().add("dialog-accept");
return this;
}
public Builder ok(Runnable ok) {
public Builder ok(@Nullable Runnable ok) {
JFXButton btnOk = new JFXButton(i18n("button.ok"));
btnOk.getStyleClass().add("dialog-accept");
if (ok != null) {
@@ -137,7 +159,22 @@ public final class MessageDialogPane extends StackPane {
return this;
}
public Builder yesOrNo(Runnable yes, Runnable no) {
public Builder addCancel(@Nullable Runnable cancel) {
return addCancel(i18n("button.cancel"), cancel);
}
public Builder addCancel(String cancelText, @Nullable Runnable cancel) {
JFXButton btnCancel = new JFXButton(cancelText);
btnCancel.getStyleClass().add("dialog-cancel");
if (cancel != null) {
btnCancel.setOnAction(e -> cancel.run());
}
dialog.addButton(btnCancel);
dialog.setCancelButton(btnCancel);
return this;
}
public Builder yesOrNo(@Nullable Runnable yes, @Nullable Runnable no) {
JFXButton btnYes = new JFXButton(i18n("button.yes"));
btnYes.getStyleClass().add("dialog-accept");
if (yes != null) {
@@ -145,27 +182,14 @@ public final class MessageDialogPane extends StackPane {
}
dialog.addButton(btnYes);
JFXButton btnNo = new JFXButton(i18n("button.no"));
btnNo.getStyleClass().add("dialog-cancel");
if (no != null) {
btnNo.setOnAction(e -> no.run());
}
dialog.addButton(btnNo);
dialog.setCancelButton(btnNo);
addCancel(i18n("button.no"), no);
return this;
}
public Builder actionOrCancel(ButtonBase actionButton, Runnable cancel) {
dialog.addButton(actionButton);
JFXButton btnCancel = new JFXButton(i18n("button.cancel"));
btnCancel.getStyleClass().add("dialog-cancel");
if (cancel != null) {
btnCancel.setOnAction(e -> cancel.run());
}
dialog.addButton(btnCancel);
dialog.setCancelButton(btnCancel);
addCancel(cancel);
return this;
}
@@ -28,7 +28,6 @@ import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
@@ -165,19 +164,12 @@ public class MultiFileItem<T> extends VBox {
pane.setLeft(left);
if (StringUtils.isNotBlank(subtitle)) {
Optional<String> shortSubtitle = StringUtils.truncate(subtitle);
Label right;
if (shortSubtitle.isPresent()) {
right = new Label(shortSubtitle.get());
right.setTooltip(new Tooltip(subtitle));
} else {
right = new Label(subtitle);
}
BorderPane.setAlignment(right, Pos.CENTER_RIGHT);
right.setWrapText(true);
right.getStyleClass().add("subtitle-label");
right.setStyle("-fx-font-size: 10;");
pane.setRight(right);
Label center = new Label(subtitle);
BorderPane.setAlignment(center, Pos.CENTER_RIGHT);
center.setWrapText(true);
center.getStyleClass().add("subtitle-label");
center.setStyle("-fx-font-size: 10;");
pane.setCenter(center);
}
return pane;
@@ -50,8 +50,9 @@ public class OptionToggleButton extends StackPane {
titleLabel.textProperty().bind(title);
Label subtitleLabel = new Label();
subtitleLabel.setMouseTransparent(true);
subtitleLabel.setWrapText(true);
subtitleLabel.textProperty().bind(subtitle);
pane.setLeft(left);
pane.setCenter(left);
left.setAlignment(Pos.CENTER_LEFT);
JFXToggleButton toggleButton = new JFXToggleButton();
@@ -0,0 +1,62 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ServerAddressValidator extends ValidatorBase {
private final boolean nullable;
public ServerAddressValidator() {
this(false);
}
public ServerAddressValidator(@NamedArg("nullable") boolean nullable) {
this(i18n("input.url"), nullable);
}
public ServerAddressValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) {
super(message);
this.nullable = nullable;
}
@Override
protected void eval() {
if (srcControl.get() instanceof TextInputControl) {
evalTextInputField();
}
}
private static final Pattern PATTERN = Pattern.compile("[-a-zA-Z0-9@:%._+~#=]{1,256}(:\\d+)?");
private void evalTextInputField() {
TextInputControl textField = ((TextInputControl) srcControl.get());
if (StringUtils.isBlank(textField.getText()))
hasErrors.set(!nullable);
else
hasErrors.set(!PATTERN.matcher(textField.getText()).matches());
}
}
@@ -1,3 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.validation.base.ValidatorBase;
@@ -163,10 +163,10 @@ public class DecoratorController {
config().backgroundImageUrlProperty()));
}
private Image defaultBackground = newImage("/assets/img/background.jpg");
private Image defaultBackground;
/**
* Load background image from bg/, background.png, background.jpg
* Load background image from bg/, background.png, background.jpg, background.gif
*/
private Image loadDefaultBackgroundImage() {
Optional<Image> image = randomImageIn(Paths.get("bg"));
@@ -176,7 +176,15 @@ public class DecoratorController {
if (!image.isPresent()) {
image = tryLoadImage(Paths.get("background.jpg"));
}
return image.orElse(defaultBackground);
if (!image.isPresent()) {
image = tryLoadImage(Paths.get("background.gif"));
}
return image.orElseGet(() -> {
if (defaultBackground == null)
defaultBackground = newImage("/assets/img/background.jpg");
return defaultBackground;
});
}
private Optional<Image> randomImageIn(Path imageDir) {
@@ -190,7 +198,7 @@ public class DecoratorController {
.filter(Files::isReadable)
.filter(it -> {
String ext = getExtension(it).toLowerCase();
return ext.equals("png") || ext.equals("jpg");
return ext.equals("png") || ext.equals("jpg") || ext.equals("gif");
})
.collect(toList());
} catch (IOException e) {
@@ -91,9 +91,11 @@ class AdditionalInstallersPage extends InstallersPage {
String optiFine = analyzer.getVersion(OPTIFINE).orElse(null);
String fabric = analyzer.getVersion(FABRIC).orElse(null);
String fabricApi = analyzer.getVersion(FABRIC_API).orElse(null);
String quilt = analyzer.getVersion(QUILT).orElse(null);
String quiltApi = analyzer.getVersion(QUILT_API).orElse(null);
InstallerItem[] libraries = group.getLibraries();
String[] versions = new String[]{game, forge, liteLoader, optiFine, fabric, fabricApi};
String[] versions = new String[]{game, forge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi};
String currentGameVersion = Lang.nonNull(getVersion("game"), game);
@@ -39,6 +39,7 @@ import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
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.TabControl;
import org.jackhuang.hmcl.ui.construct.TabHeader;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
@@ -128,19 +129,19 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
item.setTitle(i18n("resourcepack"));
item.setLeftGraphic(wrap(SVG::textureBox));
item.activeProperty().bind(tab.getSelectionModel().selectedItemProperty().isEqualTo(resourcePackTab));
item.setOnAction(e -> tab.select(resourcePackTab));
item.setOnAction(e -> selectTabIfCurseForgeAvailable(resourcePackTab));
})
// .addNavigationDrawerItem(item -> {
// item.setTitle(i18n("download.curseforge.customization"));
// item.setLeftGraphic(wrap(SVG::script));
// item.activeProperty().bind(tab.getSelectionModel().selectedItemProperty().isEqualTo(customizationTab));
// item.setOnAction(e -> tab.select(customizationTab));
// item.setOnAction(e -> selectTabIfCurseForgeAvailable(customizationTab));
// })
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("world"));
item.setLeftGraphic(wrap(SVG::earth));
item.activeProperty().bind(tab.getSelectionModel().selectedItemProperty().isEqualTo(worldTab));
item.setOnAction(e -> tab.select(worldTab));
item.setOnAction(e -> selectTabIfCurseForgeAvailable(worldTab));
});
FXUtils.setLimitWidth(sideBar, 200);
setLeft(sideBar);
@@ -149,7 +150,14 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
setCenter(transitionPane);
}
private <T extends Node> Supplier<T> loadVersionFor(Supplier<T> nodeSupplier) {
private void selectTabIfCurseForgeAvailable(TabControl.Tab<?> newTab) {
if (CurseForgeRemoteModRepository.isAvailable())
tab.select(newTab);
else
Controllers.dialog(i18n("download.curseforge.unavailable"));
}
private static <T extends Node> Supplier<T> loadVersionFor(Supplier<T> nodeSupplier) {
return () -> {
T node = nodeSupplier.get();
if (node instanceof VersionPage.VersionLoadable) {
@@ -159,7 +167,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
};
}
private void download(Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) {
private static void download(Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) {
if (version == null) version = profile.getSelectedVersion();
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version).toPath() : profile.getRepository().getBaseDirectory().toPath();
@@ -234,7 +242,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
tab.select(worldTab);
}
private class DownloadNavigator implements Navigation {
private static final class DownloadNavigator implements Navigation {
private final Map<String, Object> settings = new HashMap<>();
@Override
@@ -31,6 +31,7 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.VersionList;
@@ -40,7 +41,10 @@ import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
@@ -52,9 +56,9 @@ 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.HMCLService;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.Locales;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -122,86 +126,9 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
btnRefresh.setGraphic(wrap(SVG.refresh(Theme.blackFillBinding(), -1, -1)));
list.setCellFactory(listView -> new ListCell<RemoteVersion>() {
IconedTwoLineListItem content = new IconedTwoLineListItem();
RipplerContainer ripplerContainer = new RipplerContainer(content);
StackPane pane = new StackPane();
{
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(content, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(ripplerContainer);
}
@Override
public void updateItem(RemoteVersion remoteVersion, boolean empty) {
super.updateItem(remoteVersion, empty);
if (empty) {
setGraphic(null);
return;
}
setGraphic(pane);
content.setTitle(remoteVersion.getSelfVersion());
if (remoteVersion.getReleaseDate() != null) {
content.setSubtitle(Locales.DATE_TIME_FORMATTER.get().format(remoteVersion.getReleaseDate().toInstant()));
} else {
content.setSubtitle("");
}
if (remoteVersion instanceof GameRemoteVersion) {
switch (remoteVersion.getVersionType()) {
case RELEASE:
content.getTags().setAll(i18n("version.game.release"));
content.setImage(new Image("/assets/img/grass.png", 32, 32, false, true));
break;
case SNAPSHOT:
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(new Image("/assets/img/command.png", 32, 32, false, true));
break;
default:
content.getTags().setAll(i18n("version.game.old"));
content.setImage(new Image("/assets/img/craft_table.png", 32, 32, false, true));
break;
}
} else if (remoteVersion instanceof LiteLoaderRemoteVersion) {
content.setImage(new Image("/assets/img/chicken.png", 32, 32, false, true));
if (StringUtils.isNotBlank(content.getSubtitle())) {
content.getTags().setAll(remoteVersion.getGameVersion());
} else {
content.setSubtitle(remoteVersion.getGameVersion());
}
} else if (remoteVersion instanceof OptiFineRemoteVersion) {
content.setImage(new Image("/assets/img/command.png", 32, 32, false, true));
if (StringUtils.isNotBlank(content.getSubtitle())) {
content.getTags().setAll(remoteVersion.getGameVersion());
} else {
content.setSubtitle(remoteVersion.getGameVersion());
}
} else if (remoteVersion instanceof ForgeRemoteVersion) {
content.setImage(new Image("/assets/img/forge.png", 32, 32, false, true));
if (StringUtils.isNotBlank(content.getSubtitle())) {
content.getTags().setAll(remoteVersion.getGameVersion());
} else {
content.setSubtitle(remoteVersion.getGameVersion());
}
} else if (remoteVersion instanceof FabricRemoteVersion) {
content.setImage(new Image("/assets/img/fabric.png", 32, 32, false, true));
if (StringUtils.isNotBlank(content.getSubtitle())) {
content.getTags().setAll(remoteVersion.getGameVersion());
} else {
content.setSubtitle(remoteVersion.getGameVersion());
}
} else if (remoteVersion instanceof FabricAPIRemoteVersion) {
content.setImage(new Image("/assets/img/fabric.png", 32, 32, false, true));
if (StringUtils.isNotBlank(content.getSubtitle())) {
content.getTags().setAll(remoteVersion.getGameVersion());
} else {
content.setSubtitle(remoteVersion.getGameVersion());
}
}
}
});
MutableObject<RemoteVersionListCell> lastCell = new MutableObject<>();
EnumMap<VersionIconType, Image> icons = new EnumMap<>(VersionIconType.class);
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, icons));
list.setOnMouseClicked(e -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
@@ -290,4 +217,86 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
private void onSponsor() {
HMCLService.openRedirectLink("bmclapi_sponsor");
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
final IconedTwoLineListItem content = new IconedTwoLineListItem();
final RipplerContainer ripplerContainer = new RipplerContainer(content);
final StackPane pane = new StackPane();
private final MutableObject<RemoteVersionListCell> lastCell;
private final EnumMap<VersionIconType, Image> icons;
RemoteVersionListCell(MutableObject<RemoteVersionListCell> lastCell, EnumMap<VersionIconType, Image> icons) {
this.lastCell = lastCell;
this.icons = icons;
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(content, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(ripplerContainer);
}
private Image getIcon(VersionIconType type) {
return icons.computeIfAbsent(type, iconType -> new Image(iconType.getResourceUrl(), 32, 32, false, true));
}
@Override
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.getValue() && !isVisible())
return;
lastCell.setValue(this);
if (empty) {
setGraphic(null);
return;
}
setGraphic(pane);
content.setTitle(remoteVersion.getSelfVersion());
if (remoteVersion.getReleaseDate() != null) {
content.setSubtitle(Locales.DATE_TIME_FORMATTER.get().format(remoteVersion.getReleaseDate().toInstant()));
} else {
content.setSubtitle(null);
}
if (remoteVersion instanceof GameRemoteVersion) {
switch (remoteVersion.getVersionType()) {
case RELEASE:
content.getTags().setAll(i18n("version.game.release"));
content.setImage(getIcon(VersionIconType.GRASS));
break;
case SNAPSHOT:
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(getIcon(VersionIconType.COMMAND));
break;
default:
content.getTags().setAll(i18n("version.game.old"));
content.setImage(getIcon(VersionIconType.CRAFT_TABLE));
break;
}
} else {
VersionIconType iconType;
if (remoteVersion instanceof LiteLoaderRemoteVersion)
iconType = VersionIconType.CHICKEN;
else if (remoteVersion instanceof OptiFineRemoteVersion)
iconType = VersionIconType.COMMAND;
else if (remoteVersion instanceof ForgeRemoteVersion)
iconType = VersionIconType.FORGE;
else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion)
iconType = VersionIconType.FABRIC;
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = VersionIconType.QUILT;
else
iconType = null;
content.setImage(iconType != null ? getIcon(iconType) : null);
if (content.getSubtitle() == null)
content.setSubtitle(remoteVersion.getGameVersion());
else
content.getTags().setAll(remoteVersion.getGameVersion());
}
}
}
}
@@ -137,6 +137,10 @@ public final class ExportWizardProvider implements WizardProvider {
if (background_jpg.isFile())
zip.putFile(background_jpg, "background.jpg");
File background_gif = new File("background.gif").getAbsoluteFile();
if (background_gif.isFile())
zip.putFile(background_gif, "background.gif");
zip.putFile(launcherJar.get(), launcherJar.get().getFileName().toString());
}
}
@@ -92,19 +92,13 @@ public class AboutPage extends StackPane {
mcmod.setSubtitle(i18n("about.thanks_to.mcmod.statement"));
mcmod.setExternalLink("https://www.mcmod.cn/");
IconedTwoLineListItem noin = new IconedTwoLineListItem();
noin.setImage(new Image("/assets/img/noin.png", 32, 32, false, true));
noin.setTitle(i18n("about.thanks_to.noin"));
noin.setSubtitle(i18n("about.thanks_to.noin.statement"));
noin.setExternalLink("https://mcer.cn/cato");
IconedTwoLineListItem contributors = new IconedTwoLineListItem();
contributors.setImage(new Image("/assets/img/github.png", 32, 32, false, true));
contributors.setTitle(i18n("about.thanks_to.contributors"));
contributors.setSubtitle(i18n("about.thanks_to.contributors.statement"));
contributors.setExternalLink("https://github.com/huanghongxun/HMCL/graphs/contributors");
thanks.getContent().setAll(yushijinhun, bangbang93, glavo, mcbbs, mcmod, noin, gamerteam, redLnn, contributors);
thanks.getContent().setAll(yushijinhun, bangbang93, glavo, mcbbs, mcmod, gamerteam, redLnn, contributors);
}
ComponentList community = new ComponentList();
@@ -134,17 +128,17 @@ public class AboutPage extends StackPane {
{
IconedTwoLineListItem javafx = new IconedTwoLineListItem();
javafx.setTitle("JavaFX");
javafx.setSubtitle("Copyright (c) 2013, 2021, Oracle and/or its affiliates.\nLicensed under the GPL 2 with Classpath Exception.");
javafx.setSubtitle("Copyright © 2013, 2021, Oracle and/or its affiliates.\nLicensed under the GPL 2 with Classpath Exception.");
javafx.setExternalLink("https://openjfx.io/");
IconedTwoLineListItem jfoenix = new IconedTwoLineListItem();
jfoenix.setTitle("JFoenix");
jfoenix.setSubtitle("Copyright (c) 2016 JFoenix.\nLicensed under the MIT License.");
jfoenix.setSubtitle("Copyright © 2016 JFoenix.\nLicensed under the MIT License.");
jfoenix.setExternalLink("http://www.jfoenix.com/");
IconedTwoLineListItem gson = new IconedTwoLineListItem();
gson.setTitle("Gson");
gson.setSubtitle("Copyright 2008 Google Inc.\nLicensed under the Apache 2.0 License.");
gson.setSubtitle("Copyright © 2008 Google Inc.\nLicensed under the Apache 2.0 License.");
gson.setExternalLink("https://github.com/google/gson");
IconedTwoLineListItem xz = new IconedTwoLineListItem();
@@ -154,27 +148,27 @@ public class AboutPage extends StackPane {
IconedTwoLineListItem fxgson = new IconedTwoLineListItem();
fxgson.setTitle("fx-gson");
fxgson.setSubtitle("Copyright (c) 2016 Joffrey Bion.\nLicensed under the MIT License.");
fxgson.setSubtitle("Copyright © 2016 Joffrey Bion.\nLicensed under the MIT License.");
fxgson.setExternalLink("https://github.com/joffrey-bion/fx-gson");
IconedTwoLineListItem constantPoolScanner = new IconedTwoLineListItem();
constantPoolScanner.setTitle("Constant Pool Scanner");
constantPoolScanner.setSubtitle("Copyright 1997-2010 Oracle and/or its affiliates.\nLicensed under the GPL 2 or the CDDL.");
constantPoolScanner.setSubtitle("Copyright © 1997-2010 Oracle and/or its affiliates.\nLicensed under the GPL 2 or the CDDL.");
constantPoolScanner.setExternalLink("https://github.com/jenkinsci/constant-pool-scanner");
IconedTwoLineListItem openNBT = new IconedTwoLineListItem();
openNBT.setTitle("OpenNBT");
openNBT.setSubtitle("Copyright (C) 2013-2021 Steveice10.\nLicensed under the MIT License.");
openNBT.setSubtitle("Copyright © 2013-2021 Steveice10.\nLicensed under the MIT License.");
openNBT.setExternalLink("https://github.com/Steveice10/OpenNBT");
IconedTwoLineListItem jstun = new IconedTwoLineListItem();
jstun.setTitle("JSTUN");
jstun.setSubtitle("Copyright (c) 2005 Thomas King <king@t-king.de>.\nLicensed under the Apache License 2.0.");
jstun.setSubtitle("Copyright © 2005 Thomas King <king@t-king.de>.\nLicensed under the Apache License 2.0.");
jstun.setExternalLink("https://github.com/tking/JSTUN");
IconedTwoLineListItem minecraftJFXSkin = new IconedTwoLineListItem();
minecraftJFXSkin.setTitle("minecraft-jfx-skin");
minecraftJFXSkin.setSubtitle("Copyright (c) 2016 InfinityStudio.\nLicensed under the GPL 3.");
minecraftJFXSkin.setSubtitle("Copyright © 2016 InfinityStudio.\nLicensed under the GPL 3.");
minecraftJFXSkin.setExternalLink("https://github.com/InfinityStudio/minecraft-jfx-skin");
dep.getContent().setAll(javafx, jfoenix, gson, xz, fxgson, constantPoolScanner, openNBT, jstun, minecraftJFXSkin);
@@ -53,10 +53,9 @@ public class DownloadSettingsPage extends StackPane {
getChildren().setAll(scrollPane);
{
ComponentList downloadSource = new ComponentList();
VBox downloadSource = new VBox(8);
downloadSource.getStyleClass().add("card-non-transparent");
{
VBox pane = new VBox(8);
VBox chooseWrapper = new VBox();
chooseWrapper.setPadding(new Insets(8, 0, 8, 0));
@@ -75,7 +74,7 @@ public class DownloadSettingsPage extends StackPane {
JFXComboBox<String> cboVersionListSource = new JFXComboBox<>();
cboVersionListSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
versionListSourcePane.setRight(cboVersionListSource);
FXUtils.setLimitWidth(cboVersionListSource, 420);
FXUtils.setLimitWidth(cboVersionListSource, 400);
cboVersionListSource.getItems().setAll(DownloadProviders.providersById.keySet());
selectedItemPropertyFor(cboVersionListSource).bindBidirectional(config().versionListSourceProperty());
@@ -91,31 +90,28 @@ public class DownloadSettingsPage extends StackPane {
JFXComboBox<String> cboDownloadSource = new JFXComboBox<>();
cboDownloadSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
downloadSourcePane.setRight(cboDownloadSource);
downloadSourcePane.setCenter(cboDownloadSource);
FXUtils.setLimitWidth(cboDownloadSource, 420);
cboDownloadSource.getItems().setAll(DownloadProviders.rawProviders.keySet());
selectedItemPropertyFor(cboDownloadSource).bindBidirectional(config().downloadTypeProperty());
}
pane.getChildren().setAll(chooseWrapper, versionListSourcePane, downloadSourcePane);
downloadSource.getContent().add(pane);
downloadSource.getChildren().setAll(chooseWrapper, versionListSourcePane, downloadSourcePane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.version_list_source")), downloadSource);
}
{
ComponentList downloadThreads = new ComponentList();
VBox downloadThreads = new VBox(16);
downloadThreads.getStyleClass().add("card-non-transparent");
{
VBox pane = new VBox(16);
pane.setPadding(new Insets(8, 0, 8, 0));
{
JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto"));
VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0));
chkAutoDownloadThreads.selectedProperty().bindBidirectional(config().autoDownloadThreadsProperty());
pane.getChildren().add(chkAutoDownloadThreads);
downloadThreads.getChildren().add(chkAutoDownloadThreads);
chkAutoDownloadThreads.selectedProperty().addListener((a, b, newValue) -> {
if (newValue) {
@@ -136,7 +132,7 @@ public class DownloadSettingsPage extends StackPane {
JFXTextField threadsField = new JFXTextField();
FXUtils.setLimitWidth(threadsField, 60);
threadsField.textProperty().bindBidirectional(config().downloadThreadsProperty(), SafeStringConverter.fromInteger());
FXUtils.bindInt(threadsField, config().downloadThreadsProperty());
AtomicBoolean changedByTextField = new AtomicBoolean(false);
FXUtils.onChangeAndOperate(config().downloadThreadsProperty(), value -> {
@@ -150,7 +146,7 @@ public class DownloadSettingsPage extends StackPane {
});
hbox.getChildren().setAll(label, slider, threadsField);
pane.getChildren().add(hbox);
downloadThreads.getChildren().add(hbox);
}
{
@@ -158,26 +154,22 @@ public class DownloadSettingsPage extends StackPane {
VBox.setMargin(hintPane, new Insets(0, 0, 0, 30));
hintPane.disableProperty().bind(config().autoDownloadThreadsProperty());
hintPane.setText(i18n("settings.launcher.download.threads.hint"));
pane.getChildren().add(hintPane);
downloadThreads.getChildren().add(hintPane);
}
downloadThreads.getContent().add(pane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadThreads);
}
{
ComponentList proxyList = new ComponentList();
VBox proxyWrapper = new VBox();
proxyWrapper.setPadding(new Insets(8, 0, 8, 0));
proxyWrapper.setSpacing(10);
VBox proxyList = new VBox(10);
proxyList.getStyleClass().add("card-non-transparent");
VBox proxyPane = new VBox();
{
JFXCheckBox chkDisableProxy = new JFXCheckBox(i18n("settings.launcher.proxy.disable"));
proxyWrapper.getChildren().add(chkDisableProxy);
VBox.setMargin(chkDisableProxy, new Insets(8, 0, 0, 0));
proxyList.getChildren().add(chkDisableProxy);
reversedSelectedPropertyFor(chkDisableProxy).bindBidirectional(config().hasProxyProperty());
proxyPane.disableProperty().bind(chkDisableProxy.selectedProperty());
}
@@ -221,7 +213,7 @@ public class DownloadSettingsPage extends StackPane {
GridPane.setRowIndex(txtProxyHost, 1);
GridPane.setColumnIndex(txtProxyHost, 1);
gridPane.getChildren().add(txtProxyHost);
txtProxyHost.textProperty().bindBidirectional(config().proxyHostProperty());
FXUtils.bindString(txtProxyHost, config().proxyHostProperty());
txtProxyHost.getValidators().setAll(new NumberValidator(i18n("input.number"), false));
}
@@ -240,11 +232,10 @@ public class DownloadSettingsPage extends StackPane {
FXUtils.setValidateWhileTextChanged(txtProxyPort, true);
gridPane.getChildren().add(txtProxyPort);
txtProxyPort.textProperty().bindBidirectional(config().proxyPortProperty(),
SafeStringConverter.fromInteger()
.restrict(it -> it >= 0 && it <= 0xFFFF)
.fallbackTo(0)
.asPredicate(Validator.addTo(txtProxyPort)));
FXUtils.bind(txtProxyPort, config().proxyPortProperty(), SafeStringConverter.fromInteger()
.restrict(it -> it >= 0 && it <= 0xFFFF)
.fallbackTo(0)
.asPredicate(Validator.addTo(txtProxyPort)));
}
proxyPane.getChildren().add(gridPane);
}
@@ -281,7 +272,7 @@ public class DownloadSettingsPage extends StackPane {
GridPane.setRowIndex(txtProxyUsername, 0);
GridPane.setColumnIndex(txtProxyUsername, 1);
authPane.getChildren().add(txtProxyUsername);
txtProxyUsername.textProperty().bindBidirectional(config().proxyUserProperty());
FXUtils.bindString(txtProxyUsername, config().proxyUserProperty());
}
{
@@ -310,9 +301,8 @@ public class DownloadSettingsPage extends StackPane {
proxyPane.getChildren().add(authPane);
}
proxyWrapper.getChildren().add(proxyPane);
proxyList.getChildren().add(proxyPane);
}
proxyList.getContent().add(proxyWrapper);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.proxy")), proxyList);
}
@@ -30,6 +30,7 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.setting.Accounts;
@@ -117,7 +118,8 @@ public class FeedbackPage extends VBox implements PageAware {
JFXListView<FeedbackResponse> listView = new JFXListView<>();
spinnerPane.setContent(listView);
Bindings.bindContent(listView.getItems(), feedbacks);
listView.setCellFactory(x -> new MDListCell<FeedbackResponse>(listView) {
MutableObject<Object> lastCell = new MutableObject<>();
listView.setCellFactory(x -> new MDListCell<FeedbackResponse>(listView, lastCell) {
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton likeButton = new JFXButton();
private final JFXButton unlikeButton = new JFXButton();
@@ -230,7 +232,7 @@ public class FeedbackPage extends VBox implements PageAware {
Controllers.dialog(new AddFeedbackDialog());
}
private class LoginDialog extends JFXDialogLayout {
private static final class LoginDialog extends JFXDialogLayout {
private final SpinnerPane spinnerPane = new SpinnerPane();
private final Label errorLabel = new Label();
private final BooleanProperty logging = new SimpleBooleanProperty();
@@ -241,10 +243,7 @@ public class FeedbackPage extends VBox implements PageAware {
VBox vbox = new VBox(8);
setBody(vbox);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.textProperty().bind(BindingMapping.of(logging).map(logging ->
logging
? i18n("account.hmcl.hint")
: i18n("account.hmcl.hint")));
hintPane.textProperty().bind(BindingMapping.of(logging).map(logging -> i18n("account.hmcl.hint")));
hintPane.setOnMouseClicked(e -> {
if (logging.get() && OAuthServer.lastlyOpenedURL != null) {
FXUtils.copyText(OAuthServer.lastlyOpenedURL);
@@ -63,7 +63,7 @@ public class HelpPage extends SpinnerPane {
private void loadHelp() {
showSpinner();
Task.<List<HelpCategory>>supplyAsync(() -> HttpRequest.GET("https://hmcl.huangyuhui.net/api/help/").getJson(new TypeToken<List<HelpCategory>>() {
Task.<List<HelpCategory>>supplyAsync(() -> HttpRequest.GET("https://docs.hmcl.net/index.json").getJson(new TypeToken<List<HelpCategory>>() {
}.getType()))
.thenAcceptAsync(Schedulers.javafx(), helpCategories -> {
for (HelpCategory category : helpCategories) {
@@ -57,7 +57,10 @@ import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jackhuang.hmcl.util.platform.JavaVersion;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
@@ -89,7 +92,16 @@ public final class MainPage extends StackPane implements DecoratorPage {
titleNode.setAlignment(Pos.CENTER_LEFT);
ImageView titleIcon = new ImageView();
titleIcon.setImage(new Image("/assets/img/icon.png", 20, 20, false, false));
if (JavaVersion.CURRENT_JAVA.getParsedVersion() < 9) {
// JavaFX 8 has some problems with @2x images
// Force load the original icon
try (InputStream is = MainPage.class.getResourceAsStream("/assets/img/icon.png")) {
titleIcon.setImage(new Image(is, 20, 20, false, false));
} catch (IOException ignored) {
}
} else {
titleIcon.setImage(new Image("/assets/img/icon.png", 20, 20, false, false));
}
Label titleLabel = new Label(Metadata.FULL_TITLE);
titleLabel.getStyleClass().add("jfx-decorator-title");
@@ -146,12 +146,10 @@ public class PersonalizationPage extends StackPane {
JFXTextField txtLogFontSize = new JFXTextField();
FXUtils.setLimitWidth(txtLogFontSize, 50);
txtLogFontSize.textProperty().bindBidirectional(config().fontSizeProperty(),
SafeStringConverter.fromFiniteDouble()
.restrict(it -> it > 0)
.fallbackTo(12.0)
.asPredicate(Validator.addTo(txtLogFontSize)));
FXUtils.bind(txtLogFontSize, config().fontSizeProperty(), SafeStringConverter.fromFiniteDouble()
.restrict(it -> it > 0)
.fallbackTo(12.0)
.asPredicate(Validator.addTo(txtLogFontSize)));
hBox.getChildren().setAll(cboLogFont, txtLogFontSize);
@@ -34,6 +34,8 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
@@ -153,7 +155,13 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
multiplayerItem.setLeftGraphic(wrap(SVG::lan));
multiplayerItem.setActionButtonVisible(false);
multiplayerItem.setTitle(i18n("multiplayer"));
multiplayerItem.setOnAction(e -> Controllers.navigate(Controllers.getMultiplayerPage()));
JFXHyperlink link = new JFXHyperlink(i18n("multiplayer.hint.details"));
link.setOnAction(e -> FXUtils.openLink("https://hmcl.huangyuhui.net/api/redirect/multiplayer-migrate"));
multiplayerItem.setOnAction(e -> Controllers.dialog(
new MessageDialogPane.Builder(i18n("multiplayer.hint"), null, MessageDialogPane.MessageType.INFO)
.addAction(link)
.ok(null)
.build()));
// sixth item in left sidebar
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
@@ -35,7 +35,6 @@ import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.awt.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
@@ -139,12 +138,7 @@ public final class SettingsPage extends SettingsView {
}
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", logFile)));
if (Desktop.isDesktopSupported()) {
try {
Desktop.getDesktop().open(logFile.toFile());
} catch (IOException ignored) {
}
}
FXUtils.showFileInExplorer(logFile);
});
}
@@ -27,6 +27,7 @@ import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.text.TextAlignment;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -84,10 +85,17 @@ public class SponsorPage extends StackPane {
StackPane pane = new StackPane();
pane.getStyleClass().add("card");
listView = new JFXListView<>();
MutableObject<Object> lastCell = new MutableObject<>();
listView.setCellFactory((listView) -> new JFXListCell<Sponsor>() {
@Override
public void updateItem(Sponsor item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell.getValue() && !isVisible())
return;
lastCell.setValue(this);
if (!empty) {
setText(item.getName());
setGraphic(null);
@@ -1,153 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FutureCallback;
import java.util.Objects;
import java.util.Optional;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAware {
private final FutureCallback<CreationRequest> callback;
private final LocalServerDetector lanServerDetectorThread;
private final BooleanProperty allowAllJoinRequests = new SimpleBooleanProperty(true);
private LocalServerDetector.PingResponse server;
CreateMultiplayerRoomDialog(FutureCallback<CreationRequest> callback) {
this.callback = callback;
setTitle(i18n("multiplayer.session.create"));
GridPane body = new GridPane();
body.setMaxWidth(500);
body.getColumnConstraints().addAll(new ColumnConstraints(), FXUtils.getColumnHgrowing());
body.setVgap(8);
body.setHgap(16);
body.setDisable(true);
setBody(body);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("multiplayer.session.create.hint"));
GridPane.setColumnSpan(hintPane, 2);
body.addRow(0, hintPane);
Label nameField = new Label();
nameField.setText(Optional.ofNullable(Accounts.getSelectedAccount())
.map(Account::getUsername)
.map(username -> i18n("multiplayer.session.name.format", username))
.orElse(""));
body.addRow(1, new Label(i18n("multiplayer.session.create.name")), nameField);
Label portLabel = new Label(i18n("multiplayer.nat.testing"));
portLabel.setText(i18n("multiplayer.nat.testing"));
body.addRow(2, new Label(i18n("multiplayer.session.create.port")), portLabel);
JFXCheckBox allowAllJoinRequestsCheckBox = new JFXCheckBox(i18n("multiplayer.session.create.join.allow"));
allowAllJoinRequestsCheckBox.selectedProperty().bindBidirectional(allowAllJoinRequests);
GridPane.setColumnSpan(allowAllJoinRequestsCheckBox, 2);
body.addRow(3, allowAllJoinRequestsCheckBox);
setValid(false);
JFXHyperlink noinLink = new JFXHyperlink();
noinLink.setText("mcer.cn");
noinLink.setOnAction(e -> FXUtils.openLink("https://mcer.cn/circle/cato"));
setActions(warningLabel, noinLink, acceptPane, cancelButton);
lanServerDetectorThread = new LocalServerDetector(3);
lanServerDetectorThread.onDetectedLanServer().register(event -> {
runInFX(() -> {
if (event.getLanServer() != null && event.getLanServer().isValid()) {
nameField.setText(event.getLanServer().getMotd());
portLabel.setText(event.getLanServer().getAd().toString());
setValid(true);
} else {
nameField.setText("");
portLabel.setText("");
onFailure(i18n("multiplayer.session.create.port.error"));
setValid(false);
}
server = event.getLanServer();
body.setDisable(false);
getProgressBar().setVisible(false);
});
});
}
@Override
protected void onAccept() {
setLoading();
callback.call(new CreationRequest(
Objects.requireNonNull(server),
allowAllJoinRequests.get()
), () -> {
runInFX(this::onSuccess);
}, msg -> {
runInFX(() -> onFailure(msg));
});
}
@Override
public void onDialogShown() {
getProgressBar().setVisible(true);
getProgressBar().setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
lanServerDetectorThread.start();
}
@Override
public void onDialogClosed() {
lanServerDetectorThread.interrupt();
}
public static class CreationRequest {
private final LocalServerDetector.PingResponse server;
private final boolean allowAllJoinRequests;
public CreationRequest(LocalServerDetector.PingResponse server, boolean allowAllJoinRequests) {
this.server = server;
this.allowAllJoinRequests = allowAllJoinRequests;
}
public LocalServerDetector.PingResponse getServer() {
return server;
}
public boolean isAllowAllJoinRequests() {
return allowAllJoinRequests;
}
}
}
@@ -1,74 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class LocalServerBroadcaster implements Runnable {
private final int port;
private final MultiplayerManager.CatoSession session;
public LocalServerBroadcaster(int port, MultiplayerManager.CatoSession session) {
this.port = port;
this.session = session;
}
public int getPort() {
return port;
}
@Override
public void run() {
DatagramSocket socket;
InetAddress broadcastAddress;
try {
socket = new DatagramSocket();
broadcastAddress = InetAddress.getByName("224.0.2.60");
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to create datagram socket", e);
return;
}
while (session.isRunning()) {
try {
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd", session.getName()), port).getBytes(StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(data, 0, data.length, broadcastAddress, 4445);
socket.send(packet);
LOG.finest("Broadcast server 0.0.0.0:" + port);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send motd packet", e);
}
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
return;
}
}
socket.close();
}
}
@@ -1,141 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import static org.jackhuang.hmcl.util.Logging.LOG;
public class LocalServerDetector extends Thread {
private final EventManager<DetectedLanServerEvent> onDetectedLanServer = new EventManager<>();
private final int retry;
public LocalServerDetector(int retry) {
this.retry = retry;
setName("LocalServerDetector");
setDaemon(true);
}
public EventManager<DetectedLanServerEvent> onDetectedLanServer() {
return onDetectedLanServer;
}
@Override
public void run() {
MulticastSocket socket;
InetAddress broadcastAddress;
try {
socket = new MulticastSocket(4445);
socket.setSoTimeout(5000);
socket.joinGroup(broadcastAddress = InetAddress.getByName("224.0.2.60"));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to create datagram socket", e);
return;
}
byte[] buf = new byte[1024];
int tried = 0;
while (!isInterrupted()) {
DatagramPacket packet = new DatagramPacket(buf, 1024);
try {
socket.receive(packet);
} catch (SocketTimeoutException e) {
if (tried++ > retry) {
onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, null));
break;
}
continue;
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to detect lan server", e);
break;
}
String response = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
LOG.fine("Local server " + packet.getAddress() + ":" + packet.getPort() + " broadcast message: " + response);
onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, PingResponse.parsePingResponse(response)));
break;
}
try {
socket.leaveGroup(broadcastAddress);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to leave multicast listening group", e);
}
socket.close();
}
public static class DetectedLanServerEvent extends Event {
private final PingResponse lanServer;
public DetectedLanServerEvent(Object source, PingResponse lanServer) {
super(source);
this.lanServer = lanServer;
}
public PingResponse getLanServer() {
return lanServer;
}
}
public static class PingResponse {
private final String motd;
private final Integer ad;
public PingResponse(String motd, Integer ad) {
this.motd = motd;
this.ad = ad;
}
public String getMotd() {
return motd;
}
public Integer getAd() {
return ad;
}
public boolean isValid() {
return ad != null;
}
public static PingResponse parsePingResponse(String message) {
return new PingResponse(
StringUtils.substringBefore(
StringUtils.substringAfter(message, "[MOTD]"),
"[/MOTD]"),
Lang.toIntOrNull(StringUtils.substringBefore(
StringUtils.substringAfter(message, "[AD]"),
"[/AD]"))
);
}
}
}
@@ -1,154 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
public final class MultiplayerChannel {
private MultiplayerChannel() {
}
@JsonType(
property = "type",
subtypes = {
@JsonSubtype(clazz = HandshakeRequest.class, name = "handshake"),
@JsonSubtype(clazz = JoinRequest.class, name = "join"),
@JsonSubtype(clazz = KeepAliveRequest.class, name = "keepalive")
}
)
public static class Request {
}
public static class HandshakeRequest extends Request {
}
public static class JoinRequest extends Request {
private final String clientVersion;
private final String username;
public JoinRequest(String clientVersion, String username) {
this.clientVersion = clientVersion;
this.username = username;
}
public String getClientVersion() {
return clientVersion;
}
public String getUsername() {
return username;
}
}
public static class KeepAliveRequest extends Request {
private final long timestamp;
public KeepAliveRequest(long timestamp) {
this.timestamp = timestamp;
}
public long getTimestamp() {
return timestamp;
}
}
@JsonType(
property = "type",
subtypes = {
@JsonSubtype(clazz = HandshakeResponse.class, name = "handshake"),
@JsonSubtype(clazz = JoinResponse.class, name = "join"),
@JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive"),
@JsonSubtype(clazz = KickResponse.class, name = "kick")
}
)
public static class Response {
}
public static class HandshakeResponse extends Response {
}
public static class JoinResponse extends Response {
private final String sessionName;
private final int port;
public JoinResponse(String sessionName, int port) {
this.sessionName = sessionName;
this.port = port;
}
public String getSessionName() {
return sessionName;
}
public int getPort() {
return port;
}
}
public static class KeepAliveResponse extends Response {
private final long timestamp;
public KeepAliveResponse(long timestamp) {
this.timestamp = timestamp;
}
public long getTimestamp() {
return timestamp;
}
}
public static class KickResponse extends Response {
private final String msg;
public KickResponse(String msg) {
this.msg = msg;
}
public String getMsg() {
return msg;
}
public static final String VERSION_NOT_MATCHED = "version_not_matched";
public static final String KICKED = "kicked";
public static final String JOIN_ACEEPTANCE_TIMEOUT = "join_acceptance_timeout";
}
public static class CatoClient extends Event {
private final String username;
public CatoClient(Object source, String username) {
super(source);
this.username = username;
}
public String getUsername() {
return username;
}
}
public static String verifyJson(String jsonString) {
if (jsonString.indexOf('\r') >= 0 || jsonString.indexOf('\n') >= 0) {
throw new IllegalArgumentException();
}
return jsonString;
}
}
@@ -1,227 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.*;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.TimerTask;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
import static org.jackhuang.hmcl.util.Logging.LOG;
public class MultiplayerClient extends Thread {
private final String id;
private final int port;
private int gamePort;
private boolean connected = false;
private final EventManager<ConnectedEvent> onConnected = new EventManager<>();
private final EventManager<Event> onDisconnected = new EventManager<>();
private final EventManager<KickEvent> onKicked = new EventManager<>();
private final EventManager<Event> onHandshake = new EventManager<>();
public MultiplayerClient(String id, int port) {
this.id = id;
this.port = port;
setName("MultiplayerClient");
setDaemon(true);
}
public synchronized void setGamePort(int gamePort) {
this.gamePort = gamePort;
}
public synchronized int getGamePort() {
return gamePort;
}
public EventManager<ConnectedEvent> onConnected() {
return onConnected;
}
public EventManager<Event> onDisconnected() {
return onDisconnected;
}
public EventManager<KickEvent> onKicked() {
return onKicked;
}
public EventManager<Event> onHandshake() {
return onHandshake;
}
@Override
public void run() {
LOG.info("Connecting to 127.0.0.1:" + port);
for (int i = 0; i < 5; i++) {
KeepAliveThread keepAliveThread = null;
try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port);
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {
MultiplayerServer.Endpoint endpoint = new MultiplayerServer.Endpoint(socket, writer);
socket.setSoTimeout(30000);
LOG.info("Connected to 127.0.0.1:" + port);
endpoint.write(new HandshakeRequest());
endpoint.write(new JoinRequest(MultiplayerManager.CATO_VERSION, id));
LOG.fine("Sent join request with id=" + id);
keepAliveThread = new KeepAliveThread(endpoint);
keepAliveThread.start();
TimerTask task = Lang.setTimeout(() -> {
// If after 15 seconds, we didn't receive the HandshakeResponse,
// We fail to establish the connection with server.
try {
LOG.log(Level.WARNING, "Socket connection timeout, closing socket");
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to close socket", e);
}
}, 25 * 1000);
String line;
while ((line = reader.readLine()) != null) {
if (isInterrupted()) {
return;
}
LOG.fine("Message from server:" + line);
Response response = JsonUtils.fromNonNullJson(line, Response.class);
if (response instanceof JoinResponse) {
JoinResponse joinResponse = JsonUtils.fromNonNullJson(line, JoinResponse.class);
setGamePort(joinResponse.getPort());
connected = true;
onConnected.fireEvent(new ConnectedEvent(this, joinResponse.getSessionName(), joinResponse.getPort()));
LOG.fine("Received join response with port " + joinResponse.getPort());
} else if (response instanceof KickResponse) {
LOG.fine("Kicked by the server");
onKicked.fireEvent(new KickEvent(this, ((KickResponse) response).getMsg()));
return;
} else if (response instanceof KeepAliveResponse) {
} else if (response instanceof HandshakeResponse) {
LOG.fine("Established connection with server");
onHandshake.fireEvent(new Event(this));
task.cancel();
} else {
LOG.log(Level.WARNING, "Unrecognized packet from server:" + line);
}
}
} catch (ConnectException e) {
LOG.info("Failed to connect to 127.0.0.1:" + port + ", tried " + i + " time(s)");
try {
Thread.sleep(2000);
} catch (InterruptedException ex) {
LOG.warning("MultiplayerClient interrupted");
return;
}
continue;
} catch (IOException | JsonParseException e) {
e.printStackTrace();
} finally {
if (keepAliveThread != null) {
keepAliveThread.interrupt();
}
}
}
LOG.info("Lost connection to 127.0.0.1:" + port);
onDisconnected.fireEvent(new Event(this));
}
public boolean isConnected() {
return connected;
}
private static class KeepAliveThread extends Thread {
private final MultiplayerServer.Endpoint endpoint;
public KeepAliveThread(MultiplayerServer.Endpoint endpoint) {
this.endpoint = endpoint;
}
@Override
public void run() {
while (!isInterrupted()) {
try {
endpoint.write(new KeepAliveRequest(System.currentTimeMillis()));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send keep alive packet", e);
break;
}
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
break;
}
}
}
}
public static class ConnectedEvent extends Event {
private final String sessionName;
private final int port;
public ConnectedEvent(Object source, String sessionName, int port) {
super(source);
this.sessionName = sessionName;
this.port = port;
}
public String getSessionName() {
return sessionName;
}
public int getPort() {
return port;
}
}
public static class KickEvent extends Event {
private final String reason;
public KickEvent(Object source, String reason) {
super(source);
this.reason = reason;
}
public String getReason() {
return reason;
}
}
}
@@ -1,630 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.application.Platform;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.Lang.wrap;
import static org.jackhuang.hmcl.util.Logging.LOG;
/**
* Cato Management.
*/
public final class MultiplayerManager {
static final String CATO_VERSION = "1.2.2";
private static final String CATO_DOWNLOAD_URL = "https://gitcode.net/to/cato/-/raw/master/client/";
private static final String CATO_HASH_URL = CATO_DOWNLOAD_URL + "cato-all-files.sha1";
private static final String CATO_PATH = getCatoPath();
public static final int CATO_AGREEMENT_VERSION = 2;
private static final String REMOTE_ADDRESS = "127.0.0.1";
private static final String LOCAL_ADDRESS = "0.0.0.0";
private static CompletableFuture<Map<String, String>> HASH;
private MultiplayerManager() {
}
private static CompletableFuture<Map<String, String>> getCatoHash() {
FXUtils.checkFxUserThread();
if (HASH == null) {
HASH = CompletableFuture.supplyAsync(wrap(() -> {
String hashList = HttpRequest.GET(CATO_HASH_URL).getString();
Map<String, String> hashes = new HashMap<>();
for (String line : hashList.split("\n")) {
String[] items = line.trim().split(" ");
if (items.length == 2 && items[0].length() == 40) {
hashes.put(items[1], items[0]);
} else {
LOG.warning("Failed to parse cato hash file, hash line " + line);
}
}
return hashes;
}));
}
return HASH;
}
public static Task<Void> downloadCato() {
return Task.fromCompletableFuture(getCatoHash()).thenComposeAsync(catoHashes ->
new FileDownloadTask(
NetworkUtils.toURL(CATO_DOWNLOAD_URL + getCatoFileName()),
getCatoExecutable().toFile(),
catoHashes.get(getCatoFileName()) == null ? null : new FileDownloadTask.IntegrityCheck("SHA-1", catoHashes.get(getCatoFileName()))
).thenRunAsync(() -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
Set<PosixFilePermission> perm = Files.getPosixFilePermissions(getCatoExecutable());
perm.add(PosixFilePermission.OWNER_EXECUTE);
Files.setPosixFilePermissions(getCatoExecutable(), perm);
}
}));
}
public static Path getCatoExecutable() {
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH);
}
private static CompletableFuture<CatoSession> startCato(String token, State state) {
return getCatoHash().thenApplyAsync(wrap(catoHashes -> {
Path exe = getCatoExecutable();
if (!Files.isRegularFile(exe)) {
throw new CatoNotExistsException(exe);
}
if (!isPortAvailable(3478)) {
throw new CatoAlreadyStartedException();
}
try {
String hash = catoHashes.get(getCatoFileName());
if (hash != null) {
ChecksumMismatchException.verifyChecksum(exe, "SHA-1", hash);
}
} catch (IOException e) {
Files.deleteIfExists(exe);
throw e;
}
String[] commands = StringUtils.isBlank(token)
? new String[]{exe.toString()}
: new String[]{exe.toString(), "-auth.token", token};
Process process = new ProcessBuilder()
.command(commands)
.start();
return new CatoSession(state, process, Arrays.asList(commands));
}));
}
public static CompletableFuture<CatoSession> joinSession(String token, String peer, Mode mode, int remotePort, int localPort, JoinSessionHandler handler) throws IncompatibleCatoVersionException {
LOG.info(String.format("Joining session (token=%s,peer=%s,mode=%s,remotePort=%d,localPort=%d)", token, peer, mode, remotePort, localPort));
return startCato(token, State.SLAVE).thenComposeAsync(wrap(session -> {
CompletableFuture<CatoSession> future = new CompletableFuture<>();
session.forwardPort(peer, LOCAL_ADDRESS, localPort, REMOTE_ADDRESS, remotePort, mode);
Consumer<CatoExitEvent> onExit = event -> {
boolean ready = session.isReady();
switch (event.getExitCode()) {
case 1:
if (!ready) {
future.completeExceptionally(new CatoExitTimeoutException());
}
break;
}
future.completeExceptionally(new CatoExitException(event.getExitCode(), ready));
};
session.onExit.register(onExit);
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
future.completeExceptionally(new PeerConnectionTimeoutException());
session.stop();
}, 15 * 1000);
session.onPeerConnected.register(event -> {
peerConnectionTimeoutTask.cancel();
MultiplayerClient client = new MultiplayerClient(session.getId(), localPort);
session.addRelatedThread(client);
session.setClient(client);
TimerTask task = Lang.setTimeout(() -> {
Platform.runLater(() -> future.completeExceptionally(new JoinRequestTimeoutException()));
session.stop();
}, 30 * 1000);
client.onConnected().register(connectedEvent -> {
try {
int port = findAvailablePort();
session.forwardPort(peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), mode);
session.addRelatedThread(Lang.thread(new LocalServerBroadcaster(port, session), "LocalServerBroadcaster", true));
session.setName(connectedEvent.getSessionName());
client.setGamePort(port);
Platform.runLater(() -> {
session.onExit.unregister(onExit);
future.complete(session);
});
} catch (IOException e) {
session.stop();
Platform.runLater(() -> future.completeExceptionally(e));
}
task.cancel();
});
client.onKicked().register(kickedEvent -> {
session.stop();
task.cancel();
Platform.runLater(() -> {
future.completeExceptionally(new KickedException(kickedEvent.getReason()));
});
});
client.onDisconnected().register(disconnectedEvent -> {
Platform.runLater(() -> {
if (!client.isConnected()) {
// We fail to establish connection with server
future.completeExceptionally(new ConnectionErrorException());
}
});
});
client.onHandshake().register(handshakeEvent -> {
if (handler != null) {
handler.onWaitingForJoinResponse();
}
});
client.start();
});
return future;
}));
}
public static CompletableFuture<CatoSession> createSession(String token, String sessionName, int gamePort, boolean allowAllJoinRequests) {
LOG.info(String.format("Creating session (token=%s,sessionName=%s,gamePort=%d)", token, sessionName, gamePort));
return startCato(token, State.MASTER).thenComposeAsync(wrap(session -> {
CompletableFuture<CatoSession> future = new CompletableFuture<>();
MultiplayerServer server = new MultiplayerServer(sessionName, gamePort, allowAllJoinRequests);
server.startServer();
session.setName(sessionName);
session.allowForwardingAddress(REMOTE_ADDRESS, server.getPort());
session.allowForwardingAddress(REMOTE_ADDRESS, gamePort);
session.showAllowedAddress();
Consumer<CatoExitEvent> onExit = event -> {
boolean ready = session.isReady();
switch (event.getExitCode()) {
case 1:
if (!ready) {
future.completeExceptionally(new CatoExitTimeoutException());
}
break;
}
future.completeExceptionally(new CatoExitException(event.getExitCode(), ready));
};
session.onExit.register(onExit);
session.setServer(server);
session.addRelatedThread(server);
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
future.completeExceptionally(new PeerConnectionTimeoutException());
session.stop();
}, 15 * 1000);
session.onPeerConnected.register(event -> {
peerConnectionTimeoutTask.cancel();
Platform.runLater(() -> {
session.onExit.unregister(onExit);
future.complete(session);
});
});
return future;
}));
}
public static final Pattern INVITATION_CODE_PATTERN = Pattern.compile("^(?<id>.*?)#(?<port>\\d{2,5})$");
public static Invitation parseInvitationCode(String invitationCode) throws JsonParseException {
Matcher matcher = INVITATION_CODE_PATTERN.matcher(invitationCode);
if (!matcher.find()) throw new IllegalArgumentException("Invalid invitation code");
return new Invitation(matcher.group("id"), Integer.parseInt(matcher.group("port")));
}
public static int findAvailablePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
public static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
private static String getCatoFileName() {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "cato-client-windows-amd64.exe";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
return "cato-client-windows-arm64.exe";
} else if (Architecture.SYSTEM_ARCH == Architecture.X86) {
return "cato-client-windows-i386.exe";
} else {
return "";
}
case OSX:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "cato-client-darwin-amd64";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
return "cato-client-darwin-arm64";
} else {
return "";
}
case LINUX:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "cato-client-linux-amd64";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM32) {
return "cato-client-linux-arm7";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
return "cato-client-linux-arm64";
} else {
return "";
}
default:
return "";
}
}
public static String getCatoPath() {
String name = getCatoFileName();
if (StringUtils.isBlank(name)) return "";
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/" + name;
}
public static class CatoSession extends ManagedProcess {
private final EventManager<CatoExitEvent> onExit = new EventManager<>();
private final EventManager<CatoIdEvent> onIdGenerated = new EventManager<>();
private final EventManager<Event> onPeerConnected = new EventManager<>();
private String name;
private final State type;
private String id;
private boolean peerConnected = false;
private MultiplayerClient client;
private MultiplayerServer server;
private final BufferedWriter writer;
CatoSession(State type, Process process, List<String> commands) {
super(process, commands);
Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
LOG.info("Started cato with command: " + new CommandBuilder().addAll(commands));
this.type = type;
addRelatedThread(Lang.thread(this::waitFor, "CatoExitWaiter", true));
pumpInputStream(this::checkCatoLog);
pumpErrorStream(this::checkCatoLog);
writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
}
public synchronized MultiplayerClient getClient() {
return client;
}
public synchronized CatoSession setClient(MultiplayerClient client) {
this.client = client;
return this;
}
public MultiplayerServer getServer() {
return server;
}
public CatoSession setServer(MultiplayerServer server) {
this.server = server;
return this;
}
private void checkCatoLog(String log) {
LOG.info("Cato: " + log);
if (id == null) {
Matcher matcher = TEMP_TOKEN_PATTERN.matcher(log);
if (matcher.find()) {
id = matcher.group("id");
onIdGenerated.fireEvent(new CatoIdEvent(this, id));
}
}
if (!peerConnected) {
Matcher matcher = PEER_CONNECTED_PATTERN.matcher(log);
if (matcher.find()) {
peerConnected = true;
onPeerConnected.fireEvent(new Event(this));
}
}
}
private void waitFor() {
try {
int exitCode = getProcess().waitFor();
LOG.info("cato exited with exitcode " + exitCode);
onExit.fireEvent(new CatoExitEvent(this, exitCode));
} catch (InterruptedException e) {
onExit.fireEvent(new CatoExitEvent(this, CatoExitEvent.EXIT_CODE_INTERRUPTED));
} finally {
try {
if (writer != null)
writer.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to close cato stdin writer", e);
}
}
destroyRelatedThreads();
}
public boolean isReady() {
return id != null;
}
public synchronized String getName() {
return name;
}
public synchronized void setName(String name) {
this.name = name;
}
public State getType() {
return type;
}
@Nullable
public String getId() {
return id;
}
public String generateInvitationCode(int serverPort) {
if (id == null) {
throw new IllegalStateException("id not generated");
}
return id + "#" + serverPort;
}
public synchronized void invokeCommand(String command) throws IOException {
LOG.info("Invoking cato: " + command);
writer.write(command);
writer.newLine();
writer.flush();
}
public void forwardPort(String peerId, String localAddress, int localPort, String remoteAddress, int remotePort, Mode mode) throws IOException {
invokeCommand(String.format("net add %s %s:%d %s:%d %s", peerId, localAddress, localPort, remoteAddress, remotePort, mode.getName()));
}
public void allowForwardingAddress(String address, int port) throws IOException {
invokeCommand(String.format("ufw net open %s:%d", address, port));
}
public void showAllowedAddress() throws IOException {
invokeCommand("ufw net whitelist");
}
public EventManager<CatoExitEvent> onExit() {
return onExit;
}
public EventManager<CatoIdEvent> onIdGenerated() {
return onIdGenerated;
}
public EventManager<Event> onPeerConnected() {
return onPeerConnected;
}
private static final Pattern TEMP_TOKEN_PATTERN = Pattern.compile("id\\((?<id>\\w+)\\)");
private static final Pattern PEER_CONNECTED_PATTERN = Pattern.compile("Connected to main net");
private static final Pattern LOG_PATTERN = Pattern.compile("(\\[\\d+])\\s+(\\w+)\\s+(\\w+-{0,1}\\w+):\\s(.*)");
}
public static class CatoExitEvent extends Event {
private final int exitCode;
public CatoExitEvent(Object source, int exitCode) {
super(source);
this.exitCode = exitCode;
}
public int getExitCode() {
return exitCode;
}
public static final int EXIT_CODE_INTERRUPTED = -1;
public static final int EXIT_CODE_SESSION_EXPIRED = 10;
}
public static class CatoIdEvent extends Event {
private final String id;
public CatoIdEvent(Object source, String id) {
super(source);
this.id = id;
}
public String getId() {
return id;
}
}
enum State {
DISCONNECTED,
CONNECTING,
MASTER,
SLAVE
}
public static class Invitation {
private final String id;
@SerializedName("p")
private final int channelPort;
public Invitation(String id, int channelPort) {
this.id = id;
this.channelPort = channelPort;
}
public String getId() {
return id;
}
public int getChannelPort() {
return channelPort;
}
}
public interface JoinSessionHandler {
void onWaitingForJoinResponse();
}
public static class IncompatibleCatoVersionException extends Exception {
private final String expected;
private final String actual;
public IncompatibleCatoVersionException(String expected, String actual) {
this.expected = expected;
this.actual = actual;
}
public String getExpected() {
return expected;
}
public String getActual() {
return actual;
}
}
public enum Mode {
P2P,
BRIDGE;
String getName() {
return name().toLowerCase(Locale.ROOT);
}
}
public static class CatoExitException extends RuntimeException {
private final int exitCode;
private final boolean ready;
public CatoExitException(int exitCode, boolean ready) {
this.exitCode = exitCode;
this.ready = ready;
}
public int getExitCode() {
return exitCode;
}
public boolean isReady() {
return ready;
}
}
public static class CatoExitTimeoutException extends RuntimeException {
}
public static class CatoSessionExpiredException extends RuntimeException {
}
public static class CatoAlreadyStartedException extends RuntimeException {
}
public static class JoinRequestTimeoutException extends RuntimeException {
}
public static class PeerConnectionTimeoutException extends RuntimeException {
}
public static class ConnectionErrorException extends RuntimeException {
}
public static class KickedException extends RuntimeException {
private final String reason;
public KickedException(String reason) {
this.reason = reason;
}
public String getReason() {
return reason;
}
}
public static class CatoNotExistsException extends RuntimeException {
private final Path file;
public CatoNotExistsException(Path file) {
this.file = file;
}
public Path getFile() {
return file;
}
}
}
@@ -1,517 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import de.javawi.jstun.test.DiscoveryInfo;
import de.javawi.jstun.test.DiscoveryTest;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.HMCLService;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Level;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.KickResponse.*;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("multiplayer")));
private final ObjectProperty<MultiplayerManager.State> multiplayerState = new SimpleObjectProperty<>(MultiplayerManager.State.DISCONNECTED);
private final ReadOnlyStringWrapper token = new ReadOnlyStringWrapper();
private final ReadOnlyObjectWrapper<@Nullable Result<DiscoveryInfo>> natState = new ReadOnlyObjectWrapper<>();
private final ReadOnlyIntegerWrapper gamePort = new ReadOnlyIntegerWrapper(-1);
private final ReadOnlyObjectWrapper<MultiplayerManager.CatoSession> session = new ReadOnlyObjectWrapper<>();
private final ObservableList<MultiplayerChannel.CatoClient> clients = FXCollections.observableArrayList();
private Consumer<MultiplayerManager.CatoExitEvent> onExit;
private Consumer<MultiplayerManager.CatoIdEvent> onIdGenerated;
private Consumer<Event> onPeerConnected;
public MultiplayerPage() {
testNAT();
}
@Override
public void onPageShown() {
checkAgreement(this::downloadCatoIfNecessary);
}
@Override
protected Skin<?> createDefaultSkin() {
return new MultiplayerPageSkin(this);
}
public ObservableList<MultiplayerChannel.CatoClient> getClients() {
return clients;
}
public MultiplayerManager.State getMultiplayerState() {
return multiplayerState.get();
}
public ObjectProperty<MultiplayerManager.State> multiplayerStateProperty() {
return multiplayerState;
}
public void setMultiplayerState(MultiplayerManager.State multiplayerState) {
this.multiplayerState.set(multiplayerState);
}
public Result<DiscoveryInfo> getNatState() {
return natState.get();
}
public ReadOnlyObjectProperty<Result<DiscoveryInfo>> natStateProperty() {
return natState.getReadOnlyProperty();
}
public String getToken() {
return token.get();
}
public ReadOnlyStringProperty tokenProperty() {
return token.getReadOnlyProperty();
}
public int getGamePort() {
return gamePort.get();
}
public ReadOnlyIntegerProperty gamePortProperty() {
return gamePort.getReadOnlyProperty();
}
public MultiplayerManager.CatoSession getSession() {
return session.get();
}
public ReadOnlyObjectProperty<MultiplayerManager.CatoSession> sessionProperty() {
return session.getReadOnlyProperty();
}
private void testNAT() {
Task.supplyAsync(() -> {
DiscoveryTest tester = new DiscoveryTest(null, 0, "stun.miwifi.com", 3478);
return tester.test();
}).whenComplete(Schedulers.javafx(), (info, exception) -> {
if (exception == null) {
natState.set(Result.ok(info));
} else {
natState.set(Result.error());
}
LOG.log(Level.INFO, "Nat test result " + MultiplayerPageSkin.getNATType(natState.get()), exception);
}).start();
}
private void checkAgreement(Runnable runnable) {
if (globalConfig().getMultiplayerAgreementVersion() < MultiplayerManager.CATO_AGREEMENT_VERSION) {
JFXDialogLayout agreementPane = new JFXDialogLayout();
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
agreementPane.setBody(new Label(i18n("multiplayer.agreement.prompt")));
JFXHyperlink agreementLink = new JFXHyperlink(i18n("launcher.agreement"));
agreementLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-agreement"));
JFXButton yesButton = new JFXButton(i18n("launcher.agreement.accept"));
yesButton.getStyleClass().add("dialog-accept");
yesButton.setOnAction(e -> {
globalConfig().setMultiplayerAgreementVersion(MultiplayerManager.CATO_AGREEMENT_VERSION);
runnable.run();
agreementPane.fireEvent(new DialogCloseEvent());
});
JFXButton noButton = new JFXButton(i18n("launcher.agreement.decline"));
noButton.getStyleClass().add("dialog-cancel");
noButton.setOnAction(e -> {
agreementPane.fireEvent(new DialogCloseEvent());
fireEvent(new PageCloseEvent());
});
agreementPane.setActions(agreementLink, yesButton, noButton);
Controllers.dialog(agreementPane);
} else {
runnable.run();
}
}
private void downloadCatoIfNecessary() {
if (StringUtils.isBlank(MultiplayerManager.getCatoPath())) {
Controllers.dialog(i18n("multiplayer.download.unsupported"), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
return;
}
if (!MultiplayerManager.getCatoExecutable().toFile().exists()) {
setDisabled(true);
TaskExecutor executor = MultiplayerManager.downloadCato()
.whenComplete(Schedulers.javafx(), exception -> {
setDisabled(false);
if (exception != null) {
if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else {
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
}
} else {
Controllers.showToast(i18n("multiplayer.download.success"));
}
}).executor();
Controllers.taskDialog(executor, i18n("multiplayer.download"), TaskCancellationAction.NORMAL);
executor.start();
} else {
setDisabled(false);
}
}
public void copyInvitationCode() {
if (getSession() == null || !getSession().isReady() || gamePort.get() < 0 || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
FXUtils.copyText(getSession().generateInvitationCode(getSession().getServer().getPort()));
}
public void createRoom() {
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
throw new IllegalStateException("CatoSession already ready");
}
Controllers.dialog(new CreateMultiplayerRoomDialog((result, resolve, reject) -> {
int gamePort = result.getServer().getAd();
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
MultiplayerManager.createSession(globalConfig().getMultiplayerToken(), result.getServer().getMotd(), gamePort, result.isAllowAllJoinRequests())
.thenAcceptAsync(session -> {
session.getServer().setOnClientAdding((client, resolveClient, rejectClient) -> {
runInFX(() -> {
Controllers.dialog(new MessageDialogPane.Builder(i18n("multiplayer.session.create.join.prompt", client.getUsername()), i18n("multiplayer.session.create.join"), MessageDialogPane.MessageType.INFO)
.yesOrNo(resolveClient, () -> rejectClient.accept(MultiplayerChannel.KickResponse.JOIN_ACEEPTANCE_TIMEOUT))
.cancelOnTimeout(30 * 1000)
.build());
});
});
session.getServer().onClientAdded().register(event -> {
runInFX(() -> {
clients.add(event);
});
});
session.getServer().onClientDisconnected().register(event -> {
runInFX(() -> {
clients.remove(event);
});
});
initCatoSession(session);
this.gamePort.set(gamePort);
setMultiplayerState(MultiplayerManager.State.MASTER);
resolve.run();
}, Platform::runLater)
.exceptionally(throwable -> {
reject.accept(localizeCreateErrorMessage(throwable, isStaticToken));
return null;
});
}));
}
public void joinRoom() {
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
throw new IllegalStateException("CatoSession already ready");
}
Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join"), (result, resolve, reject) -> {
PromptDialogPane.Builder.HintQuestion hintQuestion = (PromptDialogPane.Builder.HintQuestion) result.get(0);
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue();
MultiplayerManager.Invitation invitation;
try {
invitation = MultiplayerManager.parseInvitationCode(invitationCode);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to join session", e);
reject.accept(i18n("multiplayer.session.join.invitation_code.error"));
return;
}
int localPort; // invitation channel
try {
localPort = MultiplayerManager.findAvailablePort();
} catch (Exception e) {
reject.accept(i18n("multiplayer.session.join.port.error"));
return;
}
try {
MultiplayerManager.joinSession(
globalConfig().getMultiplayerToken(),
invitation.getId(),
globalConfig().isMultiplayerRelay() && (StringUtils.isNotBlank(globalConfig().getMultiplayerToken()) || StringUtils.isNotBlank(System.getProperty("hmcl.multiplayer.relay")))
? MultiplayerManager.Mode.BRIDGE
: MultiplayerManager.Mode.P2P,
invitation.getChannelPort(),
localPort, new MultiplayerManager.JoinSessionHandler() {
@Override
public void onWaitingForJoinResponse() {
runInFX(() -> {
hintQuestion.setQuestion(i18n("multiplayer.session.join.wait"));
});
}
})
.thenAcceptAsync(session -> {
initCatoSession(session);
AtomicBoolean kicked = new AtomicBoolean();
session.getClient().onDisconnected().register(() -> {
runInFX(() -> {
stopCatoSession();
if (!kicked.get()) {
Controllers.dialog(i18n("multiplayer.session.join.lost_connection"));
}
});
});
session.getClient().onKicked().register(kickedEvent -> {
runInFX(() -> {
kicked.set(true);
Controllers.dialog(i18n("multiplayer.session.join.kicked", localizeKickMessage(kickedEvent.getReason())));
});
});
gamePort.set(session.getClient().getGamePort());
setMultiplayerState(MultiplayerManager.State.SLAVE);
resolve.run();
}, Platform::runLater)
.exceptionally(throwable -> {
reject.accept(localizeJoinErrorMessage(throwable, isStaticToken));
return null;
});
} catch (MultiplayerManager.IncompatibleCatoVersionException e) {
reject.accept(i18n("multiplayer.session.join.invitation_code.version"));
}
})
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("multiplayer.session.join.hint")))
.addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator())));
}
private String localizeKickMessage(String message) {
if (VERSION_NOT_MATCHED.equals(message)) {
return i18n("multiplayer.session.join.kicked.version_not_matched");
} else if (KICKED.equals(message)) {
return i18n("multiplayer.session.join.kicked.kicked");
} else if (JOIN_ACEEPTANCE_TIMEOUT.equals(message)) {
return i18n("multiplayer.session.join.kicked.join_acceptance_timeout");
} else {
return message;
}
}
private String localizeErrorMessage(Throwable t, boolean isStaticToken, Function<Throwable, String> fallback) {
Throwable e = resolveException(t);
if (e instanceof CancellationException) {
LOG.info("Connection rejected by the server");
return i18n("message.cancelled");
} else if (e instanceof MultiplayerManager.KickedException) {
LOG.info("Kicked by server");
return i18n("multiplayer.session.join.kicked", localizeKickMessage(((MultiplayerManager.KickedException) e).getReason()));
} else if (e instanceof MultiplayerManager.CatoAlreadyStartedException) {
LOG.info("Cato already started");
return i18n("multiplayer.session.error.already_started");
} else if (e instanceof MultiplayerManager.CatoNotExistsException) {
LOG.log(Level.WARNING, "Cato not found " + ((MultiplayerManager.CatoNotExistsException) e).getFile(), e);
return i18n("multiplayer.session.error.file_not_found");
} else if (e instanceof MultiplayerManager.JoinRequestTimeoutException) {
LOG.info("Cato already started");
return i18n("multiplayer.session.join.wait_timeout");
} else if (e instanceof MultiplayerManager.ConnectionErrorException) {
LOG.info("Failed to establish connection with server");
return i18n("multiplayer.session.join.error.connection");
} else if (e instanceof MultiplayerManager.CatoExitTimeoutException) {
LOG.info("Cato failed to connect to main net");
if (isStaticToken) {
return i18n("multiplayer.exit.timeout.static_token");
} else {
return i18n("multiplayer.exit.timeout.dynamic_token");
}
} else if (e instanceof MultiplayerManager.CatoExitException) {
LOG.info("Cato exited accidentally");
int exitCode = ((MultiplayerManager.CatoExitException) e).getExitCode();
if (!((MultiplayerManager.CatoExitException) e).isReady()) {
return i18n("multiplayer.exit.before_ready", exitCode);
} else {
return i18n("multiplayer.exit.after_ready", exitCode);
}
} else if (e instanceof ChecksumMismatchException) {
return i18n("exception.artifact_malformed");
} else {
return fallback.apply(e);
}
}
private String localizeCreateErrorMessage(Throwable t, boolean isStaticToken) {
return localizeErrorMessage(t, isStaticToken, e -> {
LOG.log(Level.WARNING, "Failed to create session", e);
if (isStaticToken) {
return i18n("multiplayer.session.create.error.static_token") + e.getLocalizedMessage();
} else {
return i18n("multiplayer.session.create.error.dynamic_token") + e.getLocalizedMessage();
}
});
}
private String localizeJoinErrorMessage(Throwable t, boolean isStaticToken) {
return localizeErrorMessage(t, isStaticToken, e -> {
LOG.log(Level.WARNING, "Failed to join session", e);
return i18n("multiplayer.session.join.error");
});
}
public void kickPlayer(MultiplayerChannel.CatoClient client) {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.create.members.kick.prompt"), i18n("multiplayer.session.create.members.kick"), MessageDialogPane.MessageType.WARNING,
() -> {
getSession().getServer().kickPlayer(client);
}, null);
}
public void closeRoom() {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.close.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
this::stopCatoSession, null);
}
public void quitRoom() {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.SLAVE) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.quit.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
this::stopCatoSession, null);
}
public void cancelRoom() {
if (getSession() == null || getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.CONNECTING) {
throw new IllegalStateException("CatoSession not existing or already ready");
}
stopCatoSession();
}
private void initCatoSession(MultiplayerManager.CatoSession session) {
runInFX(() -> {
onExit = session.onExit().registerWeak(this::onCatoExit);
onIdGenerated = session.onIdGenerated().registerWeak(this::onCatoIdGenerated);
onPeerConnected = session.onPeerConnected().registerWeak(this::onCatoPeerConnected);
this.clients.clear();
this.session.set(session);
});
}
private void stopCatoSession() {
if (getSession() != null) {
getSession().stop();
}
clearCatoSession();
}
private void clearCatoSession() {
this.session.set(null);
this.token.set(null);
this.gamePort.set(-1);
this.multiplayerState.set(MultiplayerManager.State.DISCONNECTED);
}
private void onCatoExit(MultiplayerManager.CatoExitEvent event) {
runInFX(() -> {
boolean ready = ((MultiplayerManager.CatoSession) event.getSource()).isReady();
switch (event.getExitCode()) {
case 0:
break;
case MultiplayerManager.CatoExitEvent.EXIT_CODE_SESSION_EXPIRED:
Controllers.dialog(i18n("multiplayer.session.expired"));
break;
case 1:
if (!ready) {
Controllers.dialog(i18n("multiplayer.exit.timeout"));
}
break;
case -1:
// do nothing
break;
default:
if (!((MultiplayerManager.CatoSession) event.getSource()).isReady()) {
Controllers.dialog(i18n("multiplayer.exit.before_ready", event.getExitCode()));
} else {
Controllers.dialog(i18n("multiplayer.exit.after_ready", event.getExitCode()));
}
break;
}
clearCatoSession();
});
}
private void onCatoPeerConnected(Event event) {
runInFX(() -> {
});
}
private void onCatoIdGenerated(MultiplayerManager.CatoIdEvent event) {
runInFX(() -> {
token.set(event.getId());
setMultiplayerState(((MultiplayerManager.CatoSession) event.getSource()).getType());
});
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
}
@@ -1,341 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPasswordField;
import de.javawi.jstun.test.DiscoveryInfo;
import javafx.beans.binding.Bindings;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.HMCLService;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimatedPageSkin<MultiplayerPage> {
private ObservableList<Node> clients;
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
protected MultiplayerPageSkin(MultiplayerPage control) {
super(control);
{
VBox roomPane = new VBox();
{
AdvancedListItem createRoomItem = new AdvancedListItem();
createRoomItem.setTitle(i18n("multiplayer.session.create"));
createRoomItem.setLeftGraphic(wrap(SVG::plusCircleOutline));
createRoomItem.setActionButtonVisible(false);
createRoomItem.setOnAction(e -> control.createRoom());
AdvancedListItem joinRoomItem = new AdvancedListItem();
joinRoomItem.setTitle(i18n("multiplayer.session.join"));
joinRoomItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
joinRoomItem.setActionButtonVisible(false);
joinRoomItem.setOnAction(e -> control.joinRoom());
AdvancedListItem copyLinkItem = new AdvancedListItem();
copyLinkItem.setTitle(i18n("multiplayer.session.copy_room_code"));
copyLinkItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
copyLinkItem.setActionButtonVisible(false);
copyLinkItem.setOnAction(e -> control.copyInvitationCode());
AdvancedListItem cancelItem = new AdvancedListItem();
cancelItem.setTitle(i18n("button.cancel"));
cancelItem.setLeftGraphic(wrap(SVG::closeCircle));
cancelItem.setActionButtonVisible(false);
cancelItem.setOnAction(e -> control.cancelRoom());
AdvancedListItem quitItem = new AdvancedListItem();
quitItem.setTitle(i18n("multiplayer.session.quit"));
quitItem.setLeftGraphic(wrap(SVG::closeCircle));
quitItem.setActionButtonVisible(false);
quitItem.setOnAction(e -> control.quitRoom());
AdvancedListItem closeRoomItem = new AdvancedListItem();
closeRoomItem.setTitle(i18n("multiplayer.session.close"));
closeRoomItem.setLeftGraphic(wrap(SVG::closeCircle));
closeRoomItem.setActionButtonVisible(false);
closeRoomItem.setOnAction(e -> control.closeRoom());
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
if (state == MultiplayerManager.State.DISCONNECTED) {
roomPane.getChildren().setAll(createRoomItem, joinRoomItem);
} else if (state == MultiplayerManager.State.CONNECTING) {
roomPane.getChildren().setAll(cancelItem);
} else if (state == MultiplayerManager.State.MASTER) {
roomPane.getChildren().setAll(copyLinkItem, closeRoomItem);
} else if (state == MultiplayerManager.State.SLAVE) {
roomPane.getChildren().setAll(quitItem);
}
});
}
AdvancedListBox sideBar = new AdvancedListBox()
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("version.launch"));
item.setLeftGraphic(wrap(SVG::rocketLaunchOutline));
item.setOnAction(e -> {
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
});
})
.startCategory(i18n("multiplayer.session"))
.add(roomPane)
.startCategory(i18n("help"))
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("help"));
item.setLeftGraphic(wrap(SVG::helpCircleOutline));
item.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/multiplayer"));
})
.addNavigationDrawerItem(report -> {
report.setTitle(i18n("feedback"));
report.setLeftGraphic(wrap(SVG::messageAlertOutline));
report.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-feedback"));
});
FXUtils.setLimitWidth(sideBar, 200);
setLeft(sideBar);
}
{
VBox content = new VBox(16);
content.setPadding(new Insets(10));
content.setFillWidth(true);
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
setCenter(scrollPane);
ComponentList roomPane = new ComponentList();
{
TransitionPane transitionPane = new TransitionPane();
roomPane.getContent().setAll(transitionPane);
VBox disconnectedPane = new VBox(8);
{
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("multiplayer.state.disconnected.hint"));
Label label = new Label(i18n("multiplayer.state.disconnected"));
disconnectedPane.getChildren().setAll(hintPane, label);
}
VBox connectingPane = new VBox(8);
{
Label label = new Label(i18n("multiplayer.state.connecting"));
connectingPane.getChildren().setAll(label);
}
VBox masterPane = new VBox(8);
{
HintPane masterHintPane = new HintPane();
masterHintPane.setText(i18n("multiplayer.state.master.hint"));
Label label = new Label(i18n("multiplayer.state.master"));
label.textProperty().bind(Bindings.createStringBinding(() ->
i18n("multiplayer.state.master", control.getSession() == null ? "" : control.getSession().getName(), control.getGamePort()),
control.gamePortProperty(), control.sessionProperty()));
Label membersLabel = new Label(i18n("multiplayer.session.create.members"));
VBox clientsPane = new VBox(8);
clients = MappedObservableList.create(control.getClients(), ClientItem::new);
Bindings.bindContent(clientsPane.getChildren(), clients);
masterPane.getChildren().setAll(masterHintPane, label, membersLabel, clientsPane);
}
BorderPane slavePane = new BorderPane();
{
HintPane slaveHintPane = new HintPane();
slaveHintPane.setText(i18n("multiplayer.state.slave.hint"));
slavePane.setTop(slaveHintPane);
Label label = new Label();
label.textProperty().bind(Bindings.createStringBinding(() ->
i18n("multiplayer.state.slave", control.getSession() == null ? "" : control.getSession().getName(), "0.0.0.0:" + control.getGamePort()),
control.sessionProperty(), control.gamePortProperty()));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
slavePane.setCenter(label);
JFXButton copyButton = new JFXButton(i18n("multiplayer.state.slave.copy"));
copyButton.setOnAction(e -> FXUtils.copyText("0.0.0.0:" + control.getGamePort()));
slavePane.setRight(copyButton);
}
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
if (state == MultiplayerManager.State.DISCONNECTED) {
transitionPane.setContent(disconnectedPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.CONNECTING) {
transitionPane.setContent(connectingPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.MASTER) {
transitionPane.setContent(masterPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.SLAVE) {
transitionPane.setContent(slavePane, ContainerAnimations.NONE.getAnimationProducer());
}
});
}
ComponentList natDetectionPane = new ComponentList();
{
GridPane pane = new GridPane();
ColumnConstraints title = new ColumnConstraints();
ColumnConstraints value = new ColumnConstraints();
pane.getColumnConstraints().setAll(title, value);
value.setFillWidth(true);
value.setHgrow(Priority.ALWAYS);
pane.setHgap(16);
pane.setVgap(8);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("multiplayer.nat.hint"));
GridPane.setColumnSpan(hintPane, 2);
pane.addRow(0, hintPane);
Label natResult = new Label();
natResult.textProperty().bind(BindingMapping.of(getSkinnable().natStateProperty())
.map(MultiplayerPageSkin::getNATType));
pane.addRow(1, new Label(i18n("multiplayer.nat.type")), natResult);
natDetectionPane.getContent().add(pane);
}
ComponentList thanksPane = new ComponentList();
{
GridPane gridPane = new GridPane();
gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing(), new ColumnConstraints());
gridPane.setVgap(8);
gridPane.setHgap(16);
// Token acts like password, we hide it here preventing users from accidentally leaking their token when taking screenshots.
JFXPasswordField tokenField = new JFXPasswordField();
tokenField.textProperty().bindBidirectional(globalConfig().multiplayerTokenProperty());
tokenField.setPromptText(i18n("multiplayer.session.create.token.prompt"));
JFXHyperlink applyLink = new JFXHyperlink(i18n("multiplayer.session.create.token.apply"));
applyLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-static-token"));
gridPane.addRow(0, new Label(i18n("multiplayer.session.create.token")), tokenField, applyLink);
OptionToggleButton relay = new OptionToggleButton();
relay.disableProperty().bind(tokenField.textProperty().isEmpty());
relay.selectedProperty().bindBidirectional(globalConfig().multiplayerRelayProperty());
relay.setTitle(i18n("multiplayer.relay"));
relay.setSubtitle(i18n("multiplayer.relay.hint"));
HBox pane = new HBox();
pane.setAlignment(Pos.CENTER_LEFT);
JFXHyperlink aboutLink = new JFXHyperlink(i18n("about"));
aboutLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-about"));
HBox placeholder = new HBox();
HBox.setHgrow(placeholder, Priority.ALWAYS);
pane.getChildren().setAll(
new Label("Based on Cato"),
aboutLink,
placeholder,
FXUtils.segmentToTextFlow(i18n("multiplayer.powered_by"), Controllers::onHyperlinkAction));
thanksPane.getContent().addAll(gridPane, relay, pane);
}
content.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("multiplayer.session")),
roomPane,
ComponentList.createComponentListTitle(i18n("multiplayer.nat")),
natDetectionPane,
ComponentList.createComponentListTitle(i18n("settings")),
thanksPane
);
}
}
public static String getNATType(@Nullable Result<DiscoveryInfo> info) {
if (info == null) {
return i18n("multiplayer.nat.testing");
} else if (info.isError()) {
return i18n("multiplayer.nat.failed");
} else if (info.get().isBlockedUDP()) {
return i18n("multiplayer.nat.type.blocked_udp");
} else if (info.get().isFullCone()) {
return i18n("multiplayer.nat.type.full_cone");
} else if (info.get().isOpenAccess()) {
return i18n("multiplayer.nat.type.open_access");
} else if (info.get().isPortRestrictedCone()) {
return i18n("multiplayer.nat.type.port_restricted_cone");
} else if (info.get().isRestrictedCone()) {
return i18n("multiplayer.nat.type.restricted_cone");
} else if (info.get().isSymmetric()) {
return i18n("multiplayer.nat.type.symmetric");
} else if (info.get().isSymmetricUDPFirewall()) {
return i18n("multiplayer.nat.type.symmetric_udp_firewall");
} else {
return i18n("multiplayer.nat.type.unknown");
}
}
private class ClientItem extends StackPane {
ClientItem(MultiplayerChannel.CatoClient client) {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(8));
pane.setLeft(new Label(client.getUsername()));
JFXButton kickButton = new JFXButton();
kickButton.setGraphic(SVG.close(Theme.blackFillBinding(), 16, 16));
kickButton.getStyleClass().add("toggle-icon-tiny");
kickButton.setOnAction(e -> getSkinnable().kickPlayer(client));
pane.setRight(kickButton);
RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container);
getStyleClass().add("md-list-cell");
}
}
}
@@ -1,238 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 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.multiplayer;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.FutureCallback;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
import static org.jackhuang.hmcl.util.Logging.LOG;
public class MultiplayerServer extends Thread {
private ServerSocket socket;
private final String sessionName;
private final int gamePort;
private final boolean allowAllJoinRequests;
private FutureCallback<CatoClient> onClientAdding;
private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>();
private final EventManager<MultiplayerChannel.CatoClient> onClientDisconnected = new EventManager<>();
private final EventManager<Event> onKeepAlive = new EventManager<>();
private final EventManager<Event> onHandshake = new EventManager<>();
private final Map<String, Endpoint> clients = new ConcurrentHashMap<>();
private final Map<String, Endpoint> nameClientMap = new ConcurrentHashMap<>();
public MultiplayerServer(String sessionName, int gamePort, boolean allowAllJoinRequests) {
this.sessionName = sessionName;
this.gamePort = gamePort;
this.allowAllJoinRequests = allowAllJoinRequests;
setName("MultiplayerServer");
setDaemon(true);
}
public void setOnClientAdding(FutureCallback<CatoClient> callback) {
onClientAdding = callback;
}
public EventManager<MultiplayerChannel.CatoClient> onClientAdded() {
return onClientAdded;
}
public EventManager<MultiplayerChannel.CatoClient> onClientDisconnected() {
return onClientDisconnected;
}
public EventManager<Event> onKeepAlive() {
return onKeepAlive;
}
public EventManager<Event> onHandshake() {
return onHandshake;
}
public void startServer() throws IOException {
startServer(0);
}
public void startServer(int port) throws IOException {
if (socket != null) {
throw new IllegalStateException("MultiplayerServer already started");
}
socket = new ServerSocket(port);
start();
}
public int getPort() {
if (socket == null) {
throw new IllegalStateException("MultiplayerServer not started");
}
return socket.getLocalPort();
}
@Override
public void run() {
LOG.info("Multiplayer Server listening 127.0.0.1:" + socket.getLocalPort());
try {
while (!isInterrupted()) {
Socket clientSocket = socket.accept();
clientSocket.setSoTimeout(10000);
Lang.thread(() -> handleClient(clientSocket), "MultiplayerServerClientThread", true);
}
} catch (IOException ignored) {
}
}
public void kickPlayer(CatoClient player) {
Endpoint client = nameClientMap.get(player.getUsername());
if (client == null) return;
try {
if (client.socket.isConnected()) {
client.write(new KickResponse(KickResponse.KICKED));
client.socket.close();
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to kick player " + player.getUsername() + ". Maybe already disconnected?", e);
}
}
private void handleClient(Socket targetSocket) {
String address = targetSocket.getRemoteSocketAddress().toString();
String clientName = null;
LOG.info("Accepted client " + address);
try (Socket clientSocket = targetSocket;
BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream(), StandardCharsets.UTF_8))) {
clientSocket.setKeepAlive(true);
Endpoint endpoint = new Endpoint(clientSocket, writer);
clients.put(address, endpoint);
String line;
while ((line = reader.readLine()) != null) {
if (isInterrupted()) {
return;
}
LOG.fine("Message from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
MultiplayerChannel.Request request = JsonUtils.fromNonNullJson(line, MultiplayerChannel.Request.class);
if (request instanceof JoinRequest) {
JoinRequest joinRequest = (JoinRequest) request;
LOG.info("Received join request with clientVersion=" + joinRequest.getClientVersion() + ", id=" + joinRequest.getUsername());
clientName = joinRequest.getUsername();
if (!Objects.equals(MultiplayerManager.CATO_VERSION, joinRequest.getClientVersion())) {
try {
endpoint.write(new KickResponse(KickResponse.VERSION_NOT_MATCHED));
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send kick response.", e);
return;
}
}
CatoClient catoClient = new CatoClient(this, clientName);
nameClientMap.put(clientName, endpoint);
onClientAdded.fireEvent(catoClient);
if (onClientAdding != null && !allowAllJoinRequests) {
onClientAdding.call(catoClient, () -> {
try {
endpoint.write(new JoinResponse(sessionName, gamePort));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send join response.", e);
try {
socket.close();
} catch (IOException ioException) {
LOG.log(Level.WARNING, "Failed to close socket caused by join response sending failure.", e);
this.interrupt();
}
}
}, msg -> {
try {
endpoint.write(new KickResponse(msg));
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send kick response.", e);
}
});
} else {
// Allow all join requests.
endpoint.write(new JoinResponse(sessionName, gamePort));
}
} else if (request instanceof KeepAliveRequest) {
endpoint.write(new KeepAliveResponse(System.currentTimeMillis()));
onKeepAlive.fireEvent(new Event(this));
} else if (request instanceof HandshakeRequest) {
endpoint.write(new HandshakeResponse());
onHandshake.fireEvent(new Event(this));
} else {
LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
}
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to handle client socket.", e);
} catch (JsonParseException e) {
LOG.log(Level.SEVERE, "Failed to parse client request. This should not happen.", e);
} finally {
if (clientName != null) {
onClientDisconnected.fireEvent(new CatoClient(this, clientName));
}
clients.remove(address);
if (clientName != null) nameClientMap.remove(clientName);
}
}
public static class Endpoint {
public final Socket socket;
public final BufferedWriter writer;
public Endpoint(Socket socket, BufferedWriter writer) {
this.socket = socket;
this.writer = writer;
}
public synchronized void write(Object object) throws IOException {
writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(object)));
writer.newLine();
writer.flush();
}
}
}
@@ -47,6 +47,8 @@ public class DatapackListPage extends ListPageBase<DatapackListPageSkin.Datapack
private final Path worldDir;
private final Datapack datapack;
// Strongly referencing items, preventing GC collection
@SuppressWarnings("FieldCanBeLocal")
private final ObservableList<DatapackListPageSkin.DatapackInfoObject> items;
public DatapackListPage(String worldName, Path worldDir) {
@@ -32,7 +32,10 @@ public class ModDownloadListPage extends DownloadListPage {
supportChinese.set(true);
downloadSources.get().setAll("mods.curseforge", "mods.modrinth");
downloadSource.set("mods.curseforge");
if (CurseForgeRemoteModRepository.isAvailable())
downloadSource.set("mods.curseforge");
else
downloadSource.set("mods.modrinth");
}
private class Repository extends LocalizedRemoteModRepository {
@@ -31,6 +31,7 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.setting.Theme;
@@ -84,12 +85,9 @@ class ModListPageSkin extends SkinBase<ModListPage> {
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG::refresh, skinnable::refresh),
createToolbarButton2(i18n("mods.add"), SVG::plus, skinnable::add),
createToolbarButton2(i18n("folder.mod"), SVG::folderOpen, () ->
skinnable.openModFolder()),
createToolbarButton2(i18n("mods.check_updates"), SVG::update, () ->
skinnable.checkUpdates()),
createToolbarButton2(i18n("download"), SVG::downloadOutline, () ->
skinnable.download()));
createToolbarButton2(i18n("folder.mod"), SVG::folderOpen, skinnable::openModFolder),
createToolbarButton2(i18n("mods.check_updates"), SVG::update, skinnable::checkUpdates),
createToolbarButton2(i18n("download"), SVG::downloadOutline, skinnable::download));
HBox toolbarSelecting = new HBox();
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG::delete, () -> {
@@ -121,7 +119,8 @@ class ModListPageSkin extends SkinBase<ModListPage> {
center.getStyleClass().add("large-spinner-pane");
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new ModInfoListCell(listView));
MutableObject<Object> lastCell = new MutableObject<>();
listView.setCellFactory(x -> new ModInfoListCell(listView, lastCell));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
@@ -276,10 +275,10 @@ class ModListPageSkin extends SkinBase<ModListPage> {
}
}
private static Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
private static Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
class ModInfoListCell extends MDListCell<ModInfoObject> {
final class ModInfoListCell extends MDListCell<ModInfoObject> {
JFXCheckBox checkBox = new JFXCheckBox();
TwoLineListItem content = new TwoLineListItem();
JFXButton restoreButton = new JFXButton();
@@ -287,8 +286,8 @@ class ModListPageSkin extends SkinBase<ModListPage> {
JFXButton revealButton = new JFXButton();
BooleanProperty booleanProperty;
ModInfoListCell(JFXListView<ModInfoObject> listView) {
super(listView);
ModInfoListCell(JFXListView<ModInfoObject> listView, MutableObject<Object> lastCell) {
super(listView, lastCell);
HBox container = new HBox(8);
container.setPickOnBounds(false);
@@ -332,6 +331,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
restoreButton.setOnMouseClicked(e -> {
menu.get().getContent().setAll(dataItem.getModInfo().getMod().getOldFiles().stream()
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(), () -> {
popup.get().hide();
getSkinnable().rollback(dataItem.getModInfo(), localModFile);
}))
.collect(Collectors.toList())
@@ -23,7 +23,6 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.logging.Level;
import java.util.stream.Collectors;
@@ -123,7 +122,7 @@ public enum ModTranslations {
return true;
}
try {
String modData = IOUtils.readFullyAsString(ModTranslations.class.getResourceAsStream(resourceName), StandardCharsets.UTF_8);
String modData = IOUtils.readFullyAsString(ModTranslations.class.getResourceAsStream(resourceName));
mods = Arrays.stream(modData.split("\n")).filter(line -> !line.startsWith("#")).map(Mod::new).collect(Collectors.toList());
return true;
} catch (Exception e) {
@@ -27,6 +27,7 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.mod.RemoteMod;
@@ -158,8 +159,8 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
public static class ModUpdateCell extends MDListCell<LocalModFile.ModUpdate> {
TwoLineListItem content = new TwoLineListItem();
public ModUpdateCell(JFXListView<LocalModFile.ModUpdate> listView) {
super(listView);
public ModUpdateCell(JFXListView<LocalModFile.ModUpdate> listView, MutableObject<Object> lastCell) {
super(listView, lastCell);
getContainer().getChildren().setAll(content);
}
@@ -276,9 +277,7 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
dependents = mods.stream()
.map(mod -> {
return Task
.runAsync(Schedulers.javafx(), () -> {
mod.getKey().setOld(true);
})
.runAsync(Schedulers.javafx(), () -> mod.getKey().setOld(true))
.thenComposeAsync(() -> {
FileDownloadTask task = new FileDownloadTask(
new URL(mod.getValue().getFile().getUrl()),
@@ -325,6 +324,8 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
@Override
public void execute() throws Exception {
if (!isDependentsSucceeded())
throw getException();
}
}
}
@@ -32,7 +32,10 @@ public class ModpackDownloadListPage extends DownloadListPage {
supportChinese.set(true);
downloadSources.get().setAll("mods.curseforge", "mods.modrinth");
downloadSource.set("mods.curseforge");
if (CurseForgeRemoteModRepository.isAvailable())
downloadSource.set("mods.curseforge");
else
downloadSource.set("mods.modrinth");
}
private class Repository extends LocalizedRemoteModRepository {
@@ -66,7 +66,8 @@ public class VersionIconDialog extends DialogPane {
createIcon(VersionIconType.CRAFT_TABLE),
createIcon(VersionIconType.FABRIC),
createIcon(VersionIconType.FORGE),
createIcon(VersionIconType.FURNACE)
createIcon(VersionIconType.FURNACE),
createIcon(VersionIconType.QUILT)
);
}
@@ -43,7 +43,6 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.JavaVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -259,7 +258,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
JFXTextField txtMaxMemory = new JFXTextField();
FXUtils.setLimitWidth(txtMaxMemory, 60);
FXUtils.setValidateWhileTextChanged(txtMaxMemory, true);
txtMaxMemory.textProperty().bindBidirectional(maxMemory, SafeStringConverter.fromInteger());
FXUtils.bindInt(txtMaxMemory, maxMemory);
txtMaxMemory.setValidators(new NumberValidator(i18n("input.number"), false));
lowerBoundPane.getChildren().setAll(label, slider, txtMaxMemory, new Label("MB"));
@@ -628,19 +627,19 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
// unbind data fields
if (lastVersionSetting != null) {
FXUtils.unbindInt(txtWidth, lastVersionSetting.widthProperty());
FXUtils.unbindInt(txtHeight, lastVersionSetting.heightProperty());
FXUtils.unbind(txtWidth, lastVersionSetting.widthProperty());
FXUtils.unbind(txtHeight, lastVersionSetting.heightProperty());
maxMemory.unbindBidirectional(lastVersionSetting.maxMemoryProperty());
javaCustomOption.valueProperty().unbindBidirectional(lastVersionSetting.javaDirProperty());
gameDirCustomOption.valueProperty().unbindBidirectional(lastVersionSetting.gameDirProperty());
nativesDirCustomOption.valueProperty().unbindBidirectional(lastVersionSetting.nativesDirProperty());
FXUtils.unbindString(txtJVMArgs, lastVersionSetting.javaArgsProperty());
FXUtils.unbindString(txtGameArgs, lastVersionSetting.minecraftArgsProperty());
FXUtils.unbindString(txtMetaspace, lastVersionSetting.permSizeProperty());
FXUtils.unbindString(txtWrapper, lastVersionSetting.wrapperProperty());
FXUtils.unbindString(txtPreLaunchCommand, lastVersionSetting.preLaunchCommandProperty());
FXUtils.unbindString(txtPostExitCommand, lastVersionSetting.postExitCommandProperty());
FXUtils.unbindString(txtServerIP, lastVersionSetting.serverIpProperty());
FXUtils.unbind(txtJVMArgs, lastVersionSetting.javaArgsProperty());
FXUtils.unbind(txtGameArgs, lastVersionSetting.minecraftArgsProperty());
FXUtils.unbind(txtMetaspace, lastVersionSetting.permSizeProperty());
FXUtils.unbind(txtWrapper, lastVersionSetting.wrapperProperty());
FXUtils.unbind(txtPreLaunchCommand, lastVersionSetting.preLaunchCommandProperty());
FXUtils.unbind(txtPostExitCommand, lastVersionSetting.postExitCommandProperty());
FXUtils.unbind(txtServerIP, lastVersionSetting.serverIpProperty());
FXUtils.unbindBoolean(chkAutoAllocate, lastVersionSetting.autoMemoryProperty());
FXUtils.unbindBoolean(chkFullscreen, lastVersionSetting.fullscreenProperty());
noGameCheckPane.selectedProperty().unbindBidirectional(lastVersionSetting.notCheckGameProperty());
@@ -24,10 +24,7 @@ import org.jackhuang.hmcl.util.io.JarUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.util.Map;
import java.util.Map.Entry;
@@ -63,6 +60,7 @@ public final class IntegrityChecker {
private static boolean verifyJar(Path jarPath) throws IOException {
PublicKey publickey = getPublicKey();
MessageDigest md = DigestUtils.getDigest("SHA-512");
byte[] signature = null;
Map<String, byte[]> fileFingerprints = new TreeMap<>();
@@ -77,7 +75,8 @@ public final class IntegrityChecker {
if (SIGNATURE_FILE.equals(filename)) {
signature = IOUtils.readFullyAsByteArray(in);
} else {
fileFingerprints.put(filename, DigestUtils.digest("SHA-512", in));
md.reset();
fileFingerprints.put(filename, DigestUtils.digest(md, in));
}
}
}
@@ -91,7 +90,8 @@ public final class IntegrityChecker {
Signature verifier = Signature.getInstance("SHA512withRSA");
verifier.initVerify(publickey);
for (Entry<String, byte[]> entry : fileFingerprints.entrySet()) {
verifier.update(DigestUtils.digest("SHA-512", entry.getKey().getBytes(UTF_8)));
md.reset();
verifier.update(md.digest(entry.getKey().getBytes(UTF_8)));
verifier.update(entry.getValue());
}
return verifier.verify(signature);
@@ -94,7 +94,7 @@ public final class UpdateChecker {
private static boolean isDevelopmentVersion(String version) {
return version.contains("@") || // eg. @develop@
version.contains("SNAPSHOT"); // eg. 3.1.SNAPSHOT
version.contains("SNAPSHOT"); // eg. 3.5.SNAPSHOT
}
public static void requestCheckUpdate(UpdateChannel channel) {
@@ -29,12 +29,12 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.UpgradeDialog;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.ui.SwingUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.JavaVersion;
import javax.swing.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -65,7 +65,7 @@ public final class UpdateHandler {
performMigration();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to perform migration", e);
JOptionPane.showMessageDialog(null, i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e), "Error", JOptionPane.ERROR_MESSAGE);
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e));
}
return true;
}
@@ -75,13 +75,13 @@ public final class UpdateHandler {
applyUpdate(Paths.get(args[1]));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to apply update", e);
JOptionPane.showMessageDialog(null, i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e), "Error", JOptionPane.ERROR_MESSAGE);
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e));
}
return true;
}
if (isFirstLaunchAfterUpgrade()) {
JOptionPane.showMessageDialog(null, i18n("fatal.migration_requires_manual_reboot"), "Info", JOptionPane.INFORMATION_MESSAGE);
SwingUtils.showInfoDialog(i18n("fatal.migration_requires_manual_reboot"));
return true;
}
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.util;
import java.nio.file.Path;
import java.util.Set;
import static org.jackhuang.hmcl.util.Logging.LOG;
/**
* Utility for Adding JavaFX to module path.
*
@@ -30,6 +32,6 @@ public final class JavaFXPatcher {
}
public static void patch(Set<String> modules, Path... jarPaths) {
// Nothing to do with Java 8
LOG.info("No need to patch JavaFX with Java 8");
}
}
@@ -43,6 +43,7 @@ package org.jackhuang.hmcl.util;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.ui.SwingUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.platform.Platform;
@@ -55,10 +56,12 @@ import java.io.*;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.List;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toSet;
@@ -72,6 +75,8 @@ public final class SelfDependencyPatcher {
private final List<DependencyDescriptor> dependencies = DependencyDescriptor.readDependencies();
private final List<Repository> repositories;
private final Repository defaultRepository;
private final byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
private final MessageDigest digest = DigestUtils.getDigest("SHA-1");
private SelfDependencyPatcher() throws IncompatibleVersionException {
// We can only self-patch JavaFX on specific platform.
@@ -98,14 +103,26 @@ public final class SelfDependencyPatcher {
private static final Path DEPENDENCIES_DIR_PATH = HMCL_DIRECTORY.resolve("dependencies").resolve(Platform.getPlatform().toString()).resolve("openjfx");
static List<DependencyDescriptor> readDependencies() {
ArrayList<DependencyDescriptor> dependencies;
//noinspection ConstantConditions
try (Reader reader = new InputStreamReader(SelfDependencyPatcher.class.getResourceAsStream(DEPENDENCIES_LIST_FILE), UTF_8)) {
Map<String, List<DependencyDescriptor>> allDependencies =
new Gson().fromJson(reader, new TypeToken<Map<String, List<DependencyDescriptor>>>(){}.getType());
return allDependencies.get(Platform.getPlatform().toString());
Map<String, ArrayList<DependencyDescriptor>> allDependencies =
new Gson().fromJson(reader, new TypeToken<Map<String, ArrayList<DependencyDescriptor>>>(){}.getType());
dependencies = allDependencies.get(Platform.getPlatform().toString());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
try {
ClassLoader classLoader = SelfDependencyPatcher.class.getClassLoader();
Class.forName("netscape.javascript.JSObject", false, classLoader);
Class.forName("org.w3c.dom.html.HTMLDocument", false, classLoader);
} catch (Throwable e) {
LOG.log(Level.WARNING, "Disable javafx.web because JRE is incomplete", e);
dependencies.removeIf(it -> "javafx.web".equals(it.module) || "javafx.media".equals(it.module));
}
return dependencies;
}
public String module;
@@ -261,9 +278,10 @@ public final class SelfDependencyPatcher {
* @throws IOException When the files cannot be fetched or saved.
*/
private void fetchDependencies(List<DependencyDescriptor> dependencies) throws IOException {
SwingUtils.initLookAndFeel();
boolean isFirstTime = true;
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
Repository repository = defaultRepository;
int count = 0;
@@ -360,8 +378,18 @@ public final class SelfDependencyPatcher {
return missing;
}
private static void verifyChecksum(DependencyDescriptor dependency) throws IOException, ChecksumMismatchException {
ChecksumMismatchException.verifyChecksum(dependency.localPath(), "SHA-1", dependency.sha1());
private void verifyChecksum(DependencyDescriptor dependency) throws IOException, ChecksumMismatchException {
digest.reset();
try (InputStream is = Files.newInputStream(dependency.localPath())) {
int read;
while ((read = is.read(buffer, 0, IOUtils.DEFAULT_BUFFER_SIZE)) > -1) {
digest.update(buffer, 0, read);
}
}
String sha1 = Hex.encodeHex(digest.digest());
if (!dependency.sha1().equalsIgnoreCase(sha1))
throw new ChecksumMismatchException("SHA-1", dependency.sha1(), sha1);
}
public static class PatchException extends Exception {
Binary file not shown.
+11 -11
View File
@@ -133,10 +133,10 @@ fi
if [[ "$_HMCL_ARCH" == "loongarch64" ]]; then
if [ "$_HMCL_USE_CHINESE" == true ]; then
echo "运行 HMCL 需要 Java 运行时环境,请安装龙芯 JDK8 (http://www.loongnix.cn/zh/api/java/downloads-jdk8/index.html) 并设置环境变量后重试。" 1>&2
echo "运行 HMCL 需要 Java 运行时环境,请安装龙芯 JDK8 (https://docs.hmcl.net/downloads/loongnix.html) 并设置环境变量后重试。" 1>&2
else
echo "The Java runtime environment is required to run HMCL." 1>&2
echo "Please install Loongson JDK8 (http://www.loongnix.cn/zh/api/java/downloads-jdk8/index.html) and set the environment variables, then try again." 1>&2
echo "Please install Loongson JDK8 (https://docs.hmcl.net/downloads/loongnix.html) and set the environment variables, then try again." 1>&2
fi
exit 1
fi
@@ -144,11 +144,11 @@ fi
case "$_HMCL_OS" in
linux)
_HMCL_DOWNLOAD_PAGE_OS="&os=Linux";;
_HMCL_DOWNLOAD_PAGE_OS="linux";;
osx)
_HMCL_DOWNLOAD_PAGE_OS="&os=macOS";;
_HMCL_DOWNLOAD_PAGE_OS="macos";;
windows)
_HMCL_DOWNLOAD_PAGE_OS="&os=Windows";;
_HMCL_DOWNLOAD_PAGE_OS="windows";;
*)
echo "Unknown os: $_HMCL_OS" 1>&2
exit 1
@@ -157,20 +157,20 @@ esac
case "$_HMCL_ARCH" in
arm64)
_HMCL_DOWNLOAD_PAGE_ARCH="&bitness=64-bit&architecture=ARM";;
_HMCL_DOWNLOAD_PAGE_ARCH="arm64";;
arm32)
_HMCL_DOWNLOAD_PAGE_ARCH="&bitness=32-bit&architecture=ARM";;
_HMCL_DOWNLOAD_PAGE_ARCH="arm32";;
x86_64)
_HMCL_DOWNLOAD_PAGE_ARCH="&bitness=64-bit&architecture=x86";;
_HMCL_DOWNLOAD_PAGE_ARCH="x86_64";;
x86)
_HMCL_DOWNLOAD_PAGE_ARCH="&bitness=32-bit&architecture=x86";;
_HMCL_DOWNLOAD_PAGE_ARCH="x86";;
*)
echo "Unknown architecture: $_HMCL_ARCH" 1>&2
exit 1
;;
esac
_HMCL_DOWNLOAD_PAGE="https://bell-sw.com/pages/downloads/?version=java-17-lts$_HMCL_DOWNLOAD_PAGE_OS$_HMCL_DOWNLOAD_PAGE_ARCH&package=jdk-full"
_HMCL_DOWNLOAD_PAGE="https://docs.hmcl.net/downloads/$_HMCL_DOWNLOAD_PAGE_OS/$_HMCL_DOWNLOAD_PAGE_ARCH.html"
if [ "$_HMCL_USE_CHINESE" == true ]; then
echo "运行 HMCL 需要 Java 运行时环境,请安装 Java 并设置环境变量后重试。" 1>&2
@@ -180,4 +180,4 @@ else
echo "Please install Java and set the environment variables and try again." 1>&2
echo "$_HMCL_DOWNLOAD_PAGE" 1>&2
fi
exit 1
exit 1
+1 -1
View File
@@ -885,7 +885,7 @@
-fx-background-radius: 4;
-fx-padding: 8px;
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2);
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 5, 0.06, -0.5, 1);
}
.card:selected {
@@ -18,7 +18,7 @@
<JFXTextField fx:id="txtServerUrl" promptText="%account.injector.server_url" onAction="#onAddNext" />
</body>
<actions>
<Label fx:id="lblCreationWarning" />
<Label fx:id="lblCreationWarning" wrapText="true" />
<HBox>
<JFXButton onMouseClicked="#onAddCancel" text="%button.cancel" styleClass="dialog-cancel" />
<SpinnerPane fx:id="nextPane" styleClass="small-spinner-pane">
@@ -17,12 +17,12 @@
<SpinnerPane fx:id="spinnerPane" styleClass="large-spinner-pane">
<VBox fx:id="borderPane" alignment="CENTER" FXUtils.limitWidth="500">
<ComponentList>
<BorderPane><left><Label text="%modpack.task.install.will" /></left><right><Label fx:id="lblModpackLocation" /></right></BorderPane>
<BorderPane><left><Label BorderPane.alignment="CENTER_LEFT" text="%archive.name" /></left><right><JFXTextField fx:id="txtModpackName" StackPane.margin="$insets" /></right></BorderPane>
<BorderPane><left><Label text="%modpack.task.install.will" /></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblModpackLocation" /></center></BorderPane>
<BorderPane><left><Label BorderPane.alignment="CENTER_LEFT" text="%archive.name" /></left><center><JFXTextField BorderPane.alignment="CENTER_RIGHT" fx:id="txtModpackName" StackPane.margin="$insets" /></center></BorderPane>
<BorderPane><left><Label text="%modpack.name"/></left><right><Label fx:id="lblName" /></right></BorderPane>
<BorderPane><left><Label text="%archive.version"/></left><right><Label fx:id="lblVersion" /></right></BorderPane>
<BorderPane><left><Label text="%archive.author"/></left><right><Label fx:id="lblAuthor" /></right></BorderPane>
<BorderPane><left><Label text="%modpack.name"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblName" /></center></BorderPane>
<BorderPane><left><Label text="%archive.version"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblVersion" /></center></BorderPane>
<BorderPane><left><Label text="%archive.author"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblAuthor" /></center></BorderPane>
<BorderPane>
<left><JFXButton fx:id="btnDescription" onMouseClicked="#onDescribe" text="%modpack.description" styleClass="jfx-button-border" /></left>
<right><JFXButton buttonType="RAISED" fx:id="btnInstall" onMouseClicked="#onInstall" text="%button.install" styleClass="jfx-button-raised" /></right>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import com.jfoenix.controls.JFXButton?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.*?>
<?import java.lang.String?>
@@ -8,18 +7,18 @@
xmlns:fx="http://javafx.com/fxml"
type="StackPane">
<HBox spacing="8" styleClass="jfx-dialog-layout">
<Label fx:id="graphic" translateX="10" translateY="10" minWidth="40" maxWidth="40" minHeight="40" maxHeight="40" />
<Label fx:id="graphic" translateX="10" translateY="10" minWidth="40" maxWidth="40" minHeight="40"
maxHeight="40"/>
<VBox HBox.hgrow="ALWAYS">
<StackPane>
<styleClass>
<String fx:value="jfx-layout-heading" />
<String fx:value="title" />
<String fx:value="jfx-layout-heading"/>
<String fx:value="title"/>
</styleClass>
<Label fx:id="title" text="%message.info" />
<Label fx:id="title" text="%message.info"/>
</StackPane>
<StackPane fx:id="content" styleClass="jfx-layout-body">
</StackPane>
<HBox fx:id="actions" styleClass="jfx-layout-actions" />
<StackPane fx:id="content" styleClass="jfx-layout-body"/>
<HBox fx:id="actions" styleClass="jfx-layout-actions"/>
</VBox>
</HBox>
</fx:root>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more