Compare commits

..
134 Commits
Author SHA1 Message Date
Glavo 5a14449b44 重构 Decorator (#6557) 2026-08-02 00:10:40 +08:00
辞庐 612998a98f feat: [Windows] 支持自动使用高性能显卡 (#5420)
resolves #4639
2026-08-01 22:14:34 +08:00
快乐凤爪王 a50e327f70 Fix: 防止 DisabledJavaItem 的 Label 响应鼠标事件 (#6563) 2026-08-01 21:31:08 +08:00
快乐凤爪王 7aeb1642b1 Feat: 为 GameList 添加搜索无结果提示 (#6566) 2026-08-01 21:30:26 +08:00
快乐凤爪王 5fc1536d6a Refactor: Style of JavaManagementPage (#6345) 2026-08-01 21:24:27 +08:00
ToobLac 463cf2ff0f [Chores] 遗漏的 mod -> addon 重命名 (#6535) 2026-08-01 21:11:28 +08:00
Glavo 144e822a85 使用 Xvfb 运行 Gradle Action (#6570) 2026-08-01 20:59:38 +08:00
快乐凤爪王 87789beeb7 Fix: 修复 ModDownloadListPage 末尾项 Padding (#6564) 2026-08-01 20:50:06 +08:00
Glavo bb86100c54 Bump Gson to 2.14.0 (#6562) 2026-07-31 21:23:42 +08:00
Glavo 2ceca9d8cc Bump HelloNBT to 0.4.0 (#6561) 2026-07-31 21:23:24 +08:00
快乐凤爪王 d5fc365a96 Feat: Add colors for addon channel icons in DownloadPage (#6441) 2026-07-31 20:38:28 +08:00
辞庐 d5c86a8d88 清理 CurseManifestMinecraft (#6545) 2026-07-31 20:30:36 +08:00
Glavo bd6beb1369 在 Windows 平台支持将 HMCL 固定在任务栏 (#6542) 2026-07-31 20:28:38 +08:00
Glavo b1719e77bb docs: update nullability guidelines in AGENTS.md (#6559) 2026-07-31 20:23:07 +08:00
快乐凤爪王 77f0a90f10 Fix: 在 ModpackSelectionPage 中非 IOException 的异常导致崩溃 (#6551) 2026-07-31 20:19:00 +08:00
庐庐绫 ff6110ba89 fix: 背景图片非当前选项配置重启后丢失 (#6547)
close #6546
2026-07-31 20:18:00 +08:00
快乐凤爪王 e7e964b3dd Refactor: ModpackSelectionPage 文件选择器关闭后不清空页面栈 (#6550) 2026-07-31 20:14:55 +08:00
Glavo 4e29c29a7a 更新模组数据 (2026-07-21) (#6543) 2026-07-30 21:46:55 +08:00
Glavo 25ad1dbf44 修复 GameInstancePage.java 中的拼写错误 (#6533) 2026-07-29 20:38:16 +08:00
辞庐andGlavo d2e7e0d626 实例列表左侧游戏文件夹列表添加平滑滚动 (#6495)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-29 20:19:59 +08:00
辞庐andGlavo c68bbf2904 统一管理页面工具栏 下载 按钮文案 (#6499)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-29 20:17:57 +08:00
ToobLac d0fc46f930 [Bugfix] 修复导出整合包文件选择界面UI线程阻塞 (#6363)
Fixes #6354
2026-07-29 20:02:24 +08:00
Mike Lei f1b861eb21 fix(build): fix missing icon in the dock/dash of Linux DEs (#6303) 2026-07-29 19:54:33 +08:00
Glavo 5cde4ce757 不再用 Version 表示游戏实例 (#6322) 2026-07-29 18:42:58 +08:00
庐庐绫 d297417909 fix: show/hide 前先 setIconified(false) 确保窗口状态正常 (#6525)
close #6523
2026-07-28 09:40:06 +08:00
Glavo 2854eac27d 在高刷屏上自动提高动画帧率 (#6422) 2026-07-27 21:30:27 +08:00
ToobLac c9fd7c6a1c [Enhancement] 优化主题包导出对话框的交互 (#6516)
Resolves #6512
2026-07-27 21:03:54 +08:00
Glavo 0e0517ab3c 优化模组信息读取速度 (#6515) 2026-07-27 07:12:00 +08:00
ToobLacandGlavo 7a4bea10d0 [Bugfix] 修复 #6348 资源包管理处理非UTF-8编码的options.txt文件时出错 (#6351)
Fixes #6348

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-26 21:31:35 +08:00
辞庐 b87d40ffcc 优化 实例列表无版本时点击下载页展示 (#6494) 2026-07-26 20:56:41 +08:00
Glavo 1277bf8b5d 使用 kala-encoding-detector 作为编码推测库 (#6477) 2026-07-26 00:47:43 +08:00
辞庐 c2e807055a fix: 下载整合包时未设置任务名 (#6487) 2026-07-25 21:07:43 +08:00
辞庐 9021ec1960 清理模组管理页面冗余 “实例无模组加载器” 提示 (#6500) 2026-07-25 21:01:22 +08:00
辞庐 82a47e9e2d 优化实例列表界面 (#6493) 2026-07-25 21:00:42 +08:00
neveler 4841204fa0 update i18n (#6491) 2026-07-25 21:00:26 +08:00
辞庐 897f5fe11b fix: 形参化类 'Tab<?>' 的原始使用 (#6498) 2026-07-25 20:57:58 +08:00
Glavo 4657e3273e 复制实例时同步保存游戏设置 (#6492) 2026-07-24 20:39:59 +08:00
Glavo 4edea5f5b9 Add support for Minecraft 26.3-snapshot-5+ to unofficial supported platforms (#6481) 2026-07-23 19:51:00 +08:00
辞庐 36bd7d4491 修复 GameAdvancedListItem#loadVersion UI 操作可能不会 JavaFX 线程运行的问题 (#6445)
resolves #6443
2026-07-22 22:11:55 +08:00
neveler 3aa8603bd1 提取 pr-size-label 工作流中的 js 代码到独立文件 (#6458) 2026-07-22 22:06:57 +08:00
庐庐绫 ca7a61e20a fix: 修复模组列表加载时检查错误实例的模组更新 (#6478)
close #6448
2026-07-22 21:57:59 +08:00
辞庐 21afc81cf8 fix: NetwordUtils#doPost(URI u, Map<String, String> params) 未进行 URL 编码 (#6457) 2026-07-22 21:49:46 +08:00
neveler 94a0e26be5 update i18n: remove game.crash.reason.mod_repeat_installation (#6472) 2026-07-22 21:47:13 +08:00
辞庐 14e8d2d0b5 清理 VanillaInstallWizardProvider (#6474) 2026-07-22 21:46:49 +08:00
快乐凤爪王 49c408cb23 Refactor: InstallersPageSkin versionNamePane (#6349) 2026-07-22 21:45:49 +08:00
辞庐 b81c3398ac fix: 切换版本隔离设置后自动刷新其他子页面 功能失效 (#6468) 2026-07-22 13:21:58 +08:00
快乐凤爪王 2855df3693 Feat: Implement dynamic folder icon and use to game directory list (#6435) 2026-07-21 22:03:32 +08:00
快乐凤爪王 8184dc6cee Fix: Alignment for background opacity percentage label (#6450) 2026-07-21 21:55:12 +08:00
快乐凤爪王 df21559c0e Chore: Clean up duplicate key-value pairs for No results found (#6449) 2026-07-21 21:53:21 +08:00
快乐凤爪王 73ff3081f8 Feat: No results found tip for ThemePackManagementPage (#6451) 2026-07-21 21:49:52 +08:00
辞庐 fe05bb58a4 更新时 日志输出新进程命令行时 打码敏感信息 (#6456) 2026-07-21 21:30:32 +08:00
Glavo 4c9cd950e4 移除对 JavaFX 14 的兼容性 (#6467) 2026-07-21 20:40:56 +08:00
neveler 646d9a09a5 update i18n (#6466) 2026-07-21 20:38:56 +08:00
Glavo 255fe75640 Bump JavaFX to 21.0.11 on FreeBSD x86-64 (#6463) 2026-07-21 19:40:18 +08:00
neveler de15dd8e98 Remove extra whitespace (#6462) 2026-07-21 17:57:57 +08:00
Glavo 886319a6c7 避免在代码中直接引用 Controllers.getStage() (#6431) 2026-07-20 21:41:55 +08:00
辞庐 3320f6c341 fix: 整合包安装完成回调执行器不统一 (#6446) 2026-07-20 21:21:33 +08:00
快乐凤爪王 b85be78166 Feat: Add empty state placeholders for versions (#6426) 2026-07-20 21:21:06 +08:00
Glavo a34a7439d7 Clarify nullability and documentation in AGENTS.md (#6440) 2026-07-20 00:02:59 +08:00
ZZZank 591fd51600 实现游戏日志窗口开关自动换行 (#6424) 2026-07-19 21:53:57 +08:00
快乐凤爪王 c1407a9620 Fix: Resolve ModLoaderType.LEGACY_FABRIC for Modrinth (#6434) 2026-07-19 02:46:28 +08:00
Glavo e0a91579f2 更新内置主题相关译文 (#6429) 2026-07-17 21:29:51 +08:00
快乐凤爪王 b68f404c3c Refactor: Style of ModInfoDialog (#6423) 2026-07-17 21:28:02 +08:00
Glavo c1f50f9ba3 删除 Gemini 审查配置文件 (#6430) 2026-07-17 21:17:31 +08:00
辞庐 d8bb2131f2 允许拖入 nbt 文件 (#6427) 2026-07-17 21:08:45 +08:00
Glavo aca761385b Add support for Minecraft 26.3-snapshot-4+ to unofficial supported platforms (#6425) 2026-07-17 21:00:14 +08:00
3gf8jv4dv 15faccf376 chore(workflow): add pr-size-label workflow file (#6381) 2026-07-17 20:23:48 +08:00
Glavo 241c9c419c 修复 FXUtils 意外的被提前初始化的问题 (#6421) 2026-07-16 21:29:12 +08:00
辞庐 4b36f8ce03 fix: 任务窗口速度与取消未对齐 (#6140) 2026-07-16 21:28:31 +08:00
Glavo 5c0baad782 提早加载配置文件 (#6420) 2026-07-16 20:50:20 +08:00
Apostrophe 850a56405c 修复游戏 Java 路径显示滞后问题 (#6419) 2026-07-16 19:59:17 +08:00
快乐凤爪王 e8487c467b Refactor: Replace deprecated URL constructor (#6409) 2026-07-16 03:37:54 +08:00
Glavo 9866d88a16 完善游戏设置页面帮助文本 (#6415) 2026-07-15 21:15:00 +08:00
辞庐 af2d46ae89 Revert "Refactor: Remove redundant downloadSourceComboBox from ModDownloadListPageSkin" (#6410)
Reverts HMCL-dev/HMCL#6370
2026-07-15 19:56:20 +08:00
ToobLac 6f7c561ecb [Bugfix] 修复 1.21.1 安装信雅互联仍显示不支持 Fabric 模组的问题 (#6404)
Fixes #6391
2026-07-14 21:41:39 +08:00
ToobLac 93abe31acf [Chores] 处理升级到 9.6.1 后 Gradle 弃用提示 (#6405) 2026-07-14 21:29:58 +08:00
Glavo a6f8659349 Bump Gradle Wrapper Neo to 0.2.0 (#6397) 2026-07-14 00:06:56 +08:00
Glavo 8728c89093 Bump version root to 3.17 (#6392) 2026-07-13 20:34:38 +08:00
辞庐 2dccd8099d Bump authlib-injector to 1.2.8 (#6390) 2026-07-13 20:33:23 +08:00
Glavo d8a03cfed8 支持主题色跟随系统 (#6385) 2026-07-13 03:42:50 +08:00
Glavo 26a874a030 Bump Gradle to 9.6.1 (#6388) 2026-07-13 03:00:15 +08:00
Glavo 7c5c3e2fb4 使用 Gradle Wrapper Neo 代替 Gradle Wrapper (#6387) 2026-07-13 02:52:47 +08:00
Glavo 62d7b36248 添加“窗口透明”选项 (#6383) 2026-07-13 00:41:12 +08:00
Glavo dec6837528 修复 Minecraft 26.1+ 无法安装 Quilt 的问题 (#6386) 2026-07-13 00:38:52 +08:00
快乐凤爪王 3484792e81 Feat: Added status label opacity when installer item is incompatible (#6335) 2026-07-12 22:37:57 +08:00
快乐凤爪王 2b310e035d Fix: Lock AccountListItemSkin.btnRefresh width to prevent layout shifting (#6369) 2026-07-12 22:01:34 +08:00
Glavo 6e960612dd 固定默认游戏文件夹和官方启动器游戏文件夹的 ID (#6382) 2026-07-12 22:00:30 +08:00
辞庐 73a776b021 fix: 整合包安装界面高度限制失效 (#6371) 2026-07-12 21:46:46 +08:00
辞庐and3gf8jv4dv 31a2f49ed2 更新 fatal.unsupported_platform 设置指引 (#6251)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-07-12 20:49:51 +08:00
快乐凤爪王 ab268cfe48 Feat: DisplayName for JavaPackageType (#6377) 2026-07-12 20:35:32 +08:00
Glavo 721aeccfc0 修复 JFXPasswordField 导致 CPU 占用率异常增高的问题 (#6373) 2026-07-12 01:35:01 +08:00
Glavo e4348f727a 优化下载线程数设置 UI (#6374) 2026-07-12 01:34:17 +08:00
快乐凤爪王 dfc96f10da Refactor: Remove redundant downloadSourceComboBox from ModDownloadListPageSkin (#6370) 2026-07-11 21:19:37 +08:00
快乐凤爪王 a5c7fe08d8 Feat: No results found tip for ModDownloadListPage (#6368) 2026-07-11 02:37:09 +08:00
Glavo a6e0acca45 在滚动条处于按下状态时保持不透明状态 (#6366) 2026-07-10 23:57:20 +08:00
Glavo 3d312f9247 修复在 JFXComboBox 弹出窗口的滚动条上释放鼠标时也会导致弹出窗口隐藏的问题 (#6365) 2026-07-10 21:44:51 +08:00
Glavo 18dc5b69c0 Fix #6320: 修复安装新实例时,Fabric API 安装路径未遵循版本隔离策略的问题 (#6355) 2026-07-10 20:36:29 +08:00
快乐凤爪王 98af2c85b6 Feat: Animate menu button arrow counter-clockwise on popup display (#6344) 2026-07-10 20:35:39 +08:00
Glavo c4c0b1a1ce 修复自动分配内存逻辑异常的问题 (#6356) 2026-07-10 20:21:44 +08:00
辞庐 5e621ce340 HttpRequest 添加 PUT 与 DELETE 请求支持 (#6312) 2026-07-09 21:02:14 +08:00
快乐凤爪王 1d1c8dfdc9 Fix: Adjusted the size of the ModInfoListCell ResourcePackListCell icon (#6347) 2026-07-09 20:55:33 +08:00
快乐凤爪王 9b68b8825d Chore: Removed unnecessary punctuation marks of allow_auto_agent.subtitle (#6346) 2026-07-09 20:55:11 +08:00
快乐凤爪王 5c17783223 Fix: Restore subtitle when RemoteVersionListCell is reused (#6329) 2026-07-08 22:03:59 +08:00
Glavo a921be45f1 Fix #6290: GameAdvancedListItem 应等待 HMCLGameRepository 加载完成后再加载图标 (#6325) 2026-07-08 21:59:06 +08:00
辞庐 2dd1c0f85e 优化日志窗口配色 (#6313) 2026-07-08 21:54:24 +08:00
快乐凤爪王 7ba229b3e5 Fix: Adjust GameListCell spacing (#6337) 2026-07-08 21:47:49 +08:00
辞庐 59a299630b fix: #6328 MCIM 描述没有对齐 (#6333) 2026-07-08 21:46:26 +08:00
快乐凤爪王 b41b89e233 Feat: Added numbered square tag to Java runtime list cells (#6334) 2026-07-08 21:40:50 +08:00
ToobLac af137e5ff1 [Bugfix] 修复 #6338: macOS 导出整合包默认名称包含2个后缀名 (#6340)
Fixes #6338
2026-07-08 21:39:37 +08:00
Glavo 74ddbf5623 Streamline memory allocation options and improve preset management (#6330)
Fix #6307
2026-07-08 21:15:36 +08:00
快乐凤爪王 accef4a93f Fix: WorldListPage MDListCell tags (#6339) 2026-07-08 20:37:16 +08:00
快乐凤爪王 9c3a955fad Refactor: Launch pane button (#6341) 2026-07-08 20:27:35 +08:00
Glavo 1c0e506d34 Enhance renderer selection based on game version (#6316) 2026-07-05 20:42:24 +08:00
庐庐绫 6374ec0de7 fix: 修复联机核心第三方下载渠道按钮高度不一致 (#6247)
close #6245
2026-07-05 20:06:23 +08:00
快乐凤爪王 a6b3872af4 Fix: Fixed truncated authlib injector warning text (#6301) 2026-07-05 20:06:02 +08:00
辞庐 ad98a5a6d4 HMCL ProcessListener 游戏退出日志行开头添加时间 (#6314) 2026-07-05 20:04:51 +08:00
快乐凤爪王 05393af537 Fix: Adjust account item spacing (#6302) 2026-07-05 19:57:14 +08:00
Glavo 3438da2ab9 Add game version handling for graphics backend visibility (#6300) 2026-07-05 19:56:02 +08:00
辞庐 3163b465ec fix: 拷贝 uuid 按钮冗余 SpinnerPane (#6315) 2026-07-05 19:54:19 +08:00
辞庐 573dac471c 禁用密码框右键菜单的 剪切 & 复制 按钮 (#6310) 2026-07-05 19:53:43 +08:00
ToobLacandGlavo 12f54f353a [Bugfix] 修复 CF 文件下载未带有 api key 的问题 (#6297)
Fixes #6293

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-03 21:26:58 +08:00
Glavo 246699d571 refactor: update preset management terminology for clarity (#6298) 2026-07-03 21:26:14 +08:00
Haoran Xu 3480e6974b Docs: synchronize localized README content (#6235) 2026-07-03 21:12:23 +08:00
Zkitefly 6d14351833 feat(mod): 支持读取Forge新模组元数据中的logo文件 (#6253) 2026-07-03 20:22:24 +08:00
Glavo fd0d83257d 调整旧版实例设置迁移与默认父预设解析逻辑 (#6287) 2026-07-03 20:19:20 +08:00
ToobLac 0eb1d3cf4b [Enhancement] 无论何时都显示关闭愚人节模式的按钮 (#6105)
Closes #5982
2026-07-02 21:54:39 +08:00
辞庐 f9b8dad3d2 修复安装手动打包整合包时任务名未本地化的问题 (#6248) 2026-07-02 21:50:49 +08:00
Glavo 57fbd0910e 不再将从旧版本游戏文件夹设置中迁移出的游戏预设名称设置为文件夹名称 (#6292) 2026-07-02 21:47:51 +08:00
快乐凤爪王 7767a57f50 Fix: Restrict JVM memory text fields to numeric input only (#6260) 2026-07-02 21:46:11 +08:00
快乐凤爪王 f024dba940 Fix: Dynamically lock fixedCellSize of VersionsPageSkin (#6288) 2026-07-02 21:15:03 +08:00
Wulian233 52736d3c86 导出主题时支持设置主题版本 (#6284) 2026-07-02 21:06:36 +08:00
ToobLac f75b472068 [Chores] 其他类向记录的转换 (#6277) 2026-07-02 21:03:36 +08:00
ToobLac d8e0c9e9c7 [Chore] 优化default_addon_source的中文翻译 (#6278) 2026-07-01 20:38:04 +08:00
快乐凤爪王 9601c1c1ea Fix: Aligned the height of the jfx-color-picker with the jfx-text-field (#6285) 2026-07-01 20:37:30 +08:00
353 changed files with 21755 additions and 8587 deletions

No files matched your search

-3
View File
@@ -1,3 +0,0 @@
code_review:
pull_request_opened:
code_review: false
+105
View File
@@ -0,0 +1,105 @@
module.exports = async ({ github, context, core }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
const ignoredFilePatterns = [
/(?:^|\/)\.cnb\/.+\.ya?ml$/i,
/(?:^|\/)\.gitee\/.+\.ya?ml$/i,
/(?:^|\/)\.github\/.+\.ya?ml$/i,
/(?:^|\/)\.idea\/.+\.xml$/i,
/(?:^|\/)\.editorconfig$/i,
/(?:^|\/)\.gitattributes$/i,
/(?:^|\/)\.gitignore$/i,
/(?:^|\/).+\.kts$/i,
/(?:^|\/).+\.md$/i,
/(?:^|\/)LICENSE(?:_.*)?$/i,
/(?:^|\/)gradlew(?:\.bat|\.ps1)?$/i,
/(?:^|\/)gradle\/wrapper\/gradle-wrapper\.properties$/i,
/(?:^|\/)lang\/.+\.properties$/i,
/(?:^|\/)l10n\/.+\.properties$/i,
/(?:^|\/)checkstyle\.xml$/i,
/(?:^|\/).+\.(?:png|jpe?g|gif|ico|svg|json|ttf|woff2?|webp|avif|jxl|heic|mp4|webm|mp3|ogg|wav|txt|toml)$/i
];
async function processPR(issueNumber) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: issueNumber, per_page: 100,
});
const countedFiles = [];
const ignoredFiles = [];
let totalChanges = 0;
for (const file of files) {
const ignored = ignoredFilePatterns.some(pattern => pattern.test(file.filename));
if (ignored) {
ignoredFiles.push(file.filename);
continue;
}
const fileChanges = file.changes ?? ((file.additions || 0) + (file.deletions || 0));
totalChanges += fileChanges;
countedFiles.push(`${file.filename} (${fileChanges})`);
}
let targetLabel;
if (totalChanges <= 9) targetLabel = "1+";
else if (totalChanges <= 39) targetLabel = "10+";
else if (totalChanges <= 99) targetLabel = "40+";
else if (totalChanges <= 499) targetLabel = "100+";
else if (totalChanges <= 999) targetLabel = "500+";
else if (totalChanges <= 1999) targetLabel = "1000+";
else if (totalChanges <= 4999) targetLabel = "2000+";
else targetLabel = "5000+";
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: issueNumber, per_page: 100,
});
const sizeLabels = currentLabels.map(label => label.name).filter(name => /^\d+\+$/.test(name));
for (const name of sizeLabels) {
if (name !== targetLabel) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: issueNumber, name,
}).catch(() => { });
}
}
if (!currentLabels.some(label => label.name === targetLabel)) {
await github.rest.issues.addLabels({
owner, repo, issue_number: issueNumber, labels: [targetLabel],
});
}
core.info(`Pull request #${issueNumber} has ${totalChanges} counted changed lines, applied ${targetLabel}.`);
core.info(`Ignored files: ${ignoredFiles.length ? ignoredFiles.join(", ") : "none"}`);
core.info(`Counted files: ${countedFiles.length ? countedFiles.join(", ") : "none"}`);
}
if (context.eventName === "workflow_dispatch") {
core.info("Manual trigger detected. Fetching all open pull requests...");
const openPRs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100,
});
const unlabelledPRs = openPRs.filter(pr => {
return !pr.labels.some(label => /^\d+\+$/.test(label.name));
});
core.info(`Found ${openPRs.length} open pull request(s), ${unlabelledPRs.length} of them lack a size label.`);
for (const pr of unlabelledPRs) {
core.info(`----------------------------------------`);
core.info(`Processing unlabelled pull request #${pr.number}: ${pr.title}`);
await processPR(pr.number);
}
} else {
const pr = context.payload.pull_request;
if (!pr) {
core.info("No pull request found in payload, skipping.");
return;
}
await processPR(pr.number);
}
}
+2 -1
View File
@@ -24,8 +24,9 @@ jobs:
with:
cache-cleanup: never
- name: Build with Gradle
run: ./gradlew build --no-daemon --parallel
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
env:
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
+31
View File
@@ -0,0 +1,31 @@
name: PR Size Label
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts
sparse-checkout-cone-mode: true
- name: Label PR by filtered changed lines
uses: actions/github-script@v9
with:
script: |
const script = require("./.github/scripts/pr-size-label.js");
await script({ github, context, core });
+7 -3
View File
@@ -4,9 +4,11 @@ These rules apply to all Java code written or modified in this repository.
## Nullability
- Annotate every class with JetBrains Annotations `@NotNullByDefault`.
- Any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
- Nullability must never be implicit.
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class declared in a newly added Java source file must be annotated with JetBrains Annotations `@NotNullByDefault`.
- When writing or modifying Java code, any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
- Nullability in code being written or modified must never be implicit.
## Immutability
@@ -15,6 +17,8 @@ These rules apply to all Java code written or modified in this repository.
## Documentation
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class, field, and method must have documentation.
- Documentation must use `///` Markdown-style Javadoc comments.
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
+8 -7
View File
@@ -52,7 +52,7 @@ if (buildNumber != null) {
}
}
val embedResources by configurations.registering
val embedResources = configurations.register("embedResources")
dependencies {
implementation(project(":HMCLCore"))
@@ -166,7 +166,7 @@ val hmclProperties = buildList {
}
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
val createPropertiesFile by tasks.registering {
val createPropertiesFile = tasks.register("createPropertiesFile") {
outputs.file(hmclPropertiesFile)
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
@@ -258,7 +258,7 @@ tasks.processResources {
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
val makeExecutables by tasks.registering {
val makeExecutables = tasks.register("makeExecutables") {
val extensions = listOf("exe", "sh")
dependsOn(tasks.jar)
@@ -286,7 +286,7 @@ val makeExecutables by tasks.registering {
}
}
val makeDeb by tasks.registering(CreateDeb::class) {
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
dependsOn(makeExecutables)
val debFile = layout.file(provider { artifactFile("deb") })
@@ -299,6 +299,7 @@ val makeDeb by tasks.registering(CreateDeb::class) {
version.set(project.version.toString())
releaseType.set(debChannel)
launcherClassName.set("org.jackhuang.hmcl.Launcher")
appShFile.set(layout.file(provider { artifactFile("sh") }))
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
outputFile.set(debFile)
@@ -438,19 +439,19 @@ tasks.register<CheckTranslations>("checkTranslations") {
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList") {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
val createLocaleNamesResourceBundle = tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
@@ -38,7 +38,6 @@ import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
@@ -68,14 +67,18 @@ public class JFXPasswordField extends PasswordField {
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) {
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";");
}
useJFXContextMenu(this);
}
/// Prevents the legacy JFoenix skin from continuously requesting another layout pass.
// https://github.com/HMCL-dev/HMCL/issues/5822
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
@Override
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
}
/**
* Initialize the style class to 'jfx-password-field'.
* <p>
@@ -294,4 +297,3 @@ public class JFXPasswordField extends PasswordField {
}
}
@@ -15,9 +15,13 @@ import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventTarget;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBoxBase;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
@@ -25,6 +29,8 @@ import javafx.scene.text.Text;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import java.util.List;
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
protected final ObjectProperty<Paint> promptTextFill;
private boolean invalid = true;
@@ -150,6 +156,30 @@ public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
}
});
for (Node child : getChildren()) {
if (child instanceof ListView<?> listView && "list-view".equals(listView.getId())) {
// The default hideOnClick logic of ComboBoxListViewSkin doesn't work well with FloatScrollBarSkin.
// We implement hideOnClick ourselves to prevent the popup from
// hiding when releasing the mouse on the scrollbar.
this.setHideOnClick(false);
listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> {
EventTarget target = t.getTarget();
if (target instanceof Node targetNode) {
List<String> s = targetNode.getStyleClass();
if (s.contains("thumb")
|| s.contains("track")
|| s.contains("decrement-arrow")
|| s.contains("increment-arrow")) {
return;
}
}
comboBox.hide();
});
}
}
}
protected void layoutChildren(double x, double y, double w, double h) {
@@ -52,8 +52,6 @@ public final class EntryPoint {
checkWine();
setupJavaFXVMOptions();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
if (!isInsideMacAppBundle())
@@ -74,71 +72,6 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
try {
@@ -225,15 +158,8 @@ public final class EntryPoint {
private static void checkWine() {
if (OperatingSystem.isRunningUnderWine()) {
SwingUtils.initLookAndFeel();
LOG.warning("HMCL is running under Wine or its distributions!");
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
showWarning(i18n("fatal.wine_warning"));
}
}
@@ -273,10 +199,21 @@ public final class EntryPoint {
}
}
static void showWarning(String message) {
SwingUtils.initLookAndFeel();
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
}
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
private static void showErrorAndExit(String message) {
static void showErrorAndExit(String message) {
SwingUtils.showErrorDialog(message);
exit(1);
}
@@ -17,6 +17,8 @@
*/
package org.jackhuang.hmcl;
import com.sun.jna.Pointer;
import com.sun.jna.WString;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
@@ -29,8 +31,6 @@ import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Duration;
@@ -41,6 +41,7 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
@@ -48,7 +49,14 @@ import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
import org.jackhuang.hmcl.util.platform.windows.Shell32;
import org.jackhuang.hmcl.util.platform.windows.User32;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.io.File;
import java.io.IOException;
import java.lang.management.ManagementFactory;
@@ -60,7 +68,6 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@@ -98,18 +105,7 @@ public final class Launcher extends Application {
}
try {
try {
SettingsManager.init();
initializeSettingsRuntime();
} catch (SambaException e) {
showAlert(AlertType.WARNING, i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigInTempDir();
checkConfigOwner();
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
EntryPoint.exit(1);
}
initializeSettingsRuntime();
// https://lapcatsoftware.com/articles/app-translocation.html
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
@@ -146,6 +142,7 @@ public final class Launcher extends Application {
UpdateChecker.init();
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage);
primaryStage.show();
});
} catch (Throwable e) {
@@ -274,52 +271,10 @@ public final class Launcher extends Application {
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
if (showAlert(AlertType.ERROR,
i18n("fatal.config_loading_failure.unix", owner, command),
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
EntryPoint.exit(1);
}
@Override
public void stop() throws Exception {
Controllers.onApplicationStop();
FileSaver.shutdown();
SettingsManager.shutdown();
LOG.shutdown();
}
@@ -376,6 +331,20 @@ public final class Launcher extends Application {
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
try {
SettingsManager.init();
} catch (SambaException e) {
EntryPoint.showWarning(i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigOwner();
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
EntryPoint.exit(1);
}
setupJavaFXVMOptions();
setupWindowsAppUserModelID();
launch(Launcher.class, args);
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
@@ -408,5 +377,177 @@ public final class Launcher extends Application {
});
}
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
private static void setupWindowsAppUserModelID() {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
return;
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
return;
try {
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
if (hr < 0) {
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
} else {
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
}
} catch (Throwable e) {
LOG.warning("Failed to set AppUserModelID", e);
}
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
setUpAnimationFrameRate:
{
if (System.getProperty("javafx.animation.pulse") != null) {
break setUpAnimationFrameRate;
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
int value = Integer.parseInt(animationFrameRate);
if (value <= 0)
throw new NumberFormatException(animationFrameRate);
if (value != 60)
System.setProperty("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
break setUpAnimationFrameRate;
}
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
break setUpAnimationFrameRate;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
if (pointer != null) {
try {
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
if (refreshRate > 0) {
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
if (refreshRate >= 90) {
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
}
}
} finally {
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
}
}
}
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
SwingUtils.initLookAndFeel();
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
int result = JOptionPane.showOptionDialog(null,
i18n("fatal.config_loading_failure.unix", owner, command),
i18n("message.error"),
JOptionPane.DEFAULT_OPTION,
JOptionPane.ERROR_MESSAGE,
null,
options,
options[0]);
if (result == 0) {
try {
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
} catch (Throwable e) {
LOG.warning("Failed to copy command to clipboard", e);
}
}
EntryPoint.exit(1);
}
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
}
@@ -37,6 +37,9 @@ public final class Metadata {
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
/// Explicit Application User Model ID used for Windows taskbar grouping and pinning.
public static final String WINDOWS_APP_USER_MODEL_ID = "org.jackhuang.hmcl";
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
@@ -42,16 +42,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class HMCLGameLauncher extends DefaultLauncher {
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
this(repository, version, authInfo, options, null);
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) {
this(repository, manifest, authInfo, options, null);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(repository, version, authInfo, options, listener, true);
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(repository, manifest, authInfo, options, listener, true);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(repository, version, authInfo, options, listener, daemon);
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(repository, manifest, authInfo, options, listener, daemon);
}
@Override
@@ -66,7 +66,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isDisableAutoGameOptions())
return;
Path runDir = repository.getRunDirectory(version.getId());
Path runDir = repository.getRunDirectory(manifest.id());
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
@@ -91,7 +91,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
*/
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(manifest));
if (gameVersion.compareTo("1.1") < 0)
return;
@@ -161,7 +161,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isAllowAutoAgent()
&& !options.isNoGeneratedJVMArgs()
&& !options.isNoGeneratedOptimizingJVMArgs()
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
try {
result.add("-javaagent:" + extractLwjglUnsafeAgent());
@@ -178,9 +178,9 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.getArtifact().getFileName();
String fileName = library.artifact().getFileName();
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
Path agentPath = repository.getLibraryFile(manifest, library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
@@ -20,8 +20,9 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import javafx.beans.binding.Binding;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
@@ -45,7 +46,7 @@ import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.ProxyType;
import org.jackhuang.hmcl.setting.SettingFileUtils;
import org.jackhuang.hmcl.setting.GameSettingsPresetID;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
@@ -81,10 +82,10 @@ public final class HMCLGameRepository extends DefaultGameRepository {
/// @param repository the owning game repository
/// @param instanceId the game instance ID, or `null` when only repository context is available
@NotNullByDefault
public record InstanceReference(HMCLGameRepository repository, @Nullable String instanceId) {
public record InstanceReference(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
}
/// Directory under the version root that stores HMCL-managed instance metadata.
/// Directory under the instance root that stores HMCL-managed instance metadata.
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
/// Directory under the instance metadata directory that stores instance configuration files.
@@ -100,22 +101,22 @@ public final class HMCLGameRepository extends DefaultGameRepository {
private final GameDirectory gameDirectory;
/// The selected instance ID persisted for this repository's game directory.
private final StringBinding selectedInstance;
private final ObjectBinding<@Nullable GameInstanceID> selectedInstance;
// instance game settings
private final Map<String, GameSettings.Instance> instanceGameSettings = new HashMap<>();
private final Map<GameInstanceID, GameSettings.Instance> instanceGameSettings = new HashMap<>();
/// Instance IDs whose local game settings file has already been checked.
private final Set<String> loadedInstanceGameSettings = new HashSet<>();
private final Set<String> readOnlyInstanceGameSettings = new HashSet<>();
private final Set<String> beingModpackVersions = new HashSet<>();
private final Set<GameInstanceID> loadedInstanceGameSettings = new HashSet<>();
private final Set<GameInstanceID> readOnlyInstanceGameSettings = new HashSet<>();
private final Set<GameInstanceID> beingModpackInstances = new HashSet<>();
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
public final EventManager<Event> onInstanceIconChanged = new EventManager<>();
/// Creates a repository backed by the given game directory.
public HMCLGameRepository(GameDirectory gameDirectory) {
super(gameDirectory.getPath().toPath());
this.gameDirectory = gameDirectory;
this.selectedInstance = Bindings.stringValueAt(settings().getSelectedInstance(), gameDirectory.getId());
this.selectedInstance = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId());
gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath()));
}
@@ -125,26 +126,26 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
/// Returns the selected instance ID property for this repository's game directory.
public StringBinding selectedInstanceProperty() {
public Binding<@Nullable GameInstanceID> selectedInstanceProperty() {
return selectedInstance;
}
/// Returns the selected instance ID for this repository's game directory.
public @Nullable String getSelectedInstance() {
public @Nullable GameInstanceID getSelectedInstance() {
return selectedInstance.get();
}
/// Sets the selected instance ID for this repository's game directory.
public void setSelectedInstance(@Nullable String instance) {
settings().setSelectedInstance(gameDirectory.getId(), instance);
public void setSelectedInstance(@Nullable GameInstanceID instanceId) {
settings().setSelectedInstance(gameDirectory.getId(), instanceId);
}
/// Refreshes the selected instance ID after versions are loaded.
/// Refreshes the selected instance ID after instances are loaded.
public void refreshSelectedInstance() {
@Nullable String selectedInstance = settings().getSelectedInstance(gameDirectory.getId());
@Nullable String refreshedInstance = selectedInstance;
if (!hasVersion(refreshedInstance)) {
refreshedInstance = getVersions().isEmpty() ? null : getVersions().iterator().next().getId();
@Nullable GameInstanceID selectedInstance = settings().getSelectedInstance(gameDirectory.getId());
@Nullable GameInstanceID refreshedInstance = selectedInstance;
if (refreshedInstance == null || !hasInstance(refreshedInstance)) {
refreshedInstance = getInstanceManifests().isEmpty() ? null : getInstanceManifests().iterator().next().id();
}
if (!Objects.equals(selectedInstance, refreshedInstance)) {
setSelectedInstance(refreshedInstance);
@@ -162,24 +163,24 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public Path getRunDirectory(String id) {
if (beingModpackVersions.contains(id) || isModpack(id)) {
return getVersionRoot(id);
public Path getRunDirectory(GameInstanceID instanceId) {
if (beingModpackInstances.contains(instanceId) || isModpack(instanceId)) {
return getInstanceRoot(instanceId);
}
GameSettings.Instance localSetting = getInstanceGameSettings(id);
GameSettings.Instance localSetting = getInstanceGameSettings(instanceId);
boolean useInstanceRunningDirectory =
localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY);
String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory);
if (StringUtils.isBlank(runningDirectory)) {
return useInstanceRunningDirectory ? getVersionRoot(id) : super.getRunDirectory(id);
return useInstanceRunningDirectory ? getInstanceRoot(instanceId) : super.getRunDirectory(instanceId);
}
try {
return Path.of(runningDirectory);
} catch (InvalidPathException ignored) {
return getVersionRoot(id);
return getInstanceRoot(instanceId);
}
}
@@ -201,24 +202,24 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), "");
}
public Stream<Version> getDisplayVersions() {
return getVersions().stream()
public Stream<GameInstanceManifest> getDisplayInstanceManifests() {
return getInstanceManifests().stream()
.filter(v -> !v.isHidden())
.sorted(Comparator.comparing((Version v) -> Lang.requireNonNullElse(v.getReleaseTime(), Instant.EPOCH))
.thenComparing(v -> VersionNumber.asVersion(v.getId())));
.sorted(Comparator.comparing((GameInstanceManifest v) -> Lang.requireNonNullElse(v.releaseTime(), Instant.EPOCH))
.thenComparing(v -> VersionNumber.asVersion(v.id().id())));
}
@Override
protected void refreshVersionsImpl() {
protected void refreshImpl() {
instanceGameSettings.clear();
loadedInstanceGameSettings.clear();
readOnlyInstanceGameSettings.clear();
super.refreshVersionsImpl();
versions.keySet().forEach(this::loadInstanceGameSettings);
super.refreshImpl();
getInstanceManifests().stream().map(GameInstanceManifest::id).forEach(this::loadInstanceGameSettings);
try {
Path file = getBaseDirectory().resolve("launcher_profiles.json");
if (!Files.exists(file) && !versions.isEmpty()) {
if (!Files.exists(file) && !getInstanceManifests().isEmpty()) {
Files.createDirectories(file.getParent());
Files.writeString(file, PROFILE);
}
@@ -229,7 +230,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
public void changeDirectory(Path newDirectory) {
setBaseDirectory(newDirectory);
refreshVersionsAsync().start();
refreshAsync().start();
}
private void clean(Path directory) throws IOException {
@@ -237,43 +238,56 @@ public final class HMCLGameRepository extends DefaultGameRepository {
FileUtils.deleteDirectory(directory.resolve("logs"));
}
public void clean(String id) throws IOException {
public void clean(GameInstanceID instanceId) throws IOException {
clean(getBaseDirectory());
clean(getRunDirectory(id));
clean(getRunDirectory(instanceId));
}
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
Path srcDir = getVersionRoot(srcId);
Path dstDir = getVersionRoot(dstId);
/// Removes an instance from disk and clears its cached HMCL settings state.
@Override
public boolean removeInstanceFromDisk(GameInstanceID instanceId) {
boolean removed = super.removeInstanceFromDisk(instanceId);
if (removed) {
instanceGameSettings.remove(instanceId);
loadedInstanceGameSettings.remove(instanceId);
readOnlyInstanceGameSettings.remove(instanceId);
beingModpackInstances.remove(instanceId);
}
return removed;
}
Version fromVersion = getVersion(srcId);
public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException {
Path srcDir = getInstanceRoot(srcId);
Path dstDir = getInstanceRoot(dstId);
GameInstanceManifest fromManifest = getInstanceManifest(srcId);
List<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
blackList.add(srcId + ".jar");
blackList.add(srcId + ".json");
blackList.add(srcId.id() + ".jar");
blackList.add(srcId.id() + ".json");
if (!copySaves)
blackList.add("saves");
if (Files.exists(dstDir)) throw new IOException("Version exists");
if (Files.exists(dstDir)) throw new IOException("Instance exists");
Files.createDirectories(dstDir);
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
Path fromJson = srcDir.resolve(srcId + ".json");
Path fromJar = srcDir.resolve(srcId + ".jar");
Path toJson = dstDir.resolve(dstId + ".json");
Path toJar = dstDir.resolve(dstId + ".jar");
Path fromJson = srcDir.resolve(srcId.id() + ".json");
Path fromJar = srcDir.resolve(srcId.id() + ".jar");
Path toJson = dstDir.resolve(dstId.id() + ".json");
Path toJar = dstDir.resolve(dstId.id() + ".jar");
if (Files.exists(fromJar)) {
Files.copy(fromJar, toJar);
}
Files.copy(fromJson, toJson);
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
JsonUtils.writeToJsonFile(toJson, fromManifest.withId(dstId).withJar(dstId));
boolean copyOriginalGameDir;
try {
copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getVersionRoot(srcId));
copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getInstanceRoot(srcId));
} catch (IOException e) {
copyOriginalGameDir = true;
}
@@ -284,7 +298,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
newGameSettings.runningDirectoryProperty().setValue("");
initInstanceGameSettings(dstId, newGameSettings);
saveGameSettings(dstId);
saveGameSettingsSync(dstId);
Path dstGameDir = getRunDirectory(dstId);
@@ -292,73 +306,70 @@ public final class HMCLGameRepository extends DefaultGameRepository {
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
}
private GameSettings.Instance copyInstanceGameSettings(String id) {
GameSettings.Instance setting = getInstanceGameSettings(id);
private GameSettings.Instance copyInstanceGameSettings(GameInstanceID instanceId) {
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
if (setting != null) {
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
}
GameSettings.Instance copied = new GameSettings.Instance();
copied.parentProperty().setValue(getEffectiveGameSettings(id).getPreset().idProperty().getValue());
copied.parentProperty().setValue(getEffectiveGameSettings(instanceId).getPreset().idProperty().getValue());
return copied;
}
/// Returns the HMCL-managed metadata directory under the version root.
/// Returns the HMCL-managed metadata directory under the instance root.
///
/// This directory stores instance-scoped files owned by HMCL.
public Path getInstanceMetadataDirectory(String id) {
return getVersionRoot(id).resolve(INSTANCE_METADATA_DIRECTORY);
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
}
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
public Path getInstanceConfigDirectory(String id) {
return getInstanceMetadataDirectory(id).resolve(INSTANCE_CONFIG_DIRECTORY);
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
}
/// Returns the HMCL-managed state directory under the instance metadata directory.
public Path getInstanceStateDirectory(String id) {
return getInstanceMetadataDirectory(id).resolve(INSTANCE_STATE_DIRECTORY);
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
}
/// Returns the current local game settings path under the instance configuration directory.
private Path getInstanceGameSettingsFile(String id) {
return getInstanceConfigDirectory(id).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
private Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
}
private void loadInstanceGameSettings(String id) {
loadedInstanceGameSettings.add(id);
InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(id));
private void loadInstanceGameSettings(GameInstanceID instanceId) {
loadedInstanceGameSettings.add(instanceId);
InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(instanceId));
if (result.setting() != null) {
initInstanceGameSettings(id, result.setting(), result.allowSave());
initInstanceGameSettings(instanceId, result.setting(), result.allowSave());
return;
}
if (!result.allowSave()) {
readOnlyInstanceGameSettings.add(id);
readOnlyInstanceGameSettings.add(instanceId);
return;
}
@Nullable GameSettingsPresetID legacyParent = gameDirectory.getLegacyGameSettings();
if (SettingsManager.getGameSettings(legacyParent) == null) {
legacyParent = null;
}
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
this, id,
getParentGameSettings(null).idProperty().getValue());
this, instanceId,
legacyParent);
if (migrationResult != null) {
initInstanceGameSettings(id, migrationResult.setting());
initInstanceGameSettings(instanceId, migrationResult.setting());
try {
saveGameSettingsSync(id);
saveGameSettingsSync(instanceId);
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + id, e);
LOG.warning("Failed to save migrated instance game settings for " + instanceId, e);
}
return;
}
GameSettings.Preset gameDirectoryPreset = SettingsManager.getGameSettings(gameDirectory.getLegacyGameSettings());
if (gameDirectoryPreset != null && gameDirectoryPreset.defaultIsolationTypeProperty().getValue() == DefaultIsolationType.ALWAYS) {
GameSettings.Instance setting = new GameSettings.Instance();
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
initInstanceGameSettings(id, setting);
saveGameSettings(id);
}
}
/// Loads a new-format instance game settings file.
@@ -385,8 +396,9 @@ public final class HMCLGameRepository extends DefaultGameRepository {
+ file + ", Actual: " + schemaResult.actual());
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: "
+ GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual());
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: "
+ GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual());
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA ->
LOG.warning("Unsupported instance game settings schema. Expected: "
+ GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual());
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
@@ -419,35 +431,35 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public @Nullable GameSettings.Instance createInstanceGameSettings(String id) {
if (!hasVersion(id)) {
public @Nullable GameSettings.Instance createInstanceGameSettings(GameInstanceID instanceId) {
if (!hasInstance(instanceId)) {
return null;
}
if (readOnlyInstanceGameSettings.contains(id)) {
if (readOnlyInstanceGameSettings.contains(instanceId)) {
return null;
}
if (instanceGameSettings.containsKey(id)) {
return getInstanceGameSettings(id);
if (instanceGameSettings.containsKey(instanceId)) {
return getInstanceGameSettings(instanceId);
}
GameSettings.Instance setting = new GameSettings.Instance();
return initInstanceGameSettings(id, setting);
return initInstanceGameSettings(instanceId, setting);
}
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting) {
return initInstanceGameSettings(id, setting, true);
private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting) {
return initInstanceGameSettings(instanceId, setting, true);
}
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting, boolean allowSave) {
private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting, boolean allowSave) {
normalizeRunningDirectoryOverride(setting);
setting.setSavable(allowSave);
loadedInstanceGameSettings.add(id);
instanceGameSettings.put(id, setting);
loadedInstanceGameSettings.add(instanceId);
instanceGameSettings.put(instanceId, setting);
if (allowSave) {
readOnlyInstanceGameSettings.remove(id);
setting.addListener(a -> saveGameSettings(id));
readOnlyInstanceGameSettings.remove(instanceId);
setting.addListener(a -> saveGameSettings(instanceId));
} else {
readOnlyInstanceGameSettings.add(id);
readOnlyInstanceGameSettings.add(instanceId);
}
return setting;
}
@@ -460,98 +472,124 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Nullable
public GameSettings.Instance getInstanceGameSettings(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
public GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) {
if (!loadedInstanceGameSettings.contains(instanceId)) {
loadInstanceGameSettings(instanceId);
}
return instanceGameSettings.get(id);
return instanceGameSettings.get(instanceId);
}
@Nullable
public GameSettings.Instance getInstanceGameSettingsOrCreate(String id) {
GameSettings.Instance setting = getInstanceGameSettings(id);
public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) {
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
if (setting == null) {
setting = createInstanceGameSettings(id);
setting = createInstanceGameSettings(instanceId);
}
return setting;
}
/// Returns whether the instance-specific game settings file cannot be overwritten safely.
///
/// @param id the instance ID
/// @param instanceId the instance ID
/// @return whether the instance settings are loaded in read-only mode
public boolean isInstanceGameSettingsReadOnly(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
public boolean isInstanceGameSettingsReadOnly(GameInstanceID instanceId) {
if (!loadedInstanceGameSettings.contains(instanceId)) {
loadInstanceGameSettings(instanceId);
}
return readOnlyInstanceGameSettings.contains(id);
return readOnlyInstanceGameSettings.contains(instanceId);
}
/// Backs up and overwrites the instance-specific game settings file with the currently loaded settings.
///
/// @param id the instance ID
public void forceOverwriteInstanceGameSettings(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
/// @param instanceId the instance ID
public void forceOverwriteInstanceGameSettings(GameInstanceID instanceId) {
if (!loadedInstanceGameSettings.contains(instanceId)) {
loadInstanceGameSettings(instanceId);
}
GameSettings.Instance setting = instanceGameSettings.get(id);
GameSettings.Instance setting = instanceGameSettings.get(instanceId);
if (setting == null) {
setting = new GameSettings.Instance();
instanceGameSettings.put(id, setting);
loadedInstanceGameSettings.add(id);
instanceGameSettings.put(instanceId, setting);
loadedInstanceGameSettings.add(instanceId);
}
boolean installAutoSave = !setting.isSavable();
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
SettingFileUtils.backupInvalidConfig(file);
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
setting.setSavable(true);
setting.setBackupOnNextSave(false);
readOnlyInstanceGameSettings.remove(id);
saveGameSettings(id);
readOnlyInstanceGameSettings.remove(instanceId);
saveGameSettings(instanceId);
if (installAutoSave) {
setting.addListener(a -> saveGameSettings(id));
setting.addListener(a -> saveGameSettings(instanceId));
}
}
/// Returns the explicit parent preset of the instance, falling back to the default preset.
public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) {
@Nullable GameSettingsPresetID parent = instance != null && instance.parentProperty().getValue() != null
? instance.parentProperty().getValue()
: gameDirectory.getLegacyGameSettings();
@Nullable GameSettingsPresetID parent = instance != null ? instance.parentProperty().getValue() : null;
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
}
public GameSettings.Effective getEffectiveGameSettings(String id) {
GameSettings.Instance instance = getInstanceGameSettings(id);
public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) {
GameSettings.Instance instance = getInstanceGameSettings(instanceId);
return GameSettings.resolve(getParentGameSettings(instance), instance);
}
public void applyDefaultIsolationSetting(String id) {
if (!hasVersion(id)) {
public void applyDefaultIsolationSetting(GameInstanceID instanceId) {
if (!hasInstance(instanceId)) {
return;
}
GameSettings.Preset preset = getParentGameSettings(null);
GameSettings.Instance instanceSetting = getInstanceGameSettings(instanceId);
GameSettings.Preset preset = getParentGameSettings(instanceSetting);
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
boolean isolated = switch (type) {
case NEVER -> false;
case ALWAYS -> true;
case MODDED -> LibraryAnalyzer.isModded(this, getVersion(id).resolve(this));
case MODDED -> LibraryAnalyzer.isModded(getResolvedInstanceManifest(instanceId));
};
if (isolated) {
GameSettings.Instance setting = getInstanceGameSettingsOrCreate(id);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
GameSettings.Instance setting = instanceSetting != null ? instanceSetting : getInstanceGameSettingsOrCreate(instanceId);
if (setting != null && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveGameSettings(instanceId);
}
}
}
public Optional<Path> getVersionIconFile(String id) {
Path root = getVersionRoot(id);
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
public boolean shouldIsolateNewInstance(boolean modded) {
GameSettings.Preset preset = getParentGameSettings(null);
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
return switch (type) {
case NEVER -> false;
case ALWAYS -> true;
case MODDED -> modded;
};
}
/// Applies default isolation to a new instance before its manifest is saved.
public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId, boolean modded) {
if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(instanceId)) {
return;
}
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
if (setting == null) {
setting = initInstanceGameSettings(instanceId, new GameSettings.Instance());
}
if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveGameSettings(instanceId);
}
}
public Optional<Path> getInstanceIconFile(GameInstanceID instanceId) {
Path root = getInstanceRoot(instanceId);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
@@ -563,19 +601,19 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return Optional.empty();
}
public void setVersionIconFile(String id, Path iconFile) throws IOException {
public void setInstanceIconFile(GameInstanceID instanceId, Path iconFile) throws IOException {
String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) {
throw new IllegalArgumentException("Unsupported icon file: " + ext);
}
deleteIconFile(id);
deleteIconFile(instanceId);
FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext));
FileUtils.copyFile(iconFile, getInstanceRoot(instanceId).resolve("icon." + ext));
}
public void deleteIconFile(String id) {
Path root = getVersionRoot(id);
public void deleteIconFile(GameInstanceID instanceId) {
Path root = getInstanceRoot(instanceId);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
try {
@@ -586,69 +624,69 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public Image getVersionIconImage(@Nullable String id) {
if (id == null || !isLoaded())
return VersionIconType.DEFAULT.getIcon();
public Image getInstanceIconImage(@Nullable GameInstanceID instanceId) {
if (instanceId == null || !isLoaded())
return GameInstanceIconType.DEFAULT.getIcon();
GameSettings.Instance setting = getInstanceGameSettings(id);
VersionIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
GameInstanceIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) : GameInstanceIconType.DEFAULT;
if (iconType == VersionIconType.DEFAULT) {
Version version = getVersion(id).resolve(this);
Optional<Path> iconFile = getVersionIconFile(id);
if (iconType == GameInstanceIconType.DEFAULT) {
GameInstanceManifest.Resolved resolvedInstanceManifest = getResolvedInstanceManifest(instanceId);
Optional<Path> iconFile = getInstanceIconFile(instanceId);
if (iconFile.isPresent()) {
try {
return FXUtils.loadImage(iconFile.get(), 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load version icon of " + id, e);
LOG.warning("Failed to load instance icon of " + instanceId, e);
}
}
if (LibraryAnalyzer.isModded(this, version)) {
LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(version, null);
if (LibraryAnalyzer.isModded(resolvedInstanceManifest)) {
LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, null);
if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC))
return VersionIconType.FABRIC.getIcon();
return GameInstanceIconType.FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
return VersionIconType.QUILT.getIcon();
return GameInstanceIconType.QUILT.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC))
return VersionIconType.LEGACY_FABRIC.getIcon();
return GameInstanceIconType.LEGACY_FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
return VersionIconType.NEO_FORGE.getIcon();
return GameInstanceIconType.NEO_FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
return VersionIconType.FORGE.getIcon();
return GameInstanceIconType.FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
return VersionIconType.CLEANROOM.getIcon();
return GameInstanceIconType.CLEANROOM.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER))
return VersionIconType.CHICKEN.getIcon();
return GameInstanceIconType.CHICKEN.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE))
return VersionIconType.OPTIFINE.getIcon();
return GameInstanceIconType.OPTIFINE.getIcon();
}
String gameVersion = getGameVersion(version).orElse(null);
String gameVersion = getGameVersion(resolvedInstanceManifest.launchManifest()).orElse(null);
if (gameVersion != null) {
GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion);
if (versionNumber.isAprilFools()) {
return VersionIconType.APRIL_FOOLS.getIcon();
return GameInstanceIconType.APRIL_FOOLS.getIcon();
} else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) {
return VersionIconType.COMMAND.getIcon();
return GameInstanceIconType.COMMAND.getIcon();
} else if (versionNumber instanceof GameVersionNumber.Old) {
return VersionIconType.CRAFT_TABLE.getIcon();
return GameInstanceIconType.CRAFT_TABLE.getIcon();
}
}
return VersionIconType.GRASS.getIcon();
return GameInstanceIconType.GRASS.getIcon();
} else {
return iconType.getIcon();
}
}
public void saveGameSettings(String id) {
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id))
public void saveGameSettings(GameInstanceID instanceId) {
if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId))
return;
GameSettings.Instance setting = instanceGameSettings.get(id);
GameSettings.Instance setting = instanceGameSettings.get(instanceId);
if (setting == null) {
return;
}
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
@@ -664,19 +702,19 @@ public final class HMCLGameRepository extends DefaultGameRepository {
/// Saves instance-specific game settings synchronously.
///
/// @param id the instance ID
/// @param instanceId the instance ID
/// @throws IOException if saving the file fails
private void saveGameSettingsSync(String id) throws IOException {
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id)) {
private void saveGameSettingsSync(GameInstanceID instanceId) throws IOException {
if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId)) {
return;
}
GameSettings.Instance setting = instanceGameSettings.get(id);
GameSettings.Instance setting = instanceGameSettings.get(instanceId);
if (setting == null) {
return;
}
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
@@ -687,35 +725,42 @@ public final class HMCLGameRepository extends DefaultGameRepository {
/// Result of loading an instance-specific game settings file.
///
/// @param setting the loaded instance settings, or `null` when unavailable
/// @param setting the loaded instance settings, or `null` when unavailable
/// @param allowSave whether the file may be overwritten
private record InstanceGameSettingsLoadResult(
@Nullable GameSettings.Instance setting,
boolean allowSave) {
}
public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveGameSettings(version);
public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveGameSettings(instanceId);
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.get(GameSettings::autoMemoryProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(instanceId));
@Nullable Integer maxMemory;
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(instanceId)
.setGameDir(gameDir)
.setJava(javaVersion)
.setVersionType(Metadata.TITLE)
.setVersionName(version)
.setVersionName(instanceId.id())
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.get(GameSettings::gameArgumentsProperty)))
.setOverrideJavaArguments(StringUtils.tokenize(vs.get(GameSettings::jvmOptionsProperty)))
.setMaxMemory(noJVMOptions && autoMemory ? null : (int) (getAllocatedMemory(
vs.getMaxMemory() * 1024L * 1024L,
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
autoMemory
) / 1024 / 1024))
.setMinMemory(vs.get(GameSettings::minMemoryProperty))
.setMetaspace(Lang.toIntOrNull(vs.get(GameSettings::permSizeProperty)))
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
.setMaxMemory(maxMemory)
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
.setEnvironmentVariables(
Lang.mapOf(StringUtils.tokenize(vs.get(GameSettings::environmentVariablesProperty))
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
.stream()
.map(it -> {
int idx = it.indexOf('=');
@@ -733,16 +778,16 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
.setNoGeneratedJVMArgs(noJVMOptions)
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
.setUseCustomNatives(vs.get(GameSettings::useCustomNativesProperty))
.setNativesDir(vs.get(GameSettings::nativesDirectoryProperty))
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
.setRenderer(vs.getRenderer())
.setRenderer(vs.getRenderer(gameVersionNumber))
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
.setUseNativeGLFW(vs.get(GameSettings::useNativeGLFWProperty))
.setUseNativeOpenAL(vs.get(GameSettings::useNativeOpenALProperty))
.setUseNativeGLFW(vs.getInheritable(GameSettings::useNativeGLFWProperty))
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
@@ -752,7 +797,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
builder.setQuickPlayOption(quickPlayOption);
}
Path json = getModpackConfiguration(version);
Path json = getModpackConfiguration(instanceId);
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
@@ -771,27 +816,27 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public Path getModpackConfiguration(String version) {
return getVersionRoot(version).resolve("modpack.cfg");
public Path getModpackConfiguration(GameInstanceID instanceId) {
return getInstanceRoot(instanceId).resolve("modpack.cfg");
}
public void markVersionAsModpack(String id) {
beingModpackVersions.add(id);
public void markInstanceAsModpack(GameInstanceID instanceId) {
beingModpackInstances.add(instanceId);
}
public void undoMark(String id) {
beingModpackVersions.remove(id);
public void undoMark(GameInstanceID instanceId) {
beingModpackInstances.remove(instanceId);
}
public void markVersionLaunchedAbnormally(String id) {
public void markInstanceLaunchedAbnormally(GameInstanceID instanceId) {
try {
Files.createFile(getVersionRoot(id).resolve(".abnormal"));
Files.createFile(getInstanceRoot(instanceId).resolve(".abnormal"));
} catch (IOException ignored) {
}
}
public boolean unmarkVersionLaunchedAbnormally(String id) {
Path file = getVersionRoot(id).resolve(".abnormal");
public boolean unmarkInstanceLaunchedAbnormally(GameInstanceID instanceId) {
Path file = getInstanceRoot(instanceId).resolve(".abnormal");
if (Files.isRegularFile(file)) {
try {
Files.delete(file);
@@ -808,54 +853,60 @@ public final class HMCLGameRepository extends DefaultGameRepository {
private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}";
// These version ids are forbidden because they may conflict with modpack configuration filenames
private static final Set<String> FORBIDDEN_VERSION_IDS = new HashSet<>(Arrays.asList(
"modpack", "minecraftinstance", "manifest"));
// These instance ids are forbidden because they may conflict with modpack configuration filenames
private static final Set<String> FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest");
public static boolean isValidVersionId(String id) {
if (FORBIDDEN_VERSION_IDS.contains(id))
public static boolean isValidInstanceId(String id) {
if (FORBIDDEN_INSTANCE_IDS.contains(id))
return false;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS &&
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
FORBIDDEN_INSTANCE_IDS.contains(id.toLowerCase(Locale.ROOT)))
return false;
return FileUtils.isNameValidForJar(id);
}
/**
* Returns true if the given version id conflicts with an existing version.
* Returns true if the given instance id conflicts with an existing instance.
*/
public boolean versionIdConflicts(String id) {
public boolean instanceIdConflicts(String instanceId) {
try {
return instanceIdConflicts(new GameInstanceID(instanceId));
} catch (IllegalArgumentException e) {
return false;
}
}
public boolean instanceIdConflicts(GameInstanceID id) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// on Windows, filenames are case-insensitive
for (String existingId : versions.keySet()) {
if (existingId.equalsIgnoreCase(id)) {
for (GameInstanceManifest manifest : getInstanceManifests()) {
if (manifest.id().toString().equalsIgnoreCase(id.toString())) {
return true;
}
}
return false;
} else {
return versions.containsKey(id);
return hasInstance(id);
}
}
public static long getAllocatedMemory(long minimum, long available, boolean auto) {
if (auto) {
available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
if (available <= 0) {
return minimum;
}
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
final long suggested = Math.min(available <= threshold
? (long) (available * 0.8)
: (long) (threshold * 0.8 + (available - threshold) * 0.2),
16L * 1024 * 1024 * 1024);
return Math.max(minimum, suggested);
} else {
return minimum;
public static long getAutoAllocatedMemory(long available) {
long usable = available - 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
if (usable <= 0) {
return available;
}
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
final long suggested;
if (usable <= threshold)
suggested = (long) (usable * 0.8);
else
suggested = Math.min(
(long) (threshold * 0.8 + (usable - threshold) * 0.2),
16L * 1024 * 1024 * 1024);
return suggested;
}
public static ProxyOption getProxyOption() {
@@ -37,29 +37,29 @@ import java.util.List;
public final class HMCLModpackInstallTask extends Task<Void> {
private final Path zipFile;
private final String name;
private final GameInstanceID instanceId;
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependency;
private final Modpack modpack;
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final List<Task<?>> dependents = new ArrayList<>(4);
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, String name) {
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, GameInstanceID instanceId) {
this.repository = repository;
this.dependency = repository.getDependency();
this.zipFile = zipFile;
this.name = name;
this.instanceId = instanceId;
this.modpack = modpack;
Path run = repository.getRunDirectory(name);
Path json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && Files.notExists(json))
throw new IllegalArgumentException("Version " + name + " already exists");
Path run = repository.getRunDirectory(this.instanceId);
Path json = repository.getModpackConfiguration(this.instanceId);
if (repository.hasInstance(this.instanceId) && Files.notExists(json))
throw new IllegalArgumentException("Instance " + instanceId + " already exists");
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
dependents.add(dependency.newGameBuilder().name(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync());
onDone().register(event -> {
if (event.isFailed()) repository.removeVersionFromDisk(name);
if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId);
});
ModpackConfiguration<Modpack> config = null;
@@ -68,12 +68,12 @@ public final class HMCLModpackInstallTask extends Task<Void> {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
}
} catch (JsonParseException | IOException ignore) {
}
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(this.instanceId)).withStage("hmcl.modpack"));
}
@Override
@@ -89,9 +89,9 @@ public final class HMCLModpackInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
GameInstanceManifest originalManifest = JsonUtils.GSON.fromJson(json, GameInstanceManifest.class).withId(instanceId).withJar(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalManifest, null);
Task<GameInstanceManifest> libraryTask = Task.supplyAsync(() -> originalManifest);
// reinstall libraries
// libraries of Forge and OptiFine should be obtained by installation.
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
@@ -42,12 +42,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) {
return null;
}
@Override
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -55,7 +55,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(repository, zipFile, modpack, name));
return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new HMCLModpackInstallTask(repository, zipFile, modpack, instanceId));
}
@Override
@@ -63,21 +63,21 @@ public final class HMCLModpackProvider implements ModpackProvider {
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
if (game.getJar() == null)
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
if (game.jar() == null)
if (StringUtils.isBlank(manifest.getVersion()))
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
else
manifest.setManifest(HMCLModpackManifest.INSTANCE);
else
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
return manifest;
}
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, name);
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
}
}
@@ -34,7 +34,10 @@ import org.jackhuang.hmcl.launch.*;
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
@@ -47,8 +50,10 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -57,6 +62,7 @@ import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
@@ -71,6 +77,7 @@ import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.logging.Logger.TIME_FORMATTER;
import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
public final class LauncherHelper {
@@ -79,7 +86,7 @@ public final class LauncherHelper {
private final HMCLGameRepository repository;
private Account account;
private final String selectedVersion;
private final GameInstanceID selectedInstanceId;
private Path scriptFile;
private final GameSettings.Effective setting;
private LauncherVisibility launcherVisibility;
@@ -87,14 +94,14 @@ public final class LauncherHelper {
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(HMCLGameRepository repository, Account account, String selectedVersion) {
public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) {
this.repository = Objects.requireNonNull(repository);
this.account = Objects.requireNonNull(account);
this.selectedVersion = Objects.requireNonNull(selectedVersion);
this.setting = repository.getEffectiveGameSettings(selectedVersion);
this.selectedInstanceId = selectedInstanceId;
this.setting = repository.getEffectiveGameSettings(selectedInstanceId);
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("version.launch"));
this.launchingStepsPane.setTitle(i18n("instance.launch"));
}
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
@@ -127,7 +134,7 @@ public final class LauncherHelper {
public void launch() {
FXUtils.checkFxUserThread();
LOG.info("Launching game version: " + selectedVersion);
LOG.info("Launching game version: " + selectedInstanceId);
Controllers.dialog(launchingStepsPane);
launch0();
@@ -143,9 +150,9 @@ public final class LauncherHelper {
PROCESSES.removeIf(it -> it.get() == null);
DefaultDependencyManager dependencyManager = repository.getDependency();
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
AtomicReference<GameInstanceManifest> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedInstanceManifest(selectedInstanceId).launchManifest()));
Optional<String> gameVersion = repository.getGameVersion(version.get());
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
boolean integrityCheck = repository.unmarkInstanceLaunchedAbnormally(selectedInstanceId);
CountDownLatch launchingLatch = new CountDownLatch(1);
List<String> javaAgents = new ArrayList<>(0);
List<String> javaArguments = new ArrayList<>(0);
@@ -162,17 +169,17 @@ public final class LauncherHelper {
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
Task.composeAsync(() -> {
try {
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedInstanceId));
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
if (provider == null) return null;
else return provider.createCompletionTask(dependencyManager, selectedVersion);
else return provider.createCompletionTask(dependencyManager, selectedInstanceId);
} catch (IOException e) {
return null;
}
}),
Task.composeAsync(() -> {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|| !(setting.getRenderer() instanceof Renderer.Driver renderer)
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer)
|| renderer.mesaDriverName() == null)
return null;
@@ -197,7 +204,12 @@ public final class LauncherHelper {
})
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> {
if (gameVersion.isEmpty()) {
return null;
}
return new GameVerificationFixTask(dependencyManager, gameVersion.get(), version.get());
})
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
@@ -223,7 +235,7 @@ public final class LauncherHelper {
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
selectedInstanceId, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
@@ -231,9 +243,39 @@ public final class LauncherHelper {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(javaVersionRef.get().getBinary());
try {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object current = reg.queryValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg
);
if (current == null) {
boolean result = reg.setValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg,
"GpuPreference=2;"
);
if (result)
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
else
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
} else {
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
}
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
}
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedInstanceId), 10));
return new HMCLGameLauncher(
repository,
@@ -266,7 +308,7 @@ public final class LauncherHelper {
} else {
runLater(() -> {
launchingStepsPane.fireEvent(new DialogCloseEvent());
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
});
}
}).withFakeProgress(
@@ -305,7 +347,7 @@ public final class LauncherHelper {
} else if (ex instanceof NotDecompressingNativesException) {
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
} else if (ex instanceof LibraryDownloadException) {
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
int responseCode = rce.getResponseCode();
@@ -368,7 +410,7 @@ public final class LauncherHelper {
message = StringUtils.getStackTrace(ex);
}
Controllers.dialog(message,
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
MessageType.ERROR);
}
}
@@ -381,13 +423,13 @@ public final class LauncherHelper {
executor.start();
}
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, Version version) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, GameInstanceManifest manifest) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null));
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
try {
return setting.getJava(gameVersion, version);
return setting.getJava(gameVersion, manifest);
} catch (InterruptedException e) {
throw new CancellationException();
}
@@ -445,7 +487,7 @@ public final class LauncherHelper {
}
if (targetJavaVersion == null)
targetJavaVersion = version.getJavaVersion();
targetJavaVersion = manifest.javaVersion();
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
@@ -475,8 +517,8 @@ public final class LauncherHelper {
if (java != null) {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.isMandatory()) {
violatedMandatoryConstraints.add(constraint);
} else {
@@ -492,7 +534,7 @@ public final class LauncherHelper {
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
if (suggestedJava != null) {
FXUtils.runInFX(() -> {
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
@@ -519,7 +561,7 @@ public final class LauncherHelper {
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
gameJavaVersion = manifest.javaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
else
@@ -539,7 +581,7 @@ public final class LauncherHelper {
}
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
if (!setting.get(GameSettings::useCustomNativesProperty)) {
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)) {
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
return result;
} else {
@@ -576,7 +618,7 @@ public final class LauncherHelper {
}
// 32-bit JVM cannot make use of too much memory.
if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) {
if (java.getBits() == Bits.BIT_32 && !setting.getInheritable(GameSettings::autoMemoryProperty) && setting.getMaxMemory() > 1.5 * 1024) {
// 1.5 * 1024 is an inaccurate number.
// Actual memory limit depends on operating system and memory.
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
@@ -623,7 +665,7 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.modlauncher8"));
break;
case VANILLA_X86:
if (!setting.get(GameSettings::useCustomNativesProperty)
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
&& Platform.isSupportedTranslationX86_64()) {
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
}
@@ -635,7 +677,7 @@ public final class LauncherHelper {
// Cannot allocate too much memory exceeding free space.
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) {
if (totalMemorySizeMB > 0 && !setting.getInheritable(GameSettings::autoMemoryProperty) && totalMemorySizeMB < setting.getMaxMemory()) {
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
}
@@ -645,14 +687,14 @@ public final class LauncherHelper {
// Forge 2760~2773 will crash game with LiteLoader.
boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0);
boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
boolean hasLiteLoader = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
}
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0);
boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
boolean hasOptiFine = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
}
@@ -755,7 +797,10 @@ public final class LauncherHelper {
case HIDE_AND_REOPEN:
runLater(() -> {
Optional.ofNullable(Controllers.getStage())
.ifPresent(Stage::show);
.ifPresent(stage -> {
stage.setIconified(false);
stage.show();
});
});
break;
case KEEP:
@@ -793,7 +838,7 @@ public final class LauncherHelper {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final GameInstanceManifest manifest;
private final LaunchOptions launchOptions;
private ManagedProcess process;
private volatile boolean lwjgl;
@@ -805,9 +850,9 @@ public final class LauncherHelper {
private Thread submitLogThread;
private LinkedBlockingQueue<Log> logBuffer;
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
public HMCLProcessListener(HMCLGameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.repository = repository;
this.version = version;
this.manifest = manifest;
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
@@ -890,8 +935,10 @@ public final class LauncherHelper {
runLater(() -> {
// If application was stopped and execution services did not finish termination,
// these codes will be executed.
if (Controllers.getStage() != null) {
Controllers.getStage().hide();
@Nullable Stage stage = Controllers.getStage();
if (stage != null) {
stage.setIconified(false);
stage.hide();
launchingLatch.countDown();
}
});
@@ -964,7 +1011,7 @@ public final class LauncherHelper {
@Override
public void onExit(int exitCode, ExitType exitType) {
if (showLogs) {
logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO));
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
submitLogThread.interrupt();
try {
submitLogThread.join();
@@ -990,8 +1037,8 @@ public final class LauncherHelper {
}
if (exitType != ExitType.NORMAL) {
repository.markVersionLaunchedAbnormally(version.getId());
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
repository.markInstanceLaunchedAbnormally(manifest.id());
runLater(() -> new GameCrashWindow(process, exitType, repository, manifest, launchOptions, logs).show());
}
checkExit();
@@ -20,7 +20,7 @@ package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.ui.versions.ModTranslations;
import org.jackhuang.hmcl.ui.instances.ModTranslations;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
@@ -123,13 +123,13 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public RemoteAddon getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
}
@Override
public RemoteAddon.File getModFile(String modId, String fileId) throws IOException {
return getBackedRemoteModRepository().getModFile(modId, fileId);
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
}
@Override
@@ -17,8 +17,7 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import kala.encdet.EncodingDetector;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -42,22 +41,22 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript,
PathMatcher logMatcher) {
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
Path runDirectory = repository.getRunDirectory(instanceId);
Path baseDirectory = repository.getBaseDirectory();
List<GameInstanceID> instances = new ArrayList<>();
String currentVersionId = versionId;
HashSet<String> resolvedSoFar = new HashSet<>();
GameInstanceID currentInstanceId = instanceId;
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
while (true) {
if (resolvedSoFar.contains(currentVersionId)) break;
resolvedSoFar.add(currentVersionId);
Version currentVersion = gameRepository.getVersion(currentVersionId);
versions.add(currentVersionId);
if (resolvedSoFar.contains(currentInstanceId)) break;
resolvedSoFar.add(currentInstanceId);
GameInstanceManifest currentVersion = repository.getInstanceManifest(currentInstanceId);
instances.add(currentInstanceId);
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
currentVersionId = currentVersion.getInheritsFrom();
if (currentVersion.inheritsFrom() != null) {
currentInstanceId = currentVersion.inheritsFrom();
} else {
break;
}
@@ -74,10 +73,10 @@ public final class LogExporter {
zipper.putTextFile(logs, "minecraft.log");
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
for (String id : versions) {
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
if (Files.exists(versionJson)) {
zipper.putFile(versionJson, id + ".json");
for (GameInstanceID id : instances) {
Path instanceJson = repository.getInstanceJson(id);
if (Files.exists(instanceJson)) {
zipper.putFile(instanceJson, id + ".json");
}
}
} catch (IOException e) {
@@ -91,7 +90,7 @@ public final class LogExporter {
for (Path file : stream) {
if (Files.isRegularFile(file)) {
if (logMatcher == null || logMatcher.matches(file)) {
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
try (BufferedReader reader = EncodingDetector.MODERN_WEB.newBufferedReader(file)) {
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
} catch (IOException e) {
LOG.warning("Failed to read log file: " + file, e);
@@ -26,6 +26,8 @@ import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
private final Path zipFile;
@@ -36,6 +38,8 @@ public class ManuallyCreatedModpackInstallTask extends Task<Path> {
this.zipFile = zipFile;
this.charset = charset;
this.name = name;
setName(i18n("modpack.installing"));
}
@Override
@@ -156,13 +156,13 @@ public final class ModpackHelper {
}
}
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, String name, Modpack modpack) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
repository.markInstanceAsModpack(instanceId);
ExceptionalRunnable<?> success = () -> {
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
repository.refresh();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.undoMark(instanceId);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
@@ -175,7 +175,7 @@ public final class ModpackHelper {
}
};
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name)
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
@@ -200,13 +200,13 @@ public final class ModpackHelper {
});
}
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, String name, Modpack modpack, String iconUrl) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, String iconUrl) {
repository.markInstanceAsModpack(instanceId);
ExceptionalRunnable<?> success = () -> {
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
repository.refresh();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.undoMark(instanceId);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
@@ -220,45 +220,45 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), name))
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
switch (configuration.getType()) {
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(repository, name, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name))
.thenComposeAsync(repository.refreshVersionsAsync())
return new ModpackUpdateTask(repository, instanceId, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId))
.thenComposeAsync(repository.refreshAsync())
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
default:
throw new UnsupportedModpackException();
}
}
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
ModpackProvider provider = getProviderByType(configuration.getType());
if (provider == null) {
throw new UnsupportedModpackException();
}
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(repository.refreshVersionsAsync());
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.thenComposeAsync(repository.refreshAsync());
else
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(repository.refreshVersionsAsync());
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
.thenComposeAsync(repository.refreshAsync());
}
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
@@ -333,35 +333,35 @@ public final class ModpackHelper {
}
}
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
});
}
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.toGameSettings(manifest, setting);
});
}
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, String version) {
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Effective effective = repository.getEffectiveGameSettings(version);
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY,
GameSettings.PROPERTY_MAX_MEMORY,
GameSettings.PROPERTY_PERM_SIZE
));
setting.autoMemoryProperty().setValue(effective.get(GameSettings::autoMemoryProperty));
setting.minMemoryProperty().setValue(effective.get(GameSettings::minMemoryProperty));
setting.autoMemoryProperty().setValue(effective.getInheritable(GameSettings::autoMemoryProperty));
setting.minMemoryProperty().setValue(effective.getInheritable(GameSettings::minMemoryProperty));
setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory());
setting.permSizeProperty().setValue(effective.get(GameSettings::permSizeProperty));
setting.permSizeProperty().setValue(effective.getInheritable(GameSettings::permSizeProperty));
}
});
}
@@ -45,7 +45,10 @@ import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.WeakHashMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@@ -65,21 +68,10 @@ public final class TexturesLoader {
}
// ==== Texture Loading ====
public static class LoadedTexture {
private final Image image;
private final Map<String, String> metadata;
public LoadedTexture(Image image, Map<String, String> metadata) {
this.image = requireNonNull(image);
this.metadata = requireNonNull(metadata);
}
public Image getImage() {
return image;
}
public Map<String, String> getMetadata() {
return metadata;
public record LoadedTexture(Image image, Map<String, String> metadata) {
public LoadedTexture {
requireNonNull(image);
requireNonNull(metadata);
}
}
@@ -158,7 +150,7 @@ public final class TexturesLoader {
}
public static TextureModel getDefaultModel(UUID uuid) {
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model"))
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).metadata().get("model"))
? TextureModel.WIDE
: TextureModel.SLIM;
}
@@ -168,7 +168,7 @@ public final class HMCLJavaRepository implements JavaRepository {
update.put("component", gameJavaVersion.component());
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
result.remoteFiles().getFiles().forEach((path, file) -> {
result.remoteFiles().files().forEach((path, file) -> {
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
if (downloadInfo != null) {
@@ -190,12 +190,12 @@ public final class HMCLJavaRepository implements JavaRepository {
public Task<JavaRuntime> getInstallJavaTask(Platform platform, String name, Map<String, Object> update, Path archiveFile) {
Path javaDir = getJavaDir(platform, name);
return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> {
if (!result.getInfo().getPlatform().equals(platform))
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
if (!result.info().getPlatform().equals(platform))
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.info().getPlatform());
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
return JavaRuntime.of(executable, result.getInfo(), true);
return JavaRuntime.of(executable, result.info(), true);
});
}
@@ -29,7 +29,7 @@ import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.game.JavaVersionConstraint;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -315,13 +315,13 @@ public final class JavaManager {
}
@Nullable
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, version);
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, manifest);
}
@Nullable
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
LibraryAnalyzer analyzer = manifest != null ? LibraryAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null;
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -342,8 +342,8 @@ public final class JavaManager {
boolean violationSuggested = false;
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.isMandatory()) {
violationMandatory = true;
} else {
@@ -34,33 +34,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
* @author Glavo
*/
@JsonAdapter(JavaManifest.Serializer.class)
public final class JavaManifest {
private final JavaInfo info;
@Nullable
private final Map<String, Object> update;
@Nullable
private final Map<String, JavaLocalFiles.Local> files;
public JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
this.info = info;
this.update = update;
this.files = files;
}
public JavaInfo getInfo() {
return info;
}
public Map<String, Object> getUpdate() {
return update;
}
public Map<String, JavaLocalFiles.Local> getFiles() {
return files;
}
public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
public static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
@@ -69,16 +43,16 @@ public final class JavaManifest {
@Override
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
JsonObject res = new JsonObject();
res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName());
res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName());
res.addProperty("java.version", src.getInfo().getVersion());
res.addProperty("java.vendor", src.getInfo().getVendor());
res.addProperty("os.name", src.info().getPlatform().getOperatingSystem().getCheckedName());
res.addProperty("os.arch", src.info().getPlatform().getArchitecture().getCheckedName());
res.addProperty("java.version", src.info().getVersion());
res.addProperty("java.vendor", src.info().getVendor());
if (src.getUpdate() != null)
res.add("update", context.serialize(src.getUpdate()));
if (src.update() != null)
res.add("update", context.serialize(src.update()));
if (src.getFiles() != null)
res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE));
if (src.files() != null)
res.add("files", context.serialize(src.files(), LOCAL_FILES_TYPE));
return res;
}
@@ -34,8 +34,6 @@ import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
/// Persistent configuration for a game directory.
@JsonAdapter(GameDirectory.Serializer.class)
@NotNullByDefault
@@ -118,7 +116,7 @@ public final class GameDirectory implements Observable {
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
addPropertyChangedListener(onInvalidating(this::invalidate));
addPropertyChangedListener(arg -> invalidate());
}
/// Returns a debug string containing the game directory path and display metadata.
@@ -23,7 +23,8 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -39,6 +40,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
@@ -54,6 +56,14 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@NotNullByDefault
public final class GameDirectoryManager {
/// The stable ID shared by newly created and migrated current-workspace game directories.
static final GameDirectoryID DEFAULT_GAME_DIRECTORY_ID =
new GameDirectoryID(new UUID(0x7105bc1f490e5e8cL, 0x878cf5844c3d4bc3L));
/// The stable ID shared by newly created and migrated user-home game directories.
static final GameDirectoryID HOME_GAME_DIRECTORY_ID =
new GameDirectoryID(new UUID(0xf3eafde8506e5a77L, 0xbc88f24b4728dfb2L));
/// The default current-workspace game directory path.
private static final PortablePath CURRENT_GAME_DIRECTORY_PATH = PortablePath.of(".minecraft");
@@ -132,10 +142,10 @@ public final class GameDirectoryManager {
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
/// The selected instance ID projected from the selected repository.
private static final ReadOnlyStringWrapper selectedInstance = new ReadOnlyStringWrapper(GameDirectoryManager.class, "selectedInstance");
private static final ReadOnlyObjectWrapper<GameInstanceID> selectedInstance = new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
private static final ChangeListener<String> selectedRepositoryInstanceListener =
private static final ChangeListener<GameInstanceID> selectedRepositoryInstanceListener =
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
/// Initializes game directory state from the stores loaded by [SettingsManager].
@@ -154,12 +164,14 @@ public final class GameDirectoryManager {
if (localGameDirectories().isNewlyCreated() && localGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = localGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(newGameDirectoryId(), null, CURRENT_GAME_DIRECTORY_PATH));
gameDirectories.getGameDirectories().add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
}
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = userGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(newGameDirectoryId(), null, HOME_GAME_DIRECTORY_PATH));
gameDirectories.getGameDirectories().add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
}
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
@@ -195,11 +207,11 @@ public final class GameDirectoryManager {
selectedRepository.set(repository);
selectedInstance.set(repository.getSelectedInstance());
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
repository.refreshVersionsAsync().start();
repository.refreshAsync().start();
});
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> {
runInFX(() -> {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository != null && repository == event.getSource()) {
@@ -216,9 +228,11 @@ public final class GameDirectoryManager {
if (localGameDirectories().getGameDirectories().isEmpty()
&& userGameDirectories().getGameDirectories().isEmpty()) {
localGameDirectories().getGameDirectories()
.add(new GameDirectory(newGameDirectoryId(), null, CURRENT_GAME_DIRECTORY_PATH));
.add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
userGameDirectories().getGameDirectories()
.add(new GameDirectory(newGameDirectoryId(), null, HOME_GAME_DIRECTORY_PATH));
.add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
return true;
} else {
return false;
@@ -466,17 +480,17 @@ public final class GameDirectoryManager {
}
/// Returns the selected instance property projected from the selected repository.
public static ReadOnlyStringProperty selectedInstanceProperty() {
public static ReadOnlyObjectProperty<@Nullable GameInstanceID> selectedInstanceProperty() {
return selectedInstance.getReadOnlyProperty();
}
/// Returns the selected instance ID for the selected repository.
public static @Nullable String getSelectedInstance() {
public static @Nullable GameInstanceID getSelectedInstance() {
return getSelectedRepository().getSelectedInstance();
}
/// Sets the selected instance ID for the selected repository.
public static void setSelectedInstance(@Nullable String instance) {
public static void setSelectedInstance(@Nullable GameInstanceID instance) {
getSelectedRepository().setSelectedInstance(instance);
}
@@ -21,7 +21,7 @@ import javafx.scene.image.Image;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.ui.FXUtils;
public enum VersionIconType {
public enum GameInstanceIconType {
DEFAULT("/assets/img/grass.png"),
GRASS("/assets/img/grass.png"),
@@ -42,21 +42,21 @@ public enum VersionIconType {
// Please append new items at last
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) {
return switch (modLoaderType) {
case FORGE -> VersionIconType.FORGE;
case NEO_FORGE -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.FABRIC;
case QUILT -> VersionIconType.QUILT;
case LITE_LOADER -> VersionIconType.CHICKEN;
case CLEANROOM -> VersionIconType.CLEANROOM;
default -> VersionIconType.COMMAND;
case FORGE -> GameInstanceIconType.FORGE;
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
case FABRIC -> GameInstanceIconType.FABRIC;
case QUILT -> GameInstanceIconType.QUILT;
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
default -> GameInstanceIconType.COMMAND;
};
}
private final String resourceUrl;
VersionIconType(String resourceUrl) {
GameInstanceIconType(String resourceUrl) {
this.resourceUrl = resourceUrl;
}
@@ -67,7 +67,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
public sealed abstract class GameSettings extends ObservableSetting {
/// Suggested maximum heap memory in MiB.
static final int SUGGESTED_MEMORY;
public static final int SUGGESTED_MEMORY;
static {
double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
@@ -152,10 +152,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The icon of the instance.
@SerializedName("icon")
private final SettingProperty<VersionIconType> icon = newSettingProperty("icon", VersionIconType.DEFAULT);
private final SettingProperty<GameInstanceIconType> icon = newSettingProperty("icon", GameInstanceIconType.DEFAULT);
/// Returns the instance icon property.
public SettingProperty<VersionIconType> iconProperty() {
public SettingProperty<GameInstanceIconType> iconProperty() {
return icon;
}
@@ -331,6 +331,11 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Creates a new inheritable property.
protected final <T extends @Nullable Object> InheritableProperty<@Nullable T> newInheritableProperty(String name) {
return new SimpleInheritableProperty<>(this, name, null);
}
/// Creates a new inheritable property with the given default value.
protected final <T extends @UnknownNullability Object> InheritableProperty<T> newInheritableProperty(String name, T defaultValue) {
return new SimpleInheritableProperty<>(this, name, defaultValue);
}
@@ -388,10 +393,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// User customized JVM options.
@SerializedName(PROPERTY_JVM_OPTIONS)
private final SettingProperty<String> jvmOptions = newSettingProperty(PROPERTY_JVM_OPTIONS, "");
private final InheritableProperty<String> jvmOptions = newInheritableProperty(PROPERTY_JVM_OPTIONS, "");
/// Returns the user customized JVM options property.
public SettingProperty<String> jvmOptionsProperty() {
public InheritableProperty<String> jvmOptionsProperty() {
return jvmOptions;
}
@@ -448,10 +453,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// If `true`, HMCL will automatically adjust memory allocation.
@SerializedName(PROPERTY_AUTO_MEMORY)
private final SettingProperty<Boolean> autoMemory = newSettingProperty(PROPERTY_AUTO_MEMORY, true);
private final InheritableProperty<Boolean> autoMemory = newInheritableProperty(PROPERTY_AUTO_MEMORY, true);
/// Returns the automatic memory allocation property.
public SettingProperty<Boolean> autoMemoryProperty() {
public InheritableProperty<Boolean> autoMemoryProperty() {
return autoMemory;
}
@@ -460,10 +465,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The minimum heap memory in MiB.
@SerializedName(PROPERTY_MIN_MEMORY)
private final SettingProperty<@Nullable Integer> minMemory = newSettingProperty(PROPERTY_MIN_MEMORY);
private final InheritableProperty<@Nullable Integer> minMemory = newInheritableProperty(PROPERTY_MIN_MEMORY);
/// Returns the minimum heap memory property.
public SettingProperty<@Nullable Integer> minMemoryProperty() {
public InheritableProperty<@Nullable Integer> minMemoryProperty() {
return minMemory;
}
@@ -472,10 +477,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The maximum heap memory in MiB.
@SerializedName(PROPERTY_MAX_MEMORY)
private final SettingProperty<@Nullable Integer> maxMemory = newSettingProperty(PROPERTY_MAX_MEMORY, SUGGESTED_MEMORY);
private final InheritableProperty<@Nullable Integer> maxMemory = newInheritableProperty(PROPERTY_MAX_MEMORY, SUGGESTED_MEMORY);
/// Returns the maximum heap memory property.
public SettingProperty<@Nullable Integer> maxMemoryProperty() {
public InheritableProperty<@Nullable Integer> maxMemoryProperty() {
return maxMemory;
}
@@ -484,10 +489,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The permanent generation or metaspace size in MiB.
@SerializedName(PROPERTY_PERM_SIZE)
private final SettingProperty<String> permSize = newSettingProperty(PROPERTY_PERM_SIZE, "");
private final InheritableProperty<String> permSize = newInheritableProperty(PROPERTY_PERM_SIZE, "");
/// Returns the permanent generation or metaspace size property.
public SettingProperty<String> permSizeProperty() {
public InheritableProperty<String> permSizeProperty() {
return permSize;
}
@@ -593,10 +598,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// User customized arguments passed to the game.
@SerializedName(PROPERTY_GAME_ARGS)
private final SettingProperty<String> gameArguments = newSettingProperty(PROPERTY_GAME_ARGS, "");
private final InheritableProperty<String> gameArguments = newInheritableProperty(PROPERTY_GAME_ARGS, "");
/// Returns the customized game arguments property.
public SettingProperty<String> gameArgumentsProperty() {
public InheritableProperty<String> gameArgumentsProperty() {
return gameArguments;
}
@@ -638,15 +643,28 @@ public sealed abstract class GameSettings extends ObservableSetting {
return vulkanRenderer;
}
/// Property name for high performance GPU preference.
public static final String PROPERTY_HIGH_PERFORMANCE = "highPerformance";
/// Whether to launch games with high-performance GPU.
@SerializedName(PROPERTY_HIGH_PERFORMANCE)
private final InheritableProperty<Boolean> highPerformance =
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, false);
/// Returns the high performance GPU preference property.
public InheritableProperty<Boolean> highPerformanceProperty() {
return highPerformance;
}
/// Property name for customized environment variables.
public static final String PROPERTY_ENVIRONMENT_VARIABLES = "environmentVariables";
/// User customized environment variables passed to the game.
@SerializedName(PROPERTY_ENVIRONMENT_VARIABLES)
private final SettingProperty<String> environmentVariables = newSettingProperty(PROPERTY_ENVIRONMENT_VARIABLES, "");
private final InheritableProperty<String> environmentVariables = newInheritableProperty(PROPERTY_ENVIRONMENT_VARIABLES, "");
/// Returns the customized environment variables property.
public SettingProperty<String> environmentVariablesProperty() {
public InheritableProperty<String> environmentVariablesProperty() {
return environmentVariables;
}
@@ -763,10 +781,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// If `true`, HMCL does not patch native libraries.
@SerializedName(PROPERTY_NOT_PATCH_NATIVES)
private final SettingProperty<Boolean> notPatchNatives = newSettingProperty(PROPERTY_NOT_PATCH_NATIVES, false);
private final InheritableProperty<Boolean> notPatchNatives = newInheritableProperty(PROPERTY_NOT_PATCH_NATIVES, false);
/// Returns the native library patching property.
public SettingProperty<Boolean> notPatchNativesProperty() {
public InheritableProperty<Boolean> notPatchNativesProperty() {
return notPatchNatives;
}
@@ -775,10 +793,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// If `true`, HMCL leaves native library management to the user.
@SerializedName(PROPERTY_USE_CUSTOM_NATIVES)
private final SettingProperty<Boolean> useCustomNatives = newSettingProperty(PROPERTY_USE_CUSTOM_NATIVES, false);
private final InheritableProperty<Boolean> useCustomNatives = newInheritableProperty(PROPERTY_USE_CUSTOM_NATIVES, false);
/// Returns the custom native libraries property.
public SettingProperty<Boolean> useCustomNativesProperty() {
public InheritableProperty<Boolean> useCustomNativesProperty() {
return useCustomNatives;
}
@@ -787,10 +805,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The path to the native library directory, or an empty string for the default path.
@SerializedName(PROPERTY_NATIVES_DIRECTORY)
private final SettingProperty<String> nativesDirectory = newSettingProperty(PROPERTY_NATIVES_DIRECTORY, "");
private final InheritableProperty<String> nativesDirectory = newInheritableProperty(PROPERTY_NATIVES_DIRECTORY, "");
/// Returns the native library directory property.
public SettingProperty<String> nativesDirectoryProperty() {
public InheritableProperty<String> nativesDirectoryProperty() {
return nativesDirectory;
}
@@ -799,10 +817,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// If `true`, HMCL will use native GLFW.
@SerializedName(PROPERTY_USE_NATIVE_GLFW)
private final SettingProperty<Boolean> useNativeGLFW = newSettingProperty(PROPERTY_USE_NATIVE_GLFW, false);
private final InheritableProperty<Boolean> useNativeGLFW = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW, false);
/// Returns the native GLFW property.
public SettingProperty<Boolean> useNativeGLFWProperty() {
public InheritableProperty<Boolean> useNativeGLFWProperty() {
return useNativeGLFW;
}
@@ -811,10 +829,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// If `true`, HMCL will use native OpenAL.
@SerializedName(PROPERTY_USE_NATIVE_OPENAL)
private final SettingProperty<Boolean> useNativeOpenAL = newSettingProperty(PROPERTY_USE_NATIVE_OPENAL, false);
private final InheritableProperty<Boolean> useNativeOpenAL = newInheritableProperty(PROPERTY_USE_NATIVE_OPENAL, false);
/// Returns the native OpenAL property.
public SettingProperty<Boolean> useNativeOpenALProperty() {
public InheritableProperty<Boolean> useNativeOpenALProperty() {
return useNativeOpenAL;
}
@@ -898,11 +916,6 @@ public sealed abstract class GameSettings extends ObservableSetting {
return instance;
}
/// Returns the effective value for a property whose override state is tracked by the property itself.
public <T extends @UnknownNullability Object> T get(Function<GameSettings, SettingProperty<T>> propertyGetter) {
return inherited(preset, instance, propertyGetter);
}
/// Returns the effective value for an inheritable property.
public <T extends @UnknownNullability Object> T getInheritable(
Function<GameSettings, InheritableProperty<T>> propertyGetter) {
@@ -916,11 +929,11 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Finds the effective Java runtime.
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable Version version) throws InterruptedException {
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable GameInstanceManifest manifest) throws InterruptedException {
JavaVersionType javaVersionType = getInheritable(GameSettings::javaTypeProperty);
switch (javaVersionType) {
case AUTO:
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
case CUSTOM:
try {
return JavaManager.getJava(Path.of(getInheritable(GameSettings::customJavaPathProperty)));
@@ -930,7 +943,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
case VERSION: {
String javaVersion = getInheritable(GameSettings::customJavaVersionProperty);
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
}
int majorVersion = -1;
@@ -948,13 +961,13 @@ public sealed abstract class GameSettings extends ObservableSetting {
Collection<JavaRuntime> allJava = JavaManager.getAllJava().stream()
.filter(it -> it.getParsedVersion() == finalMajorVersion)
.collect(Collectors.toList());
return JavaManager.findSuitableJava(allJava, gameVersion, version);
return JavaManager.findSuitableJava(allJava, gameVersion, manifest);
}
case DETECTED: {
DetectedJava detectedJava = getInheritable(GameSettings::detectedJavaProperty);
String javaVersion = detectedJava.version();
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
}
if (StringUtils.isNotBlank(detectedJava.pathHash())) {
@@ -981,7 +994,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// Returns the effective maximum heap memory in MiB.
public int getMaxMemory() {
Integer value = get(GameSettings::maxMemoryProperty);
Integer value = getInheritable(GameSettings::maxMemoryProperty);
return value != null && value > 0 ? value : SUGGESTED_MEMORY;
}
@@ -996,11 +1009,16 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Returns the effective renderer.
public Renderer getRenderer() {
return switch (getInheritable(GameSettings::graphicsBackendProperty)) {
public Renderer getRenderer(GameVersionNumber version) {
GraphicsAPI graphicsAPI = getInheritable(GameSettings::graphicsBackendProperty);
if (graphicsAPI == GraphicsAPI.DEFAULT) {
graphicsAPI = GraphicsAPI.getDefaultGraphicsAPI(version);
}
return switch (graphicsAPI) {
case DEFAULT -> Renderer.DEFAULT;
case OPENGL -> selectRenderer(GraphicsAPI.OPENGL, getInheritable(GameSettings::openGLRendererProperty));
case VULKAN -> selectRenderer(GraphicsAPI.VULKAN, getInheritable(GameSettings::vulkanRendererProperty));
case DEFAULT -> Renderer.DEFAULT;
};
}
@@ -129,6 +129,21 @@ public final class GameSettingsPresets extends ObservableSetting implements Json
return id;
}
/// Returns the first automatic preset number that is not used by existing presets.
public int newPresetAutoNameNumber() {
mainLoop:
for (int index = 1; ; index++) {
for (GameSettings.Preset setting : getPresets()) {
Integer autoNameNumber = setting.autoNameNumberProperty().getValue();
if ((autoNameNumber != null && autoNameNumber == index)) {
continue mainLoop;
}
}
return index;
}
}
/// Returns the preset with the given ID.
public GameSettings.@Nullable Preset getPreset(@Nullable GameSettingsPresetID id) {
if (id == null) {
@@ -156,7 +156,14 @@ final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
///
/// @param value the settings object to observe
void installAutoSave(T value) {
value.addListener(source -> save(value));
value.addListener(observable -> {
if (value.shouldSaveImmediately(observable)) {
save(value);
value.setSavePending(false);
} else {
value.setSavePending(true);
}
});
}
/// Saves a settings object.
@@ -34,6 +34,7 @@ import org.hildan.fxgson.creators.ObservableSetCreator;
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AccountID;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
@@ -87,6 +88,9 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// Theme appearance override key for title-bar transparency.
public static final String THEME_APPEARANCE_TITLE_BAR_TRANSPARENT = "titleBarTransparent";
/// Theme appearance override key for window transparency.
public static final String THEME_APPEARANCE_WINDOW_TRANSPARENT = "windowTransparent";
/// Theme appearance override key for the primary background source.
public static final String THEME_APPEARANCE_BACKGROUND = "background";
@@ -336,6 +340,15 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
return titleBarTransparent;
}
/// Whether translucent launcher backgrounds reveal content behind the window.
@SerializedName("windowTransparent")
private final BooleanProperty windowTransparent = new SimpleBooleanProperty(false);
/// Returns the window transparency property.
public BooleanProperty windowTransparentProperty() {
return windowTransparent;
}
// Background source
/// The launcher background source type.
@@ -468,17 +481,19 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// Whether UI animations are disabled.
@SerializedName("animationDisabled")
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
FXUtils.REDUCED_MOTION == Boolean.TRUE
|| !JavaRuntime.CURRENT_JIT_ENABLED
|| !FXUtils.GPU_ACCELERATION_ENABLED
);
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
/// Returns the UI animation disable property.
public BooleanProperty animationDisabledProperty() {
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
return animationDisabled;
}
public boolean isAnimationDisabled() {
// Avoid accessing FXUtils too early during startup
return Objects.requireNonNullElseGet(animationDisabled.get(), () ->
FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED);
}
// Networks
/// Whether HMCL automatically selects the number of download threads.
@@ -609,34 +624,34 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
///
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
@SerializedName(PROPERTY_SELECTED_INSTANCE)
private final ObservableMap<GameDirectoryID, String> selectedInstance = FXCollections.observableHashMap();
private final ObservableMap<GameDirectoryID, GameInstanceID> selectedInstance = FXCollections.observableHashMap();
/// Returns selected instance IDs keyed by game directory ID.
///
/// The map stores persisted selected instance values by game directory ID.
public ObservableMap<GameDirectoryID, String> getSelectedInstance() {
public ObservableMap<GameDirectoryID, GameInstanceID> getSelectedInstance() {
return selectedInstance;
}
/// Returns the selected instance ID for the given game directory ID.
///
/// The value is loaded by the game repository for the matching game directory.
public @Nullable String getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
public @Nullable GameInstanceID getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null;
}
/// Sets the selected instance ID for the given game directory ID.
///
/// Blank values remove the persisted selected instance entry.
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable String selectedInstance) {
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable GameInstanceID selectedInstance) {
if (gameDirectoryId == null) {
return;
}
if (StringUtils.isBlank(selectedInstance)) {
this.selectedInstance.remove(gameDirectoryId);
} else {
if (selectedInstance != null) {
this.selectedInstance.put(gameDirectoryId, selectedInstance);
} else {
this.selectedInstance.remove(gameDirectoryId);
}
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
@@ -54,6 +55,15 @@ public final class LauncherState extends ObservableSetting implements JsonSchema
register();
}
/// Returns whether the changed field should trigger automatic persistence immediately.
@Override
public boolean shouldSaveImmediately(Observable observable) {
return observable != x
&& observable != y
&& observable != width
&& observable != height;
}
/// The schema used by this launcher state file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
@@ -80,12 +81,6 @@ public final class LegacyConfigMigrator {
/// The legacy built-in profile name for the user-home game directory.
private static final String LEGACY_HOME_PROFILE = "Home";
/// The legacy built-in current-workspace profile ID.
private static final GameDirectoryID LEGACY_DEFAULT_PROFILE_ID = getLegacyProfileID(LEGACY_DEFAULT_PROFILE);
/// The legacy built-in user-home profile ID.
private static final GameDirectoryID LEGACY_HOME_PROFILE_ID = getLegacyProfileID(LEGACY_HOME_PROFILE);
/// The legacy Windows and portable configuration file name used before HMCL 3.16.
private static final String LEGACY_CONFIG_FILENAME = "hmcl.json";
@@ -260,6 +255,10 @@ public final class LegacyConfigMigrator {
renameMember(jsonObject, "bgpaint", "customBackgroundPaint");
renameMember(jsonObject, "backgroundPaint", "customBackgroundPaint");
migrateBackgroundOpacity(jsonObject, themeAppearanceOverrides);
jsonObject.addProperty("windowTransparent", true);
addThemeAppearanceOverride(
themeAppearanceOverrides,
LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT);
writeThemeAppearanceOverrides(jsonObject, themeAppearanceOverrides);
renameMember(jsonObject, "proxyUserName", "proxyUser");
migrateLegacySelectedVersions(jsonObject);
@@ -276,6 +275,7 @@ public final class LegacyConfigMigrator {
GameSettingsPresets gameSettingsPresets = new GameSettingsPresets();
migrateLegacyPresetSettings(gameDirectories, gameSettingsPresets, legacyConfigurations);
migrateLegacyInstanceGameSettings(gameDirectories, gameSettingsPresets);
migrateLegacyAllowAutoAgent(deserialized, gameSettingsPresets, legacyAllowAutoAgent);
migrateLegacyDisableAutoGameOptions(deserialized, gameSettingsPresets, legacyDisableAutoGameOptions);
DetachedSettings detachedSettings = new DetachedSettings(gameDirectories, gameSettingsPresets,
@@ -1272,18 +1272,95 @@ public final class LegacyConfigMigrator {
continue;
}
legacyParent = LegacyGameSettingsMigrator.toPreset(legacyGameSettings, profileName, legacySettingObject);
legacyParent = LegacyGameSettingsMigrator.toPreset(legacyGameSettings, gameSettingsPresets.newPresetAutoNameNumber(), legacySettingObject);
gameSettingsPresets.getPresets().add(legacyParent);
}
}
}
/// Migrates existing instances under migrated legacy game directories to explicit instance settings.
static void migrateLegacyInstanceGameSettings(
GameDirectories gameDirectories,
GameSettingsPresets gameSettingsPresets) {
Objects.requireNonNull(gameDirectories);
Objects.requireNonNull(gameSettingsPresets);
for (GameDirectory gameDirectory : gameDirectories.getGameDirectories()) {
@Nullable GameSettingsPresetID legacyGameSettings = gameDirectory.getLegacyGameSettings();
if (legacyGameSettings == null) {
continue;
}
GameSettings.Preset legacyParent = gameSettingsPresets.getPreset(legacyGameSettings);
if (legacyParent == null) {
continue;
}
migrateLegacyInstanceGameSettings(gameDirectory.getPath().toPath(), legacyGameSettings, legacyParent);
}
}
/// Migrates existing instances under one game directory to explicit instance settings.
private static void migrateLegacyInstanceGameSettings(
Path gameDirectory,
GameSettingsPresetID legacyGameSettings,
GameSettings.Preset legacyParent) {
Path versionsDirectory = gameDirectory.resolve("versions");
if (!Files.isDirectory(versionsDirectory)) {
return;
}
try (var stream = Files.list(versionsDirectory)) {
stream.filter(Files::isDirectory)
.forEach(instanceRoot -> migrateLegacyInstanceGameSettingsInVersionRoot(
instanceRoot,
legacyGameSettings,
legacyParent));
} catch (IOException e) {
LOG.warning("Failed to migrate legacy instance game settings under " + versionsDirectory, e);
}
}
/// Migrates one existing instance to explicit instance settings.
private static void migrateLegacyInstanceGameSettingsInVersionRoot(
Path instanceRoot,
GameSettingsPresetID legacyGameSettings,
GameSettings.Preset legacyParent) {
@Nullable Path instanceId = instanceRoot.getFileName();
if (instanceId == null || !Files.isRegularFile(instanceRoot.resolve(instanceId.toString() + ".json"))) {
return;
}
Path currentSettings = instanceRoot.resolve(LegacyGameSettingsMigrator.INSTANCE_METADATA_DIRECTORY)
.resolve(LegacyGameSettingsMigrator.INSTANCE_CONFIG_DIRECTORY)
.resolve(LegacyGameSettingsMigrator.INSTANCE_GAME_SETTINGS_FILENAME);
if (Files.exists(currentSettings)) {
return;
}
if (Files.exists(instanceRoot.resolve(LegacyGameSettingsMigrator.LEGACY_INSTANCE_SETTINGS_FILENAME))) {
return;
}
GameSettings.Instance setting = new GameSettings.Instance();
setting.parentProperty().setValue(legacyGameSettings);
if (legacyParent.defaultIsolationTypeProperty().getValue() == DefaultIsolationType.ALWAYS
&& StringUtils.isBlank(legacyParent.runningDirectoryProperty().getValue())) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
try {
FileUtils.saveSafely(currentSettings, LauncherSettings.SETTINGS_GSON.toJson(setting));
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings " + currentSettings, e);
}
}
/// Returns the legacy profile name used in `configurations`.
private static @Nullable String getLegacyProfileName(GameDirectory gameDirectory) {
if (LEGACY_DEFAULT_PROFILE_ID.equals(gameDirectory.getId())) {
if (GameDirectoryManager.DEFAULT_GAME_DIRECTORY_ID.equals(gameDirectory.getId())) {
return LEGACY_DEFAULT_PROFILE;
}
if (LEGACY_HOME_PROFILE_ID.equals(gameDirectory.getId())) {
if (GameDirectoryManager.HOME_GAME_DIRECTORY_ID.equals(gameDirectory.getId())) {
return LEGACY_HOME_PROFILE;
}
return GameDirectoryManager.getGameDirectoryCustomName(gameDirectory);
@@ -24,7 +24,6 @@ import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.property.InheritableProperty;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
@@ -43,12 +42,24 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
public final class LegacyGameSettingsMigrator {
/// Legacy file name used by old per-version `VersionSetting` data.
private static final String LEGACY_INSTANCE_SETTINGS_FILENAME = "hmclversion.cfg";
static final String LEGACY_INSTANCE_SETTINGS_FILENAME = "hmclversion.cfg";
/// Receipt file name used to record successful legacy per-version settings migration.
private static final String LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME =
"instance-game-settings.migration-receipt.json";
/// Directory under the version root that stores HMCL-managed instance metadata.
static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
/// Directory under the instance metadata directory that stores instance configuration files.
static final String INSTANCE_CONFIG_DIRECTORY = "config";
/// Directory under the instance metadata directory that stores instance state files.
private static final String INSTANCE_STATE_DIRECTORY = "state";
/// Current file name for instance-specific game settings.
static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
/// Legacy game directory modes stored by old configuration files.
private enum GameDirectoryType {
/// Use the root `.minecraft` folder.
@@ -62,22 +73,22 @@ public final class LegacyGameSettingsMigrator {
}
/// Legacy `VersionIconType` ordinal order used by old local settings.
private static final VersionIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
VersionIconType.DEFAULT,
VersionIconType.GRASS,
VersionIconType.CHEST,
VersionIconType.CHICKEN,
VersionIconType.COMMAND,
VersionIconType.OPTIFINE,
VersionIconType.CRAFT_TABLE,
VersionIconType.FABRIC,
VersionIconType.FORGE,
VersionIconType.NEO_FORGE,
VersionIconType.FURNACE,
VersionIconType.QUILT,
VersionIconType.APRIL_FOOLS,
VersionIconType.CLEANROOM,
VersionIconType.LEGACY_FABRIC
private static final GameInstanceIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
GameInstanceIconType.DEFAULT,
GameInstanceIconType.GRASS,
GameInstanceIconType.CHEST,
GameInstanceIconType.CHICKEN,
GameInstanceIconType.COMMAND,
GameInstanceIconType.OPTIFINE,
GameInstanceIconType.CRAFT_TABLE,
GameInstanceIconType.FABRIC,
GameInstanceIconType.FORGE,
GameInstanceIconType.NEO_FORGE,
GameInstanceIconType.FURNACE,
GameInstanceIconType.QUILT,
GameInstanceIconType.APRIL_FOOLS,
GameInstanceIconType.CLEANROOM,
GameInstanceIconType.LEGACY_FABRIC
};
/// Legacy `JavaVersionType` ordinal order used by old game settings.
@@ -101,9 +112,9 @@ public final class LegacyGameSettingsMigrator {
}
/// Converts a legacy profile-level setting JSON object into a preset with the given ID.
public static GameSettings.Preset toPreset(GameSettingsPresetID id, String name, @Nullable JsonObject source) {
public static GameSettings.Preset toPreset(GameSettingsPresetID id, int autoNameNumber, @Nullable JsonObject source) {
GameSettings.Preset target = new GameSettings.Preset(id);
target.nameProperty().setValue(LocalizedText.plain(name));
target.autoNameNumberProperty().setValue(autoNameNumber);
if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.VERSION_FOLDER) {
target.defaultIsolationTypeProperty().setValue(DefaultIsolationType.ALWAYS);
}
@@ -119,9 +130,9 @@ public final class LegacyGameSettingsMigrator {
/// @return the migrated instance setting, or `null` when no legacy file can be migrated
public static @Nullable InstanceMigrationResult migrateInstanceGameSettings(
HMCLGameRepository repository,
String instanceId,
GameInstanceID instanceId,
@Nullable GameSettingsPresetID parent) {
Path instanceRoot = repository.getVersionRoot(instanceId);
Path instanceRoot = repository.getInstanceRoot(instanceId);
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
if (!Files.exists(file)) {
return null;
@@ -477,10 +488,10 @@ public final class LegacyGameSettingsMigrator {
}
/// Parses the legacy icon selection with frozen ordinal order.
private static VersionIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
if (primitive == null) {
return VersionIconType.DEFAULT;
return GameInstanceIconType.DEFAULT;
}
try {
@@ -488,11 +499,11 @@ public final class LegacyGameSettingsMigrator {
int index = primitive.getAsInt();
return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length
? LEGACY_VERSION_ICON_TYPES[index]
: VersionIconType.DEFAULT;
: GameInstanceIconType.DEFAULT;
}
String value = primitive.getAsString();
for (VersionIconType iconType : LEGACY_VERSION_ICON_TYPES) {
for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) {
if (iconType.name().equalsIgnoreCase(value)) {
return iconType;
}
@@ -500,7 +511,7 @@ public final class LegacyGameSettingsMigrator {
} catch (RuntimeException ignored) {
}
return VersionIconType.DEFAULT;
return GameInstanceIconType.DEFAULT;
}
/// Reads an enum property from either ordinal or name.
@@ -55,6 +55,20 @@ public final class SettingsManager {
private SettingsManager() {
}
/// Saves deferred launcher state changes and shutdown file saver.
public static void shutdown() {
savePendingChanges();
FileSaver.shutdown();
}
/// Saves deferred launcher state changes.
public static void savePendingChanges() {
if (launcherState != null && launcherState.isSavable() && launcherState.isSavePending()) {
launcherState.setSavePending(false);
STATE_FILE.save(launcherState);
}
}
/// The local directory storing per-workspace configuration files.
private static final Path LOCAL_CONFIG_FILES_DIRECTORY = Metadata.HMCL_LOCAL_HOME.resolve("config");
@@ -28,19 +28,12 @@ import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.ResolvedTheme;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
@@ -67,23 +60,8 @@ public final class StyleSheets {
});
}
private static String toStyleSheetUri(String styleSheet, String fallback) {
if (FXUtils.JAVAFX_MAJOR_VERSION >= 17)
// JavaFX 17+ support loading stylesheets from data URIs
// https://bugs.openjdk.org/browse/JDK-8267554
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
else
try {
Path temp = Files.createTempFile("hmcl", ".css");
// For JavaFX 17 or earlier, CssParser uses the default charset
// https://bugs.openjdk.org/browse/JDK-8279328
Files.writeString(temp, styleSheet, Charset.defaultCharset());
temp.toFile().deleteOnExit();
return temp.toUri().toString();
} catch (IOException | NullPointerException e) {
LOG.error("Unable to create stylesheet, fallback to " + fallback, e);
return fallback;
}
private static String toStyleSheetUri(String styleSheet) {
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
}
private static String getFontStyleSheet() {
@@ -133,7 +111,7 @@ public final class StyleSheets {
builder.append('}');
return toStyleSheetUri(builder.toString(), defaultCss);
return toStyleSheetUri(builder.toString());
}
private static String getBrightnessStyleSheet() {
@@ -184,7 +162,7 @@ public final class StyleSheets {
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
builder.append("}\n");
return toStyleSheetUri(builder.toString(), blueCss);
return toStyleSheetUri(builder.toString());
}
public static void init(Scene scene) {
@@ -25,6 +25,9 @@ public enum ThemeColorType {
/// Uses the built-in launcher default theme color.
DEFAULT,
/// Follows the accent color provided by the operating system.
SYSTEM,
/// Uses the custom launcher theme color selected by the user.
CUSTOM,
@@ -0,0 +1,39 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.theme;
import javafx.scene.layout.Background;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
/// A launcher background and the opacity applied by its rendering node.
///
/// @param background the JavaFX background rendered by the launcher
/// @param opacity the node opacity in the range `[0, 1]`
@NotNullByDefault
public record LauncherBackground(Background background, double opacity) {
/// Creates a launcher background value.
public LauncherBackground {
Objects.requireNonNull(background);
if (!Double.isFinite(opacity) || opacity < 0.0 || opacity > 1.0) {
throw new IllegalArgumentException("Launcher background opacity must be between 0 and 1: " + opacity);
}
}
}
@@ -43,6 +43,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeAppearance(
@Nullable ThemeColorSource color,
@@ -50,7 +51,8 @@ public record ThemeAppearance(
@Nullable ColorStyle colorStyle,
@Nullable Contrast contrast,
@Nullable ThemeBackgroundSettings background,
@Nullable ThemeTitleBar titleBar) {
@Nullable ThemeTitleBar titleBar,
@Nullable Boolean windowTransparent) {
/// JSON member name for the color seed.
static final String FIELD_COLOR = "color";
@@ -70,6 +72,9 @@ public record ThemeAppearance(
/// JSON member name for title-bar settings.
static final String FIELD_TITLE_BAR = "titleBar";
/// JSON member name for window transparency.
static final String FIELD_WINDOW_TRANSPARENT = "windowTransparent";
/// Creates an appearance patch.
///
/// @param color the color seed source, or `null` when inherited
@@ -78,6 +83,7 @@ public record ThemeAppearance(
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
public ThemeAppearance {
if (background != null && background.isEmpty()) {
background = null;
@@ -100,7 +106,8 @@ public record ThemeAppearance(
readColorStyle(object),
readContrast(object),
readBackground(object),
readTitleBar(object));
readTitleBar(object),
readWindowTransparent(object));
}
/// Returns whether this appearance contains no concrete fields.
@@ -112,7 +119,8 @@ public record ThemeAppearance(
&& colorStyle == null
&& contrast == null
&& background == null
&& titleBar == null;
&& titleBar == null
&& windowTransparent == null;
}
/// Adds this appearance patch's concrete fields to a JSON object.
@@ -139,6 +147,9 @@ public record ThemeAppearance(
if (titleBar != null) {
object.add(FIELD_TITLE_BAR, titleBar.toJsonObject());
}
if (windowTransparent != null) {
object.addProperty(FIELD_WINDOW_TRANSPARENT, windowTransparent);
}
}
/// Converts this appearance patch to its JSON representation.
@@ -167,7 +178,8 @@ public record ThemeAppearance(
: patch.background != null ? patch.background : background,
titleBar != null && patch.titleBar != null
? titleBar.merge(patch.titleBar)
: patch.titleBar != null ? patch.titleBar : titleBar);
: patch.titleBar != null ? patch.titleBar : titleBar,
patch.windowTransparent != null ? patch.windowTransparent : windowTransparent);
}
/// Converts this appearance to concrete launcher theme values.
@@ -309,6 +321,19 @@ public record ThemeAppearance(
}
}
/// Reads the optional window transparency field.
private static @Nullable Boolean readWindowTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_WINDOW_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme windowTransparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
@@ -77,7 +77,7 @@ public final class ThemePackManager {
private static final Path USER_THEME_PACKS_DIRECTORY = Metadata.HMCL_USER_HOME.resolve("themes");
/// Default version used when exporting the current launcher appearance.
private static final String CURRENT_THEME_PACK_VERSION = "1.0.0";
public static final String CURRENT_THEME_PACK_VERSION = "1.0.0";
/// IDs of launcher-bundled theme packs in display order.
private static final @Unmodifiable List<String> BUILTIN_THEME_PACK_IDS = List.of(
@@ -615,13 +615,14 @@ public final class ThemePackManager {
///
/// @param outputFile the target theme-pack file
/// @param packId the exported package identifier
/// @param version the exported package version
/// @param packName the exported package display name
/// @param authorName the exported package author name
/// @throws IOException if the current appearance cannot be exported
public static void exportCurrent(Path outputFile, String packId, String packName, String authorName) throws IOException {
public static void exportCurrent(Path outputFile, String packId, String version, String packName, String authorName) throws IOException {
Objects.requireNonNull(outputFile);
ExportedThemePack themePack = createCurrent(packId, packName, authorName);
ExportedThemePack themePack = createCurrent(packId, version, packName, authorName);
try {
ThemePackExporter.export(themePack.manifest(), themePack.assets(), outputFile);
} finally {
@@ -638,12 +639,14 @@ public final class ThemePackManager {
/// Creates an exportable theme pack from the current launcher appearance.
///
/// @param packId the exported package identifier
/// @param version the exported package version
/// @param packName the exported package display name
/// @param authorName the exported package author name
/// @return the exportable theme-pack descriptor
/// @throws IOException if the current appearance cannot be represented as a theme pack
public static ExportedThemePack createCurrent(String packId, String packName, String authorName) throws IOException {
public static ExportedThemePack createCurrent(String packId, String version, String packName, String authorName) throws IOException {
packId = ThemePackManifest.requirePackageId(packId);
version = requireNonBlank(version, "version");
packName = requireNonBlank(packName, "packName");
authorName = requireNonBlank(authorName, "authorName");
@@ -657,7 +660,8 @@ public final class ThemePackManager {
currentColorStyle(),
null,
background,
new ThemeTitleBar(currentTitleBarTransparent()));
new ThemeTitleBar(currentTitleBarTransparent()),
currentWindowTransparent());
Theme theme = new Theme(
null,
null,
@@ -668,7 +672,7 @@ public final class ThemePackManager {
List.of());
ThemePackManifest manifest = new ThemePackManifest(
packId,
CURRENT_THEME_PACK_VERSION,
version,
LocalizedText.plain(packName),
List.of(new ThemePackAuthor(LocalizedText.plain(authorName))),
background.source() instanceof ThemeBackground.Image image ? image.path() : null,
@@ -708,6 +712,9 @@ public final class ThemePackManager {
if (themeColorType == ThemeColorType.DEFAULT) {
return ThemeColorSource.DEFAULT;
}
if (themeColorType == ThemeColorType.SYSTEM) {
return ThemeColorSource.custom(Themes.getSystemThemeColor());
}
if (themeColorType == ThemeColorType.BACKGROUND) {
if (backgroundType == BackgroundType.THEME_COLOR) {
return ThemeColorSource.DEFAULT;
@@ -752,6 +759,13 @@ public final class ThemePackManager {
: Objects.requireNonNullElse(resolveCurrentTitleBarTransparent(currentResolveContext()), false);
}
/// Returns whether the current launcher window should be transparent.
private static boolean currentWindowTransparent() throws IOException {
return SettingsManager.settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT)
? settings().windowTransparentProperty().get()
: Objects.requireNonNullElse(resolveCurrentWindowTransparent(currentResolveContext()), false);
}
/// Resolves the selected theme's controlled brightness.
///
/// @param context the condition resolution context
@@ -792,6 +806,16 @@ public final class ThemePackManager {
return appearance != null && appearance.titleBar() != null ? appearance.titleBar().transparent() : null;
}
/// Resolves the selected theme window transparency directive.
///
/// @param context the condition resolution context
/// @return the selected theme window transparency directive, or `null` when unavailable
/// @throws IOException if the selected theme pack cannot be read
public static @Nullable Boolean resolveCurrentWindowTransparent(ThemeResolveContext context) throws IOException {
@Nullable ThemeAppearance appearance = resolveCurrentThemeAppearance(context);
return appearance != null ? appearance.windowTransparent() : null;
}
/// Resolves the current selected theme color source without extracting any wallpaper pixels.
///
/// @param context the condition resolution context
@@ -32,9 +32,6 @@ import javafx.collections.SetChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.image.Image;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BackgroundImage;
@@ -115,6 +112,9 @@ public final class Themes {
if (FXUtils.DARK_MODE != null) {
observables.add(FXUtils.DARK_MODE);
}
if (FXUtils.ACCENT_COLOR != null) {
observables.add(FXUtils.ACCENT_COLOR);
}
bind(observables.toArray(new Observable[0]));
}
@@ -195,6 +195,7 @@ public final class Themes {
}
return switch (themeColorType) {
case DEFAULT -> ThemeColor.DEFAULT;
case SYSTEM -> getSystemThemeColor();
case CUSTOM -> fallback;
case BACKGROUND -> resolveWallpaperThemeColor(fallback, backgroundType);
};
@@ -282,9 +283,11 @@ public final class Themes {
colorScheme
);
/// The current JavaFX launcher background.
private static final ReadOnlyObjectWrapper<Background> background = new ReadOnlyObjectWrapper<>(
new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY)));
/// The current JavaFX launcher background and its node opacity.
private static final ReadOnlyObjectWrapper<LauncherBackground> background = new ReadOnlyObjectWrapper<>(
new LauncherBackground(
new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY)),
1.0));
/// The last loaded background source used to rebuild the JavaFX background without reloading images.
private static @Nullable LoadedBackground loadedBackground;
@@ -300,8 +303,8 @@ public final class Themes {
/// A loaded JavaFX background source and the wallpaper color extracted while loading it.
private sealed interface LoadedBackground {
/// Returns the loaded JavaFX background.
Background background();
/// Returns the loaded JavaFX background and its node opacity.
LauncherBackground background();
/// Returns whether this background is the configured fallback background.
boolean fallbackBackground();
@@ -316,7 +319,7 @@ public final class Themes {
/// @param wallpaperThemeColor the seed color extracted from [#image], or `null` when unavailable
/// @param fallbackBackground whether this background is the configured fallback background
record Image(
Background background,
LauncherBackground background,
javafx.scene.image.Image image,
@Nullable ThemeColor wallpaperThemeColor,
boolean fallbackBackground) implements LoadedBackground {
@@ -339,7 +342,7 @@ public final class Themes {
/// @param wallpaperThemeColor the seed color represented by [#paint], or `null` when unavailable
/// @param fallbackBackground whether this background is the configured fallback background
record Paint(
Background background,
LauncherBackground background,
@Nullable javafx.scene.paint.Paint paint,
@Nullable ThemeColor wallpaperThemeColor,
boolean fallbackBackground) implements LoadedBackground {
@@ -359,7 +362,7 @@ public final class Themes {
/// @param background the loaded JavaFX background
/// @param fallbackBackground whether this background is the configured fallback background
record ThemeColorFill(
Background background,
LauncherBackground background,
boolean fallbackBackground) implements LoadedBackground {
/// Creates a loaded theme-color background.
///
@@ -528,6 +531,15 @@ public final class Themes {
return defaultBrightness = brightness;
}
/// Returns the current operating system accent color, or the launcher default when unavailable.
public static ThemeColor getSystemThemeColor() {
if (FXUtils.ACCENT_COLOR == null) {
return ThemeColor.DEFAULT;
}
@Nullable Color accentColor = FXUtils.ACCENT_COLOR.get();
return accentColor != null ? ThemeColor.of(accentColor) : ThemeColor.DEFAULT;
}
/// Returns the current resolved launcher theme.
public static ResolvedTheme getTheme() {
return theme.get();
@@ -543,8 +555,8 @@ public final class Themes {
return colorScheme.get();
}
/// Returns the current JavaFX launcher background property.
public static ReadOnlyObjectProperty<Background> backgroundProperty() {
/// Returns the current JavaFX launcher background and node opacity property.
public static ReadOnlyObjectProperty<LauncherBackground> backgroundProperty() {
startBackgroundUpdates();
return background.getReadOnlyProperty();
}
@@ -824,7 +836,8 @@ public final class Themes {
}
@Nullable LoadedBackground loaded = loadedBackground;
if (loaded instanceof LoadedBackground.ThemeColorFill themeColorFill) {
Background newBackground = createThemeColorBackground(getLoadedBackgroundOpacity(loaded.fallbackBackground()));
LauncherBackground newBackground =
createThemeColorBackground(getLoadedBackgroundOpacity(loaded.fallbackBackground()));
loadedBackground = new LoadedBackground.ThemeColorFill(newBackground, themeColorFill.fallbackBackground());
background.set(newBackground);
}
@@ -858,17 +871,23 @@ public final class Themes {
LoadedBackground refreshed;
if (loaded instanceof LoadedBackground.Image imageBackground) {
refreshed = new LoadedBackground.Image(
createBackgroundWithOpacity(imageBackground.image(), opacity),
new LauncherBackground(
imageBackground.background().background(),
MathUtils.clamp(opacity, 0., 1.)),
imageBackground.image(),
imageBackground.wallpaperThemeColor(),
imageBackground.fallbackBackground());
} else if (loaded instanceof LoadedBackground.ThemeColorFill themeColorFill) {
refreshed = new LoadedBackground.ThemeColorFill(
createThemeColorBackground(opacity),
new LauncherBackground(
themeColorFill.background().background(),
MathUtils.clamp(opacity, 0., 1.)),
themeColorFill.fallbackBackground());
} else if (loaded instanceof LoadedBackground.Paint paintBackground) {
refreshed = new LoadedBackground.Paint(
createPaintBackground(paintBackground.paint(), opacity),
new LauncherBackground(
paintBackground.background().background(),
MathUtils.clamp(opacity, 0., 1.)),
paintBackground.paint(),
paintBackground.wallpaperThemeColor(),
paintBackground.fallbackBackground());
@@ -917,60 +936,37 @@ public final class Themes {
return FXUtils.loadImage(new CacheFileTask(url).run());
}
/// Creates a JavaFX paint background with the requested opacity.
private static Background createPaintBackground(@Nullable Paint paint, double opacity) {
opacity = MathUtils.clamp(opacity, 0., 1.);
if (paint instanceof Color || paint == null) {
Color color = (Color) paint;
if (color == null) {
color = Color.WHITE;
}
if (opacity < 1.) {
color = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * opacity);
}
return new Background(new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY));
} else {
return new Background(new BackgroundFill(paint, CornerRadii.EMPTY, Insets.EMPTY));
}
/// Creates a JavaFX paint background with a separately stored node opacity.
private static LauncherBackground createPaintBackground(@Nullable Paint paint, double opacity) {
return new LauncherBackground(
new Background(new BackgroundFill(
Objects.requireNonNullElse(paint, Color.WHITE),
CornerRadii.EMPTY,
Insets.EMPTY)),
MathUtils.clamp(opacity, 0., 1.));
}
/// Creates a JavaFX background from the current theme color scheme surface container.
private static Background createThemeColorBackground(double opacity) {
private static LauncherBackground createThemeColorBackground(double opacity) {
return createPaintBackground(getColorScheme().getColor(ColorRole.SURFACE_CONTAINER), opacity);
}
/// Creates a JavaFX image background with the requested opacity.
private static Background createBackgroundWithOpacity(Image image, double opacity) {
PixelReader pixelReader = image.getPixelReader();
if (opacity <= 0) {
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
} else if (opacity >= 1. || pixelReader == null) {
return new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
} else {
WritableImage tempImage = new WritableImage((int) image.getWidth(), (int) image.getHeight());
PixelWriter pixelWriter = tempImage.getPixelWriter();
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
Color color = pixelReader.getColor(x, y);
Color newColor = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * opacity);
pixelWriter.setColor(x, y, newColor);
}
}
return new Background(new BackgroundImage(
tempImage,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
}
/// Creates a JavaFX image background with a separately stored node opacity.
private static LauncherBackground createBackgroundWithOpacity(Image image, double opacity) {
Background imageBackground = new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(
BackgroundSize.AUTO,
BackgroundSize.AUTO,
false,
false,
false,
true)
));
return new LauncherBackground(imageBackground, MathUtils.clamp(opacity, 0., 1.));
}
/// Loads a default launcher background image from local workspace files.
@@ -1073,6 +1069,27 @@ public final class Themes {
FXUtils.DARK_MODE != null ? FXUtils.DARK_MODE : settings().themeBrightnessModeProperty()
);
/// Whether the launcher window should be transparent after applying launcher and theme settings.
private static final BooleanBinding windowTransparent = Bindings.createBooleanBinding(
() -> {
if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_WINDOW_TRANSPARENT)) {
return settings().windowTransparentProperty().get();
}
try {
return Objects.requireNonNullElse(
ThemePackManager.resolveCurrentWindowTransparent(ThemePackManager.currentResolveContext()),
false);
} catch (IOException | RuntimeException e) {
return false;
}
},
settings().windowTransparentProperty(),
settings().getThemeAppearanceOverrides(),
settings().selectedThemeProperty(),
settings().themeBrightnessModeProperty(),
FXUtils.DARK_MODE != null ? FXUtils.DARK_MODE : settings().themeBrightnessModeProperty()
);
/// The title text fill derived from the current color scheme.
private static final ObjectBinding<Color> titleFill = Bindings.createObjectBinding(
() -> titleBarTransparent.get()
@@ -1092,6 +1109,11 @@ public final class Themes {
return titleBarTransparent;
}
/// Returns whether the launcher window should be transparent after applying launcher and theme settings.
public static BooleanBinding windowTransparentProperty() {
return windowTransparent;
}
/// Returns whether the current color scheme uses dark brightness.
public static BooleanBinding darkModeProperty() {
return darkMode;
@@ -24,21 +24,12 @@ import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.Label;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
@@ -49,19 +40,18 @@ import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.account.AccountListPage;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.DecoratorController;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
import org.jackhuang.hmcl.ui.versions.GameListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.ui.instances.GameListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -73,8 +63,10 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
@@ -90,26 +82,11 @@ public final class Controllers {
public static final String SOFTWARE_RENDERING = "softwareRendering";
public static final String APRIL_FOOLS = "aprilFools";
private static final int CUSTOM_DECORATION_SHADOW_SIZE = 8;
private static final int CUSTOM_DECORATION_SHADOW_EXTENT = CUSTOM_DECORATION_SHADOW_SIZE * 2;
public static final int MIN_CONTENT_WIDTH = 800 + 2; // bg width + border width*2
public static final int MIN_CONTENT_HEIGHT = 450 + 2 + 40; // bg height + border width*2 + toolbar height
public static final int MIN_WIDTH = MIN_CONTENT_WIDTH + CUSTOM_DECORATION_SHADOW_EXTENT;
public static final int MIN_HEIGHT = MIN_CONTENT_HEIGHT + CUSTOM_DECORATION_SHADOW_EXTENT;
public static final Screen SCREEN = Screen.getPrimary();
private static InvalidationListener stageSizeChangeListener;
private static DoubleProperty stageX = new SimpleDoubleProperty();
private static DoubleProperty stageY = new SimpleDoubleProperty();
private static DoubleProperty stageWidth = new SimpleDoubleProperty();
private static DoubleProperty stageHeight = new SimpleDoubleProperty();
private static Scene scene;
private static Stage stage;
private static VersionPage versionPage;
private static GameInstancePage gameInstancePage;
private static Lazy<GameListPage> gameListPage = new Lazy<>(GameListPage::new);
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
private static DecoratorController decorator;
/// The coordinator for the main window's scene graph and navigation stack.
private static @Nullable Decorator decorator;
private static DownloadPage downloadPage;
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
AccountListPage accountListPage = new AccountListPage();
@@ -133,27 +110,27 @@ public final class Controllers {
void run() throws Exception;
}
public static Scene getScene() {
return scene;
}
public static Stage getStage() {
return stage;
/// Returns the stage currently attached to the main-window decorator.
///
/// @return the current stage, or `null` before initialization or after detachment
public static @Nullable Stage getStage() {
@Nullable Decorator currentDecorator = decorator;
return currentDecorator == null ? null : currentDecorator.getStage();
}
@FXThread
public static VersionPage getVersionPage() {
if (versionPage == null) {
versionPage = new VersionPage();
public static GameInstancePage getGameInstancePage() {
if (gameInstancePage == null) {
gameInstancePage = new GameInstancePage();
}
return versionPage;
return gameInstancePage;
}
@FXThread
public static void prepareVersionPage() {
if (versionPage == null) {
LOG.info("Prepare the version page");
versionPage = FXUtils.prepareNode(new VersionPage());
public static void prepareGameInstancePage() {
if (gameInstancePage == null) {
LOG.info("Prepare the game instance page");
gameInstancePage = FXUtils.prepareNode(new GameInstancePage());
}
}
@@ -209,71 +186,24 @@ public final class Controllers {
return terracottaPage.get();
}
/// Returns the initialized main-window decorator.
///
/// @return the application-wide main-window decorator
@FXThread
public static DecoratorController getDecorator() {
return decorator;
}
public static void saveWindowStates() {
saveWindowBounds();
}
private static void saveWindowBounds() {
if (stageX != null) {
state().setX(toContentX(stageX.get()) / SCREEN.getBounds().getWidth());
}
if (stageY != null) {
state().setY(toContentY(stageY.get()) / SCREEN.getBounds().getHeight());
}
if (stageHeight != null) {
state().setHeight(toContentHeight(stageHeight.get()));
}
if (stageWidth != null) {
state().setWidth(toContentWidth(stageWidth.get()));
}
public static Decorator getDecorator() {
return Objects.requireNonNull(decorator, "Main window is not initialized");
}
/// Releases stage-specific listeners and retained window ownership before application shutdown.
public static void onApplicationStop() {
stageSizeChangeListener = null;
saveWindowBounds();
stageX = null;
stageY = null;
stageHeight = null;
stageWidth = null;
}
private static double toContentX(double stageX) {
return stageX + CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toContentY(double stageY) {
return stageY + CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toStageX(double contentX) {
return contentX - CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toStageY(double contentY) {
return contentY - CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toContentWidth(double stageWidth) {
return Math.max(0.0, stageWidth - CUSTOM_DECORATION_SHADOW_EXTENT);
}
private static double toContentHeight(double stageHeight) {
return Math.max(0.0, stageHeight - CUSTOM_DECORATION_SHADOW_EXTENT);
}
private static double toStageWidth(double contentWidth) {
return contentWidth + CUSTOM_DECORATION_SHADOW_EXTENT;
}
private static double toStageHeight(double contentHeight) {
return contentHeight + CUSTOM_DECORATION_SHADOW_EXTENT;
if (decorator != null) {
decorator.detachStage();
}
}
/// Initializes the main application stage, scene graph, and background services.
///
/// @param stage the primary application stage, which must not have been shown
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
@@ -285,102 +215,16 @@ public final class Controllers {
LOG.info("Enable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "true");
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) {
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Screen.getPrimary().getOutputScaleX() > 1) {
LOG.info("Disable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "false");
}
}
Controllers.stage = stage;
stageSizeChangeListener = o -> {
ReadOnlyDoubleProperty sourceProperty = (ReadOnlyDoubleProperty) o;
DoubleProperty targetProperty;
switch (sourceProperty.getName()) {
case "x": {
targetProperty = stageX;
break;
}
case "y": {
targetProperty = stageY;
break;
}
case "width": {
targetProperty = stageWidth;
break;
}
case "height": {
targetProperty = stageHeight;
break;
}
default: {
targetProperty = null;
}
}
if (targetProperty != null
&& Controllers.stage != null
&& !Controllers.stage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ||
!Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized())
) {
targetProperty.set(sourceProperty.get());
}
};
WeakInvalidationListener weakListener = new WeakInvalidationListener(stageSizeChangeListener);
double initContentWidth = Math.max(MIN_CONTENT_WIDTH, state().getWidth());
double initContentHeight = Math.max(MIN_CONTENT_HEIGHT, state().getHeight());
double initWidth = toStageWidth(initContentWidth);
double initHeight = toStageHeight(initContentHeight);
{
double initContentX = state().getX() * SCREEN.getBounds().getWidth();
double initContentY = state().getY() * SCREEN.getBounds().getHeight();
boolean invalid = true;
double border = 20D;
for (Screen screen : Screen.getScreens()) {
Rectangle2D bound = screen.getBounds();
if (bound.getMinX() + border <= initContentX + initContentWidth
&& initContentX <= bound.getMaxX() - border
&& bound.getMinY() + border <= initContentY
&& initContentY <= bound.getMaxY() - border) {
invalid = false;
break;
}
}
if (invalid) {
initContentX = (0.5D - initContentWidth / SCREEN.getBounds().getWidth() / 2)
* SCREEN.getBounds().getWidth();
initContentY = (0.5D - initContentHeight / SCREEN.getBounds().getHeight() / 2)
* SCREEN.getBounds().getHeight();
}
double initX = toStageX(initContentX);
double initY = toStageY(initContentY);
stage.setX(initX);
stage.setY(initY);
stageX.set(initX);
stageY.set(initY);
}
stage.setHeight(initHeight);
stage.setWidth(initWidth);
stageHeight.set(initHeight);
stageWidth.set(initWidth);
stage.xProperty().addListener(weakListener);
stage.yProperty().addListener(weakListener);
stage.heightProperty().addListener(weakListener);
stage.widthProperty().addListener(weakListener);
stage.setOnCloseRequest(e -> Launcher.stopApplication());
decorator = new DecoratorController(stage, getRootPage());
decorator = new Decorator(getRootPage());
Scene mainScene = decorator.attachStage(stage);
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
getRootPage().getMainPage().showUpdateDialogProperty().bind(
decorator.backableProperty().not()
@@ -396,36 +240,10 @@ public final class Controllers {
Lang.thread(JavaManager::initialize, "Search Java", true);
scene = new Scene(decorator.getDecorator());
scene.setFill(Color.TRANSPARENT);
stage.setMinWidth(MIN_WIDTH);
stage.setMinHeight(MIN_HEIGHT);
decorator.getDecorator().prefWidthProperty().bind(scene.widthProperty());
decorator.getDecorator().prefHeightProperty().bind(scene.heightProperty());
StyleSheets.init(scene);
StyleSheets.init(mainScene);
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
stage.initStyle(StageStyle.TRANSPARENT);
stage.setScene(scene);
if (AnimationUtils.playWindowAnimation()) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.millis(0),
new KeyValue(decorator.getDecorator().opacityProperty(), 0, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleXProperty(), 0.8, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleYProperty(), 0.8, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleZProperty(), 0.8, Motion.EASE)
),
new KeyFrame(Duration.millis(600),
new KeyValue(decorator.getDecorator().opacityProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleXProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleYProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleZProperty(), 1, Motion.EASE)
)
);
timeline.play();
}
if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) {
Runnable continueAction = () -> {
@@ -585,7 +403,7 @@ public final class Controllers {
/// Shows a warning that confirms backing up a read-only settings file before overwriting it.
///
/// @param text the file-specific read-only warning
/// @param text the file-specific read-only warning
/// @param overwrite the action that backs up and overwrites the file
public static void confirmBackupAndOverwrite(String text, ThrowingRunnable overwrite) {
dialog(new MessageDialogPane.Builder(
@@ -694,6 +512,38 @@ public final class Controllers {
decorator.showToast(content);
}
/// Shows `directoryChooser` with the current main window as its owner.
///
/// @param directoryChooser the chooser to show
/// @return the selected directory, or `null` if the chooser is cancelled
public static @Nullable Path showDialog(DirectoryChooser directoryChooser) {
return FileUtils.toPath(directoryChooser.showDialog(getStage()));
}
/// Shows `fileChooser` for opening one file with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected file, or `null` if the chooser is cancelled
public static @Nullable Path showOpenDialog(FileChooser fileChooser) {
return FileUtils.toPath(fileChooser.showOpenDialog(getStage()));
}
/// Shows `fileChooser` for saving one file with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected file, or `null` if the chooser is cancelled
public static @Nullable Path showSaveDialog(FileChooser fileChooser) {
return FileUtils.toPath(fileChooser.showSaveDialog(getStage()));
}
/// Shows `fileChooser` for opening multiple files with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected files, or `null` if the chooser is cancelled
public static @Nullable List<Path> showOpenMultipleDialog(FileChooser fileChooser) {
return FileUtils.toPaths(fileChooser.showOpenMultipleDialog(getStage()));
}
public static void onHyperlinkAction(String href) {
if (href.startsWith("hmcl://")) {
switch (href) {
@@ -703,7 +553,7 @@ public final class Controllers {
break;
case "hmcl://game/launch":
var repository = GameDirectoryManager.getSelectedRepository();
Versions.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep);
Instances.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep);
break;
}
} else {
@@ -715,18 +565,17 @@ public final class Controllers {
return decorator == null;
}
/// Releases controller-owned pages, window state, and JavaFX helper resources.
public static void shutdown() {
onApplicationStop();
rootPage = null;
versionPage = null;
gameInstancePage = null;
gameListPage = null;
downloadPage = null;
accountListPage = null;
settingsPage = null;
terracottaPage = null;
decorator = null;
stage = null;
scene = null;
onApplicationStop();
FXUtils.shutdown();
}
@@ -49,17 +49,31 @@ public final class DialogUtils {
public static final String PROPERTY_DIALOG_SHOW_LATER = DialogUtils.class.getName() + ".dialog.showLater";
/// Shows content in the main-window dialog stack owned by `decorator`.
///
/// @param decorator the main-window decorator
/// @param content the dialog content
public static void show(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> show(decorator, content));
StackPane dialogContainer = decorator.getDialogContainer();
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showInDecorator(decorator, dialogContainer, content));
return;
}
show(decorator.getDrawerWrapper(), content, (dialog) -> {
showInDecorator(decorator, dialogContainer, content);
}
/// Shows content in a decorator's resolved dialog container.
///
/// @param decorator the main-window decorator
/// @param dialogContainer the container resolved before any deferred execution
/// @param content the dialog content
private static void showInDecorator(Decorator decorator, StackPane dialogContainer, Node content) {
show(dialogContainer, content, dialog -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingWindow(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(decorator.getDrawerWrapper());
dialog.setDialogContainer(dialogContainer);
});
}
@@ -125,12 +139,18 @@ public final class DialogUtils {
}
}
/// Queues content in the main-window dialog stack owned by `decorator`.
///
/// @param decorator the main-window decorator
/// @param content the dialog content
public static void showLater(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> showLater(decorator, content));
StackPane dialogContainer = decorator.getDialogContainer();
Runnable showDialogAction = () -> showInDecorator(decorator, dialogContainer, content);
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showLater(dialogContainer, showDialogAction));
return;
}
showLater(decorator.getDrawerWrapper(), () -> show(decorator, content));
showLater(dialogContainer, showDialogAction);
}
@SuppressWarnings("unchecked")
@@ -92,11 +92,7 @@ import org.xml.sax.SAXException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.io.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
@@ -1691,6 +1687,7 @@ public final class FXUtils {
control.setOnContextMenuRequested(e -> {
boolean hasNoSelection = control.getSelectedText().isEmpty();
boolean isPassword = control instanceof PasswordField;
IconedMenuItem undo = new IconedMenuItem(SVG.UNDO, i18n("menu.undo"), control::undo, popup);
IconedMenuItem redo = new IconedMenuItem(SVG.REDO, i18n("menu.redo"), control::redo, popup);
@@ -1704,9 +1701,9 @@ public final class FXUtils {
undo.setDisable(!control.isUndoable());
redo.setDisable(!control.isRedoable());
cut.setDisable(hasNoSelection);
cut.setDisable(hasNoSelection || isPassword);
delete.setDisable(hasNoSelection);
copy.setDisable(hasNoSelection);
copy.setDisable(hasNoSelection || isPassword);
paste.setDisable(!Clipboard.getSystemClipboard().hasString());
selectall.setDisable(control.getText() == null || control.getText().isEmpty());
@@ -33,6 +33,7 @@ import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import kala.encdet.EncodingDetector;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.*;
@@ -48,7 +49,6 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
@@ -73,7 +73,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameCrashWindow extends Stage {
private final Version version;
private final GameInstanceManifest manifest;
private final String memory;
private final String total_memory;
private final String java;
@@ -91,16 +91,16 @@ public class GameCrashWindow extends Stage {
private final List<Log> logs;
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List<Log> logs) {
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, GameInstanceManifest manifest, LaunchOptions launchOptions, List<Log> logs) {
Themes.applyNativeDarkMode(this);
this.managedProcess = managedProcess;
this.exitType = exitType;
this.repository = repository;
this.version = version;
this.manifest = manifest;
this.launchOptions = launchOptions;
this.logs = logs;
this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null));
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-");
@@ -142,14 +142,14 @@ public class GameCrashWindow extends Stage {
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
}), Task.supplyAsync(() -> {
Path latestLog = repository.getRunDirectory(version.getId()).resolve("logs/latest.log");
Path latestLog = repository.getRunDirectory(manifest.id()).resolve("logs/latest.log");
if (!Files.isReadable(latestLog)) {
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
}
String log;
try {
log = FileUtils.readTextMaybeNativeEncoding(latestLog);
log = EncodingDetector.MODERN_WEB.readString(latestLog);
} catch (IOException e) {
LOG.warning("Failed to read logs/latest.log", e);
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
@@ -167,7 +167,7 @@ public class GameCrashWindow extends Stage {
Set<String> keywords = new HashSet<>();
for (Pair<Set<CrashReportAnalyzer.Result>, Set<String>> pair : (List<Pair<Set<CrashReportAnalyzer.Result>, Set<String>>>) (List<?>) taskResult) {
for (CrashReportAnalyzer.Result result : pair.getKey()) {
results.put(result.getRule(), result);
results.put(result.rule(), result);
}
keywords.addAll(pair.getValue());
}
@@ -184,22 +184,22 @@ public class GameCrashWindow extends Stage {
for (CrashReportAnalyzer.Result result : results.values()) {
String message;
switch (result.getRule()) {
switch (result.rule()) {
case TOO_OLD_JAVA:
message = i18n("game.crash.reason.too_old_java", CrashReportAnalyzer.getJavaVersionFromMajorVersion(Integer.parseInt(result.getMatcher().group("expected"))));
message = i18n("game.crash.reason.too_old_java", CrashReportAnalyzer.getJavaVersionFromMajorVersion(Integer.parseInt(result.matcher().group("expected"))));
break;
case MOD_RESOLUTION_CONFLICT:
case MOD_RESOLUTION_MISSING:
case MOD_RESOLUTION_COLLECTION:
message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT),
translateFabricModId(result.getMatcher().group("sourcemod")),
parseFabricModId(result.getMatcher().group("destmod")),
parseFabricModId(result.getMatcher().group("destmod")));
message = i18n("game.crash.reason." + result.rule().name().toLowerCase(Locale.ROOT),
translateFabricModId(result.matcher().group("sourcemod")),
parseFabricModId(result.matcher().group("destmod")),
parseFabricModId(result.matcher().group("destmod")));
break;
case MOD_RESOLUTION_MISSING_MINECRAFT:
message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT),
translateFabricModId(result.getMatcher().group("mod")),
result.getMatcher().group("version"));
message = i18n("game.crash.reason." + result.rule().name().toLowerCase(Locale.ROOT),
translateFabricModId(result.matcher().group("mod")),
result.matcher().group("version"));
break;
case MOD_FOREST_OPTIFINE:
case TWILIGHT_FOREST_OPTIFINE:
@@ -207,15 +207,15 @@ public class GameCrashWindow extends Stage {
case JADE_FOREST_OPTIFINE:
case NEOFORGE_FOREST_OPTIFINE:
message = i18n("game.crash.reason.mod", "OptiFine");
LOG.info("Crash cause: " + result.getRule() + ": " + i18n("game.crash.reason.mod", "OptiFine"));
LOG.info("Crash cause: " + result.rule() + ": " + i18n("game.crash.reason.mod", "OptiFine"));
break;
default:
message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT),
Arrays.stream(result.getRule().getGroupNames()).map(groupName -> result.getMatcher().group(groupName))
message = i18n("game.crash.reason." + result.rule().name().toLowerCase(Locale.ROOT),
Arrays.stream(result.rule().getGroupNames()).map(groupName -> result.matcher().group(groupName))
.toArray());
break;
}
LOG.info("Crash cause: " + result.getRule() + ": " + message);
LOG.info("Crash cause: " + result.rule() + ": " + message);
segments.addAll(FXUtils.parseSegment(message, Controllers::onHyperlinkAction));
segments.add(new Text("\n\n"));
}
@@ -291,7 +291,7 @@ public class GameCrashWindow extends Stage {
}
});
return LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs,
return LogExporter.exportLogs(logFile, repository, launchOptions.getInstanceId(), logs,
new CommandBuilder().addAll(managedProcess.getCommands()).toString(),
path -> {
try {
@@ -345,7 +345,7 @@ public class GameCrashWindow extends Stage {
TwoLineListItem version = new TwoLineListItem();
version.getStyleClass().setAll("two-line-item-second-large");
version.setTitle(i18n("game.version"));
version.setSubtitle(GameCrashWindow.this.version.getId());
version.setSubtitle(GameCrashWindow.this.manifest.id().toString());
TwoLineListItem total_memory = new TwoLineListItem();
total_memory.getStyleClass().setAll("two-line-item-second-large");
@@ -35,7 +35,7 @@ import javafx.scene.control.SkinBase;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -54,7 +54,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
*/
public class InstallerItem extends Control {
private final String id;
private final VersionIconType iconType;
private final GameInstanceIconType iconType;
private final Style style;
private final ObjectProperty<InstalledState> versionProperty = new SimpleObjectProperty<>(this, "version", null);
private final ObjectProperty<State> resolvedStateProperty = new SimpleObjectProperty<>(this, "resolvedState", InstallableState.INSTANCE);
@@ -92,15 +92,15 @@ public class InstallerItem extends Control {
this.style = style;
iconType = switch (id) {
case "game" -> VersionIconType.GRASS;
case "fabric", "fabric-api" -> VersionIconType.FABRIC;
case "legacyfabric", "legacyfabric-api" -> VersionIconType.LEGACY_FABRIC;
case "forge" -> VersionIconType.FORGE;
case "cleanroom" -> VersionIconType.CLEANROOM;
case "liteloader" -> VersionIconType.CHICKEN;
case "optifine" -> VersionIconType.OPTIFINE;
case "quilt", "quilt-api" -> VersionIconType.QUILT;
case "neoforge" -> VersionIconType.NEO_FORGE;
case "game" -> GameInstanceIconType.GRASS;
case "fabric", "fabric-api" -> GameInstanceIconType.FABRIC;
case "legacyfabric", "legacyfabric-api" -> GameInstanceIconType.LEGACY_FABRIC;
case "forge" -> GameInstanceIconType.FORGE;
case "cleanroom" -> GameInstanceIconType.CLEANROOM;
case "liteloader" -> GameInstanceIconType.CHICKEN;
case "optifine" -> GameInstanceIconType.OPTIFINE;
case "quilt", "quilt-api" -> GameInstanceIconType.QUILT;
case "neoforge" -> GameInstanceIconType.NEO_FORGE;
default -> null;
};
}
@@ -338,6 +338,13 @@ public class InstallerItem extends Control {
throw new AssertionError("Unknown state type: " + state.getClass());
}
}, control.resolvedStateProperty));
FXUtils.onChangeAndOperate(
control.resolvedStateProperty,
state -> statusLabel.pseudoClassStateChanged(
PseudoClass.getPseudoClass("incompatible"),
state instanceof IncompatibleState
)
);
BorderPane.setMargin(statusLabel, new Insets(0, 0, 0, 8));
BorderPane.setAlignment(statusLabel, Pos.CENTER_LEFT);
@@ -164,6 +164,7 @@ public final class LogWindow extends Stage {
private final ListView<Log> listView = new JFXListView<>();
private final BooleanProperty autoScroll = new SimpleBooleanProperty();
private final BooleanProperty wrapText = new SimpleBooleanProperty(true);
private final StringProperty[] buttonText = new StringProperty[LEVELS.length];
private final BooleanProperty[] showLevel = new BooleanProperty[LEVELS.length];
private final JFXButton btnAlwaysOnTop = FXUtils.newToggleButton4(SVG.KEEP, 20);
@@ -174,7 +175,6 @@ public final class LogWindow extends Stage {
LogWindowImpl() {
getStyleClass().add("log-window");
listView.getStyleClass().add("no-horizontal-scrollbar");
listView.getProperties().put("no-smooth-scrolling", true);
listView.setItems(FXCollections.observableList(new CircularArrayList<>(logs.size())));
@@ -331,11 +331,26 @@ public final class LogWindow extends Stage {
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
maxWidthProperty().bind(clippedContainer.widthProperty());
prefWidthProperty().bind(clippedContainer.widthProperty());
wrapTextProperty().addListener((obs, oldWrap, nowWrap) -> {
if (nowWrap) {
maxWidthProperty().bind(clippedContainer.widthProperty());
prefWidthProperty().bind(clippedContainer.widthProperty());
listView.getStyleClass().add("no-horizontal-scrollbar");
} else {
maxWidthProperty().unbind();
prefWidthProperty().unbind();
setMaxWidth(Region.USE_PREF_SIZE);
setPrefWidth(Region.USE_COMPUTED_SIZE);
listView.getStyleClass().remove("no-horizontal-scrollbar");
}
// only for horizontal scrollbar
listView.requestLayout();
});
}
setPadding(new Insets(2));
setWrapText(true);
wrapTextProperty().bind(control.wrapText);
setGraphic(null);
}
@@ -395,6 +410,10 @@ public final class LogWindow extends Stage {
autoScrollCheckBox.setSelected(true);
control.autoScroll.bind(autoScrollCheckBox.selectedProperty());
JFXCheckBox wrapTextCheckBox = new JFXCheckBox(i18n("logwindow.wrap_text"));
wrapTextCheckBox.setSelected(true);
control.wrapText.bind(wrapTextCheckBox.selectedProperty());
JFXButton exportLogsButton = new JFXButton(i18n("button.export"));
exportLogsButton.setOnAction(e -> getSkinnable().onExportLogs());
@@ -414,7 +433,7 @@ public final class LogWindow extends Stage {
JFXButton clearButton = new JFXButton(i18n("button.clear"));
clearButton.setOnAction(e -> getSkinnable().onClear());
hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
hBox.getChildren().setAll(autoScrollCheckBox, wrapTextCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
control.getGameProcess().getProcess()
.onExit()
@@ -118,7 +118,7 @@ public final class MemoryStatusBar extends Control {
}
PhysicalMemoryStatus status = getSkinnable().getMemoryStatus();
double total = status.getTotal();
double total = status.total();
double used = status.getUsed();
double allocated = getSkinnable().getMemoryAllocated();
@@ -66,6 +66,7 @@ public enum SVG {
FILE_OPEN("M6 22q-0.825 0-1.4125-0.5875T4 20v-16q0-0.825 0.5875-1.4125T6 2h8l6 6v6h-2v-5H13v-5H6v16h9v2H6Zm15.95 0.375L19 19.425v2.225h-2v-5.65h5.65v2h-2.25l2.95 2.95-1.4 1.425Zm-15.95-2.375v-16 16Z"),
FOLDER("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8V18Q22 18.825 21.4125 19.4125T20 20H4ZM4 18H20V8H11.175L9.175 6H4V18ZM4 18V6 18Z"),
FOLDER_COPY("M3 21Q2.175 21 1.5875 20.4125T1 19V6H3V19H20V21H3ZM7 17Q6.175 17 5.5875 16.4125T5 15V4Q5 3.175 5.5875 2.5875T7 2H12L14 4H21Q21.825 4 22.4125 4.5875T23 6V15Q23 15.825 22.4125 16.4125T21 17H7ZM7 15H21V6H13.175L11.175 4H7V15ZM7 15V4 15Z"),
FOLDER_FILL("M4 20q-.825 0-1.4125-.5875T2 18v-12q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8v10q0 .825-.5875 1.4125T20 20H4Z"),
FOLDER_OPEN("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8H11.175L9.175 6H4V18L6.4 10H23.5L20.925 18.575Q20.725 19.225 20.1875 19.6125T19 20H4ZM6.1 18H19L20.8 12H7.9L6.1 18ZM6.1 18 7.9 12 6.1 18ZM4 8V6 8Z"),
FORMAT_LIST_BULLETED("M9 19V17H21V19H9ZM9 13V11H21V13H9ZM9 7V5H21V7H9ZM5 20Q4.175 20 3.5875 19.4125T3 18Q3 17.175 3.5875 16.5875T5 16Q5.825 16 6.4125 16.5875T7 18Q7 18.825 6.4125 19.4125T5 20ZM5 14Q4.175 14 3.5875 13.4125T3 12Q3 11.175 3.5875 10.5875T5 10Q5.825 10 6.4125 10.5875T7 12Q7 12.825 6.4125 13.4125T5 14ZM5 8Q4.175 8 3.5875 7.4125T3 6Q3 5.175 3.5875 4.5875T5 4Q5.825 4 6.4125 4.5875T7 6Q7 6.825 6.4125 7.4125T5 8Z"),
FORT("M1 21V17l2-2V9L1 7V3H3V5H5V3H7V5H9V3h2V7L9 9v1h6V9L13 7V3h2V5h2V3h2V5h2V3h2V7L21 9v6l2 2v4H14V18q0-.825-.5875-1.4125T12 16q-.825 0-1.4125.5875T10 18v3H1Zm2-2H8V18q0-1.65 1.175-2.825T12 14q1.65 0 2.825 1.175T16 18v1h5V17.825l-2-2V8.175L20.175 7h-4.35L17 8.175V12H7V8.175L8.175 7H3.825L5 8.175v7.65l-2 2V19Zm9-6Z"),
@@ -22,6 +22,8 @@ import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -34,8 +36,6 @@ import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Node;
import java.net.URL;
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -44,8 +44,8 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class UpgradeDialog extends JFXDialogLayout {
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
maxWidthProperty().bind(Controllers.getScene().widthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
setHeading(new Label(i18n("update.changelog")));
setBody(new JFXSpinner());
@@ -59,7 +59,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
// Downgrade update, no need to display changelog
return null;
Document document = Jsoup.parse(new URL(url), 30 * 1000);
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
if (node == null || !"h1".equals(node.nodeName())) {
@@ -19,10 +19,22 @@ package org.jackhuang.hmcl.ui;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.NativeUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.windows.IPropertyStore;
import org.jackhuang.hmcl.util.platform.windows.Shell32;
import org.jackhuang.hmcl.util.platform.windows.WinTypes;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.OptionalLong;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -55,6 +67,145 @@ public final class WindowsNativeUtils {
}
}
/// Installs permanent window listeners that keep AppUserModel relaunch properties in sync
/// with the stage's native HWND lifecycle.
///
/// JavaFX may destroy and recreate the HWND across `hide()` / `show()` (for example with
/// `HIDE_AND_REOPEN`). Properties are therefore applied on every [`WindowEvent#WINDOW_SHOWN`]
/// and removed with `VT_EMPTY` on every [`WindowEvent#WINDOW_HIDING`], as required by
/// [`SHGetPropertyStoreForWindow`](https://learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-shgetpropertystoreforwindow).
///
/// Handlers must run on the JavaFX application thread: `IPropertyStore` is a COM interface,
/// and the FX thread is already initialized for COM on Windows.
///
/// @param stage the primary launcher stage
public static void installWindowsAppUserModelRelaunchProperties(Stage stage) {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
return;
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
return;
@Nullable Path thisJar = JarUtils.thisJarPath();
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
return;
String exePath = FileUtils.getAbsolutePath(thisJar);
String iconResource = exePath + ",0";
String relaunchCommand = '"' + exePath + '"';
stage.addEventFilter(WindowEvent.WINDOW_SHOWN, event -> {
try {
applyWindowsAppUserModelRelaunchProperties(stage, relaunchCommand, iconResource);
} catch (Throwable e) {
LOG.warning("Failed to set AppUserModel relaunch properties", e);
}
});
stage.addEventFilter(WindowEvent.WINDOW_HIDING, event -> {
try {
clearWindowsAppUserModelRelaunchProperties(stage);
} catch (Throwable e) {
LOG.warning("Failed to clear AppUserModel relaunch properties", e);
}
});
}
/// Writes AppUserModel relaunch properties for the current native window of `stage`.
///
/// Successful [`IPropertyStore#SetValue`] calls on a window property store take effect
/// immediately; no `Commit` is required or useful.
///
/// @param stage the stage whose native window receives the properties
/// @param relaunchCommand the `PKEY_AppUserModel_RelaunchCommand` value
/// @param iconResource the `PKEY_AppUserModel_RelaunchIconResource` value
private static void applyWindowsAppUserModelRelaunchProperties(Stage stage, String relaunchCommand, String iconResource) {
Shell32 shell32 = Shell32.INSTANCE;
if (shell32 == null)
return;
OptionalLong handle = WindowsNativeUtils.getWindowHandle(stage);
if (handle.isEmpty() || handle.getAsLong() == WinTypes.HANDLE.INVALID_VALUE) {
LOG.warning("Failed to get window handle for AppUserModel relaunch properties");
return;
}
try (IPropertyStore store = IPropertyStore.forWindow(shell32, handle.getAsLong())) {
if (store == null) {
LOG.warning("Failed to call SHGetPropertyStoreForWindow");
return;
}
// Set Relaunch* first, then AppUserModel.ID last. Window property store values take
// effect immediately on SetValue; assigning the ID first can let the shell register the
// window before relaunch metadata is complete.
if (!setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchCommand, relaunchCommand)
|| !setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchIconResource, iconResource)
|| !setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchDisplayNameResource, Metadata.FULL_NAME)
|| !setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_ID, Metadata.WINDOWS_APP_USER_MODEL_ID)) {
return;
}
LOG.info("Set AppUserModel relaunch properties for HWND " + Long.toHexString(handle.getAsLong()));
}
}
/// Removes AppUserModel relaunch properties from the current native window before it is hidden.
///
/// @param stage the stage whose native window is about to be destroyed or hidden
private static void clearWindowsAppUserModelRelaunchProperties(Stage stage) {
Shell32 shell32 = Shell32.INSTANCE;
if (shell32 == null)
return;
OptionalLong handle = WindowsNativeUtils.getWindowHandle(stage);
if (handle.isEmpty() || handle.getAsLong() == WinTypes.HANDLE.INVALID_VALUE) {
return;
}
try (IPropertyStore store = IPropertyStore.forWindow(shell32, handle.getAsLong())) {
if (store == null) {
LOG.warning("Failed to call SHGetPropertyStoreForWindow while clearing properties");
return;
}
WinTypes.PROPVARIANT empty = new WinTypes.PROPVARIANT();
empty.setEmpty();
// Clear in reverse of apply order so ID is removed before relaunch metadata when possible.
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_ID, empty);
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchDisplayNameResource, empty);
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchIconResource, empty);
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchCommand, empty);
}
}
/// Sets a string property on an [`IPropertyStore`].
///
/// @param store the property store
/// @param key the property key
/// @param value the string value
/// @return `true` if [`IPropertyStore#SetValue`] succeeds
private static boolean setWindowsPropertyStoreString(IPropertyStore store, WinTypes.PROPERTYKEY key, String value) {
WinTypes.PROPVARIANT propvar = new WinTypes.PROPVARIANT();
propvar.setStringValue(value);
int hr = store.SetValue(key, propvar);
if (hr < 0) {
LOG.warning("Failed to set property pid=" + key.pid + " on IPropertyStore, HRESULT=0x" + Integer.toHexString(hr));
return false;
}
return true;
}
/// Removes a property by writing `VT_EMPTY`.
///
/// @param store the property store
/// @param key the property key to clear
/// @param empty a `VT_EMPTY` value
private static void clearWindowsPropertyStoreValue(IPropertyStore store, WinTypes.PROPERTYKEY key, WinTypes.PROPVARIANT empty) {
int hr = store.SetValue(key, empty);
if (hr < 0) {
LOG.warning("Failed to clear property pid=" + key.pid + " on IPropertyStore, HRESULT=0x" + Integer.toHexString(hr));
}
}
private WindowsNativeUtils() {
}
}
@@ -43,7 +43,6 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogController;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import org.jackhuang.hmcl.util.skin.NormalizedSkin;
import org.jetbrains.annotations.Nullable;
@@ -151,7 +150,7 @@ public class AccountListItem extends RadioButton {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("account.skin.upload"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png"));
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile == null) {
return null;
}
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRadioButton;
import com.jfoenix.effects.JFXDepthManager;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.canvas.Canvas;
@@ -86,6 +87,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
center.getChildren().setAll(canvas, item);
root.setCenter(center);
BorderPane.setMargin(center, new Insets(0, 0, 0, 8));
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
@@ -119,6 +121,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
JFXButton btnRefresh = FXUtils.newToggleButton4(SVG.REFRESH);
SpinnerPane spinnerRefresh = new SpinnerPane();
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
spinnerRefresh.setPrefSize(30.0, 30.0);
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
btnRefresh.setDisable(true);
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot.tooltip"));
@@ -157,12 +160,9 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
right.getChildren().add(spinnerUpload);
JFXButton btnCopyUUID = FXUtils.newToggleButton4(SVG.CONTENT_COPY);
SpinnerPane spinnerCopyUUID = new SpinnerPane();
spinnerCopyUUID.getStyleClass().add("small-spinner-pane");
btnCopyUUID.setOnAction(e -> FXUtils.copyText(skinnable.getAccount().getProfileID().toString()));
FXUtils.installFastTooltip(btnCopyUUID, i18n("account.copy_uuid"));
spinnerCopyUUID.setContent(btnCopyUUID);
right.getChildren().add(spinnerCopyUUID);
right.getChildren().add(btnCopyUUID);
JFXButton btnRemove = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
btnRemove.setOnAction(e -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::remove, null));
@@ -87,8 +87,22 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
actions.getChildren().setAll(cancel, nextPane);
}
addServerPane.setBody(txtServerUrl);
addServerPane.setActions(lblCreationWarning, actions);
VBox body = new VBox();
body.setSpacing(8);
lblCreationWarning.setMinWidth(0);
lblCreationWarning.setPrefWidth(0);
lblCreationWarning.maxWidthProperty().bind(body.widthProperty());
lblCreationWarning.visibleProperty().bind(lblCreationWarning.textProperty().isNotEmpty());
lblCreationWarning.managedProperty().bind(lblCreationWarning.visibleProperty());
body.getChildren().addAll(
txtServerUrl,
lblCreationWarning
);
addServerPane.setBody(body);
addServerPane.setActions(actions);
txtServerUrl.getValidators().addAll(new RequiredValidator(), new URLValidator());
FXUtils.setValidateWhileTextChanged(txtServerUrl, true);
@@ -154,7 +168,6 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
this.setContent(addServerPane, ContainerAnimations.NONE);
lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty());
nextPane.hideSpinner();
onEscPressed(this, this::onAddCancel);
@@ -148,14 +148,14 @@ public class OfflineAccountSkinPane extends StackPane {
UUID uuid = this.account.getProfileID();
if (result == null || result.skin() == null && result.cape() == null) {
canvas.updateSkin(
TexturesLoader.getDefaultSkin(uuid).getImage(),
TexturesLoader.getDefaultSkin(uuid).image(),
TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM,
null
);
return;
}
canvas.updateSkin(
result.skin() != null ? result.skin().image() : TexturesLoader.getDefaultSkin(uuid).getImage(),
result.skin() != null ? result.skin().image() : TexturesLoader.getDefaultSkin(uuid).image(),
result.model() == TextureModel.SLIM,
result.cape() != null ? result.cape().image() : null);
}
@@ -37,7 +37,7 @@ public final class AnimationUtils {
public static void init() {
}
private static final boolean ENABLED = !settings().animationDisabledProperty().get();
private static final boolean ENABLED = !settings().isAnimationDisabled();
private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD();
public static boolean isAnimationEnabled() {
@@ -29,7 +29,10 @@ import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jetbrains.annotations.Nullable;
public class ComponentList extends Control implements NoPaddingComponent {
@@ -136,13 +139,25 @@ public class ComponentList extends Control implements NoPaddingComponent {
}
public static Node createComponentListTitle(String title) {
HBox node = new HBox();
return createComponentListTitle(title, null);
}
public static Node createComponentListTitle(String title, @Nullable String helpMessage) {
HBox node = new HBox(4);
node.setAlignment(Pos.CENTER_LEFT);
node.setPadding(new Insets(8, 0, 0, 0));
{
Label advanced = new Label(title);
node.getChildren().setAll(advanced);
}
if (helpMessage != null) {
var helpIcon = new StackPane(SVG.HELP.createIcon(16));
FXUtils.installFastTooltip(helpIcon, helpMessage);
node.getChildren().add(helpIcon);
}
return node;
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.beans.property.*;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.control.Control;
@@ -228,4 +229,30 @@ public class ComponentSublist extends Control implements NoPaddingComponent {
public void setTip(String tip) {
this.tip.set(tip);
}
/// Whether the sublist header should use a larger title font.
private static final PseudoClass PSEUDO_LARGE_TITLE = PseudoClass.getPseudoClass("large-title");
/// The property controlling whether the sublist header uses a larger title font.
private final BooleanProperty largeTitle = new SimpleBooleanProperty(this, "largeTitle", false) {
@Override
protected void invalidated() {
pseudoClassStateChanged(PSEUDO_LARGE_TITLE, get());
}
};
/// Returns the property controlling whether the sublist header uses a larger title font.
public BooleanProperty largeTitleProperty() {
return largeTitle;
}
/// Returns whether the sublist header uses a larger title font.
public final boolean isLargeTitle() {
return largeTitle.get();
}
/// Sets whether the sublist header uses a larger title font.
public final void setLargeTitle(boolean value) {
largeTitle.set(value);
}
}
@@ -145,10 +145,7 @@ final class ComponentSublistWrapper extends VBox implements NoPaddingComponent {
});
});
Node headerLeft = sublist.getHeaderLeft();
if (headerLeft != null) {
header.setTitleContent(headerLeft);
}
FXUtils.onChangeAndOperate(sublist.largeTitleProperty(), header::setLargeTitle);
InvalidationListener updateTitleTrailing = observable -> {
Node titleTrailing = sublist.getTitleTrailing();
@@ -120,7 +120,7 @@ public class FileSelector extends HBox {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().addAll(getExtensionFilters());
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_file") : chooserTitle);
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path file = Controllers.showOpenDialog(chooser);
if (file != null) {
String path = FileUtils.getAbsolutePath(file);
customField.setText(path);
@@ -131,7 +131,7 @@ public class FileSelector extends HBox {
private void openDirectoryChooser(JFXTextField customField) {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_directory") : chooserTitle);
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
Path dir = Controllers.showDialog(chooser);
if (dir != null) {
String path = FileUtils.getAbsolutePath(dir);
customField.setText(path);
@@ -23,8 +23,8 @@ import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.NumberBinding;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Orientation;
import javafx.geometry.Point2D;
import javafx.scene.Node;
@@ -43,11 +43,10 @@ import org.jackhuang.hmcl.util.MathUtils;
public class FloatScrollBarSkin implements Skin<ScrollBar> {
private ScrollBar scrollBar;
private Region group;
private Rectangle track = new Rectangle();
private Rectangle thumb = new Rectangle();
private final Rectangle track = new Rectangle();
private final Rectangle thumb = new Rectangle();
@SuppressWarnings({"FieldCanBeLocal", "FieldMayBeFinal"})
private ChangeListener<Boolean> thumbHoverListener;
private BooleanBinding hoverOrPressed;
private Animation thumbHoverAnimation;
public FloatScrollBarSkin(final ScrollBar scrollBar) {
@@ -214,7 +213,8 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
}
};
this.thumbHoverListener = FXUtils.onWeakChangeAndOperate(thumb.hoverProperty(), newValue -> {
this.hoverOrPressed = thumb.hoverProperty().or(thumb.pressedProperty());
FXUtils.onChangeAndOperate(hoverOrPressed, newValue -> {
if (thumbHoverAnimation != null) {
thumbHoverAnimation.stop();
thumbHoverAnimation = null;
@@ -249,7 +249,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
thumbHoverAnimation.stop();
thumbHoverAnimation = null;
}
thumbHoverListener = null;
hoverOrPressed = null;
scrollBar = null;
group = null;
}
@@ -26,10 +26,12 @@ import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jetbrains.annotations.Nullable;
@@ -274,6 +276,8 @@ public abstract class LineComponent extends StackPane implements NoPaddingCompon
/// Updates the fixed preferred height after the row width or subtitle content changes.
private void updatePreferredHeight() {
if (FXUtils.getLimitHeight(this) > 0) return;
double width = getWidth();
if (width <= 0) {
setMinHeight(MIN_HEIGHT);
@@ -20,11 +20,11 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.Window;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -57,7 +57,9 @@ public class LineFileChooserButton extends LineButton {
public void fire() {
super.fire();
Stage owner = Controllers.getStage(); // TODO: Allow user to set owner stage
Scene scene = this.getScene();
Window owner = scene != null ? scene.getWindow() : null; // TODO: Allow user to set owner stage
String windowTitle = getFileChooserTitle();
Path initialDirectory = null;
@@ -53,8 +53,13 @@ public abstract class MDListCell<T> extends ListCell<T> {
@Override
protected void updateItem(T item, boolean empty) {
T oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
updateControl(item, empty);
if (empty) {
setGraphic(null);
@@ -85,8 +85,8 @@ public final class RadioChoiceList<T extends @UnknownNullability Object> extends
group.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
Choice<T> choice = newValue != null ? choiceByToggle.get(newValue) : null;
selectedChoice.set(choice);
selectedValue.set(choice != null ? choice.getValue() : null);
selectedChoice.set(choice);
});
selectedValue.addListener((observable, oldValue, newValue) -> {
@@ -96,6 +96,12 @@ public final class RadioChoiceList<T extends @UnknownNullability Object> extends
});
}
/// Replaces the displayed choices.
@SafeVarargs
public final void setChoices(Choice<T>... choices) {
setChoices(List.of(choices));
}
/// Replaces the displayed choices.
public void setChoices(Collection<? extends Choice<T>> choices) {
this.choices.setAll(choices);
@@ -88,7 +88,7 @@ public interface TabControl {
setSelectedIndex(index);
Tab tab = getModelItem(index);
Tab<?> tab = getModelItem(index);
if (tab != null) {
setSelectedItem(tab);
}
@@ -106,7 +106,7 @@ public interface TabControl {
final int itemCount = getItemCount();
for (int i = 0; i < itemCount; i++) {
final Tab value = getModelItem(i);
final Tab<?> value = getModelItem(i);
if (value != null && value.equals(tab)) {
select(i);
return;
@@ -21,11 +21,12 @@ import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.task.*;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -67,16 +68,18 @@ public class TaskExecutorDialogPane extends BorderPane {
center.getChildren().setAll(lblTitle, taskListPane);
}
BorderPane bottom = new BorderPane();
this.setBottom(bottom);
HBox bottom = new HBox();
bottom.setAlignment(Pos.CENTER_LEFT);
bottom.setPadding(new Insets(0, 8, 8, 8));
bottom.setSpacing(8);
this.setBottom(bottom);
{
lblProgress = new Label();
bottom.setLeft(lblProgress);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
btnCancel = new JFXButton(i18n("button.cancel"));
btnCancel.getStyleClass().add("dialog-cancel");
bottom.setRight(btnCancel);
bottom.getChildren().setAll(lblProgress, spacer, btnCancel);
}
setCancel(cancel);
File diff suppressed because it is too large. Load diff
@@ -1,253 +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.decorator;
import com.jfoenix.controls.JFXSnackbar;
import com.jfoenix.controls.JFXSnackbarLayout;
import javafx.animation.Interpolator;
import javafx.beans.property.BooleanProperty;
import javafx.scene.Node;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogUtils;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane.AnimationProducer;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
public class DecoratorController {
private final Decorator decorator;
private final Navigator navigator;
public DecoratorController(Stage stage, Node mainPage) {
decorator = new Decorator(stage);
decorator.titleTransparentProperty().bind(Themes.titleBarTransparentProperty());
navigator = new Navigator();
navigator.setOnNavigated(this::onNavigated);
navigator.init(mainPage);
decorator.getContent().setAll(navigator);
decorator.onCloseNavButtonActionProperty().set(e -> close());
decorator.onBackNavButtonActionProperty().set(e -> back());
decorator.onRefreshNavButtonActionProperty().set(e -> refresh());
setupAuthlibInjectorDnD();
decorator.contentBackgroundProperty().bind(Themes.backgroundProperty());
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
if (!(e.getTarget() instanceof Node t)) {
return;
}
Node newTarget;
JFXDialogPane currentDialogPane = null;
if (decorator.getDrawerWrapper() != null) {
currentDialogPane = (JFXDialogPane) decorator.getDrawerWrapper().getProperties().get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
}
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
newTarget = currentDialogPane.peek().get();
} else {
newTarget = navigator.getCurrentPage();
}
boolean needsRedirect = true;
while (t != null) {
if (t == newTarget) {
// current event target is in newTarget
needsRedirect = false;
break;
}
t = t.getParent();
}
if (!needsRedirect) {
return;
}
e.consume();
newTarget.fireEvent(e.copyFor(e.getSource(), newTarget));
});
// press ESC to go back
onEscPressed(navigator, this::back);
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
// press F11 to toggle full screen
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.F11) {
stage.setFullScreen(!stage.isFullScreen());
e.consume();
}
});
}
navigator.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.BACK) {
back();
e.consume();
}
});
}
public Decorator getDecorator() {
return decorator;
}
// ==== Navigation ====
public void navigate(Node node, AnimationProducer animationProducer, Duration duration, Interpolator interpolator) {
navigator.navigate(node, animationProducer, duration, interpolator);
}
public BooleanProperty backableProperty() {
return navigator.backableProperty();
}
private void close() {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
if (page.isPageCloseable()) {
page.closePage();
return;
}
}
navigator.clear();
}
private void back() {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
if (page.back()) {
if (navigator.canGoBack()) {
navigator.close();
}
}
} else {
if (navigator.canGoBack()) {
navigator.close();
}
}
}
private void refresh() {
if (navigator.getCurrentPage() instanceof Refreshable) {
Refreshable refreshable = (Refreshable) navigator.getCurrentPage();
if (refreshable.refreshableProperty().get())
refreshable.refresh();
}
}
private void onNavigated(Navigator.NavigationEvent event) {
if (event.getSource() != this.navigator) return;
Node to = event.getNode();
if (to instanceof Refreshable) {
decorator.canRefreshProperty().bind(((Refreshable) to).refreshableProperty());
} else {
decorator.canRefreshProperty().unbind();
decorator.canRefreshProperty().set(false);
}
decorator.canCloseProperty().set(navigator.size() > 2);
if (to instanceof DecoratorPage) {
decorator.showCloseAsHomeProperty().set(!((DecoratorPage) to).isPageCloseable());
} else {
decorator.showCloseAsHomeProperty().set(true);
}
decorator.setNavigationDirection(event.getDirection());
// state property should be updated at last.
if (to instanceof DecoratorPage) {
decorator.stateProperty().bind(((DecoratorPage) to).stateProperty());
} else {
decorator.stateProperty().unbind();
decorator.stateProperty().set(new DecoratorPage.State("", null, navigator.canGoBack(), false, true));
}
if (to instanceof Region) {
Region region = (Region) to;
// Let root pane fix window size.
StackPane parent = (StackPane) region.getParent();
region.prefWidthProperty().bind(parent.widthProperty());
region.prefHeightProperty().bind(parent.heightProperty());
}
}
// ==== Dialog ====
public void showDialog(Node node) {
DialogUtils.show(decorator, node);
}
private void closeDialog(Node node) {
DialogUtils.close(node);
}
public void showDialogLater(Node node) {
DialogUtils.showLater(decorator, node);
}
// ==== Toast ====
public void showToast(String content) {
decorator.getSnackbar().fireEvent(new JFXSnackbar.SnackbarEvent(new JFXSnackbarLayout(content)));
}
// ==== Wizard ====
public void startWizard(WizardProvider wizardProvider) {
startWizard(wizardProvider, null);
}
public void startWizard(WizardProvider wizardProvider, String category) {
FXUtils.checkFxUserThread();
navigator.navigate(new DecoratorWizardDisplayer(wizardProvider, category),
ContainerAnimations.FORWARD, Motion.SHORT4, Motion.EASE);
}
// ==== Authlib Injector DnD ====
private void setupAuthlibInjectorDnD() {
decorator.addEventFilter(DragEvent.DRAG_OVER, AuthlibInjectorDnD.dragOverHandler());
decorator.addEventFilter(DragEvent.DRAG_DROPPED, AuthlibInjectorDnD.dragDroppedHandler(
url -> Controllers.dialog(new AddAuthlibInjectorServerPane(url))));
}
}
@@ -44,27 +44,11 @@ public interface DecoratorPage extends Refreshable {
((Node) this).getStyleClass().add("content-background");
}
class State {
private final String title;
private final Node titleNode;
private final boolean backable;
private final boolean refreshable;
private final boolean animate;
private final double leftPaneWidth;
record State(String title, Node titleNode, boolean backable, boolean refreshable, boolean animate, double leftPaneWidth) {
public State(String title, Node titleNode, boolean backable, boolean refreshable, boolean animate) {
this(title, titleNode, backable, refreshable, animate, 0);
}
public State(String title, Node titleNode, boolean backable, boolean refreshable, boolean animate, double leftPaneWidth) {
this.title = title;
this.titleNode = titleNode;
this.backable = backable;
this.refreshable = refreshable;
this.animate = animate;
this.leftPaneWidth = leftPaneWidth;
}
public static State fromTitle(String title) {
return new State(title, null, true, false, true);
}
@@ -76,29 +60,5 @@ public interface DecoratorPage extends Refreshable {
public static State fromTitleNode(Node titleNode) {
return new State(null, titleNode, true, false, true);
}
public String getTitle() {
return title;
}
public Node getTitleNode() {
return titleNode;
}
public boolean isBackable() {
return backable;
}
public boolean isRefreshable() {
return refreshable;
}
public boolean isAnimate() {
return animate;
}
public double getLeftPaneWidth() {
return leftPaneWidth;
}
}
}
@@ -1,577 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
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.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
public class DecoratorSkin extends SkinBase<Decorator> {
private final StackPane root, parent;
private final StackPane titleContainer;
private final Stage primaryStage;
private final TransitionPane navBarPane;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener onWindowsStatusChange;
private final EventHandler<MouseEvent> onTitleBarDoubleClick;
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
public DecoratorSkin(Decorator control) {
super(control);
primaryStage = control.getPrimaryStage();
Decorator skinnable = getSkinnable();
root = new StackPane();
root.getStyleClass().add("window");
StackPane shadowContainer = new StackPane();
shadowContainer.getStyleClass().add("body");
shadowContainer.setEffect(new DropShadow(BlurType.ONE_PASS_BOX, Color.rgb(0, 0, 0, 0.4), 10, 0.3, 0.0, 0.0));
parent = new StackPane();
Rectangle clip = new Rectangle();
clip.widthProperty().bind(parent.widthProperty());
clip.heightProperty().bind(parent.heightProperty());
clip.setArcWidth(8);
clip.setArcHeight(8);
parent.setClip(clip);
skinnable.getSnackbar().registerSnackbarContainer(parent);
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
onWindowsStatusChange = observable -> {
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
} else {
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
};
onTitleBarDoubleClick = event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
primaryStage.setMaximized(!primaryStage.isMaximized());
event.consume();
}
};
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
onWindowsStatusChange.invalidated(null);
} else {
onWindowsStatusChange = null;
onTitleBarDoubleClick = null;
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
shadowContainer.getChildren().setAll(parent);
root.getChildren().setAll(shadowContainer);
StackPane wrapper = new StackPane();
BorderPane frame = new BorderPane();
frame.getStyleClass().addAll("jfx-decorator");
wrapper.getChildren().setAll(frame);
skinnable.setDrawerWrapper(wrapper);
parent.getChildren().add(wrapper);
// center node with an animation layer at bottom, a container layer at middle and a "welcome" layer at top.
StackPane container = new StackPane();
FXUtils.setOverflowHidden(container);
// content layer at middle
{
StackPane contentPlaceHolder = new StackPane();
contentPlaceHolder.getStyleClass().add("jfx-decorator-content-container");
Bindings.bindContent(contentPlaceHolder.getChildren(), skinnable.contentProperty());
container.getChildren().add(contentPlaceHolder);
}
// welcome and hint layer at top
{
StackPane floatLayer = new StackPane();
Bindings.bindContent(floatLayer.getChildren(), skinnable.containerProperty());
ListChangeListener<Node> listener = c -> {
if (skinnable.getContainer().isEmpty()) {
floatLayer.setMouseTransparent(true);
floatLayer.setVisible(false);
} else {
floatLayer.setMouseTransparent(false);
floatLayer.setVisible(true);
}
};
skinnable.containerProperty().addListener(listener);
listener.onChanged(null);
container.getChildren().add(floatLayer);
}
frame.setCenter(container);
titleContainer = new StackPane();
titleContainer.setPickOnBounds(false);
titleContainer.getStyleClass().addAll("jfx-tool-bar");
// Maybe, we can automatically identify whether the top part of the picture is light-coloured or dark when the title is transparent,
// and decide whether the whole top bar should be rendered in white or black. TODO
FXUtils.onChangeAndOperate(skinnable.titleTransparentProperty(), titleTransparent -> {
if (titleTransparent) {
wrapper.backgroundProperty().bind(skinnable.contentBackgroundProperty());
container.backgroundProperty().unbind();
container.setBackground(null);
titleContainer.getStyleClass().remove("background");
titleContainer.getStyleClass().add("gray-background");
} else {
container.backgroundProperty().bind(skinnable.contentBackgroundProperty());
wrapper.backgroundProperty().unbind();
wrapper.setBackground(null);
titleContainer.getStyleClass().add("background");
titleContainer.getStyleClass().remove("gray-background");
}
});
control.capableDraggingWindow(titleContainer);
BorderPane titleBar = new BorderPane();
titleContainer.getChildren().add(titleBar);
Rectangle buttonsContainerPlaceHolder = new Rectangle();
{
navBarPane = new TransitionPane();
navBarPane.setId("decoratorTitleTransitionPane");
FXUtils.onChangeAndOperate(skinnable.stateProperty(), s -> {
if (s == null) return;
Node node = createNavBar(skinnable, s.getLeftPaneWidth(), s.isBackable(), skinnable.canCloseProperty().get(), skinnable.showCloseAsHomeProperty().get(), s.isRefreshable(), s.getTitle(), s.getTitleNode());
if (s.isAnimate()) {
TransitionPane.AnimationProducer animation = switch (skinnable.getNavigationDirection()) {
case NEXT -> NavBarAnimations.NEXT;
case PREVIOUS -> NavBarAnimations.PREVIOUS;
default -> ContainerAnimations.FADE;
};
skinnable.setNavigationDirection(Navigation.NavigationDirection.START);
navBarPane.setContent(node, animation, Motion.SHORT4);
} else {
navBarPane.getChildren().setAll(node);
}
});
titleBar.setCenter(navBarPane);
titleBar.setRight(buttonsContainerPlaceHolder);
}
frame.setTop(titleContainer);
{
HBox buttonsContainer = new HBox();
buttonsContainer.setAlignment(Pos.TOP_RIGHT);
buttonsContainer.setMaxHeight(40);
{
JFXButton btnHelp = new JFXButton();
btnHelp.setFocusTraversable(false);
btnHelp.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty()));
btnHelp.getStyleClass().add("jfx-decorator-button");
btnHelp.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL));
JFXButton btnMin = new JFXButton();
btnMin.setFocusTraversable(false);
btnMin.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
btnMin.getStyleClass().add("jfx-decorator-button");
btnMin.setOnAction(e -> skinnable.minimize());
JFXButton btnClose = new JFXButton();
btnClose.setFocusTraversable(false);
btnClose.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
btnClose.getStyleClass().add("jfx-decorator-button");
btnClose.setOnAction(e -> skinnable.close());
buttonsContainer.getChildren().setAll(btnHelp, btnMin, btnClose);
}
AnchorPane layer = new AnchorPane();
layer.setPickOnBounds(false);
layer.getChildren().add(buttonsContainer);
AnchorPane.setTopAnchor(buttonsContainer, 0.0);
AnchorPane.setRightAnchor(buttonsContainer, 0.0);
buttonsContainerPlaceHolder.widthProperty().bind(buttonsContainer.widthProperty());
parent.getChildren().add(layer);
}
getChildren().add(root);
}
private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean canBack, boolean canClose, boolean showCloseAsHome, boolean canRefresh, String title, Node titleNode) {
BorderPane navBar = new BorderPane();
navBar.getStyleClass().add("navigation-bar");
{
HBox navLeft = new HBox();
navLeft.setAlignment(Pos.CENTER_LEFT);
navLeft.setPadding(new Insets(0, 5, 0, 5));
if (canBack) {
JFXButton backNavButton = new JFXButton();
skinnable.forbidDraggingWindow(backNavButton);
backNavButton.setFocusTraversable(false);
backNavButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty()));
backNavButton.getStyleClass().add("jfx-decorator-button");
backNavButton.onActionProperty().bind(skinnable.onBackNavButtonActionProperty());
backNavButton.visibleProperty().set(canBack);
navLeft.getChildren().add(backNavButton);
}
if (canClose) {
JFXButton closeNavButton = new JFXButton();
skinnable.forbidDraggingWindow(closeNavButton);
closeNavButton.setFocusTraversable(false);
closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeNavButton.getStyleClass().add("jfx-decorator-button");
closeNavButton.onActionProperty().bind(skinnable.onCloseNavButtonActionProperty());
if (showCloseAsHome)
closeNavButton.setGraphic(SVG.HOME.createIcon(Themes.titleFillProperty()));
else
closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
navLeft.getChildren().add(closeNavButton);
}
if (canBack || canClose) {
navBar.setLeft(navLeft);
}
BorderPane center = new BorderPane();
if (title != null) {
Label titleLabel = new Label();
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT);
titleLabel.getStyleClass().add("jfx-decorator-title");
if (titleNode == null) {
titleLabel.maxWidthProperty().bind(Bindings.createDoubleBinding(
() -> skinnable.getWidth() - 150 - navLeft.getWidth(),
skinnable.widthProperty(), navLeft.widthProperty()));
} else {
titleLabel.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
// 8 (margin-left)
return leftPaneWidth - 8 - navLeft.getWidth();
}, navLeft.widthProperty()));
}
titleLabel.setText(title);
center.setLeft(titleLabel);
BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT);
}
if (titleNode != null) {
center.setCenter(titleNode);
BorderPane.setAlignment(titleNode, Pos.CENTER_LEFT);
BorderPane.setMargin(titleNode, new Insets(0, 0, 0, 8));
}
if (onTitleBarDoubleClick != null)
center.setOnMouseClicked(onTitleBarDoubleClick);
center.setOnMouseDragged(mouseEvent -> {
if (!getSkinnable().isDragging() && primaryStage.isMaximized()) {
getSkinnable().setDragging(true);
mouseInitX = mouseEvent.getScreenX();
mouseInitY = mouseEvent.getScreenY();
primaryStage.setMaximized(false);
stageInitWidth = primaryStage.getWidth();
stageInitHeight = primaryStage.getHeight();
primaryStage.setY(stageInitY = 0);
primaryStage.setX(stageInitX = mouseInitX - stageInitWidth / 2);
}
});
navBar.setCenter(center);
if (canRefresh) {
HBox navRight = new HBox();
navRight.setAlignment(Pos.CENTER_RIGHT);
JFXButton refreshNavButton = new JFXButton();
refreshNavButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty()));
refreshNavButton.getStyleClass().add("jfx-decorator-button");
refreshNavButton.onActionProperty().bind(skinnable.onRefreshNavButtonActionProperty());
skinnable.forbidDraggingWindow(refreshNavButton);
navRight.getChildren().setAll(refreshNavButton);
navBar.setRight(navRight);
}
}
return navBar;
}
private boolean isRightEdge(double x, double y, Bounds boundsInParent) {
return x < root.getWidth() && x >= root.getWidth() - root.snappedLeftInset();
}
private boolean isTopEdge(double x, double y, Bounds boundsInParent) {
return y >= 0 && y <= root.snappedTopInset();
}
private boolean isBottomEdge(double x, double y, Bounds boundsInParent) {
return y < root.getHeight() && y >= root.getHeight() - root.snappedLeftInset();
}
private boolean isLeftEdge(double x, double y, Bounds boundsInParent) {
return x >= 0 && x <= root.snappedLeftInset();
}
private void resizeStage(double newWidth, double newHeight) {
if (newWidth < 0)
newWidth = primaryStage.getWidth();
if (newWidth < primaryStage.getMinWidth())
newWidth = primaryStage.getMinWidth();
if (newWidth < titleContainer.getMinWidth())
newWidth = titleContainer.getMinWidth();
if (newHeight < 0)
newHeight = primaryStage.getHeight();
if (newHeight < primaryStage.getMinHeight())
newHeight = primaryStage.getMinHeight();
if (newHeight < titleContainer.getMinHeight())
newHeight = titleContainer.getMinHeight();
// Width and height must be set simultaneously to avoid JDK-8344372 (https://github.com/openjdk/jfx/pull/1654)
primaryStage.setWidth(newWidth);
primaryStage.setHeight(newHeight);
}
private void onMouseMoved(MouseEvent mouseEvent) {
if (!primaryStage.isFullScreen() && primaryStage.isResizable()) {
double x = mouseEvent.getX(), y = mouseEvent.getY();
Bounds boundsInParent = root.getBoundsInParent();
double diagonalSize = root.snappedLeftInset() + 10;
if (this.isRightEdge(x, y, boundsInParent)) {
if (y < diagonalSize) {
root.setCursor(Cursor.NE_RESIZE);
} else if (y > root.getHeight() - diagonalSize) {
root.setCursor(Cursor.SE_RESIZE);
} else {
root.setCursor(Cursor.E_RESIZE);
}
} else if (this.isLeftEdge(x, y, boundsInParent)) {
if (y < diagonalSize) {
root.setCursor(Cursor.NW_RESIZE);
} else if (y > root.getHeight() - diagonalSize) {
root.setCursor(Cursor.SW_RESIZE);
} else {
root.setCursor(Cursor.W_RESIZE);
}
} else if (this.isTopEdge(x, y, boundsInParent)) {
if (x < diagonalSize) {
root.setCursor(Cursor.NW_RESIZE);
} else if (x > root.getWidth() - diagonalSize) {
root.setCursor(Cursor.NE_RESIZE);
} else {
root.setCursor(Cursor.N_RESIZE);
}
} else if (this.isBottomEdge(x, y, boundsInParent)) {
if (x < diagonalSize) {
root.setCursor(Cursor.SW_RESIZE);
} else if (x > root.getWidth() - diagonalSize) {
root.setCursor(Cursor.SE_RESIZE);
} else {
root.setCursor(Cursor.S_RESIZE);
}
} else {
root.setCursor(Cursor.DEFAULT);
}
} else {
root.setCursor(Cursor.DEFAULT);
}
}
private void onMouseReleased(MouseEvent mouseEvent) {
getSkinnable().setDragging(false);
}
private void onMouseDragged(MouseEvent mouseEvent) {
if (!getSkinnable().isDragging()) {
getSkinnable().setDragging(true);
mouseInitX = mouseEvent.getScreenX();
mouseInitY = mouseEvent.getScreenY();
stageInitX = primaryStage.getX();
stageInitY = primaryStage.getY();
stageInitWidth = primaryStage.getWidth();
stageInitHeight = primaryStage.getHeight();
}
if (primaryStage.isFullScreen() || !mouseEvent.isPrimaryButtonDown() || mouseEvent.isStillSincePress())
return;
double dx = mouseEvent.getScreenX() - mouseInitX;
double dy = mouseEvent.getScreenY() - mouseInitY;
Cursor cursor = root.getCursor();
if (getSkinnable().isAllowMove()) {
if (cursor == Cursor.DEFAULT) {
primaryStage.setX(stageInitX + dx);
primaryStage.setY(stageInitY + dy);
mouseEvent.consume();
}
}
if (getSkinnable().isResizable()) {
if (cursor == Cursor.E_RESIZE) {
resizeStage(stageInitWidth + dx, -1);
mouseEvent.consume();
} else if (cursor == Cursor.S_RESIZE) {
resizeStage(-1, stageInitHeight + dy);
mouseEvent.consume();
} else if (cursor == Cursor.W_RESIZE) {
resizeStage(stageInitWidth - dx, -1);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
mouseEvent.consume();
} else if (cursor == Cursor.N_RESIZE) {
resizeStage(-1, stageInitHeight - dy);
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
} else if (cursor == Cursor.SE_RESIZE) {
resizeStage(stageInitWidth + dx, stageInitHeight + dy);
mouseEvent.consume();
} else if (cursor == Cursor.SW_RESIZE) {
resizeStage(stageInitWidth - dx, stageInitHeight + dy);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
mouseEvent.consume();
} else if (cursor == Cursor.NW_RESIZE) {
resizeStage(stageInitWidth - dx, stageInitHeight - dy);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
} else if (cursor == Cursor.NE_RESIZE) {
resizeStage(stageInitWidth + dx, stageInitHeight - dy);
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
}
}
}
enum NavBarAnimations implements TransitionPane.AnimationProducer {
NEXT {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), 50, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), -50, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator))
);
}
@Override
public TransitionPane.AnimationProducer opposite() {
return NEXT;
}
},
PREVIOUS {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), -50, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), 50, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator))
);
}
@Override
public TransitionPane.AnimationProducer opposite() {
return PREVIOUS;
}
};
}
}
@@ -57,7 +57,7 @@ public abstract class DecoratorTransitionPage extends Control implements Decorat
if (to instanceof DecoratorPage) {
state.bind(Bindings.createObjectBinding(() -> {
State state = ((DecoratorPage) to).stateProperty().get();
return new State(state.getTitle(), state.getTitleNode(), backable.get(), state.isRefreshable(), true, leftPaneWidth.get());
return new State(state.title(), state.titleNode(), backable.get(), state.refreshable(), true, leftPaneWidth.get());
}, ((DecoratorPage) to).stateProperty()));
} else {
state.unbind();
@@ -0,0 +1,400 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Renders the clipped background, title bar, and navigation content owned by a [Decorator].
@NotNullByDefault
final class MainWindowPane extends StackPane {
/// The diameter, in pixels, of the rounded window corners.
private static final double ARC = 8.0;
/// The decorator whose state and actions are represented by this pane.
private final Decorator decorator;
/// The clip that rounds normal window corners and becomes square while the window fills the screen.
private final Rectangle clip = new Rectangle();
/// The frame containing the title bar and current navigation page.
private final BorderPane frame;
/// The title bar containing page navigation state.
private final BorderPane titleBar;
/// The transition container used when the title-bar state changes.
private final TransitionPane navBarPane;
/// Retains listener delegates that are registered through weak listener wrappers.
@SuppressWarnings("FieldCanBeLocal")
private final WeakListenerHolder holder = new WeakListenerHolder();
/// Creates and wires the main-window content for `decorator`.
///
/// @param decorator the owning window decorator
MainWindowPane(Decorator decorator) {
this.decorator = decorator;
clip.widthProperty().bind(widthProperty());
clip.heightProperty().bind(heightProperty());
clip.setArcWidth(ARC);
clip.setArcHeight(ARC);
setClip(clip);
backgroundProperty().bind(Bindings.createObjectBinding(
() -> Themes.windowTransparentProperty().get()
? null
: new Background(new BackgroundFill(
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
CornerRadii.EMPTY,
Insets.EMPTY)),
Themes.windowTransparentProperty(),
Themes.colorSchemeProperty()));
Region backgroundNode = createBackgroundNode();
frame = new BorderPane();
frame.getStyleClass().add("jfx-decorator");
StackPane center = new StackPane();
center.getStyleClass().add("jfx-decorator-content-container");
FXUtils.setOverflowHidden(center);
center.getChildren().setAll(decorator.getNavigator());
frame.setCenter(center);
HBox rightButtonsContainer = createWindowButtons();
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(rightButtonsContainer);
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
frame.setTop(titleBar);
updateTitleBarBackground();
InvalidationListener titleBarBackgroundListener = observable -> updateTitleBarBackground();
Themes.colorSchemeProperty().addListener(holder.weak(titleBarBackgroundListener));
Themes.titleBarTransparentProperty().addListener(holder.weak(titleBarBackgroundListener));
InvalidationListener navBarListener = observable -> updateNavBar();
decorator.stateProperty().addListener(holder.weak(navBarListener));
updateNavBar();
decorator.capableDraggingWindow(titleBar);
getChildren().setAll(backgroundNode, frame);
}
/// Updates the content-corner shape for an edge-to-edge window state.
///
/// @param edgeToEdge whether the attached window is maximized or full-screen
void setWindowEdgeToEdge(boolean edgeToEdge) {
double arc = edgeToEdge ? 0.0 : ARC;
clip.setArcWidth(arc);
clip.setArcHeight(arc);
}
/// Creates the launcher-background layer and keeps it synchronized with the active theme.
///
/// @return the mouse-transparent background node
private Region createBackgroundNode() {
Region backgroundNode = new Region();
backgroundNode.setMouseTransparent(true);
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
holder.onWeakChangeAndOperate(Themes.backgroundProperty(), background -> {
double opacity = background.opacity();
if (opacity >= 0.001) {
backgroundNode.setBackground(background.background());
backgroundNode.setOpacity(opacity);
} else {
backgroundNode.setBackground(null);
backgroundNode.setOpacity(0);
}
});
return backgroundNode;
}
/// Creates the help, minimize, and application-close buttons.
///
/// @return a size-limited top-right button row
private HBox createWindowButtons() {
HBox buttons = new HBox();
buttons.setAlignment(Pos.TOP_RIGHT);
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton helpButton = new JFXButton();
helpButton.setFocusTraversable(false);
helpButton.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty()));
helpButton.getStyleClass().add("jfx-decorator-button");
helpButton.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL));
decorator.forbidDraggingWindow(helpButton);
JFXButton minimizeButton = new JFXButton();
minimizeButton.setFocusTraversable(false);
minimizeButton.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
minimizeButton.getStyleClass().add("jfx-decorator-button");
minimizeButton.setOnAction(event -> decorator.minimizeWindow());
decorator.forbidDraggingWindow(minimizeButton);
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.setOnAction(event -> decorator.closeWindow());
decorator.forbidDraggingWindow(closeButton);
buttons.getChildren().setAll(helpButton, minimizeButton, closeButton);
return buttons;
}
/// Recomputes the title-bar background for the current theme and transparency preference.
private void updateTitleBarBackground() {
@Nullable ColorScheme colorScheme = Themes.getColorScheme();
if (colorScheme == null) {
titleBar.setBackground(null);
} else if (Themes.titleBarTransparentProperty().get()) {
Color surface = colorScheme.getSurface();
titleBar.setBackground(new Background(new BackgroundFill(
new Color(surface.getRed(), surface.getGreen(), surface.getBlue(), 0.5),
CornerRadii.EMPTY,
Insets.EMPTY)));
} else {
titleBar.setBackground(new Background(new BackgroundFill(
colorScheme.getPrimaryContainer(),
CornerRadii.EMPTY,
Insets.EMPTY)));
}
}
/// Rebuilds the navigation bar from the current page state.
private void updateNavBar() {
DecoratorPage.@Nullable State state = decorator.getState();
if (state == null) {
navBarPane.getChildren().clear();
return;
}
Node navBar = createNavBar(state);
if (state.animate()) {
TransitionPane.AnimationProducer animation = switch (decorator.getNavigationDirection()) {
case NEXT -> NavBarAnimations.NEXT;
case PREVIOUS -> NavBarAnimations.PREVIOUS;
default -> ContainerAnimations.FADE;
};
decorator.setNavigationDirection(Navigation.NavigationDirection.START);
navBarPane.setContent(navBar, animation, Motion.SHORT4);
} else {
navBarPane.getChildren().setAll(navBar);
}
}
/// Creates the page-specific navigation controls and title.
///
/// @param state the current page state
/// @return a newly constructed navigation bar
private Node createNavBar(DecoratorPage.State state) {
HBox navBar = new HBox();
navBar.setAlignment(Pos.CENTER_LEFT);
// Left navigation buttons
if (state.backable()) {
JFXButton backButton = new JFXButton();
backButton.setFocusTraversable(false);
backButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty()));
backButton.getStyleClass().add("jfx-decorator-button");
backButton.setOnAction(event -> decorator.back());
decorator.forbidDraggingWindow(backButton);
navBar.getChildren().add(backButton);
}
if (decorator.canCloseProperty().get()) {
boolean showCloseAsHome = decorator.showCloseAsHomeProperty().get();
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic((showCloseAsHome ? SVG.HOME : SVG.CLOSE).createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.setOnAction(event -> decorator.closeCurrentPage());
decorator.forbidDraggingWindow(closeButton);
navBar.getChildren().add(closeButton);
}
// Center title area
StackPane titleArea = new StackPane();
titleArea.setAlignment(Pos.CENTER_LEFT);
titleArea.setMinWidth(0);
HBox.setHgrow(titleArea, Priority.ALWAYS);
if (state.titleNode() != null) {
titleArea.getChildren().setAll(state.titleNode());
StackPane.setMargin(state.titleNode(), new Insets(0, 0, 0, 8));
} else if (state.title() != null) {
Label titleLabel = new Label(state.title());
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
titleLabel.getStyleClass().add("jfx-decorator-title");
titleLabel.setMinWidth(0);
titleArea.getChildren().setAll(titleLabel);
}
decorator.registerTitleBar(titleArea);
if (!navBar.getChildren().isEmpty()) {
Insets padding = new Insets(0, 0, 0, 5);
navBar.setPadding(padding);
titleArea.setPadding(padding);
}
navBar.getChildren().add(titleArea);
// Right refresh button
if (state.refreshable()) {
JFXButton refreshButton = new JFXButton();
refreshButton.setFocusTraversable(false);
refreshButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty()));
refreshButton.getStyleClass().add("jfx-decorator-button");
refreshButton.setOnAction(event -> decorator.refresh());
decorator.forbidDraggingWindow(refreshButton);
navBar.getChildren().add(refreshButton);
}
return navBar;
}
/// Produces directional transitions for page-title changes.
private enum NavBarAnimations implements TransitionPane.AnimationProducer {
/// Moves the next title in from the right.
NEXT {
/// {@inheritDoc}
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
/// {@inheritDoc}
@Override
public Timeline animate(
Pane container,
Node previousNode,
Node nextNode,
Duration duration,
Interpolator interpolator) {
return createTimeline(previousNode, nextNode, 50, -50, duration, interpolator);
}
/// {@inheritDoc}
@Override
public TransitionPane.AnimationProducer opposite() {
return PREVIOUS;
}
},
/// Moves the previous title in from the left.
PREVIOUS {
/// {@inheritDoc}
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(-container.getWidth());
}
/// {@inheritDoc}
@Override
public Timeline animate(
Pane container,
Node previousNode,
Node nextNode,
Duration duration,
Interpolator interpolator) {
return createTimeline(previousNode, nextNode, -50, 50, duration, interpolator);
}
/// {@inheritDoc}
@Override
public TransitionPane.AnimationProducer opposite() {
return NEXT;
}
};
/// Creates a title transition with the supplied horizontal offsets.
///
/// @param previousNode the title being removed
/// @param nextNode the title being shown
/// @param nextOffset the initial offset of the next title
/// @param previousOffset the final offset of the previous title
/// @param duration the transition duration
/// @param interpolator the transition interpolator
/// @return the unstarted transition timeline
private static Timeline createTimeline(
Node previousNode,
Node nextNode,
double nextOffset,
double previousOffset,
Duration duration,
Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), nextOffset, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), previousOffset, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator)));
}
}
}
@@ -21,13 +21,14 @@ import com.jfoenix.controls.JFXButton;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jetbrains.annotations.NotNullByDefault;
@@ -48,18 +49,23 @@ public class GameDirectoryListItemSkin extends SkinBase<GameDirectoryListItem> {
root.setPickOnBounds(false);
RipplerContainer container = new RipplerContainer(root);
SVGContainer left = (skinnable.isSelected() ? SVG.FOLDER_FILL : SVG.FOLDER).createIcon(20);
left.setMouseTransparent(true);
BorderPane.setMargin(left, new Insets(0, 6, 0, 6));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
root.setLeft(left);
FXUtils.onChangeAndOperate(skinnable.selectedProperty(), active -> {
skinnable.pseudoClassStateChanged(SELECTED, active);
SVG targetIcon = active ? SVG.FOLDER_FILL : SVG.FOLDER;
if (left.getIcon() != targetIcon) {
left.setIcon(targetIcon, Motion.SHORT4);
}
});
FXUtils.onClicked(getSkinnable(), () -> GameDirectoryManager.setSelectedGameDirectory(skinnable.getGameDirectory()));
Node left = SVG.FOLDER.createIcon(20);
left.setMouseTransparent(true);
BorderPane.setMargin(left, new Insets(0, 6, 0, 6));
root.setLeft(left);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
TwoLineListItem item = new TwoLineListItem();
item.setPickOnBounds(false);
BorderPane.setAlignment(item, Pos.CENTER);
@@ -27,11 +27,15 @@ import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
@@ -42,6 +46,8 @@ import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public abstract class AbstractInstallersPage extends Control implements WizardPage {
public static final SettingsMap.Key<GameInstanceID> INSTANCE_ID = new SettingsMap.Key<>("instanceId");
public static final String FABRIC_QUILT_API_TIP = "fabricQuiltApi";
protected final WizardController controller;
@@ -112,6 +118,14 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
return new InstallersPageSkin(this);
}
/// Returns whether the name field includes clear and reset controls.
///
/// @return `true` to display the controls; `false` otherwise
protected abstract boolean showExtendPane();
/// Restores the default value of the name field.
protected abstract void resetDefaultName();
protected static class InstallersPageSkin extends SkinBase<AbstractInstallersPage> {
/**
* Constructor for all SkinBase instances.
@@ -127,11 +141,27 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
{
HBox versionNamePane = new HBox(8);
versionNamePane.getStyleClass().add("card-non-transparent");
versionNamePane.setStyle("-fx-padding: 20 8 20 16");
versionNamePane.setStyle("-fx-padding: 20 16 20 16");
versionNamePane.setAlignment(Pos.CENTER_LEFT);
control.txtName.setMaxWidth(300);
versionNamePane.getChildren().setAll(new Label(i18n("version.name")), control.txtName);
HBox.setHgrow(control.txtName, Priority.ALWAYS);
versionNamePane.getChildren().addAll(new Label(i18n("instance.name")), control.txtName);
if (control.showExtendPane()) {
JFXButton clearButton = FXUtils.newToggleButton4(SVG.CLOSE);
FXUtils.installFastTooltip(clearButton, i18n("button.clear"));
clearButton.disableProperty().bind(control.txtName.textProperty().isEmpty().or(control.txtName.disableProperty()));
clearButton.setOnAction(e -> control.txtName.clear());
JFXButton resetButton = FXUtils.newToggleButton4(SVG.RESTORE);
FXUtils.installFastTooltip(resetButton, i18n("button.reset"));
resetButton.disableProperty().bind(control.txtName.disableProperty());
resetButton.setOnAction(e -> control.resetDefaultName());
versionNamePane.getChildren().addAll(clearButton, resetButton);
}
root.setTop(versionNamePane);
}
@@ -23,9 +23,8 @@ import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.game.GameRepository;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.Lang;
@@ -38,17 +37,17 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
class AdditionalInstallersPage extends AbstractInstallersPage {
protected final BooleanProperty compatible = new SimpleBooleanProperty();
protected final GameRepository repository;
protected final HMCLGameRepository repository;
protected final String gameVersion;
protected final Version version;
protected final GameInstanceManifest manifest;
public AdditionalInstallersPage(String gameVersion, Version version, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
this.gameVersion = gameVersion;
this.version = version;
this.manifest = manifest;
this.repository = repository;
txtName.setText(version.getId());
txtName.setText(manifest.id().toString());
txtName.setEditable(false);
for (InstallerItem library : group.getLibraries()) {
@@ -81,8 +80,8 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
@Override
protected void reload() {
Version resolved = version.resolvePreservingPatches(repository);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolved, repository.getGameVersion(resolved).orElse(null));
GameInstanceManifest.Resolved resolvedManifest = repository.resolve(manifest);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null));
String game = analyzer.getVersion(MINECRAFT).orElse(null);
String currentGameVersion = Lang.nonNull(getVersion("game"), game);
@@ -111,4 +110,13 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
@Override
public void cleanup(SettingsMap settings) {
}
@Override
protected boolean showExtendPane() {
return false;
}
@Override
protected void resetDefaultName() {
}
}
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -42,10 +43,10 @@ import org.jackhuang.hmcl.ui.construct.TabHeader;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.DownloadListPage;
import org.jackhuang.hmcl.ui.versions.HMCLLocalizedDownloadListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.ui.instances.DownloadListPage;
import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
@@ -68,11 +69,11 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage {
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_MOD =
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_MOD =
(downloadProvider, repository, version, mod, file) -> download(downloadProvider, repository, version, file, "mods");
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
(downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks");
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_SHADER =
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER =
(downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks");
private final ReadOnlyObjectWrapper<DecoratorPage.State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1));
@@ -92,16 +93,16 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
this(null);
}
public DownloadPage(String uploadVersion) {
public DownloadPage(GameInstanceID uploadInstance) {
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
"game", versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
Versions.downloadModpackImpl(downloadProvider, repository, uploadVersion, modpack, file);
Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file);
}, false);
JFXButton installLocalModpackButton = FXUtils.newRaisedButton(i18n("install.modpack"));
installLocalModpackButton.setOnAction(e -> Versions.importModpack());
installLocalModpackButton.setOnAction(e -> Instances.importModpack());
page.getActions().add(installLocalModpackButton);
return page;
@@ -134,17 +135,19 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
private static <T extends Node> Supplier<T> loadVersionFor(Supplier<T> nodeSupplier) {
return () -> {
T node = nodeSupplier.get();
if (node instanceof VersionPage.GameInstanceLoadable loadable) {
if (node instanceof GameInstancePage.GameInstanceLoadable loadable) {
loadable.loadInstance(GameDirectoryManager.getSelectedRepository(), null);
}
return node;
};
}
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable String version, RemoteAddon.Version file, String subdirectoryName) {
if (version == null) version = repository.getSelectedInstance();
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
if (instanceId == null) {
instanceId = repository.getSelectedInstance();
}
Path runDirectory = repository.hasVersion(version) ? repository.getRunDirectory(version) : repository.getBaseDirectory();
Path runDirectory = instanceId != null && repository.hasInstance(instanceId) ? repository.getRunDirectory(instanceId) : repository.getBaseDirectory();
Set<String> existingFiles;
@@ -306,10 +309,10 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
GameBuilder builder = dependencyManager.gameBuilder();
GameBuilder builder = dependencyManager.newGameBuilder();
String name = (String) settings.get("name");
builder.name(name);
GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
builder.name(instanceId);
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
settings.asStringMap().forEach((key, value) -> {
@@ -318,10 +321,11 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
builder.version(remoteVersion);
});
repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion());
return builder.buildAsync().whenComplete(any -> {
repository.refreshVersions();
repository.applyDefaultIsolationSetting(name);
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
repository.refresh();
repository.applyDefaultIsolationSetting(instanceId);
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
}
@Override
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.InstallerItem;
@@ -42,8 +43,8 @@ public class InstallersPage extends AbstractInstallersPage {
txtName.getValidators().addAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.versionIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
installable.bind(createBooleanBinding(txtName::validate, txtName.textProperty()));
txtName.textProperty().addListener((obs, oldText, newText) -> isNameModifiedByUser = true);
@@ -91,22 +92,22 @@ public class InstallersPage extends AbstractInstallersPage {
}
protected void onInstall() {
String name = txtName.getText();
GameInstanceID instanceId = new GameInstanceID(txtName.getText());
if (!checkName(name)) {
if (!checkName(instanceId.id())) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
MessageDialogPane.MessageType.QUESTION)
.yesOrNo(() -> {
controller.getSettings().put("name", name);
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}, () -> {
// The user selects Cancel and does nothing.
})
.build());
} else {
controller.getSettings().put("name", name);
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}
}
@@ -143,4 +144,15 @@ public class InstallersPage extends AbstractInstallersPage {
txtName.setText(nameBuilder.toString());
isNameModifiedByUser = false;
}
@Override
protected boolean showExtendPane() {
return true;
}
@Override
protected void resetDefaultName() {
isNameModifiedByUser = false;
setTxtNameWithLoaders();
}
}
@@ -66,8 +66,8 @@ public final class LocalModpackPage extends ModpackPage {
if (installAsVersion) {
txtModpackName.getValidators().setAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.versionIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
} else {
txtModpackName.getValidators().setAll(
new RequiredValidator(),
@@ -75,7 +75,7 @@ public final class LocalModpackPage extends ModpackPage {
&& GameDirectoryManager.getGameDirectories().stream()
.noneMatch(existingProfile ->
str.equals(GameDirectoryManager.getGameDirectoryCustomName(existingProfile)))),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
}
});
}
@@ -90,7 +90,7 @@ public final class LocalModpackPage extends ModpackPage {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile == null) {
controller.onEnd();
return;
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
import org.jackhuang.hmcl.game.ModpackHelper;
@@ -57,14 +58,14 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
this(repository, modpackFile, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, String updateVersion) {
this(repository, null, updateVersion);
public ModpackInstallWizardProvider(HMCLGameRepository repository, GameInstanceID updateInstanceId) {
this(repository, null, updateInstanceId);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, String updateVersion) {
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, GameInstanceID updateInstanceId) {
this.repository = repository;
this.file = modpackFile;
this.updateVersion = updateVersion;
this.updateVersion = updateInstanceId != null ? updateInstanceId.toString() : null;
}
public void setIconUrl(String iconUrl) {
@@ -99,6 +100,8 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
if ((selected == null && serverModpackManifest == null) || modpack == null || name == null) return null;
GameInstanceID instanceId = new GameInstanceID(name);
if (updateVersion != null) {
if (selected == null) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
@@ -106,9 +109,9 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
}
try {
if (serverModpackManifest != null) {
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), name, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(name)));
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
} else {
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), name, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(name)));
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
}
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
@@ -120,11 +123,11 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
return null;
} else {
if (serverModpackManifest != null) {
return ModpackHelper.getInstallTask(repository, serverModpackManifest, name, modpack)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
} else {
return ModpackHelper.getInstallTask(repository, selected, name, modpack, iconUrl)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
}
}
}
@@ -61,11 +61,11 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
{
var archiveNamePane = new LinePane();
{
archiveNamePane.setTitle(i18n("version.name"));
FXUtils.setLimitHeight(archiveNamePane, 75);
archiveNamePane.setTitle(i18n("instance.name"));
txtModpackName = new JFXTextField();
txtModpackName.setPrefWidth(300);
FXUtils.setLimitHeight(archiveNamePane, 75);
// BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32));
BorderPane.setAlignment(txtModpackName, Pos.CENTER_RIGHT);
archiveNamePane.setRight(txtModpackName);
@@ -27,6 +27,7 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.SVGPath;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.FileDownloadTask;
@@ -42,9 +43,7 @@ import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -112,9 +111,8 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip", "*.mrpack"));
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile == null) {
Platform.runLater(controller::onEnd);
return;
}
@@ -158,15 +156,15 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
TaskCancellationAction.NORMAL
);
}
} catch (IOException e) {
handler.reject(e.getMessage());
} catch (Exception e) {
handler.reject(i18n("message.failed"));
}
}, "", new URLValidator());
}
public void onChooseRepository() {
String modPackName = controller.getSettings().get(MODPACK_NAME);
DownloadPage downloadPage = new DownloadPage(modPackName);
DownloadPage downloadPage = new DownloadPage(modPackName != null ? new GameInstanceID(modPackName) : null);
downloadPage.showModpackDownloads();
Controllers.navigate(downloadPage);
}
@@ -66,8 +66,8 @@ public final class RemoteModpackPage extends ModpackPage {
txtModpackName.setText(manifest.getName().trim());
txtModpackName.getValidators().addAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.versionIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
}
btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription()));
@@ -21,8 +21,8 @@ import javafx.scene.Node;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
import org.jackhuang.hmcl.download.game.LibraryDownloadException;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.Task;
@@ -49,15 +49,15 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependencyManager;
private final String gameVersion;
private final Version version;
private final GameInstanceManifest manifest;
private final String libraryId;
private final String oldLibraryVersion;
private final DownloadProvider downloadProvider;
public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull Version version, @NotNull String libraryId, @Nullable String oldLibraryVersion) {
public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull GameInstanceManifest manifest, @NotNull String libraryId, @Nullable String oldLibraryVersion) {
this.repository = repository;
this.gameVersion = gameVersion;
this.version = version;
this.manifest = manifest;
this.libraryId = libraryId;
this.oldLibraryVersion = oldLibraryVersion;
this.downloadProvider = DownloadProviders.getDownloadProvider();
@@ -76,7 +76,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
// We remove library but not save it,
// so if installation failed will not break down current version.
Task<Version> ret = Task.supplyAsync(() -> version);
Task<GameInstanceManifest> ret = Task.supplyAsync(() -> manifest);
var hints = new ArrayList<Task.StagesHint>();
for (Object value : settings.asStringMap().values()) {
if (value instanceof RemoteVersion remoteVersion) {
@@ -91,7 +91,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
}
return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshVersionsAsync()).withStagesHints(hints);
return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshAsync()).withStagesHints(hints);
}
@Override
@@ -103,7 +103,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
controller.onFinish();
} else if ("game".equals(libraryId)) {
String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion();
controller.onNext(new AdditionalInstallersPage(newGameVersion, version, controller, repository, downloadProvider));
controller.onNext(new AdditionalInstallersPage(newGameVersion, manifest, controller, repository, downloadProvider));
} else {
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()),
i18n("install.change_version"), controller::onFinish, controller::onCancel);
@@ -128,7 +128,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
public static void alertFailureMessage(Exception exception, Runnable next) {
if (exception instanceof LibraryDownloadException) {
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().getName()) + "\n";
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().name()) + "\n";
if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
int responseCode = rce.getResponseCode();
@@ -1,95 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.GameBuilder;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class VanillaInstallWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependencyManager;
private final DownloadProvider downloadProvider;
public VanillaInstallWizardProvider(HMCLGameRepository repository) {
this.repository = repository;
this.downloadProvider = DownloadProviders.getDownloadProvider();
this.dependencyManager = repository.getDependency(downloadProvider);
}
@Override
public void start(SettingsMap settings) {
settings.put(ModpackPage.GAME_DIRECTORY, repository.getGameDirectory());
settings.put(ModpackPage.REPOSITORY, repository);
}
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
GameBuilder builder = dependencyManager.gameBuilder();
String name = (String) settings.get("name");
builder.name(name);
builder.gameVersion(((RemoteVersion) settings.get("game")).getGameVersion());
settings.asStringMap().forEach((key, value) -> {
if (!"game".equals(key) && value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
return builder.buildAsync().whenComplete(any -> {
repository.refreshVersions();
repository.applyDefaultIsolationSetting(name);
})
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
}
@Override
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.new_game.installation"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
return finishVersionDownloadingAsync(settings);
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer.game")), "", downloadProvider, "game",
() -> controller.onNext(new InstallersPage(controller, repository, ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider)));
default:
throw new IllegalStateException("error step " + step + ", settings: " + settings + ", pages: " + controller.getPages());
}
}
@Override
public boolean cancel() {
return true;
}
}
@@ -45,7 +45,7 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -213,6 +213,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
@Override
public void updateItem(RemoteVersion remoteVersion, boolean empty) {
RemoteVersion oldRemoteVersion = getItem();
super.updateItem(remoteVersion, empty);
if (empty) {
@@ -221,6 +222,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
setGraphic(pane);
if (oldRemoteVersion == remoteVersion) return;
twoLineListItem.setTitle(I18n.getDisplayVersion(remoteVersion));
if (remoteVersion.getReleaseDate() != null) {
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
@@ -228,67 +231,65 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
twoLineListItem.setSubtitle(null);
}
if (oldRemoteVersion != remoteVersion) {
twoLineListItem.getTags().clear();
twoLineListItem.getTags().clear();
if (remoteVersion instanceof GameRemoteVersion) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
if (remoteVersion instanceof GameRemoteVersion) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
switch (versionType) {
case RELEASE -> {
twoLineListItem.addTag(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
}
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("version.game.april_fools"));
imageView.setImage(VersionIconType.APRIL_FOOLS.getIcon());
} else {
twoLineListItem.addTag(i18n("version.game.snapshot"));
imageView.setImage(VersionIconType.COMMAND.getIcon());
}
}
default -> {
twoLineListItem.addTag(i18n("version.game.old"));
imageView.setImage(VersionIconType.CRAFT_TABLE.getIcon());
switch (versionType) {
case RELEASE -> {
twoLineListItem.addTag(i18n("instance.game.release"));
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
}
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("instance.game.april_fools"));
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
} else {
twoLineListItem.addTag(i18n("instance.game.snapshot"));
imageView.setImage(GameInstanceIconType.COMMAND.getIcon());
}
}
switch (NativePatcher.checkSupportedStatus(gameVersion, Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION)) {
case UNTESTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.untested"));
case UNSUPPORTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.unsupported"));
default -> {
twoLineListItem.addTag(i18n("instance.game.old"));
imageView.setImage(GameInstanceIconType.CRAFT_TABLE.getIcon());
}
} else {
VersionIconType iconType;
if (remoteVersion instanceof LiteLoaderRemoteVersion)
iconType = VersionIconType.CHICKEN;
else if (remoteVersion instanceof OptiFineRemoteVersion)
iconType = VersionIconType.OPTIFINE;
else if (remoteVersion instanceof ForgeRemoteVersion)
iconType = VersionIconType.FORGE;
else if (remoteVersion instanceof CleanroomRemoteVersion)
iconType = VersionIconType.CLEANROOM;
else if (remoteVersion instanceof NeoForgeRemoteVersion)
iconType = VersionIconType.NEO_FORGE;
else if (remoteVersion instanceof LegacyFabricRemoteVersion || remoteVersion instanceof LegacyFabricAPIRemoteVersion)
iconType = VersionIconType.LEGACY_FABRIC;
else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion)
iconType = VersionIconType.FABRIC;
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = VersionIconType.QUILT;
else
iconType = VersionIconType.COMMAND;
imageView.setImage(iconType.getIcon());
String displayGameVersion = I18n.getDisplayVersion(GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()));
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(displayGameVersion);
else
twoLineListItem.addTag(displayGameVersion);
}
switch (NativePatcher.checkSupportedStatus(gameVersion, Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION)) {
case UNTESTED -> twoLineListItem.addTagWarning(i18n("instance.game.support_status.untested"));
case UNSUPPORTED -> twoLineListItem.addTagWarning(i18n("instance.game.support_status.unsupported"));
}
} else {
GameInstanceIconType iconType;
if (remoteVersion instanceof LiteLoaderRemoteVersion)
iconType = GameInstanceIconType.CHICKEN;
else if (remoteVersion instanceof OptiFineRemoteVersion)
iconType = GameInstanceIconType.OPTIFINE;
else if (remoteVersion instanceof ForgeRemoteVersion)
iconType = GameInstanceIconType.FORGE;
else if (remoteVersion instanceof CleanroomRemoteVersion)
iconType = GameInstanceIconType.CLEANROOM;
else if (remoteVersion instanceof NeoForgeRemoteVersion)
iconType = GameInstanceIconType.NEO_FORGE;
else if (remoteVersion instanceof LegacyFabricRemoteVersion || remoteVersion instanceof LegacyFabricAPIRemoteVersion)
iconType = GameInstanceIconType.LEGACY_FABRIC;
else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion)
iconType = GameInstanceIconType.FABRIC;
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = GameInstanceIconType.QUILT;
else
iconType = GameInstanceIconType.COMMAND;
imageView.setImage(iconType.getIcon());
String displayGameVersion = I18n.getDisplayVersion(GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()));
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(displayGameVersion);
else
twoLineListItem.addTag(displayGameVersion);
}
}
}
@@ -333,7 +334,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
{
nameField = new JFXTextField();
nameField.setPromptText(i18n("version.search.prompt"));
nameField.setPromptText(i18n("instance.search.prompt"));
nameField.textProperty().addListener(o -> updateList());
if ("game".equals(control.libraryId)) {
@@ -353,7 +354,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
);
categoryField.getSelectionModel().select(VersionTypeFilter.ALL);
}
categoryField.setConverter(stringConverter(type -> i18n("version.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.setConverter(stringConverter(type -> i18n("instance.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.getSelectionModel().selectedItemProperty().addListener(o -> updateList());
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
@@ -361,13 +362,13 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
if (control.versionList.hasType()) {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("version.game.type")), categoryField,
new Label(i18n("instance.search")), nameField,
new Label(i18n("instance.game.type")), categoryField,
refreshButton
);
} else {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("instance.search")), nameField,
refreshButton
);
}
@@ -390,7 +391,12 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
list.setFixedCellSize(65);
RemoteVersionListCell dummyCell = new RemoteVersionListCell(control);
dummyCell.twoLineListItem.setTitle("Dummy");
dummyCell.twoLineListItem.setSubtitle("Dummy");
dummyCell.imageView.setImage(GameInstanceIconType.GRASS.getIcon());
prepareNode(dummyCell.pane);
list.setFixedCellSize(dummyCell.pane.prefHeight(-1));
VBox.setVgrow(list, Priority.ALWAYS);
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.export;
import javafx.scene.Node;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
@@ -47,11 +48,11 @@ import static org.jackhuang.hmcl.setting.SettingsManager.settings;
public final class ExportWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final String version;
private final GameInstanceID instanceId;
public ExportWizardProvider(HMCLGameRepository repository, String version) {
public ExportWizardProvider(HMCLGameRepository repository, GameInstanceID instanceId) {
this.repository = repository;
this.version = version;
this.instanceId = instanceId;
}
@Override
@@ -164,7 +165,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new McbbsModpackExportTask(repository, version, exportInfo, modpackFile);
dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile);
}
@Override
@@ -184,13 +185,13 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
GameSettings.Effective setting = repository.getEffectiveGameSettings(version);
dependency = new MultiMCModpackExportTask(repository, version, exportInfo.getWhitelist(),
GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId);
dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(),
new MultiMCInstanceConfiguration(
"OneSix",
exportInfo.getName() + "-" + exportInfo.getVersion(),
null,
Lang.toIntOrNull(setting.get(GameSettings::permSizeProperty)),
Lang.toIntOrNull(setting.getInheritable(GameSettings::permSizeProperty)),
setting.getInheritable(GameSettings::commandWrapperProperty),
setting.getInheritable(GameSettings::preLaunchCommandProperty),
null,
@@ -232,7 +233,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new ServerModpackExportTask(repository, version, exportInfo, modpackFile);
dependency = new ServerModpackExportTask(repository, instanceId, exportInfo, modpackFile);
}
@Override
@@ -254,7 +255,7 @@ public final class ExportWizardProvider implements WizardProvider {
public void execute() {
dependency = new ModrinthModpackExportTask(
repository,
version,
instanceId,
exportInfo,
modpackFile
);
@@ -271,8 +272,8 @@ public final class ExportWizardProvider implements WizardProvider {
public Node createPage(WizardController controller, int step, SettingsMap settings) {
return switch (step) {
case 0 -> new ModpackTypeSelectionPage(controller);
case 1 -> new ModpackInfoPage(controller, repository, version);
case 2 -> new ModpackFileSelectionPage(controller, repository, version, ModAdviser::suggestMod);
case 1 -> new ModpackInfoPage(controller, repository, instanceId);
case 2 -> new ModpackFileSelectionPage(controller, repository, instanceId, ModAdviser::suggestMod);
default -> throw new IllegalArgumentException("step");
};
}
@@ -27,16 +27,21 @@ import javafx.scene.control.Label;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
@@ -44,6 +49,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -56,37 +62,69 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class ModpackFileSelectionPage extends BorderPane implements WizardPage {
private final WizardController controller;
private final String version;
private final GameInstanceID instanceId;
private final ModAdviser adviser;
private final ModpackFileTreeItem rootNode;
private @Nullable ModpackFileTreeItem rootNode;
public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, String version, ModAdviser adviser) {
public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId, ModAdviser adviser) {
this.controller = controller;
this.version = version;
this.instanceId = instanceId;
this.adviser = adviser;
JFXTreeView<String> treeView = new JFXTreeView<>();
rootNode = getTreeItem(repository.getRunDirectory(version), "minecraft", 0);
treeView.setRoot(rootNode);
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
onEscPressed(treeView, () -> controller.onPrev(true));
setMargin(treeView, new Insets(10, 10, 5, 10));
this.setCenter(treeView);
Label placeholder = new Label(i18n("modpack.files.empty"));
StackPane placeholderPane = new StackPane(placeholder);
placeholderPane.getStyleClass().add("notice-pane");
placeholderPane.setVisible(false);
SpinnerPane spinnerPane = new SpinnerPane();
StackPane center = new StackPane(treeView, placeholderPane);
spinnerPane.setContent(center);
setMargin(spinnerPane, new Insets(10, 10, 5, 10));
this.setCenter(spinnerPane);
HBox nextPane = new HBox();
nextPane.setPadding(new Insets(16, 16, 16, 0));
nextPane.setAlignment(Pos.CENTER_RIGHT);
{
JFXButton btnNext = FXUtils.newRaisedButton(i18n("wizard.next"));
btnNext.setPrefSize(100, 40);
btnNext.setOnAction(e -> onNext());
nextPane.getChildren().setAll(btnNext);
}
JFXButton btnNext = FXUtils.newRaisedButton(i18n("wizard.next"));
btnNext.setPrefSize(100, 40);
btnNext.setOnAction(e -> onNext());
nextPane.getChildren().setAll(btnNext);
loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext);
spinnerPane.setOnFailedAction((__) -> loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext));
this.setBottom(nextPane);
}
private void loadRoot(HMCLGameRepository repository, JFXTreeView<String> treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) {
spinnerPane.setLoading(true);
btnNext.setDisable(true);
CompletableFuture
.supplyAsync(() -> getTreeItem(repository.getRunDirectory(instanceId), "minecraft", 0), Schedulers.io())
.whenCompleteAsync((root, throwable) -> {
if (throwable == null) {
if (root != null) {
treeView.setRoot(rootNode = root);
} else {
placeholderPane.setVisible(true);
}
spinnerPane.setFailedReason(null);
btnNext.setDisable(false);
} else {
LOG.warning("Failed to load modpack file tree", throwable);
spinnerPane.setFailedReason(i18n("modpack.files.load_failed"));
}
spinnerPane.setLoading(false);
}, Schedulers.javafx());
}
private ModpackFileTreeItem getTreeItem(Path file, String basePath, int level) {
if (Files.notExists(file))
return null;
@@ -94,7 +132,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
boolean isDirectory = Files.isDirectory(file);
ModAdviser.ModSuggestion state = ModAdviser.ModSuggestion.SUGGESTED;
if (basePath.length() > "minecraft/".length()) {
if (level > 0) {
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (isDirectory ? "/" : ""), isDirectory);
String fileName = FileUtils.getName(file);
@@ -107,12 +145,12 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
if (fileName.startsWith("._")) // macOS system file
state = ModAdviser.ModSuggestion.HIDDEN;
if (FileUtils.getNameWithoutExtension(file).equals(version))
if (FileUtils.getNameWithoutExtension(file).equals(instanceId.toString()))
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (isDirectory) {
if (fileName.equals(version + "-natives")) { // Ignore <version>-natives
if (fileName.equals(instanceId + "-natives")) { // Ignore <version>-natives
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (level == 1 && fileName.startsWith("natives-")) { // Ignore natives-os-arch
@@ -123,7 +161,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
return null;
}
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? version : StringUtils.substringAfterLast(basePath, '/'), basePath);
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? instanceId.toString() : StringUtils.substringAfterLast(basePath, '/'), basePath);
if (state == ModAdviser.ModSuggestion.SUGGESTED)
node.setSelected(true);
@@ -35,6 +35,7 @@ import javafx.stage.FileChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
@@ -47,7 +48,6 @@ import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
@@ -65,9 +65,9 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackInfoPage extends Control implements WizardPage {
private final WizardController controller;
private final HMCLGameRepository gameRepository;
private final HMCLGameRepository repository;
private final ModpackExportInfo.Options options;
private final String versionName;
private final GameInstanceID instanceId;
private final boolean canIncludeLauncher;
private final ModpackExportInfo exportInfo = new ModpackExportInfo();
@@ -88,22 +88,22 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepository, String version) {
public ModpackInfoPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId) {
this.controller = controller;
this.gameRepository = gameRepository;
this.repository = repository;
this.options = controller.getSettings().get(MODPACK_INFO_OPTION);
this.versionName = version;
this.instanceId = instanceId;
if (this.options == null)
throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required");
name.set(version);
name.set(instanceId.toString());
author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse(""));
GameSettings.Effective versionSetting = gameRepository.getEffectiveGameSettings(versionName);
minMemory.set(Optional.ofNullable(versionSetting.get(GameSettings::minMemoryProperty)).orElse(0));
launchArguments.set(versionSetting.get(GameSettings::gameArgumentsProperty));
javaArguments.set(versionSetting.get(GameSettings::jvmOptionsProperty));
GameSettings.Effective versionSetting = repository.getEffectiveGameSettings(this.instanceId);
minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0));
launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty));
javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty));
canIncludeLauncher = JarUtils.thisJarPath() != null;
}
@@ -118,7 +118,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
fileChooser.setInitialFileName(name.get() + ".zip");
}
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
Path file = Controllers.showSaveDialog(fileChooser);
if (file == null) {
controller.onEnd();
return;
@@ -213,7 +213,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
var instanceNamePane = new LineTextPane();
{
instanceNamePane.setTitle(i18n("modpack.wizard.step.initialization.exported_version"));
instanceNamePane.setText(skinnable.versionName);
instanceNamePane.setText(skinnable.instanceId.toString());
list.getContent().add(instanceNamePane);
}
@@ -22,7 +22,10 @@ import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXSlider;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.InvalidationListener;
import javafx.beans.property.*;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -32,10 +35,7 @@ import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.scene.control.*;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
@@ -49,8 +49,8 @@ import org.jackhuang.hmcl.setting.property.SettingProperty;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.VersionIconDialog;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.instances.GameInstanceIconDialog;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.ServerAddress;
@@ -70,14 +70,13 @@ import java.util.Locale;
import java.util.Objects;
import java.util.function.Function;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// @author Glavo
@NotNullByDefault
public final class GameSettingsPage<S extends GameSettings> extends StackPane
implements DecoratorPage, VersionPage.GameInstanceLoadable, PageAware {
implements DecoratorPage, GameInstancePage.GameInstanceLoadable, PageAware {
private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object();
private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden");
@@ -96,14 +95,18 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
private @Nullable HMCLGameRepository repository;
/// The current instance ID.
private @Nullable String instanceId;
private @Nullable GameInstanceID instanceId;
/// The current setting.
private final ObjectProperty<@Nullable S> currentSetting = new SimpleObjectProperty<>(this, "setting");
private final ObjectProperty<GameVersionNumber> currentGameVersionNumber = new SimpleObjectProperty<>(this, "currentGameVersionNumber", GameVersionNumber.unknown());
private boolean updatingJavaSetting = false;
private boolean updatingSelectedJava = false;
private boolean updatingParentSetting = false;
private final ObjectProperty<GameSettings.@Nullable Preset> activeParentSetting =
new SimpleObjectProperty<>(this, "activeParentSetting");
// GUI
private final ScrollPane scrollPane;
@@ -130,6 +133,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
assert settingType == GameSettings.Preset.class || settingType == GameSettings.Instance.class;
this.isPresetSetting = settingType == GameSettings.Preset.class;
if (!isPresetSetting) {
bindActiveParentSetting();
}
this.scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
@@ -155,11 +161,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
{
if (isPresetSetting) {
var presetSettings = new ComponentList();
rootPane.getChildren().addAll(
ComponentList.createComponentListTitle(i18n("settings.type.global.preset")),
presetSettings,
ComponentList.createComponentListTitle(i18n("settings.game.section.basic")),
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.game.section.basic")),
basicSettings,
ComponentList.createComponentListTitle(
i18n("settings.type.global.preset"),
i18n("settings.type.global.preset.help")
),
presetSettings,
ComponentList.createComponentListTitle(i18n("settings.game.section.game")),
gameSettings,
ComponentList.createComponentListTitle(i18n("settings.launcher")),
@@ -188,9 +196,10 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
var parentGameSettingsPane = new LineSelectButton<GameSettings.@Nullable Preset>();
basicSettings.getContent().add(parentGameSettingsPane);
parentGameSettingsPane.setTitle(i18n("settings.type.global.preset"));
parentGameSettingsPane.setSubtitle(i18n("settings.type.global.preset.subtitle"));
parentGameSettingsPane.setConverter(setting -> setting != null
? PresetManagementPane.getPresetDisplayName(setting)
: getImplicitParentGameSettingsDisplayName());
: i18n("settings.type.global.preset.default"));
bindInstanceParentSetting(parentGameSettingsPane);
}
@@ -271,9 +280,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
refreshJavaSettings();
});
SettingsManager.getGameSettings().addListener(weakJavaListener);
settings().defaultGameSettingsPresetProperty().addListener(weakJavaListener);
activeParentSetting.addListener(weakJavaListener);
javaItem.selectedChoiceProperty().addListener((observable, oldChoice, newChoice) -> {
S setting = currentSetting.get();
if (setting == null || updatingSelectedJava) {
@@ -339,15 +346,15 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
defaultIsolationTypePane.setSubtitle(i18n("settings.game.default_isolation.subtitle"));
defaultIsolationTypePane.setItems(DefaultIsolationType.values());
defaultIsolationTypePane.setNullSafeConverter(type -> switch (type) {
case NEVER -> i18n("settings.game.default_isolation.never");
case ALWAYS -> i18n("settings.game.default_isolation.always");
case MODDED -> i18n("settings.game.default_isolation.modded");
});
case NEVER -> i18n("settings.game.default_isolation.never");
case ALWAYS -> i18n("settings.game.default_isolation.always");
case MODDED -> i18n("settings.game.default_isolation.modded");
});
defaultIsolationTypePane.setDescriptionConverter(type -> switch (type) {
case NEVER -> i18n("settings.game.default_isolation.never.desc");
case ALWAYS -> i18n("settings.game.default_isolation.always.desc");
case MODDED -> i18n("settings.game.default_isolation.modded.desc");
});
case NEVER -> i18n("settings.game.default_isolation.never.desc");
case ALWAYS -> i18n("settings.game.default_isolation.always.desc");
case MODDED -> i18n("settings.game.default_isolation.modded.desc");
});
bindPresetBidirectional(defaultIsolationTypePane.valueProperty(), GameSettings.Preset::defaultIsolationTypeProperty);
} else {
var isolationButton = new LineToggleButton();
@@ -359,6 +366,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
// Memory Setting
@Nullable JFXButton autoMemoryButton = !isPresetSetting ? createInheritanceButton() : null;
ObjectProperty<Boolean> memorySelectedValue = new SimpleObjectProperty<>(true);
var memorySublist = new ComponentSublist(() -> {
var memoryItem = new RadioChoiceList<Boolean>();
memoryItem.setFallbackValue(true);
@@ -378,16 +386,18 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
maxMemoryButton = createInheritanceButton();
}
var options = new ArrayList<RadioChoiceList.Choice<Boolean>>();
options.add(new RadioChoiceList.Choice<>(i18n("settings.memory.auto_allocate"), true));
var manualMemoryEditor = new HBox(8);
manualMemoryEditor.setAlignment(Pos.CENTER_RIGHT);
manualMemoryEditor.getChildren().setAll(
maxMemorySlider,
maxMemoryTextField,
new Label(i18n("settings.memory.unit.mib")));
options.add(new EditorChoice<>(i18n("settings.memory.manual_allocate"), false, manualMemoryEditor, maxMemoryButton));
memoryItem.setChoices(options);
memoryItem.setChoices(List.of(
new RadioChoiceList.Choice<>(i18n("settings.memory.auto_allocate"), true),
new EditorChoice<>(i18n("settings.memory.manual_allocate"), false, manualMemoryEditor, maxMemoryButton)
));
memoryItem.selectedValueProperty().addListener((observable, oldValue, newValue) ->
memorySelectedValue.set(newValue));
var memoryStatusBar = new MemoryStatusBar();
@@ -415,7 +425,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
maxMemoryButton,
GameSettingsPage::updateInheritanceButton,
holder,
this::getEffectiveParentGameSettings);
this::getEffectiveParentGameSettings,
activeParentSetting);
return List.of(memoryItem, memoryStatusPane);
});
@@ -425,7 +436,10 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
gameSettings.getContent().add(memorySublist);
memorySublist.setTitle(i18n("settings.memory"));
memorySublist.setHasSubtitle(true);
memorySublist.setDescription(i18n("settings.memory.auto_allocate"));
memorySelectedValue.addListener((observable, oldValue, newValue) ->
memorySublist.setDescription(getMemoryAllocationDisplayName(newValue)));
memorySublist.setDescription(getMemoryAllocationDisplayName(memorySelectedValue.get()));
bindMemorySelectedValue(memorySelectedValue);
// Launcher Visibility Setting
var launcherVisibilityPane = createInheritableButton(
@@ -653,6 +667,10 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
{
var txtMinMemory = new JFXTextField();
txtMinMemory.setPrefWidth(160);
txtMinMemory.setTextFormatter(new TextFormatter<>(change -> {
change.setText(change.getText().replaceAll("[^0-9]", ""));
return change;
}));
var rightBox = new HBox(8, txtMinMemory, new Label(i18n("settings.memory.unit.mib")));
rightBox.setAlignment(Pos.CENTER_RIGHT);
@@ -666,6 +684,10 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
{
var txtMetaspace = new JFXTextField();
txtMetaspace.setPrefWidth(160);
txtMetaspace.setTextFormatter(new TextFormatter<>(change -> {
change.setText(change.getText().replaceAll("[^0-9]", ""));
return change;
}));
var rightBox = new HBox(8, txtMetaspace, new Label(i18n("settings.memory.unit.mib")));
rightBox.setAlignment(Pos.CENTER_RIGHT);
@@ -740,6 +762,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
graphicsSettings.getContent().add(graphicsBackendPane);
graphicsBackendPane.setTitle(i18n("settings.advanced.graphics_backend"));
if (isPresetSetting)
graphicsBackendPane.setSubtitle(i18n("settings.advanced.graphics_backend.desc"));
var openGLRendererPane = createInheritableButton(
GameSettings::openGLRendererProperty,
e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT)),
@@ -762,6 +787,24 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
graphicsSettings.getContent().add(vulkanRendererPane);
vulkanRendererPane.setTitle(i18n("settings.advanced.renderer.vulkan"));
var highPerformancePane = createInheritableBooleanButton(GameSettings::highPerformanceProperty);
graphicsSettings.getContent().add(highPerformancePane);
highPerformancePane.setTitle(i18n("settings.advanced.renderer.gpu_preferences"));
highPerformancePane.setSubtitle(i18n("settings.advanced.windows_only"));
this.currentGameVersionNumber.addListener((o, oldValue, newValue) -> {
boolean showBackendChoose = isPresetSetting || newValue.compareTo("26.2-snapshot-2") >= 0;
graphicsBackendPane.setVisible(showBackendChoose);
graphicsBackendPane.setManaged(showBackendChoose);
boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(newValue);
openGLRendererPane.setVisible(showOpenGL);
openGLRendererPane.setManaged(showOpenGL);
boolean showVulkan = GraphicsAPI.VULKAN.isSupported(newValue);
vulkanRendererPane.setVisible(showVulkan);
vulkanRendererPane.setManaged(showVulkan);
});
}
var nativeLibrarySettings = new ComponentList();
@@ -810,18 +853,19 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
private void bindInstanceParentSetting(LineSelectButton<GameSettings.@Nullable Preset> button) {
ObservableList<GameSettings.Preset> items = FXCollections.observableArrayList();
ObservableList<GameSettings.Preset> presets = getAvailableParentPresets();
InvalidationListener updateItems = observable -> {
@Nullable GameSettings.Preset selected = button.getValue();
items.setAll((GameSettings.Preset) null);
items.addAll(SettingsManager.getGameSettings());
items.addAll(presets);
refreshInstanceParentSettingConverter(button);
if (selected != null && SettingsManager.getGameSettings(selected.idProperty().getValue()) == null) {
if (selected != null && getAvailableParentPreset(selected.idProperty().getValue()) == null) {
button.setValue(null);
}
};
InvalidationListener weakUpdateItems = holder.weak(updateItems);
updateItems.invalidated(SettingsManager.getGameSettings());
SettingsManager.getGameSettings().addListener(weakUpdateItems);
updateItems.invalidated(presets);
presets.addListener(weakUpdateItems);
button.setItems(items);
refreshInstanceParentSettingConverter(button);
@@ -838,7 +882,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
try {
refreshInstanceParentSettingConverter(button);
GameSettingsPresetID parent = setting.parentProperty().getValue();
button.setValue(parent != null ? SettingsManager.getGameSettings(parent) : null);
button.setValue(parent != null ? getAvailableParentPreset(parent) : null);
} finally {
updatingParentSetting = false;
}
@@ -846,28 +890,54 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
});
}
/// Refreshes parent preset display text because the implicit parent depends on the current game directory.
/// Refreshes parent preset display text after the preset list changes.
private void refreshInstanceParentSettingConverter(LineSelectButton<GameSettings.@Nullable Preset> button) {
button.setConverter(setting -> setting != null
? PresetManagementPane.getPresetDisplayName(setting)
: getImplicitParentGameSettingsDisplayName());
: i18n("settings.type.global.preset.default"));
}
/// Returns the label for the implicit parent preset selected by a null instance parent.
private String getImplicitParentGameSettingsDisplayName() {
GameSettings.Preset legacyParent = getGameDirectoryLegacyGameSettings();
return legacyParent != null
? PresetManagementPane.getPresetDisplayName(legacyParent)
: i18n("settings.type.global.preset.default");
/// Returns the presets that can be selected as an instance parent.
private ObservableList<GameSettings.Preset> getAvailableParentPresets() {
return SettingsManager.gameSettingsPresets().getPresets();
}
/// Returns the migrated game-directory-level parent preset, or `null` when this game directory uses the default preset.
private @Nullable GameSettings.Preset getGameDirectoryLegacyGameSettings() {
if (gameDirectory == null || gameDirectory.getLegacyGameSettings() == null) {
return null;
/// Returns the preset selected by an instance parent ID.
private GameSettings.@Nullable Preset getAvailableParentPreset(@Nullable GameSettingsPresetID id) {
return SettingsManager.gameSettingsPresets().getPreset(id);
}
/// Keeps [#activeParentSetting] synchronized with the current instance's resolved parent preset.
private void bindActiveParentSetting() {
InvalidationListener refresh = observable -> refreshActiveParentSetting();
InvalidationListener weakRefresh = holder.weak(refresh);
getAvailableParentPresets().addListener(weakRefresh);
SettingsManager.settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
currentSetting.addListener((observable, oldValue, newValue) -> {
if (oldValue != null) {
oldValue.removeListener(weakRefresh);
}
if (newValue != null) {
newValue.addListener(weakRefresh);
}
refreshActiveParentSetting();
});
S setting = currentSetting.get();
if (setting != null) {
setting.addListener(weakRefresh);
}
refreshActiveParentSetting();
}
return SettingsManager.getGameSettings(gameDirectory.getLegacyGameSettings());
/// Refreshes the current instance's resolved parent preset.
private void refreshActiveParentSetting() {
activeParentSetting.set(currentSetting.get() instanceof GameSettings.Instance instance
? getEffectiveParentGameSettings(instance)
: null);
}
/// Adds the title-line inheritance button for the Java selection sublist.
@@ -1023,7 +1093,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
this::createInheritanceButton,
GameSettingsPage::updateInheritanceButton,
holder,
this::getEffectiveParentGameSettings);
this::getEffectiveParentGameSettings,
activeParentSetting);
}
/// Binds an integer text field to a setting property with independent override state.
@@ -1040,7 +1111,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
this::createInheritanceButton,
GameSettingsPage::updateInheritanceButton,
holder,
this::getEffectiveParentGameSettings);
this::getEffectiveParentGameSettings,
activeParentSetting);
}
/// Binds the windowed resolution selector to the width and height settings.
@@ -1083,6 +1155,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
};
InvalidationListener weakPropertyListener = holder.weak(propertyListener);
refreshHolder.value = weakPropertyListener;
activeParentSetting.addListener(weakPropertyListener);
ChangeListener<@Nullable Boolean> focusedListener = (observable, oldValue, newValue) -> {
if (!newValue) {
@@ -1168,8 +1241,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
widthProperty.addListener(weakPropertyListener);
heightProperty.addListener(weakPropertyListener);
}
SettingsManager.getGameSettings().addListener(weakPropertyListener);
settings().defaultGameSettingsPresetProperty().addListener(weakPropertyListener);
propertyListener.invalidated(setting);
}
@@ -1406,7 +1477,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentInheritablePropertyListener(setting, activeParentProperty, propertyGetter, refreshHolder.value);
updateParentInheritablePropertyListener(
setting,
activeParentProperty,
propertyGetter,
refreshHolder.value);
InheritableProperty<String> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -1424,6 +1499,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
};
InvalidationListener weakRefresh = holder.weak(refresh);
refreshHolder.value = weakRefresh;
activeParentSetting.addListener(weakRefresh);
textProperty.addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -1489,9 +1565,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
refresh.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakRefresh);
settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
S setting = currentSetting.get();
if (setting != null) {
InheritableProperty<String> property = propertyGetter.apply(setting);
@@ -1514,7 +1587,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentInheritablePropertyListener(setting, activeParentProperty, propertyGetter, refreshHolder.value);
updateParentInheritablePropertyListener(
setting,
activeParentProperty,
propertyGetter,
refreshHolder.value);
InheritableProperty<String> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -1532,6 +1609,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
};
InvalidationListener weakRefresh = holder.weak(refresh);
refreshHolder.value = weakRefresh;
activeParentSetting.addListener(weakRefresh);
textProperty.addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -1597,9 +1675,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
refresh.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakRefresh);
settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
S setting = currentSetting.get();
if (setting != null) {
InheritableProperty<String> property = propertyGetter.apply(setting);
@@ -1650,6 +1725,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
updating.value = false;
}
refresh.invalidated(instance);
fireEvent(new GameInstancePage.WorkingDirChangedEvent());
});
currentSetting.addListener((observable, oldValue, newValue) -> {
@@ -1718,6 +1795,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
};
InvalidationListener weakRefresh = holder.weak(refresh);
refreshHolder.value = weakRefresh;
activeParentSetting.addListener(weakRefresh);
inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
GameSettings setting = currentSetting.get();
@@ -1737,6 +1815,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
updating.value = false;
}
refresh.invalidated(instance);
fireEvent(new GameInstancePage.WorkingDirChangedEvent());
event.consume();
});
@@ -1756,6 +1837,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
} finally {
updating.value = false;
}
fireEvent(new GameInstancePage.WorkingDirChangedEvent());
});
currentSetting.addListener((observable, oldValue, newValue) -> {
@@ -1769,9 +1852,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
refresh.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakRefresh);
settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
S setting = currentSetting.get();
if (setting instanceof GameSettings.Instance instance) {
instance.addListener(weakRefresh);
@@ -1789,7 +1869,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
return "";
}
return repository.getVersionRoot(instanceId).toString();
return repository.getInstanceRoot(instanceId).toString();
}
/// Keeps a listener attached to the current instance's parent preset property.
@@ -1815,13 +1895,96 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
}
/// Keeps a listener attached to the current instance's parent preset setting property.
private <T extends @UnknownNullability Object> void updateParentSettingPropertyListener(
@Nullable GameSettings setting,
ObjectProperty<@Nullable SettingProperty<T>> activeParentProperty,
Function<GameSettings, ? extends SettingProperty<T>> propertyGetter,
InvalidationListener listener) {
SettingProperty<T> oldParentProperty = activeParentProperty.get();
SettingProperty<T> newParentProperty = setting instanceof GameSettings.Instance instance
? propertyGetter.apply(getEffectiveParentGameSettings(instance))
: null;
if (oldParentProperty == newParentProperty) {
return;
}
if (oldParentProperty != null) {
oldParentProperty.removeListener(listener);
}
activeParentProperty.set(newParentProperty);
if (newParentProperty != null) {
newParentProperty.addListener(listener);
}
}
/// Binds the memory sublist description source to the effective automatic memory setting.
private void bindMemorySelectedValue(ObjectProperty<Boolean> memorySelectedValue) {
ObjectProperty<@Nullable SettingProperty<Boolean>> activeAutoMemoryProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable SettingProperty<Boolean>> activeParentAutoMemoryProperty = new SimpleObjectProperty<>();
final Holder<InvalidationListener> refreshHolder = new Holder<>();
InvalidationListener refresh = observable -> {
S setting = currentSetting.get();
updateParentSettingPropertyListener(
setting,
activeParentAutoMemoryProperty,
GameSettings::autoMemoryProperty,
refreshHolder.value);
if (setting == null) {
memorySelectedValue.set(true);
return;
}
memorySelectedValue.set(resolveEffectiveSetting(setting)
.getInheritable(GameSettings::autoMemoryProperty));
};
InvalidationListener weakRefresh = holder.weak(refresh);
refreshHolder.value = weakRefresh;
activeParentSetting.addListener(weakRefresh);
currentSetting.addListener((observable, oldValue, newValue) -> {
if (oldValue != null) {
oldValue.removeListener(weakRefresh);
}
SettingProperty<Boolean> oldAutoMemoryProperty = activeAutoMemoryProperty.get();
if (oldAutoMemoryProperty != null) {
oldAutoMemoryProperty.removeListener(weakRefresh);
}
SettingProperty<Boolean> newAutoMemoryProperty = newValue != null
? newValue.autoMemoryProperty()
: null;
activeAutoMemoryProperty.set(newAutoMemoryProperty);
if (newValue != null) {
newValue.addListener(weakRefresh);
}
if (newAutoMemoryProperty != null) {
newAutoMemoryProperty.addListener(weakRefresh);
}
refresh.invalidated(newValue);
});
S setting = currentSetting.get();
if (setting != null) {
SettingProperty<Boolean> autoMemoryProperty = setting.autoMemoryProperty();
activeAutoMemoryProperty.set(autoMemoryProperty);
setting.addListener(weakRefresh);
autoMemoryProperty.addListener(weakRefresh);
}
refresh.invalidated(setting);
}
/// Binds a radio choice list to an inheritable setting property.
private <T> void bindInheritableRadioChoiceList(
ComponentSublist sublist,
RadioChoiceList<T> item,
Function<GameSettings, InheritableProperty<T>> propertyGetter) {
ObjectProperty<@Nullable InheritableProperty<T>> activeProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable InheritableProperty<T>> activeParentProperty = new SimpleObjectProperty<>();
final Holder<Boolean> updating = new Holder<>(false);
final Holder<InvalidationListener> refreshHolder = new Holder<>();
@Nullable JFXButton inheritButton = null;
if (!isPresetSetting) {
inheritButton = createInheritanceButton();
@@ -1831,6 +1994,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
InvalidationListener propertyListener = observable -> {
GameSettings setting = currentSetting.get();
updateParentInheritablePropertyListener(
setting,
activeParentProperty,
propertyGetter,
refreshHolder.value);
InheritableProperty<T> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -1847,6 +2015,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
};
InvalidationListener weakPropertyListener = holder.weak(propertyListener);
refreshHolder.value = weakPropertyListener;
activeParentSetting.addListener(weakPropertyListener);
ChangeListener<@Nullable T> itemListener = (observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -1911,9 +2081,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
propertyListener.invalidated(newProperty);
});
SettingsManager.getGameSettings().addListener(weakPropertyListener);
settings().defaultGameSettingsPresetProperty().addListener(weakPropertyListener);
S setting = currentSetting.get();
if (setting != null) {
InheritableProperty<T> property = propertyGetter.apply(setting);
@@ -1927,7 +2094,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
/// Binds the window type editor to the window type setting.
private void bindWindowSettings(ComponentSublist sublist, RadioChoiceList<GameWindowType> item) {
ObjectProperty<@Nullable InheritableProperty<GameWindowType>> activeWindowTypeProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable InheritableProperty<GameWindowType>> activeParentWindowTypeProperty = new SimpleObjectProperty<>();
final Holder<Boolean> updating = new Holder<>(false);
final Holder<InvalidationListener> refreshHolder = new Holder<>();
@Nullable JFXButton inheritButton = null;
if (!isPresetSetting) {
inheritButton = createInheritanceButton();
@@ -1937,6 +2106,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
InvalidationListener propertyListener = observable -> {
GameSettings setting = currentSetting.get();
updateParentInheritablePropertyListener(
setting,
activeParentWindowTypeProperty,
GameSettings::windowTypeProperty,
refreshHolder.value);
InheritableProperty<GameWindowType> property = activeWindowTypeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -1953,6 +2127,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
};
InvalidationListener weakPropertyListener = holder.weak(propertyListener);
refreshHolder.value = weakPropertyListener;
activeParentSetting.addListener(weakPropertyListener);
item.selectedValueProperty().addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -2012,9 +2188,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
propertyListener.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakPropertyListener);
settings().defaultGameSettingsPresetProperty().addListener(weakPropertyListener);
S setting = currentSetting.get();
if (setting != null) {
activeWindowTypeProperty.set(setting.windowTypeProperty());
@@ -2047,8 +2220,19 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
private <T> void bindInheritableSublistDescription(ComponentSublist sublist,
Function<GameSettings, InheritableProperty<T>> propertyGetter,
Function<T, String> converter) {
InvalidationListener propertyListener = observable -> initInheritableSublistDescription(sublist, propertyGetter, converter);
ObjectProperty<@Nullable InheritableProperty<T>> activeParentProperty = new SimpleObjectProperty<>();
final Holder<InvalidationListener> refreshHolder = new Holder<>();
InvalidationListener propertyListener = observable -> {
updateParentInheritablePropertyListener(
currentSetting.get(),
activeParentProperty,
propertyGetter,
refreshHolder.value);
initInheritableSublistDescription(sublist, propertyGetter, converter);
};
InvalidationListener weakPropertyListener = holder.weak(propertyListener);
refreshHolder.value = weakPropertyListener;
activeParentSetting.addListener(weakPropertyListener);
currentSetting.addListener((observable, oldValue, newValue) -> {
if (oldValue != null) {
@@ -2059,16 +2243,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
propertyGetter.apply(newValue).addListener(weakPropertyListener);
}
initInheritableSublistDescription(sublist, propertyGetter, converter);
propertyListener.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakPropertyListener);
settings().defaultGameSettingsPresetProperty().addListener(weakPropertyListener);
S setting = currentSetting.get();
if (setting != null) {
propertyGetter.apply(setting).addListener(weakPropertyListener);
}
initInheritableSublistDescription(sublist, propertyGetter, converter);
propertyListener.invalidated(setting);
}
private <T> void initInheritableSublistDescription(ComponentSublist sublist,
@@ -2083,6 +2264,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
sublist.setDescription(converter.apply(getEffectiveValue(setting, propertyGetter)));
}
/// Returns the localized memory allocation mode display name.
private static String getMemoryAllocationDisplayName(Boolean autoMemory) {
return i18n(Boolean.TRUE.equals(autoMemory)
? "settings.memory.auto_allocate"
: "settings.memory.manual_allocate");
}
private static String getWindowTypeDisplayName(GameWindowType type) {
return switch (type) {
case FULLSCREEN -> i18n("settings.game.window_type.fullscreen");
@@ -2129,7 +2317,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
button.setOverriddenTooltip(i18n("settings.game.override_global"));
button.setInheritAvailable(!isPresetSetting);
IndependentSettingBinder.bindToggleButton(currentSetting, button, propertyGetter, holder, this::getEffectiveParentGameSettings);
IndependentSettingBinder.bindToggleButton(
currentSetting,
button,
propertyGetter,
holder,
this::getEffectiveParentGameSettings,
activeParentSetting);
return button;
}
@@ -2150,7 +2344,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
LineSelectButton<T> button,
Function<GameSettings, InheritableProperty<T>> propertyGetter) {
ObjectProperty<@Nullable InheritableProperty<T>> activeProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable InheritableProperty<T>> activeParentProperty = new SimpleObjectProperty<>();
final Holder<Boolean> updating = new Holder<>(false);
final Holder<InvalidationListener> refreshHolder = new Holder<>();
@Nullable JFXButton inheritButton = null;
if (!isPresetSetting) {
inheritButton = createInheritanceButton();
@@ -2160,6 +2356,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentInheritablePropertyListener(setting, activeParentProperty, propertyGetter, refreshHolder.value);
InheritableProperty<T> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -2176,6 +2373,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
};
InvalidationListener weakRefresh = holder.weak(refresh);
refreshHolder.value = weakRefresh;
activeParentSetting.addListener(weakRefresh);
button.valueProperty().addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -2241,9 +2440,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
refresh.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakRefresh);
settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
S setting = currentSetting.get();
if (setting != null) {
activeProperty.set(propertyGetter.apply(setting));
@@ -2258,10 +2454,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
LineInheritableToggleButton button,
Function<GameSettings, InheritableProperty<Boolean>> propertyGetter) {
ObjectProperty<@Nullable InheritableProperty<Boolean>> activeProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable InheritableProperty<Boolean>> activeParentProperty = new SimpleObjectProperty<>();
final Holder<Boolean> updating = new Holder<>(false);
final Holder<InvalidationListener> refreshHolder = new Holder<>();
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentInheritablePropertyListener(setting, activeParentProperty, propertyGetter, refreshHolder.value);
InheritableProperty<Boolean> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -2270,7 +2469,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
updating.value = true;
try {
boolean overridden = isPropertyOverridden(setting, property);
button.setRawValue(overridden ? getDirectValue(property) : getEffectiveValue(setting, propertyGetter));
button.setRawValue(overridden
? getDirectValue(property)
: getEffectiveValue(setting, propertyGetter));
button.setOverridden(overridden);
button.setEffectiveValue(getEffectiveValue(setting, propertyGetter));
} finally {
@@ -2278,6 +2479,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
};
InvalidationListener weakRefresh = holder.weak(refresh);
refreshHolder.value = weakRefresh;
activeParentSetting.addListener(weakRefresh);
button.rawValueProperty().addListener((observable, oldValue, newValue) -> {
InheritableProperty<Boolean> property = activeProperty.get();
@@ -2328,9 +2531,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
refresh.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakRefresh);
settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
S setting = currentSetting.get();
if (setting != null) {
activeProperty.set(propertyGetter.apply(setting));
@@ -2399,7 +2599,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
/// Returns the explicitly configured parent preset for an instance, falling back to the default preset.
private GameSettings.Preset getExplicitParentGameSettings(GameSettings.Instance instance) {
GameSettingsPresetID parent = instance.parentProperty().getValue();
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
GameSettings.Preset parentSetting = getAvailableParentPreset(parent);
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
}
@@ -2430,7 +2630,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
@SuppressWarnings("unchecked")
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.gameDirectory = repository.getGameDirectory();
this.repository = repository;
this.instanceId = instanceId;
@@ -2438,6 +2638,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
assert isPresetSetting == (instanceId == null);
if (instanceId != null) {
this.currentGameVersionNumber.set(GameVersionNumber.asGameVersion(repository.getGameVersion(instanceId)));
@Nullable GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
this.currentSetting.set((S) setting);
setSettingsReadOnly(
@@ -2446,6 +2648,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
setting != null ? this::forceOverwriteInstanceGameSettings : null);
loadIcon();
} else {
this.currentGameVersionNumber.set(GameVersionNumber.unknown());
this.currentSetting.set((S) SettingsManager.getDefaultGameSettingsPresetOrCreate());
setSettingsReadOnly(
SettingsManager.isGameSettingsReadOnly(),
@@ -2454,18 +2657,23 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
}
/// Returns the loaded instance ID, or `null` when this page edits preset settings.
private @Nullable GameInstanceID getLoadedInstanceId() {
return instanceId == null ? null : instanceId;
}
/// Updates the page read-only state used when settings cannot be saved safely.
///
/// @param readOnly whether the current settings should be displayed read-only
/// @param message the warning message shown when the page is read-only
/// @param message the warning message shown when the page is read-only
private void setSettingsReadOnly(boolean readOnly, String message) {
setSettingsReadOnly(readOnly, message, null);
}
/// Updates the page read-only state used when settings cannot be saved safely.
///
/// @param readOnly whether the current settings should be displayed read-only
/// @param message the warning message shown when the page is read-only
/// @param readOnly whether the current settings should be displayed read-only
/// @param message the warning message shown when the page is read-only
/// @param forceOverwrite the action used to back up and overwrite the read-only file
private void setSettingsReadOnly(boolean readOnly, String message, @Nullable Runnable forceOverwrite) {
if (readOnly && forceOverwrite != null) {
@@ -2493,12 +2701,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
/// Backs up and overwrites the current instance's `instance-game-settings.json`.
private void forceOverwriteInstanceGameSettings() {
if (repository == null || instanceId == null) {
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null) {
return;
}
Controllers.confirmBackupAndOverwrite(i18n("settings.game.instance_settings.unsupported"), () -> {
repository.forceOverwriteInstanceGameSettings(instanceId);
repository.forceOverwriteInstanceGameSettings(loadedInstanceId);
setSettingsReadOnly(false, "");
});
}
@@ -2512,10 +2721,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
private void loadIcon() {
if (repository == null || instanceId == null)
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null)
return;
iconPickerItem.setImage(repository.getVersionIconImage(instanceId));
iconPickerItem.setImage(repository.getInstanceIconImage(loadedInstanceId));
}
/// Refreshes Java selection controls and keeps inherited parent Java properties observed.
@@ -2584,7 +2794,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
initializeSelectedJava();
JavaVersionType javaVersionType = setting.javaTypeProperty().getValue();
GameSettings.Effective effectiveSetting = this.instanceId != null && repository != null ? repository.getEffectiveGameSettings(this.instanceId) : null;
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
GameSettings.Effective effectiveSetting = loadedInstanceId != null && repository != null ? repository.getEffectiveGameSettings(loadedInstanceId) : null;
JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getInheritable(GameSettings::javaTypeProperty) : javaVersionType;
boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION;
@@ -2600,20 +2811,18 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
if (JavaManager.isInitialized()) {
GameVersionNumber gameVersionNumber;
Version version;
GameVersionNumber gameVersionNumber = this.currentGameVersionNumber.get();
GameInstanceManifest manifest;
if (this.instanceId == null) {
gameVersionNumber = GameVersionNumber.unknown();
version = null;
manifest = null;
} else {
gameVersionNumber = repository != null ? GameVersionNumber.asGameVersion(repository.getGameVersion(this.instanceId)) : GameVersionNumber.unknown();
version = repository != null ? repository.getResolvedVersion(this.instanceId) : null;
manifest = repository != null && loadedInstanceId != null ? repository.getResolvedInstanceManifest(loadedInstanceId).launchManifest() : null;
}
try {
JavaRuntime java = effectiveSetting != null
? effectiveSetting.getJava(gameVersionNumber, version)
: resolveEffectiveSetting(setting).getJava(gameVersionNumber, version);
? effectiveSetting.getJava(gameVersionNumber, manifest)
: resolveEffectiveSetting(setting).getJava(gameVersionNumber, manifest);
if (java != null) {
javaSublist.setDescription(java.getBinary().toString());
} else {
@@ -2631,17 +2840,18 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
if (repository == null || instanceId == null)
return;
Controllers.dialog(new VersionIconDialog(repository, instanceId, this::loadIcon));
Controllers.dialog(new GameInstanceIconDialog(repository, instanceId, this::loadIcon));
}
private void onDeleteIcon() {
if (repository == null || instanceId == null)
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null)
return;
repository.deleteIconFile(instanceId);
GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.deleteIconFile(loadedInstanceId);
GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(loadedInstanceId);
if (localGameSettings != null) {
localGameSettings.iconProperty().setValue(VersionIconType.DEFAULT);
localGameSettings.iconProperty().setValue(GameInstanceIconType.DEFAULT);
}
loadIcon();
}
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXSlider;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
@@ -28,7 +29,6 @@ import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.property.SettingProperty;
import org.jackhuang.hmcl.ui.MemoryStatusBar;
@@ -48,7 +48,6 @@ import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Supplier;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.DataSizeUnit.GIGABYTES;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -69,7 +68,8 @@ final class IndependentSettingBinder {
Supplier<JFXButton> inheritanceButtonFactory,
BiConsumer<JFXButton, Boolean> inheritanceButtonUpdater,
WeakListenerHolder listenerHolder,
Function<GameSettings.Instance, GameSettings.Preset> parentGetter) {
Function<GameSettings.Instance, GameSettings.Preset> parentGetter,
Observable parentSettingsInvalidation) {
ObjectProperty<@Nullable SettingProperty<String>> activeProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable SettingProperty<String>> activeParentProperty = new SimpleObjectProperty<>();
final Holder<Boolean> updating = new Holder<>(false);
@@ -84,7 +84,12 @@ final class IndependentSettingBinder {
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentPropertyListener(setting, activeParentProperty, propertyGetter, parentGetter, refreshHolder.value);
updateParentPropertyListener(
setting,
activeParentProperty,
propertyGetter,
parentGetter,
refreshHolder.value);
SettingProperty<String> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -104,6 +109,7 @@ final class IndependentSettingBinder {
};
InvalidationListener weakRefresh = listenerHolder.weak(refresh);
refreshHolder.value = weakRefresh;
parentSettingsInvalidation.addListener(weakRefresh);
textField.textProperty().addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -161,9 +167,12 @@ final class IndependentSettingBinder {
Supplier<JFXButton> inheritanceButtonFactory,
BiConsumer<JFXButton, Boolean> inheritanceButtonUpdater,
WeakListenerHolder listenerHolder,
Function<GameSettings.Instance, GameSettings.Preset> parentGetter) {
Function<GameSettings.Instance, GameSettings.Preset> parentGetter,
Observable parentSettingsInvalidation) {
ObjectProperty<@Nullable SettingProperty<Integer>> activeProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable SettingProperty<Integer>> activeParentProperty = new SimpleObjectProperty<>();
final Holder<Boolean> updating = new Holder<>(false);
final Holder<InvalidationListener> refreshHolder = new Holder<>();
@Nullable JFXButton inheritButton;
if (presetSetting) {
inheritButton = null;
@@ -174,6 +183,12 @@ final class IndependentSettingBinder {
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentPropertyListener(
setting,
activeParentProperty,
propertyGetter,
parentGetter,
refreshHolder.value);
SettingProperty<Integer> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -193,6 +208,8 @@ final class IndependentSettingBinder {
}
};
InvalidationListener weakRefresh = listenerHolder.weak(refresh);
refreshHolder.value = weakRefresh;
parentSettingsInvalidation.addListener(weakRefresh);
textField.textProperty().addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -257,12 +274,16 @@ final class IndependentSettingBinder {
@Nullable JFXButton maxMemoryButton,
BiConsumer<JFXButton, Boolean> inheritanceButtonUpdater,
WeakListenerHolder listenerHolder,
Function<GameSettings.Instance, GameSettings.Preset> parentGetter) {
Function<GameSettings.Instance, GameSettings.Preset> parentGetter,
Observable parentSettingsInvalidation) {
ObjectProperty<@Nullable SettingProperty<Boolean>> activeAutoMemoryProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable SettingProperty<Integer>> activeMaxMemoryProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable SettingProperty<Boolean>> activeParentAutoMemoryProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable SettingProperty<Integer>> activeParentMaxMemoryProperty = new SimpleObjectProperty<>();
Label physicalMemoryLabel = (Label) memoryStatusLabels.getLeft();
Label allocatedMemoryLabel = (Label) memoryStatusLabels.getRight();
final Holder<Boolean> updating = new Holder<>(false);
final Holder<InvalidationListener> refreshHolder = new Holder<>();
int totalMemoryMiB = Math.max(1, (int) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()));
maxMemorySlider.setValueFactory(slider -> Bindings.createStringBinding(
@@ -271,6 +292,18 @@ final class IndependentSettingBinder {
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentPropertyListener(
setting,
activeParentAutoMemoryProperty,
GameSettings::autoMemoryProperty,
parentGetter,
refreshHolder.value);
updateParentPropertyListener(
setting,
activeParentMaxMemoryProperty,
GameSettings::maxMemoryProperty,
parentGetter,
refreshHolder.value);
SettingProperty<Boolean> autoMemoryProperty = activeAutoMemoryProperty.get();
SettingProperty<Integer> maxMemoryProperty = activeMaxMemoryProperty.get();
if (setting == null || autoMemoryProperty == null || maxMemoryProperty == null || updating.value) {
@@ -299,6 +332,8 @@ final class IndependentSettingBinder {
}
};
InvalidationListener weakRefresh = listenerHolder.weak(refresh);
refreshHolder.value = weakRefresh;
parentSettingsInvalidation.addListener(weakRefresh);
choiceList.selectedValueProperty().addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -433,8 +468,6 @@ final class IndependentSettingBinder {
}
refresh.invalidated(newValue);
});
SettingsManager.getGameSettings().addListener(weakRefresh);
settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
memoryStatusBar.memoryStatusProperty().addListener(observable -> {
GameSettings setting = currentSetting.get();
if (setting == null) {
@@ -468,12 +501,21 @@ final class IndependentSettingBinder {
LineInheritableToggleButton button,
Function<GameSettings, SettingProperty<Boolean>> propertyGetter,
WeakListenerHolder listenerHolder,
Function<GameSettings.Instance, GameSettings.Preset> parentGetter) {
Function<GameSettings.Instance, GameSettings.Preset> parentGetter,
Observable parentSettingsInvalidation) {
ObjectProperty<@Nullable SettingProperty<Boolean>> activeProperty = new SimpleObjectProperty<>();
ObjectProperty<@Nullable SettingProperty<Boolean>> activeParentProperty = new SimpleObjectProperty<>();
final Holder<Boolean> updating = new Holder<>(false);
final Holder<InvalidationListener> refreshHolder = new Holder<>();
InvalidationListener refresh = observable -> {
GameSettings setting = currentSetting.get();
updateParentPropertyListener(
setting,
activeParentProperty,
propertyGetter,
parentGetter,
refreshHolder.value);
SettingProperty<Boolean> property = activeProperty.get();
if (setting == null || property == null || updating.value) {
return;
@@ -483,7 +525,9 @@ final class IndependentSettingBinder {
try {
boolean overridden = isOverridden(setting, property);
Boolean effectiveValue = getEffectiveValue(setting, propertyGetter, parentGetter);
button.setRawValue(overridden ? getDirectValue(property) : Boolean.TRUE.equals(effectiveValue));
button.setRawValue(overridden
? getDirectValue(property)
: Boolean.TRUE.equals(effectiveValue));
button.setOverridden(overridden);
button.setEffectiveValue(Boolean.TRUE.equals(effectiveValue));
} finally {
@@ -491,6 +535,8 @@ final class IndependentSettingBinder {
}
};
InvalidationListener weakRefresh = listenerHolder.weak(refresh);
refreshHolder.value = weakRefresh;
parentSettingsInvalidation.addListener(weakRefresh);
button.rawValueProperty().addListener((observable, oldValue, newValue) -> {
GameSettings setting = currentSetting.get();
@@ -503,7 +549,8 @@ final class IndependentSettingBinder {
try {
setOverridden(setting, property, true);
property.setValue(newValue);
button.setEffectiveValue(Boolean.TRUE.equals(getEffectiveValue(setting, propertyGetter, parentGetter)));
button.setEffectiveValue(Boolean.TRUE.equals(
getEffectiveValue(setting, propertyGetter, parentGetter)));
} finally {
updating.value = false;
}
@@ -522,7 +569,8 @@ final class IndependentSettingBinder {
if (newValue) {
property.setValue(button.getRawValue());
}
button.setEffectiveValue(Boolean.TRUE.equals(getEffectiveValue(setting, propertyGetter, parentGetter)));
button.setEffectiveValue(Boolean.TRUE.equals(
getEffectiveValue(setting, propertyGetter, parentGetter)));
} finally {
updating.value = false;
}
@@ -569,47 +617,26 @@ final class IndependentSettingBinder {
Label allocatedMemoryLabel,
@Nullable Boolean autoMemory,
@Nullable Integer maxMemory) {
memoryStatusBar.memoryAllocatedProperty().set(calculateAllocatedMemory(memoryStatusBar, autoMemory, maxMemory));
updateMemoryLabels(memoryStatusBar, physicalMemoryLabel, allocatedMemoryLabel, autoMemory, maxMemory);
}
private static double calculateAllocatedMemory(
MemoryStatusBar memoryStatusBar,
@Nullable Boolean autoMemory,
@Nullable Integer maxMemory) {
long maxMemoryBytes = Math.max(0, maxMemory != null ? maxMemory : 0) * 1024L * 1024L;
return HMCLGameRepository.getAllocatedMemory(
maxMemoryBytes,
memoryStatusBar.getMemoryStatus().getAvailable(),
Boolean.TRUE.equals(autoMemory));
}
private static void updateMemoryLabels(
MemoryStatusBar memoryStatusBar,
Label physicalMemoryLabel,
Label allocatedMemoryLabel,
@Nullable Boolean autoMemory,
@Nullable Integer maxMemory) {
PhysicalMemoryStatus memoryStatus = memoryStatusBar.getMemoryStatus();
long maxMemoryBytes = Math.max(0, maxMemory != null ? maxMemory : 0) * 1024L * 1024L;
boolean autoMemoryEnabled = Boolean.TRUE.equals(autoMemory);
long memoryAllocated;
if (Boolean.TRUE.equals(autoMemory)) {
memoryAllocated = HMCLGameRepository.getAutoAllocatedMemory(memoryStatus.available());
} else if (maxMemory != null && maxMemory > 0) {
memoryAllocated = maxMemory * 1024L * 1024L;
} else {
memoryAllocated = GameSettings.SUGGESTED_MEMORY * 1024L * 1024L;
}
memoryStatusBar.memoryAllocatedProperty().set(memoryAllocated);
physicalMemoryLabel.setText(i18n("settings.memory.used_per_total",
GIGABYTES.convertFromBytes(memoryStatus.getUsed()),
GIGABYTES.convertFromBytes(memoryStatus.getTotal())));
allocatedMemoryLabel.setText(i18n(
memoryStatus.hasAvailable() && maxMemoryBytes > memoryStatus.getAvailable()
? (autoMemoryEnabled
? "settings.memory.allocate.auto.exceeded"
: "settings.memory.allocate.manual.exceeded")
: (autoMemoryEnabled
? "settings.memory.allocate.auto"
: "settings.memory.allocate.manual"),
GIGABYTES.convertFromBytes(maxMemoryBytes),
GIGABYTES.convertFromBytes(HMCLGameRepository.getAllocatedMemory(
maxMemoryBytes,
memoryStatus.getAvailable(),
autoMemoryEnabled)),
GIGABYTES.convertFromBytes(memoryStatus.getAvailable())));
GIGABYTES.convertFromBytes(memoryStatus.total())));
allocatedMemoryLabel.setText(
memoryStatus.hasAvailable() && memoryAllocated > memoryStatus.available()
? i18n("settings.memory.allocate.exceeded", GIGABYTES.convertFromBytes(memoryAllocated), GIGABYTES.convertFromBytes(memoryStatus.available()))
: i18n("settings.memory.allocate", GIGABYTES.convertFromBytes(memoryAllocated)));
}
private static <T> void toggleOverride(
@@ -656,9 +683,6 @@ final class IndependentSettingBinder {
}
refresh.invalidated(newProperty);
});
SettingsManager.getGameSettings().addListener(weakRefresh);
settings().defaultGameSettingsPresetProperty().addListener(weakRefresh);
GameSettings setting = currentSetting.get();
if (setting != null) {
SettingProperty<T> property = propertyGetter.apply(setting);
@@ -76,7 +76,6 @@ final class PresetManagementPane extends ComponentSublist {
this.holder = Objects.requireNonNull(holder);
setTitle(i18n("settings.type.global.preset.manage_all"));
setHasSubtitle(true);
LineButton createButton = new LineButton();
createButton.setTitle(i18n("settings.type.global.preset.create"));
@@ -189,7 +188,7 @@ final class PresetManagementPane extends ComponentSublist {
/// Creates a new preset and selects it for editing.
private void createPreset() {
int number = createDefaultPresetNumber();
int number = SettingsManager.gameSettingsPresets().newPresetAutoNameNumber();
PromptDialogPane.Builder.StringQuestion nameQuestion =
new PromptDialogPane.Builder.StringQuestion("", "")
.setPromptText(i18n("settings.type.global.preset.auto_name", number));
@@ -216,26 +215,6 @@ final class PresetManagementPane extends ComponentSublist {
}).addQuestion(nameQuestion));
}
/// Returns the first automatic preset number that is not used by existing presets.
private int createDefaultPresetNumber() {
for (int index = 1; ; index++) {
String name = i18n("settings.type.global.preset.auto_name", index);
boolean used = false;
for (GameSettings.Preset setting : SettingsManager.getGameSettings()) {
Integer autoNameNumber = setting.autoNameNumberProperty().getValue();
if ((autoNameNumber != null && autoNameNumber == index)
|| Objects.equals(name, getPresetDisplayName(setting))) {
used = true;
break;
}
}
if (!used) {
return index;
}
}
}
/// Asks the user for a new preset name.
private void renamePreset(GameSettings.Preset setting) {
@Nullable String currentName = getPresetCustomName(setting);
@@ -45,7 +45,10 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
@@ -154,13 +157,13 @@ public final class ImageUtils {
if (doScale) {
targetWidth = requestedWidth;
targetHeight = requestedHeight;
pixels = scale(defaultImage.array,
defaultImage.width, defaultImage.height,
pixels = scale(defaultImage.array(),
defaultImage.width(), defaultImage.height(),
targetWidth, targetHeight);
} else {
targetWidth = defaultImage.width;
targetHeight = defaultImage.height;
pixels = defaultImage.array;
targetWidth = defaultImage.width();
targetHeight = defaultImage.height();
pixels = defaultImage.array();
}
WritableImage image = new WritableImage(targetWidth, targetHeight);
@@ -219,18 +222,10 @@ public final class ImageUtils {
(byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a,
};
private static final class PngChunkHeader {
private record PngChunkHeader(int length, int chunkType) {
private static final int IDAT_HEADER = 0x49444154;
private static final int acTL_HEADER = 0x6163544c;
private final int length;
private final int chunkType;
private PngChunkHeader(int length, int chunkType) {
this.length = length;
this.chunkType = chunkType;
}
private static @Nullable PngChunkHeader readHeader(ByteBuffer headerBuffer) {
if (headerBuffer.remaining() < 8)
return null;
@@ -327,7 +322,7 @@ public final class ImageUtils {
int[] buffer = new int[Math.multiplyExact(width, height)];
for (int frameIndex = 0; frameIndex < frames.size(); frameIndex++) {
var frame = frames.get(frameIndex);
PngFrameControl control = frame.control;
PngFrameControl control = frame.control();
if (frameIndex == 0 && (
control.xOffset != 0 || control.yOffset != 0
@@ -347,7 +342,7 @@ public final class ImageUtils {
int[] currentFrameBuffer = buffer.clone();
if (control.blendOp == 0) {
for (int row = 0; row < control.height; row++) {
System.arraycopy(frame.bitmap.array,
System.arraycopy(frame.bitmap().array(),
row * control.width,
currentFrameBuffer,
(control.yOffset + row) * width + control.xOffset,
@@ -360,7 +355,7 @@ public final class ImageUtils {
int srcIndex = row * control.width + col;
int dstIndex = (control.yOffset + row) * width + control.xOffset + col;
int srcPixel = frame.bitmap.array[srcIndex];
int srcPixel = frame.bitmap().array()[srcIndex];
int dstPixel = currentFrameBuffer[dstIndex];
int srcAlpha = (srcPixel >>> 24) & 0xFF;
@@ -444,7 +439,7 @@ public final class ImageUtils {
PngAnimationControl animationControl = sequence.getAnimationControl();
int cycleCount;
if (animationControl != null) {
cycleCount = animationControl.numPlays;
cycleCount = animationControl.numPlays();
if (cycleCount == 0)
cycleCount = Timeline.INDEFINITE;
} else {
@@ -9,22 +9,12 @@ package org.jackhuang.hmcl.ui.image.apng.argb8888;
* to be compatible with the input array used to build Android Bitmap objects, though of course
* its use is not limited to that.
*/
public final class Argb8888Bitmap {
public final int[] array;
public final int width;
public final int height;
public record Argb8888Bitmap(int[] array, int width, int height) {
public Argb8888Bitmap(int width, int height) {
this(new int[width * height], width, height);
}
public Argb8888Bitmap(int[] array, int width, int height) {//}, int x, int y) {
this.array = array;
this.width = width;
this.height = height;
// this.x = y;
}
/**
* Create a new Bitmap that shares the byte array of this bitmap.
* This is useful when rendering a number of animation frames from an APNG file
@@ -37,7 +27,7 @@ public final class Argb8888Bitmap {
* from the original bitmap).
* @return new bitmap object sharing the same data array.
*/
public Argb8888Bitmap makeView(int width, int height) {//}, int x, int y) {
public Argb8888Bitmap makeView(int width, int height) {
if ((width * height) > (this.width * this.height)) {
throw new IllegalArgumentException(String.format(
"Requested width and height (%d x %d) exceeds maximum pixels allowed by host bitmap (%d x %d",
@@ -37,7 +37,7 @@ public final class Argb8888BitmapSequence {
public void receiveAnimationControl(PngAnimationControl animationControl) {
this.animationControl = animationControl;
this.animationFrames = new ArrayList<>(animationControl.numFrames);
this.animationFrames = new ArrayList<>(animationControl.numFrames());
}
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
@@ -49,7 +49,7 @@ public final class Argb8888BitmapSequence {
}
public boolean isAnimated() {
return null != animationControl && animationControl.numFrames > 0;
return null != animationControl && animationControl.numFrames() > 0;
}
public PngAnimationControl getAnimationControl() {
@@ -60,13 +60,6 @@ public final class Argb8888BitmapSequence {
return animationFrames;
}
public static final class Frame {
public final PngFrameControl control;
public final Argb8888Bitmap bitmap;
public Frame(PngFrameControl control, Argb8888Bitmap bitmap) {
this.control = control;
this.bitmap = bitmap;
}
public record Frame(PngFrameControl control, Argb8888Bitmap bitmap) {
}
}
@@ -12,28 +12,21 @@ import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
* Note that the transparency of individual entries in the palette is modified in-place
* when the trNS chunk is processed.
*
* @param argbArray The colour array is public and mutable by design.
* @see BasicArgb8888Director
*/
public class Argb8888Palette {
public record Argb8888Palette(int[] argbArray) {
protected static Argb8888Palette monochromePalette = null;
protected static Argb8888Palette greyPalette2 = null;
protected static Argb8888Palette greyPalette4 = null;
protected static Argb8888Palette greyPalette8 = null;
/**
* The colour array is public and mutable by design.
*
* @see this.get()
*/
public final int[] argbArray;
/**
* Construct a new palette using the specific array passed as the argument as-is (not copied).
*
* @param argbArray array of colours to use as-is (it is not copied).
*/
public Argb8888Palette(int[] argbArray) {
this.argbArray = argbArray;
public Argb8888Palette {
}
/**
@@ -89,29 +82,33 @@ public class Argb8888Palette {
}
public static Argb8888Palette forGreyscale(int bitDepth) throws PngException {
switch (bitDepth) {
case 1:
return switch (bitDepth) {
case 1 -> {
if (null == monochromePalette) {
monochromePalette = forGreyscale(2, 0xff); // Worth it or not really?
}
return monochromePalette;
case 2:
yield monochromePalette;
}
case 2 -> {
if (null == greyPalette2) {
greyPalette2 = forGreyscale(4, 0x55);
}
return greyPalette2;
case 4:
yield greyPalette2;
}
case 4 -> {
if (null == greyPalette4) {
greyPalette4 = forGreyscale(16, 0x11);
}
return greyPalette4;
case 8: // TODO: need??
yield greyPalette4;
}
case 8 -> {
if (null == greyPalette8) {
greyPalette8 = forGreyscale(256, 0x01);
}
return greyPalette8;
default:
throw new PngIntegrityException(String.format("Valid greyscale bit depths are 1, 2, 4, 8, not %d", bitDepth));
}
yield greyPalette8;
}
default ->
throw new PngIntegrityException(String.format("Valid greyscale bit depths are 1, 2, 4, 8, not %d", bitDepth));
};
}
}
@@ -122,8 +122,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
int writePosition = this.y * width;
int lastWritePosition = writePosition + width;
int bit = highBit;
@@ -136,7 +136,7 @@ public class Argb8888Processors {
int dest;
if (palette != null) {
dest = palette.argbArray[index];
dest = palette.argbArray()[index];
} else {
dest = 0;
}
@@ -168,8 +168,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
@@ -177,7 +177,7 @@ public class Argb8888Processors {
int dest;
if (palette != null) {
dest = palette.argbArray[index];
dest = palette.argbArray()[index];
} else {
dest = 0;
}
@@ -203,8 +203,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int v = srcBytes[srcPosition++];
@@ -236,8 +236,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
@@ -274,8 +274,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
@@ -303,8 +303,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
@@ -333,8 +333,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
@@ -364,8 +364,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
@@ -395,8 +395,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
@@ -431,8 +431,8 @@ public class Argb8888Processors {
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int[] destArray = this.bitmap.array();
final int width = this.bitmap.width();
//final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
@@ -55,7 +55,7 @@ public abstract class Argb8888ScanlineProcessor extends BasicScanlineProcessor {
public Argb8888ScanlineProcessor cloneWithNewBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
cloned.setPalette(new Argb8888Palette(palette.argbArray()));
}
return cloned;
@@ -71,7 +71,7 @@ public abstract class Argb8888ScanlineProcessor extends BasicScanlineProcessor {
public Argb8888ScanlineProcessor cloneWithSharedBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, bitmap.makeView(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
cloned.setPalette(new Argb8888Palette(palette.argbArray()));
}
return cloned;
@@ -28,7 +28,7 @@ public abstract class BasicArgb8888Director<ResultT> implements Argb8888Director
}
for (int i = 0; i < length; i++) {
final int alpha = 0xff & bytes[position + i];
palette.argbArray[i] = alpha << 24 | palette.argbArray[i] & 0x00FFFFFF;
palette.argbArray()[i] = alpha << 24 | palette.argbArray()[i] & 0x00FFFFFF;
}
}
@@ -3,25 +3,20 @@
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jetbrains.annotations.NotNull;
/**
* A PngAnimationControl object contains data parsed from the ``acTL``
* animation control chunk of an animated PNG file.
*/
public class PngAnimationControl {
public final int numFrames;
public final int numPlays;
public PngAnimationControl(int numFrames, int numPlays) {
this.numFrames = numFrames;
this.numPlays = numPlays;
}
public record PngAnimationControl(int numFrames, int numPlays) {
public boolean loopForever() {
return 0 == numPlays;
}
@Override
public String toString() {
public @NotNull String toString() {
return "PngAnimationControl{" +
"numFrames=" + numFrames +
", numPlays=" + numPlays +
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff. Show more