Compare commits

...
132 Commits
Author SHA1 Message Date
ToobLac 662a82f21f refactor(addon): 单例化 RemoteAddonRepository (#6900) 2026-10-05 13:08:42 +08:00
Glavo 3f0fcc1382 Separate window bounds tracking from minimum size configuration (#6905) 2026-10-05 10:57:21 +08:00
gunngz 258556b22c Fix #6600: 为自定义 LWJGL 本地库路径添加目录选择按钮 (#6887) 2026-10-05 10:20:05 +08:00
辞庐 d7e3cc4473 fix: 修改游戏目录名称后不会刷新 (#6904) 2026-10-05 10:09:28 +08:00
Glavo 93d4489b94 更新模组数据 (2026-10-04) (#6941) 2026-10-05 10:09:03 +08:00
Apostrophe 77eee17d36 更新README中contributor的人数至“130人以上”,并提高图片中的数量上限 (#6921) 2026-09-30 20:35:22 +08:00
辞庐 4118fa9729 添加实例文件夹后自动选中 (#6912) 2026-09-30 20:31:38 +08:00
Glavo 83ffc91a88 重构 DownloadProvider (#6919) 2026-09-30 20:26:28 +08:00
neveler fdf883d09d 更新 Chunkbase 种子地图 (#6892) 2026-09-30 20:25:24 +08:00
Glavo 342bcc5438 Bump Gradle to 9.8.0 (#6917) 2026-09-30 20:24:09 +08:00
Glavo 59bcc7fe62 在 JavaFX27+ 环境中使用原生窗口装饰 (#6885) 2026-09-22 20:21:30 +08:00
Glavo 77b42b6f99 使用 WebURL 代替 URI (#6883) 2026-09-20 20:38:59 +08:00
辞庐 2f5594d8c1 Cleanroom 环境移除 icu4j-core-mojang 依赖 (#6886) 2026-09-17 21:57:09 +08:00
13ad50208f [Enhancement] 原理图管理优化 (#6094)
Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
2026-09-17 21:15:11 +08:00
Glavo 13910c24b7 Add version entry for Minecraft 26.3-rc-3 in versions.txt (#6877) 2026-09-16 21:41:56 +08:00
Glavo cc9bdb62ca Add support for Minecraft 26.3-snapshot-10+ to unofficial supported platforms (#6870) 2026-09-16 20:50:02 +08:00
Glavo baa7c816b7 将翻译 Task 名称的逻辑移动至 I18n 中 (#6868) 2026-09-15 21:55:44 +08:00
Apostrophe 090f0b9822 修复编辑实例文件夹时不修改名称就无法保存的问题 (#6866) 2026-09-15 20:30:31 +08:00
CaveNightingale 6d9ae24912 添加安装阶段对整合包可选文件的支持 (#1771) 2026-09-14 21:51:49 +08:00
Ivan Epikhin d03851b196 i18n: update Russian translation (#6859) 2026-09-14 21:13:22 +08:00
Glavo 97cf9dcf16 Refactor native library checks for improved clarity and efficiency (#6857) 2026-09-12 20:39:34 +08:00
Glavo 7bacc2bb5a Fix #6842: Include tags in TwoLineListItem preferred width (#6844) 2026-09-12 20:20:02 +08:00
Wulian233 c7637079d7 Fix #6851: 修复右键部分选项弹出的弹窗会无限叠加 (#6854)
Close #6851
2026-09-12 20:16:18 +08:00
Glavo 8f3282e9a2 Add version entries for 26.3 snapshots and release candidates (#6849) 2026-09-12 20:13:27 +08:00
Glavo 8e491862b9 对于简体中文资源使用 zh-Hans 语言标签 (#6853) 2026-09-12 14:12:24 +08:00
Glavo e0c276075c 重命名 I18N_zh.properties 和 I18N_zh_CN.properties (#6852) 2026-09-12 13:35:16 +08:00
Glavo 19ea64e14b 同步更新所有语言文件 (#6850) 2026-09-12 10:58:09 +08:00
ToobLac 5b26b8f4eb enhance(macos): 日志、游戏崩溃、HMCL崩溃窗口按下 CMD+W 时退出 (#6813) 2026-09-11 21:37:25 +08:00
NWMA-FYWFandGlavo ed7ef92ad9 feat: 添加实例列表右键编辑功能 (#6000) (#6038)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-11 20:59:44 +08:00
ToobLac cd95891108 [Enhancement] 启动器检查更新失败时显示提示 (#6286) 2026-09-11 20:24:13 +08:00
Burning_TNT df52bc6e81 不再支持导出 MultiMC 整合包 (#4017) 2026-09-10 21:31:27 +08:00
lcxn123 9d9fc78384 fix #6202: Validate Yggdrasil access tokens on every login (#6209) 2026-09-10 21:28:21 +08:00
bluebulletandGlavo e287f672fd Feat/curse retry and cache (#6595)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-10 21:02:07 +08:00
Glavo 24702dc5a0 Fix #6833: Automatically synchronize client JARs when updating game instances (#6843) 2026-09-10 10:14:11 +08:00
ToobLac 886a25a935 [Enhancement] 延后加载并缓存资源包图标 (#6730) 2026-09-09 21:00:08 +08:00
ToobLac 450504ceda [Bugfix] 修复 #6526: macOS 下 主题模式-跟随系统设置 失效 (#6555)
Fixes #6526
2026-09-09 20:58:39 +08:00
ToobLac 8767cc0e98 [Bugfix] 修复 CurseForge 搜索部分内容正确结果很靠下的问题 & 移除没有实际意义的搜索排序方式 (#6619) 2026-09-09 20:06:12 +08:00
ToobLacandGlavo 7331f619cf [Bugfix] 修复未受支持的模组加载器标签在模组版本界面不显示 (#6289)
Fixes #6280

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-08 21:57:53 +08:00
ZZZank 7a8f931641 Fix display of mod with extra long display name (#6436) 2026-09-08 21:57:32 +08:00
辞庐 ce51dc4dd7 在修改游戏版本时清除已选的模组加载器 (#6475) 2026-09-08 21:57:16 +08:00
Glavo b97e7fbd55 Move modpack file controls into reusable tree cells (#6840) 2026-09-08 20:46:21 +08:00
辞庐andGlavo 7f578be08e 优化实例图标设置操作按钮 (#6816)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-08 20:33:08 +08:00
Glavo 513f53bc06 修复无法启动安装了 Forge 的 Minecraft 1.6.4 实例的问题 (#6839) 2026-09-08 20:18:25 +08:00
Glavo b2d4d36856 Implement task cleanup and abort functionality in DefaultGameBuilder (#6836)
Fix #6773
2026-09-08 16:39:06 +08:00
Glavo 9c031c5ac1 优化 ArchiveFileTree 构建性能 (#6838) 2026-09-08 16:37:31 +08:00
Glavo e2d3ac847d 打开 zip 文件时忽略 local file header (#6834) 2026-09-08 12:02:17 +08:00
Glavo 0f4152406c Bump kala-compress to 1.27.1-5 (#6837) 2026-09-08 12:02:00 +08:00
Glavo 1a258a2554 Fix #6828: 隐藏父实例缺失的实例 (#6832) 2026-09-07 21:15:16 +08:00
Glavo 3742be0aa8 Add *.jfr to .gitignore (#6835) 2026-09-07 15:54:23 +08:00
辞庐 0e34554344 转化 OptiFineVersion 为 Record (#6821) 2026-09-06 20:20:12 +08:00
Wulian233 1acfc55004 转化 CurseMetaMod 为 Record (#6822) 2026-09-06 20:18:54 +08:00
辞庐 1428183e36 简写部分 Hello Minecraft! Launcher 为 HMCL (#6826) 2026-09-06 20:16:34 +08:00
Wulian233 cbc6daf3d5 Fix #6819: 实例管理工具栏文字变小 (#6823)
Close #6819
2026-09-06 20:16:21 +08:00
辞庐 0ad180c082 fix: 无法启动新版本 Cleanroom (#6824) 2026-09-06 20:14:39 +08:00
Xirren 1da8c8d6ac 修复导出mrpack时,已禁用模组有关bug (#6830) 2026-09-06 20:14:06 +08:00
辞庐 ea232b0976 清理 DialogPane 冗余 progressBar (#6827) 2026-09-06 20:10:45 +08:00
辞庐 b4549b6f68 OAuthserver 仅在 localhost 监听端口 (#6811) 2026-09-05 20:14:46 +08:00
辞庐 4cf5dbdf66 fix: 两种方式打开的游戏选择 Popup 文字大小不一样大 (#6812)
resolves #6697
2026-09-05 20:05:16 +08:00
Glavo b179c44bec Bump kala-compress to 1.27.1-4 (#6809) 2026-09-05 00:08:32 +08:00
Wulian233 7dfc654d5d Fix #6398: Java列表无法刷新版本信息 (#6808)
Close #6398
2026-09-04 21:03:49 +08:00
ToobLac a4b930ded5 [Bugfix] 修复下载附加内容时依赖列表因文件缺失加载失败/类别标题不居中的问题 (#6532) 2026-09-04 20:51:10 +08:00
辞庐andGlavo 00cd20a6e9 简化 DefaultLauncher#getEnvVars (#6197)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-03 21:56:15 +08:00
Glavo 9ebf29d060 修复 1.4.7 安装 Legacy Fabric 后无法正常启动的问题 (#6807) 2026-09-03 21:16:07 +08:00
辞庐 d7d5e99185 fix: 自动安装已安装的项目会显示两份 (#6746) 2026-09-03 20:32:25 +08:00
陈梦泽 5e9ed84605 修复 GameDirectoryPage 缺失的 MessageDialogPane import (#6805) 2026-09-03 20:20:00 +08:00
ToobLac 2e01874e95 fix(ripple): 修复列表第一次点击时不显示涟漪的问题以及滚动时仍然保持涟漪的问题 (#6709)
Fixes #6703
2026-09-02 23:36:10 +08:00
辞庐 efb475879b 在选择磁盘根目录作为游戏目录时弹出警告 (#6203) 2026-09-02 21:57:19 +08:00
ToobLac cb69387151 [Chores] 移动 ModListPageSkin 到 ModListPage 内部 (#6729) 2026-09-02 20:29:55 +08:00
Glavo 70104cb9e5 重构游戏管理功能 (#6787) 2026-09-02 20:20:22 +08:00
辞庐 931fbf5181 更新 launch.advice.lwjgl_3_4_1 文案 (#6758) 2026-09-01 21:03:29 +08:00
辞庐 1d5ecb9236 使用相同逻辑提取验证服务器展示域名 (#6762) 2026-08-31 21:25:35 +08:00
辞庐 b65104e38b 优化修改实例游戏版本页标题 (#6778) 2026-08-31 21:25:25 +08:00
辞庐 8629a2bbe3 fix: 实例列表中新快照图标错误 (#6774) 2026-08-31 21:22:47 +08:00
Glavo 3142b2f894 Bump commonmark to 0.30.0 (#6793) 2026-08-31 21:20:56 +08:00
辞庐 b1759acb62 fix: 初次进入新增游戏目录页面不会自动填充 .minecraft 上级目录名称 (#6764) 2026-08-31 21:20:44 +08:00
Glavo 1363f0e70d Bump shadow plugin to 9.6.1 (#6795) 2026-08-31 21:19:26 +08:00
辞庐 45c4913b82 清理 PropertyUtils (#6763) 2026-08-31 21:15:22 +08:00
辞庐 0d47ff95e1 优化微软登录扫描二维码界面布局 (#6767) 2026-08-31 21:12:49 +08:00
Glavo 4064c5c188 Bump JUnit to 6.1.3 (#6794) 2026-08-31 21:11:18 +08:00
Glavo 56a2f75c8b Bump jsoup to 1.23.2 (#6792) 2026-08-31 21:01:46 +08:00
Glavo c4ebdeb77e Bump Gradle to 9.7.1 (#6791) 2026-08-31 21:01:22 +08:00
Glavo 13b87fff89 Bump fxsvgimage to 1.9.1 (#6790) 2026-08-31 20:52:30 +08:00
Glavo 50072799d0 Fix #6788: 修复缩放 SVG 图片时会加载 JFXPanel 类的问题 (#6789) 2026-08-31 20:48:03 +08:00
辞庐 16d45890ad 点击主页“下载”按钮时保留先前打开页面 (#6775) 2026-08-31 20:26:14 +08:00
辞庐 e0dacd0265 优化 LineSelectButton popup 副标题配色 (#6771) 2026-08-30 22:05:56 +08:00
辞庐 72071a41c9 清理 GameException (#6770) 2026-08-30 22:04:05 +08:00
辞庐 c86c19a7f9 fix: 更改模组加载器版本时下载依赖项任务未正确归类 (#6777) 2026-08-30 22:01:22 +08:00
辞庐 5475acdc9e 清理 MojangJavaRemoteVersion & MojangJavaDistribution (#6772) 2026-08-30 21:57:41 +08:00
辞庐 6daeeb0b04 清理 JsonMap (#6776) 2026-08-30 21:54:47 +08:00
辞庐 1a4ccd355d fix: 全局实例设置页面可能缺少 ”Vulkan 渲染器/驱动“选项 (#6779) 2026-08-30 21:21:01 +08:00
辞庐 204a08342d fix: LegacyFabric 实例图标异常 (#6784) 2026-08-30 21:20:02 +08:00
辞庐 ced06cb48c 清理 AuthlibInjectorDownloader (#6765) 2026-08-28 22:02:22 +08:00
辞庐 80b3883d4f 修复更换游戏组件版本对话框消息文本异常的问题 (#6757) 2026-08-28 22:01:47 +08:00
辞庐 60c7ea5a01 清理 MappedProperty (#6761) 2026-08-28 21:59:03 +08:00
辞庐 3323c6a50a 修复选择组件版本页面标题异常的问题 (#6753) 2026-08-28 21:57:48 +08:00
olivezaneandGlavo 7530f08ab1 修复 KDE Wayland 下 HMCL 不跟随缩放的问题 (#6736)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-28 21:46:26 +08:00
辞庐andGlavo 15a983de28 优化验证服务器选择样式 (#6704)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-27 21:37:15 +08:00
辞庐 5c0d4a7c7b 清理 Lang (#6707) 2026-08-27 20:49:26 +08:00
辞庐 1532247f2a feat: 支持使用系统 SDL (#6696)
closes #6452
2026-08-26 21:47:27 +08:00
Glavo 587e92543a 更新 PLATFORM.md (#6740) 2026-08-25 21:53:38 +08:00
ToobLac f0527e12b4 [Enhancement] 清理游戏实例时删除版本目录根部所有 log 文件 (#6732) 2026-08-23 20:17:34 +08:00
ToobLac b0ac4fb5d0 [Enhancement] 优先按照游戏版本排序实例 (#6731)
Fixes #4679
2026-08-23 20:17:14 +08:00
Glavo 083dbb18ad 修复安装新实例时未将 patch 合并到主 manifest 中的问题 (#6718) 2026-08-21 21:42:08 +08:00
Glavo c9585198e1 主动修剪堆内存 (#6715) 2026-08-21 21:38:41 +08:00
Glavo 0fd83e5177 修复在使用相对路径的 DefaultGameRepository 中安装实例失败的问题 (#6716) 2026-08-21 11:26:10 +08:00
辞庐 5a017ecab6 修复可通过本地安装同时安装 Cleanroom 与 Forge 的问题 (#6496) 2026-08-20 21:52:05 +08:00
辞庐 da77f52492 迁移 ModpackHelper#findMinecraftDirectoryInManuallyCreatedModpack 到 kala-compress (#6465) 2026-08-20 21:27:42 +08:00
Glavo dccfa7c9a9 将文件末尾的 CRLF 替换为 LF (#6713) 2026-08-20 21:03:12 +08:00
neveler f7bf2d468d 将 PR Checkstyle 报告同步至 Pull Request 审查评论 (#6692) 2026-08-20 20:54:32 +08:00
ToobLac a10c501d4f [Bugfix] 修复无法读取 zip 后缀名的模组的问题 (#6712) 2026-08-20 20:48:45 +08:00
Glavo 116629743e 重构 GameRepository (#6580) 2026-08-20 20:41:56 +08:00
辞庐andGlavo cfee31d339 清理未使用 i18n 键值 (#6688)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-20 20:41:33 +08:00
辞庐 3ac08d7f62 清理 FXUtils (#6706) 2026-08-19 20:29:12 +08:00
3gf8jv4dv de643becf8 fix: update modrinth category l10n for cyrillic to match upstream (#6695) 2026-08-19 20:28:41 +08:00
辞庐 edbcc1f343 优化无验证服务器提示 (#6705) 2026-08-19 19:27:06 +08:00
辞庐 91fcc4e5ef 清理未使用样式 (#6698) 2026-08-17 20:33:57 +08:00
3gf8jv4dv 34aeba1b1a fix: update modrinth category l10n for en and zh to match upstream (#6671) 2026-08-16 21:05:18 +08:00
辞庐 9a36b86cfb 简化 DownloadPage#download (#6691) 2026-08-16 21:03:26 +08:00
ApostropheandGlavo 71b4c02474 解决导出 Modrinth 整合包因大文件导致的 Out Of Memory (#6631)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-14 21:55:48 +08:00
辞庐andGlavo 45f49d079b 转化 GameVersion 为 record (#6663)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-14 21:47:11 +08:00
BANER 71d9daed16 i18n: add complete German translation (I18N_de.properties) (#6677) 2026-08-14 20:41:22 +08:00
ToobLac 5ae019ba17 fix(modloader): 修复 #6628 Cleanroom 和 LiteLoader 实例不读取子文件夹里的模组 (#6643)
Fixes #6628
2026-08-14 20:25:03 +08:00
辞庐andGlavo 770c58c2e7 优化投影管理文件夹已存在提示 (#6664)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-13 21:52:53 +08:00
ToobLac a03c11be42 fix(macos): 修复 macOS 上打开对话框后按 CMD+Q 无法退出的问题 (#6656) 2026-08-13 21:40:16 +08:00
辞庐 ca3746319f 移除 “操作被取消” toast (#6658) 2026-08-13 21:39:52 +08:00
辞庐andGlavo 56629892f8 将 ForgeInstallProfile 转化为 record (#6665)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-13 21:38:00 +08:00
BANER 5238a361cd i18n: complete Ukrainian translation (I18N_uk.properties) (#6675) 2026-08-13 20:59:33 +08:00
Glavo 0323a7f321 修复在 Linux 平台上错误的将 Intel 核显探测为独显的问题 (#6676) 2026-08-13 20:53:06 +08:00
ToobLac a3c5e8e412 [Bugfix] 修复列表中涟漪状态不更新的问题 (#6586)
Fixes #6549
2026-08-13 20:49:17 +08:00
辞庐 5c364f624a 清理 HttpRequest (#6666) 2026-08-13 20:29:58 +08:00
Glavo 22275f2c57 支持在 Linux 双显卡平台上优先使用 NVIDIA 显卡 (#6670) 2026-08-12 21:55:35 +08:00
辞庐 11e54d199c fix: .gitignore 缺少 hmcl-mod-update-list-* (#6653) 2026-08-12 20:50:20 +08:00
413 changed files with 31679 additions and 14948 deletions

No files matched your search

+1
View File
@@ -4,6 +4,7 @@
# ─── Explicitly text – source & build ────────────────────────────────────────
*.java text eol=lf
*.js text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.groovy text eol=lf
+120
View File
@@ -0,0 +1,120 @@
const fs = require("fs");
module.exports = async ({ github, context, core }) => {
const prNumberPath = process.env.PULL_REQUEST_NUMBER_PATH;
if (!prNumberPath || !fs.existsSync(prNumberPath) || !fs.statSync(prNumberPath).isFile()) {
core.setFailed("Pull request number file not found.");
return;
}
const reportsPath = process.env.REPORTS_PATH;
if (!reportsPath || !fs.existsSync(reportsPath) || !fs.statSync(reportsPath).isFile()) {
core.setFailed("Reports file not found.");
return;
}
const PULL_REQUEST_NUMBER = parseInt(fs.readFileSync(prNumberPath, "utf8").trim(), 10);
if (isNaN(PULL_REQUEST_NUMBER)) {
core.setFailed("Failed to parse the pull request number.");
return;
}
const { owner, repo } = context.repo;
let pr;
try {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
});
pr = response.data;
} catch (error) {
core.setFailed(`Failed to fetch PR #${PULL_REQUEST_NUMBER}: ${error.message}`);
return;
}
core.info(`Current PR state: ${pr.state}`);
if (pr.state !== "open") {
core.setFailed("The pull request is not open. Skipping comment creation.");
return;
}
const run = context.payload.workflow_run;
if (!run) {
core.setFailed("context.payload.workflow_run is undefined. Ensure this script runs on the 'workflow_run' event.");
return;
}
if (pr.head.sha !== run.head_sha) {
core.setFailed("PR head SHA does not match the workflow run head SHA. Skipping.");
return;
}
if (pr.head.ref !== run.head_branch) {
core.setFailed("PR head branch does not match the workflow run head branch. Skipping.");
return;
}
if (pr.head.repo.full_name !== run.head_repository.full_name) {
core.setFailed("PR head repository fullname does not match the workflow run head repository fullname. Skipping.");
return;
}
const comments = [];
const maxCommentCount = 10;
const reports = fs.readFileSync(reportsPath, "utf8").trim().split("\n").filter(Boolean);
for (const report of reports) {
if (comments.length >= maxCommentCount) break;
try {
const data = JSON.parse(report);
if (typeof data !== "object" || Array.isArray(data)) continue;
const { location, message, severity, code } = data;
if (typeof location !== "object" || Array.isArray(location)) continue;
if (typeof message !== "string") continue;
const severities = ["UNKNOWN_SEVERITY", "ERROR", "WARNING", "INFO", 0, 1, 2, 3];
if (!severities.includes(severity)) continue;
const severityText = typeof severity === "string" ? severity : severities[severity];
if (typeof code !== "object" || Array.isArray(code)) continue;
const path = location.path;
const line = location.range?.start?.line;
const codeText = code.value ? `[${code.value}] ` : "";
if (path && line) {
comments.push({
path,
line,
side: "RIGHT",
body: `**[${severityText}]** ${codeText}\n\n${message}`
});
}
} catch (error) {
core.warning(`Failed to parse report: ${error.message}`);
}
}
if (comments.length === 0) {
core.info("No diagnostics found to report as comments.");
return;
}
core.info(`Successfully prepared ${comments.length} comment${comments.length > 1 ? "s" : ""} to post.`);
try {
await github.rest.pulls.createReview({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
commit_id: run.head_sha,
body: "🤖 Static analysis found the following issues (max count 10):",
event: "COMMENT",
comments: comments
});
core.info("Review comments successfully posted to the pull request.");
} catch (error) {
core.setFailed(`Failed to post review comments to GitHub: ${error.message}`);
return;
}
}
+40
View File
@@ -0,0 +1,40 @@
name: Check Codes Comment
on:
workflow_run:
workflows: ["Check Codes"]
types:
- completed
permissions:
actions: read
contents: read
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: Checkout Script
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts/check-codes-comment.js
sparse-checkout-cone-mode: false
- name: Download Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: checkstyle-artifact
path: ${{ runner.temp }}/checkstyle-artifact
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Review Comment
uses: actions/github-script@v9
env:
REPORTS_PATH: ${{ runner.temp }}/checkstyle-artifact/ALL_REPORTS.jsonl
PULL_REQUEST_NUMBER_PATH: ${{ runner.temp }}/checkstyle-artifact/PULL_REQUEST_NUMBER
with:
script: |
const script = require("./.github/scripts/check-codes-comment.js");
await script({ github, context, core });
+27 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: 'zulu'
@@ -27,3 +27,29 @@ jobs:
cache-cleanup: never
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
- if: failure() && github.event_name == 'pull_request'
name: Setup reviewdog
uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0
with:
reviewdog_version: v0.21.0
- if: failure() && github.event_name == 'pull_request'
name: Generate Artifact
run: |
echo "${{ github.event.number }}" > PULL_REQUEST_NUMBER
> ALL_REPORTS.jsonl
find . -path "*/build/reports/checkstyle/*.xml" | while read -r xml_file; do
if [ -s "$xml_file" ]; then
echo "Processing $xml_file..."
reviewdog -f=checkstyle -name="checkstyle" -reporter=rdjsonl < "$xml_file" >> ALL_REPORTS.jsonl
else
echo "Skipping empty or missing report: $xml_file"
fi
done
- if: failure() && github.event_name == 'pull_request'
name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: checkstyle-artifact
path: |
ALL_REPORTS.jsonl
PULL_REQUEST_NUMBER
+2 -2
View File
@@ -21,8 +21,8 @@ jobs:
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts
sparse-checkout-cone-mode: true
.github/scripts/pr-size-label.js
sparse-checkout-cone-mode: false
- name: Label PR by filtered changed lines
uses: actions/github-script@v9
with:
+3
View File
@@ -4,6 +4,8 @@
hs_err_pid*
*.hprof
*.jfr
.gradle
gradle.properties
@@ -17,6 +19,7 @@ NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
terracotta-log-*
hmcl-mod-update-list-*
*.hmcl-theme
/.java/
/.local/
+12 -2
View File
@@ -4,6 +4,7 @@ import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.pack.CreateDeb
@@ -430,13 +431,22 @@ tasks.register<CheckTranslations>("checkTranslations") {
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
englishFile.set(dir.file("I18N.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
tasks.register<SyncTranslations>("syncTranslations") {
group = "localization"
description = "Synchronizes translation keys and blank lines with I18N.properties."
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
sourceFile.set(dir.file("I18N.properties"))
translationFiles.from(fileTree(dir) { include("I18N_*.properties") })
}
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
@@ -0,0 +1,101 @@
/*
* 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 com.jfoenix.controls;
import com.jfoenix.skins.JFXCheckBoxSkin;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.CheckBoxTreeItem;
import javafx.scene.layout.HBox;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
///
/// The checkbox's selected and indeterminate states are bidirectionally bound to
/// the current tree item. Reusing or clearing the cell removes the old bindings.
/// Installing a checkbox skin or switching tree items applies the selection state
/// immediately, without playing a selection animation.
/// Ordinary tree items retain the display provided by [JFXTreeCell].
/// Empty cells and `null` values display neither content nor a checkbox.
///
/// @param <T> the type of value stored in each tree item
@NotNullByDefault
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
/// The checkbox bound to the currently displayed checkable tree item.
private final JFXCheckBox checkBox = new JFXCheckBox();
/// Places the checkbox before the graphic supplied by the superclass.
private final HBox graphic = new HBox();
/// The tree item whose selection properties are currently bound, if any.
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
public JFXCheckTreeCell() {
checkBox.setAllowIndeterminate(false);
checkBox.setFocusTraversable(false);
graphic.setAlignment(Pos.CENTER_LEFT);
graphic.setPickOnBounds(false);
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
}
/// Finishes animations from the previous item and immediately displays the current state.
private void finishSelectionAnimation() {
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
skin.finishSelectionAnimation();
}
}
/// Adds the checkbox to the inherited display and updates its selection bindings.
@Override
protected void updateDisplay(@Nullable T item, boolean empty) {
// Release graphics before the superclass reuses or reparents them.
graphic.getChildren().clear();
super.updateDisplay(item, empty);
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
if (boundTreeItem != treeItem) {
if (boundTreeItem != null) {
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
}
boundTreeItem = treeItem;
if (treeItem != null) {
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
} else {
checkBox.setSelected(false);
checkBox.setIndeterminate(false);
}
finishSelectionAnimation();
}
if (treeItem != null) {
@Nullable Node itemGraphic = getGraphic();
graphic.getChildren().add(checkBox);
if (itemGraphic != null) {
graphic.getChildren().add(itemGraphic);
}
setGraphic(graphic);
}
}
}
@@ -70,7 +70,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
private void initialize() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
this.setCellFactory(listView -> new JFXListCell<T>() {
this.setCellFactory(listView -> new JFXListCell<>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
@@ -80,7 +80,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
// had to refactor the code out of the skin class to allow
// customization of the button cell
this.setButtonCell(new ListCell<T>() {
this.setButtonCell(new ListCell<>() {
{
// fixed clearing the combo box value is causing
// java prompt text to be shown because the button cell is not updated
@@ -199,6 +199,9 @@ public class JFXListCell<T> extends ListCell<T> {
*/
@Override
protected void updateItem(T item, boolean empty) {
T oldItem = getItem();
boolean oldEmpty = isEmpty();
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
super.updateItem(item, empty);
if (empty) {
setText(null);
@@ -278,6 +278,10 @@ public class JFXRippler extends StackPane {
rippler.releaseRipple();
}
public void releaseRippleImmediately() {
rippler.releaseRippleImmediately();
}
/**
* creates Ripple effect in the center of the control
*
@@ -405,6 +409,21 @@ public class JFXRippler extends StackPane {
}
}
private void releaseRippleImmediately() {
Ripple ripple = ripplesQueue.poll();
if (ripple != null) {
getChildren().remove(ripple);
if (generating.getAndSet(false)) {
if (overlayRect != null) {
overlayRect.inAnimation.stop();
if (!forceOverlay) {
overlayRect.setOpacity(0D);
}
}
}
}
}
void cacheRippleClip(boolean cached) {
cacheRipplerClip = cached;
}
@@ -28,6 +28,7 @@ import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
@@ -83,8 +84,7 @@ public class JFXTreeCell<T> extends TreeCell<T> {
treeItemRef = new WeakReference<>(newTreeItem);
}
};
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
treeItemProperty().addListener(weakTreeItemListener);
treeItemProperty().addListener(treeItemInvalidationListener);
if (getTreeItem() != null) {
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
}
@@ -104,13 +104,20 @@ public class JFXTreeCell<T> extends TreeCell<T> {
selectedPane.setVisible(isSelected());
}
private void updateDisplay(T item, boolean empty) {
/// Updates the text and graphic for the current value and tree item.
///
/// @implSpec Called when the cell value or the current tree item's graphic changes.
/// Subclasses may override this method to decorate the default display.
///
/// @param item the cell value, or `null` to clear the display
/// @param empty whether the cell has no item
protected void updateDisplay(@Nullable T item, boolean empty) {
if (item == null || empty) {
hbox = null;
setText(null);
setGraphic(null);
} else {
TreeItem<T> treeItem = getTreeItem();
@Nullable TreeItem<T> treeItem = getTreeItem();
if (treeItem != null && treeItem.getGraphic() != null) {
if (item instanceof Node) {
setText(null);
@@ -169,11 +169,8 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
};
}
private void playSelectAnimation(Boolean selection) {
if (selection == null) {
selection = false;
}
/// Animates a selection change, or applies it immediately when animations are disabled.
private void playSelectAnimation(boolean selection) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.box.setBorder(new Border(new BorderStroke(
@@ -183,17 +180,7 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
new BorderWidths(this.lineThick))));
if (!AnimationUtils.isAnimationEnabled()) {
if (selection) {
this.mark.setVisible(true);
this.mark.setScaleX(1.0);
this.mark.setScaleY(1.0);
JFXNodeUtils.updateBackground(box.getBackground(), box, control.getCheckedColor());
} else {
this.mark.setVisible(false);
this.mark.setScaleX(0.0);
this.mark.setScaleY(0.0);
JFXNodeUtils.updateBackground(box.getBackground(), box, Color.TRANSPARENT);
}
finishSelectionAnimation();
return;
}
@@ -203,6 +190,22 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
this.select.play();
}
/// Stops selection animations and immediately displays the checkbox's current selected state.
///
/// The check mark, fill, and border are updated even if no animation is running.
/// This also suppresses the initial selection animation on the first layout.
/// The skin must not have been disposed.
public void finishSelectionAnimation() {
transition.stop();
select.stop();
invalid = false;
boolean selected = getSkinnable().isSelected();
mark.setVisible(selected);
mark.setScaleX(selected ? 1.0 : 0.0);
mark.setScaleY(selected ? 1.0 : 0.0);
updateColors();
}
private void createFillTransition() {
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
@@ -38,10 +38,8 @@ import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
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;
@@ -57,8 +55,10 @@ import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.CookieHandler;
@@ -68,9 +68,11 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
@@ -78,6 +80,7 @@ import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// The main JavaFX application entry point.
public final class Launcher extends Application {
public static final CookieManager COOKIE_MANAGER = new CookieManager();
@@ -137,12 +140,8 @@ public final class Launcher extends Application {
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
Themes.applyNativeDarkMode(primaryStage);
UpdateChecker.init();
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage);
primaryStage.show();
});
} catch (Throwable e) {
@@ -308,8 +307,8 @@ public final class Launcher extends Application {
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
try {
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
@@ -396,6 +395,8 @@ public final class Launcher extends Application {
}
}
/// Sets JavaFX VM options before the toolkit starts.
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
@@ -451,14 +452,25 @@ public final class Launcher extends Application {
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
float uiScaleLowerBound;
float uiScaleUpperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
uiScaleLowerBound = 0.25f;
uiScaleUpperBound = 4f;
} else {
uiScaleLowerBound = 0.01f;
uiScaleUpperBound = 10f;
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
float scaleValue = Float.NaN;
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
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")) {
@@ -466,33 +478,60 @@ public final class Launcher extends Application {
} 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);
}
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
if ("wayland".equals(xdgSessionType)
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
try {
@Nullable Path xrdb = SystemUtils.which("xrdb");
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
@Nullable String dpiLine;
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
BufferedReader bufferedReader = new BufferedReader(reader);
Stream<String> lines = bufferedReader.lines()) {
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
.findFirst().orElse(null);
}
return dpiLine != null
? dpiLine.substring("Xft.dpi:".length()).trim()
: null;
}, java.time.Duration.ofSeconds(1)) : null;
if (xftDpi != null) {
float dpiValue = Float.parseFloat(xftDpi);
float scale = dpiValue / 96f;
if (scale > 1 && scale <= 10) {
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
scaleValue = scale;
uiScale = Float.toString(scale);
}
}
} catch (Exception e) {
LOG.warning("Failed to read Xft.dpi from xrdb", e);
}
}
}
if (Float.isFinite(scaleValue)) {
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
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);
}
}
}
@@ -23,6 +23,7 @@ import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
@@ -133,4 +134,32 @@ public final class Metadata {
return null;
}
}
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
public static Path getBundledModpackDirectory() {
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
}
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
///
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
/// signal to offer automatic install; the file is removed after a successful install.
///
/// @return the modpack path, or `null` when no package is present
public static @Nullable Path findBundledModpackFile() {
Path directory = getBundledModpackDirectory();
Path zipModpack = directory.resolve("modpack.zip");
if (Files.isRegularFile(zipModpack)) {
return zipModpack;
}
Path mrpackModpack = directory.resolve("modpack.mrpack");
if (Files.isRegularFile(mrpackModpack)) {
return mrpackModpack;
}
return null;
}
}
@@ -0,0 +1,296 @@
/*
* 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.download;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.game.AssetObject;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.*;
/// @author Glavo
@NotNullByDefault
public final class HMCLDownloadProvider extends DownloadProvider {
private volatile DownloadSource versionListSource = DownloadSource.DEFAULT;
private volatile DownloadSource fileSource = DownloadSource.DEFAULT;
private static DownloadCandidates getCandidates(
DownloadSource source,
WebURL defaultUrl, WebURL bmclapiUrl
) {
if (LocaleUtils.IS_CHINA_MAINLAND) {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
} else {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
}
}
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
if (urls.isEmpty()) {
throw new IllegalArgumentException("urls cannot be empty");
}
// true means prefer mirror, false means prefer official, null means do not use mirror
@Nullable Boolean mirrorFirst;
if (LocaleUtils.IS_CHINA_MAINLAND) {
mirrorFirst = switch (source) {
case DEFAULT, MIRROR -> true;
case OFFICIAL -> false;
};
} else {
mirrorFirst = switch (source) {
case DEFAULT, OFFICIAL -> null;
case MIRROR -> true;
};
}
if (mirrorFirst == null) {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 2);
for (String url : urls) {
DownloadCandidate candidate = DownloadCandidate.of(url);
if (candidate.url() == null) {
// Invalid URL, just add it to the list and let the download task handle it.
candidates.add(candidate);
continue;
}
@Nullable DownloadCandidate mirrorCandidate = null;
boolean fallback = false;
for (MirrorRule rule : rules) {
if (url.startsWith(rule.source)) {
mirrorCandidate = DownloadCandidate.of(rule.target + url.substring(rule.source.length()));
fallback = rule.fallback;
break;
}
}
if (mirrorCandidate == null) {
candidates.add(candidate);
} else if (mirrorFirst && !fallback) {
candidates.add(mirrorCandidate);
candidates.add(candidate);
} else {
candidates.add(candidate);
candidates.add(mirrorCandidate);
}
}
return DownloadCandidates.of(List.copyOf(candidates));
}
private final String bmclRoot;
private final List<MirrorRule> rules;
public HMCLDownloadProvider(String bmclRoot) {
this.bmclRoot = bmclRoot;
this.rules = List.of(
new MirrorRule("https://bmclapi2.bangbang93.com", bmclRoot),
new MirrorRule("https://launchermeta.mojang.com", bmclRoot),
new MirrorRule("https://piston-meta.mojang.com", bmclRoot),
new MirrorRule("https://piston-data.mojang.com", bmclRoot),
new MirrorRule("https://launcher.mojang.com", bmclRoot),
new MirrorRule("https://libraries.minecraft.net", bmclRoot + "/libraries"),
new MirrorRule("http://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new MirrorRule("https://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new MirrorRule("https://maven.minecraftforge.net", bmclRoot + "/maven"),
new MirrorRule("https://maven.neoforged.net/releases/", bmclRoot + "/maven/"),
new MirrorRule("http://dl.liteloader.com/versions", bmclRoot + "/maven"),
new MirrorRule("https://dl.liteloader.com/versions", bmclRoot + "/maven"),
new MirrorRule("https://meta.fabricmc.net", bmclRoot + "/fabric-meta"),
new MirrorRule("https://maven.fabricmc.net", bmclRoot + "/maven"),
new MirrorRule("https://authlib-injector.yushi.moe", bmclRoot + "/mirrors/authlib-injector"),
new MirrorRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new MirrorRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new MirrorRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
new MirrorRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
new MirrorRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
// https://github.com/mcmod-info-mirror/mcim-rust-api
new MirrorRule("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth", true),
new MirrorRule("https://cdn.modrinth.com", "https://mod.mcimirror.top", true),
new MirrorRule("https://api.curseforge.com", "https://mod.mcimirror.top/curseforge", true),
new MirrorRule("https://edge.forgecdn.net", "https://mod.mcimirror.top", true)
);
}
@Override
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
assert (gameVersion == null) == (type == GameComponentType.GAME);
switch (type) {
case FORGE -> {
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case NEO_FORGE -> {
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR || source == DownloadSource.DEFAULT) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case OPTIFINE -> {
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
}
}
return super.fetchVersionsAsync(type, gameVersion);
}
public void setFileSource(DownloadSource fileSource) {
this.fileSource = Objects.requireNonNull(fileSource);
}
public void setVersionListSource(DownloadSource versionListSource) {
this.versionListSource = Objects.requireNonNull(versionListSource);
}
@Override
public HMCLCacheRepository getCacheRepository() {
return HMCLCacheRepository.REPOSITORY;
}
@Override
public DownloadCandidates getGameVersionListCandidates() {
return getCandidates(
versionListSource,
GameRemoteVersion.VERSION_MANIFEST_URL,
WebURL.parse(bmclRoot + "/mc/game/version_manifest.json")
);
}
@Override
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
DownloadSource source = versionListSource;
return new FabricLikeVersionListCandidates(
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/game"))
);
}
@Override
public DownloadCandidates getCleanroomVersionListCandidates() {
return LocaleUtils.IS_CHINA_MAINLAND
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
}
@Override
public DownloadCandidates getDownloadCandidates(List<String> urls) {
return getCandidates(fileSource, rules, urls);
}
@Override
public DownloadCandidates getVersionListCandidates(List<String> urls) {
return getCandidates(versionListSource, rules, urls);
}
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + assetObject.getLocation()),
WebURL.parse(bmclRoot + "/assets/" + assetObject.getLocation())
);
}
@NotNullByDefault
private static final class FallbackTask<T> extends Task<T> {
private final Task<? extends T> task;
private final Task<? extends T> fallback;
private List<Task<?>> dependencies = List.of();
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
this.task = task;
this.fallback = fallback;
}
@Override
public Collection<? extends Task<?>> getDependents() {
return List.of(task);
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
setResult(task.getResult());
} else {
dependencies = List.of(fallback);
fallback.storeTo(this::setResult);
}
}
@Override
public List<Task<?>> getDependencies() {
return dependencies;
}
}
private record MirrorRule(String source, String target, boolean fallback) {
public MirrorRule(String source, String target) {
this(source, target, false);
}
}
}
@@ -0,0 +1,65 @@
/*
* 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.game;
import org.jackhuang.hmcl.download.DefaultCacheRepository;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jetbrains.annotations.NotNullByDefault;
/// Provides HMCL-specific game builders for an HMCL game repository.
@NotNullByDefault
public class HMCLDependencyManager extends DefaultDependencyManager {
/// Creates a dependency manager for a repository and download context.
///
/// @param repository the associated game repository
/// @param downloadProvider the remote download provider
/// @param cacheRepository the artifact cache
public HMCLDependencyManager(
HMCLGameRepository repository,
DownloadProvider downloadProvider,
DefaultCacheRepository cacheRepository) {
super(repository, downloadProvider, cacheRepository);
}
/// {@inheritDoc}
@Override
public HMCLGameRepository getGameRepository() {
return (HMCLGameRepository) super.getGameRepository();
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
validateGameInstance(instance);
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
return new HMCLGameBuilder(
this, updateTarget.getId(), updateTarget, draft, initialManifest);
}
}
@@ -0,0 +1,53 @@
/*
* 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.game;
import org.jackhuang.hmcl.download.DefaultGameBuilder;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Builds game instances and applies HMCL-specific post-installation settings.
@NotNullByDefault
public class HMCLGameBuilder extends DefaultGameBuilder {
/// Creates a builder around an HMCL target already reserved by its dependency manager.
///
/// @param dependencyManager the dependency manager for the target repository
/// @param instanceId the id of the reserved instance
/// @param updateTarget the exact update target retained by the draft, or `null` for install
/// @param draft the open draft containing `initialManifest`
/// @param initialManifest the empty target manifest retained by `draft`
HMCLGameBuilder(
HMCLDependencyManager dependencyManager,
GameInstanceID instanceId,
@Nullable HMCLGameInstance updateTarget,
DefaultGameRepositoryDraft draft,
GameInstanceManifest initialManifest) {
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
}
/// {@inheritDoc}
///
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
/// launches use the instance root. A read-only instance retains its existing setting.
@Override
protected void onInstanceCommitted(DefaultGameInstance instance) {
if (isolationEnabled) {
((HMCLGameInstance) instance).enableIsolation();
}
}
}
@@ -0,0 +1,856 @@
/*
* 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.game;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectPropertyBase;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
@NotNullByDefault
public class HMCLGameInstance extends DefaultGameInstance {
/// Whether the instance-local game settings file has already been inspected.
private boolean gameSettingsLoaded;
/// Whether the instance-local game settings file cannot be overwritten safely.
private boolean gameSettingsReadOnly;
/// Cached instance-local game settings, or `null` when none exist after loading.
private GameSettings.@Nullable Instance gameSettings;
/// Creates a registered instance bound to the given repository snapshot.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
this(snapshot, id, manifest, (Path) null);
}
/// Creates a registered instance with an optional non-conventional manifest path.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
protected HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
@Nullable Path manifestFile) {
super(snapshot, id, manifest, manifestFile);
}
/// Creates an instance that shares mutable instance-local state with another instance.
///
/// Used when the repository clones a snapshot so that settings and the icon property remain
/// available on the new wrapper.
private HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
HMCLGameInstance shareState) {
super(snapshot, id, manifest, shareState);
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
this.gameSettings = shareState.gameSettings;
}
@Override
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
/// Returns the HMCL modpack configuration file for this instance.
///
/// @return the `modpack.cfg` path in the instance root
@Override
public Path getModpackConfigurationFile() {
return getLayout().getModpackConfigurationFile(getId());
}
/// Returns whether this instance has an HMCL modpack configuration file.
///
/// @return whether [#getModpackConfigurationFile()] exists
public boolean isModpack() {
return Files.exists(getModpackConfigurationFile());
}
/// Reads this instance's HMCL modpack configuration.
///
/// @return the parsed configuration, or `null` when the file does not exist
/// @throws IOException if the configuration cannot be read
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
Path file = getModpackConfigurationFile();
if (Files.notExists(file)) {
return null;
}
try {
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration: " + file, e);
}
}
@Override
public Path getRunDirectory() {
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
}
/// Returns the loaded instance-local game settings, loading them while this instance is current.
///
/// @return the settings, or `null` when no local settings exist after loading
public @Nullable GameSettings.Instance getSettings() {
ensureGameSettingsLoaded();
return gameSettings;
}
/// Returns the instance-local game settings, creating an empty settings object when absent.
///
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
public @Nullable GameSettings.Instance getSettingsOrCreate() {
GameSettings.Instance setting = getSettings();
if (setting == null) {
setting = createSettings();
}
return setting;
}
/// Resolves this instance's effective settings against its selected parent preset.
///
/// @return the effective settings
public GameSettings.Effective getEffectiveSettings() {
@Nullable GameSettings.Instance setting = getSettings();
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
}
/// Creates empty instance-local game settings when none are loaded.
///
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
public @Nullable GameSettings.Instance createSettings() {
ensureGameSettingsLoaded();
if (gameSettingsReadOnly) {
return null;
}
if (gameSettings != null) {
return gameSettings;
}
return initSettings(new GameSettings.Instance(), true);
}
/// Returns whether the instance-local game settings file cannot be overwritten safely.
///
/// @return whether the settings are loaded in read-only mode
public boolean isSettingsReadOnly() {
ensureGameSettingsLoaded();
return gameSettingsReadOnly;
}
/// Enables instance-local running-directory selection for this instance.
///
/// A blank local running directory resolves to the instance root. This operation is idempotent
/// and schedules a settings save only when it adds the override. It leaves the instance
/// unchanged when its local settings cannot be written safely.
public void enableIsolation() {
if (isSettingsReadOnly()) {
return;
}
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
if (setting != null
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveSettings();
}
}
/// Backs up and overwrites the settings file when this instance still owns its settings.
public void forceOverwriteSettings() {
ensureGameSettingsLoaded();
if (!ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
if (setting == null) {
setting = new GameSettings.Instance();
gameSettings = setting;
gameSettingsLoaded = true;
}
boolean installAutoSave = !setting.isSavable();
Path file = getGameSettingsFile().toAbsolutePath().normalize();
SettingFileUtils.backupInvalidConfig(file);
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
setting.setSavable(true);
setting.setBackupOnNextSave(false);
gameSettingsReadOnly = false;
saveSettings();
if (installAutoSave) {
setting.addListener(a -> saveSettings());
}
}
/// Saves the settings asynchronously when writable and still owned by the current instance.
public void saveSettings() {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
LOG.warning("Failed to create directory: " + file.getParent(), e);
}
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Saves the settings synchronously when writable and still owned by the current instance.
///
/// @throws IOException if saving the file fails
public void saveSettingsSync() throws IOException {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Initializes this instance with the given settings object.
///
/// @param setting the settings to install
/// @param allowSave whether the settings may be written back to disk
/// @return the installed settings
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
normalizeRunningDirectoryOverride(setting);
setting.setSavable(allowSave);
gameSettingsLoaded = true;
gameSettings = setting;
setting.iconProperty().addListener(observable -> invalidateIconImage());
if (allowSave) {
gameSettingsReadOnly = false;
setting.addListener(a -> saveSettings());
} else {
gameSettingsReadOnly = true;
}
return setting;
}
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
/// the effective parent preset when no local settings exist.
///
/// @return a detached copy suitable for installing into another instance
public GameSettings.Instance copySettings() {
@Nullable GameSettings.Instance setting = getSettings();
if (setting != null) {
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
}
GameSettings.Instance copied = new GameSettings.Instance();
copied.parentProperty().setValue(
getEffectiveSettings().getPreset().idProperty().getValue());
return copied;
}
/// Returns the first custom icon file found in this instance's root directory.
///
/// @return the icon file, or empty when no supported icon file exists
public @Nullable Path getIconFile() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
if (Files.exists(file)) {
return file;
}
}
return null;
}
/// Replaces this instance's custom icon file.
///
/// Existing supported icon files are removed before `iconFile` is copied.
///
/// @param iconFile the source icon file
/// @throws IOException if the icon cannot be copied
/// @throws IllegalArgumentException if the file extension is unsupported
public void setIconFile(Path iconFile) throws IOException {
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("Unsupported icon file: " + extension);
}
clearIconFiles();
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
invalidateIconImage();
}
/// Deletes all supported custom icon files for this instance.
///
/// Individual deletion failures are logged and do not stop later files from being attempted.
public void deleteIconFile() {
clearIconFiles();
invalidateIconImage();
}
private void clearIconFiles() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
try {
Files.deleteIfExists(file);
} catch (IOException e) {
LOG.warning("Failed to delete instance icon file: " + file, e);
}
}
}
/// Soft-cached icon image for this instance id.
///
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
private @Nullable WeakCachedIconImageProperty iconImage;
/// Returns the observable icon image for this instance.
///
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
/// pressure. The next [#getIconImage] reloads it.
///
/// @return the icon image property
public ReadOnlyObjectProperty<Image> iconImageProperty() {
if (iconImage == null) {
iconImage = new WeakCachedIconImageProperty();
}
return iconImage;
}
/// Returns the icon image selected for this instance.
///
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
///
/// @return the selected or derived icon image
public Image getIconImage() {
return iconImageProperty().get();
}
/// Drops the soft-cached icon image and notifies observers.
public void invalidateIconImage() {
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
}
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
private @Nullable WeakReference<Image> cache;
@Override
public Object getBean() {
return HMCLGameInstance.this;
}
@Override
public String getName() {
return "iconImage";
}
@Override
public Image get() {
WeakReference<Image> current = cache;
Image image = current != null ? current.get() : null;
if (image != null) {
return image;
}
image = computeIconImage();
cache = new WeakReference<>(image);
return image;
}
/// Computes the icon image from settings, custom files, and the launch manifest.
///
/// @return the selected or derived icon image
private Image computeIconImage() {
@Nullable GameSettings.Instance setting = getSettings();
GameInstanceIconType iconType = setting != null
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
: GameInstanceIconType.DEFAULT;
if (iconType != GameInstanceIconType.DEFAULT) {
return iconType.getIcon();
}
@Nullable Path iconFile = getIconFile();
if (iconFile != null) {
try {
return FXUtils.loadImage(iconFile, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load instance icon for " + getId(), e);
}
}
for (ModLoaderType modLoader : getModLoaders()) {
return GameInstanceIconType.getIconType(modLoader).getIcon();
}
if (hasComponent(GameComponentType.OPTIFINE))
return GameInstanceIconType.OPTIFINE.getIcon();
GameVersionNumber version = getVersion();
if (version.isAprilFools())
return GameInstanceIconType.APRIL_FOOLS.getIcon();
else if (version instanceof GameVersionNumber.LegacySnapshot || version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
return GameInstanceIconType.COMMAND.getIcon();
else if (version instanceof GameVersionNumber.Old)
return GameInstanceIconType.CRAFT_TABLE.getIcon();
else
return GameInstanceIconType.GRASS.getIcon();
}
/// Clears the weak cache and notifies listeners.
void invalidate() {
cache = null;
fireValueChangedEvent();
}
}
/// Creates the marker indicating that the most recent launch ended abnormally.
public void markLaunchedAbnormally() {
try {
Files.createFile(getInstanceRoot().resolve(".abnormal"));
} catch (IOException ignored) {
}
}
/// Deletes the abnormal-launch marker when present.
///
/// @return whether a regular marker file was present
public boolean unmarkLaunchedAbnormally() {
Path file = getInstanceRoot().resolve(".abnormal");
if (!Files.isRegularFile(file)) {
return false;
}
try {
Files.delete(file);
} catch (IOException e) {
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
}
return true;
}
/// Loads settings once while this object is the repository's current instance.
private void ensureGameSettingsLoaded() {
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
loadGameSettings();
}
}
/// Returns whether this instance still owns the settings object used by the current snapshot.
private boolean ownsGameSettings() {
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
boolean owns = current == this
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
if (!owns && gameSettings != null) {
gameSettings.setSavable(false);
}
return owns;
}
private void loadGameSettings() {
gameSettingsLoaded = true;
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
if (result.setting() != null) {
initSettings(result.setting(), result.allowSave());
return;
}
if (!result.allowSave()) {
gameSettingsReadOnly = true;
return;
}
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
if (SettingsManager.getGameSettings(legacyParent) == null) {
legacyParent = null;
}
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
getRepository(), id, legacyParent);
if (migrationResult != null) {
initSettings(migrationResult.setting(), true);
try {
saveSettingsSync();
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + id, e);
}
}
}
private Path getGameSettingsFile() {
return getLayout().getInstanceGameSettingsFile(id);
}
public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveSettings();
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = getVersion();
@Nullable Integer maxMemory;
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
SystemInfo.getPhysicalMemoryStatus().available(),
javaVersion.getPlatform()) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(getId())
.setGameDir(gameDir)
.setJava(javaVersion)
.setVersionType(Metadata.TITLE)
.setVersionName(getId().id())
.setProfileName(Metadata.TITLE)
.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.getInheritable(GameSettings::environmentVariablesProperty))
.stream()
.map(it -> {
int idx = it.indexOf('=');
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
})
.collect(Collectors.toList())
)
)
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
.setProxyOption(getProxyOption())
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
.setNoGeneratedJVMArgs(noJVMOptions)
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
.setRenderer(vs.getRenderer(gameVersionNumber))
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
if (quickPlayOption != null) {
builder.setQuickPlayOption(quickPlayOption);
}
Path json = getModpackConfigurationFile();
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to parse modpack configuration file " + json, e);
}
}
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder;
}
private static ProxyOption getProxyOption() {
return switch (settings().proxyTypeProperty().get()) {
case SYSTEM -> ProxyOption.Default.INSTANCE;
case DIRECT -> ProxyOption.Direct.INSTANCE;
case HTTP, SOCKS -> {
String proxyHost = settings().proxyHostProperty().get();
int proxyPort = settings().proxyPortProperty().get();
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
yield ProxyOption.Default.INSTANCE;
}
String proxyUser = settings().proxyUserProperty().get();
String proxyPass = settings().proxyPasswordProperty().get();
if (StringUtils.isBlank(proxyUser)) {
proxyUser = null;
proxyPass = null;
} else if (proxyPass == null) {
proxyPass = "";
}
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
} else {
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
}
}
};
}
/// Loads a new-format instance game settings file.
private static LoadResult loadGameSettingsFile(Path file) {
if (!Files.exists(file)) {
return new LoadResult(null, true);
}
try {
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
if (jsonObject == null) {
LOG.warning("Instance game settings are empty: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
return new LoadResult(fallback, true);
}
JsonSchema.CompatibilityResult schemaResult =
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
switch (schemaResult.status()) {
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
if (!schemaResult.readable()) {
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setSavable(false);
return new LoadResult(fallback, false);
}
GameSettings.@Nullable Instance setting =
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
if (setting == null) {
LOG.warning("Instance game settings deserialized to null: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
}
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
}
return new LoadResult(setting, schemaResult.allowSave());
} catch (JsonParseException ex) {
LOG.warning("Failed to parse game setting " + file, ex);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
} catch (Exception ex) {
LOG.warning("Failed to load game setting " + file, ex);
return new LoadResult(null, false);
}
}
/// Keeps old local custom running directories effective under the new source-selection model.
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
}
/// Result of loading an instance-specific game settings file.
///
/// @param setting the loaded instance settings, or `null` when unavailable
/// @param allowSave whether the file may be overwritten
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
}
/// Optional reference to an HMCL game instance bound to a repository.
///
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
/// from the current snapshot while preserving repository context.
@NotNullByDefault
public static final class Optional {
private final HMCLGameRepository repository;
private final @Nullable HMCLGameInstance instance;
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
public Optional(HMCLGameRepository repository) {
this.repository = Objects.requireNonNull(repository);
this.instance = null;
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
public Optional(HMCLGameInstance instance) {
this.repository = instance.getRepository();
this.instance = instance;
}
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
///
/// @param repository the repository
/// @param instanceId the instance id, or `null` for an empty optional
/// @return an optional that is empty when `instanceId` is null or not registered
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
if (instanceId == null) {
return new Optional(repository);
}
HMCLGameInstance instance = repository.findInstance(instanceId);
return instance != null ? new Optional(instance) : new Optional(repository);
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
/// @return the optional
public static Optional of(HMCLGameInstance instance) {
return new Optional(instance);
}
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
/// @return the empty optional
public static Optional empty(HMCLGameRepository repository) {
return new Optional(repository);
}
/// Returns the repository associated with this optional.
///
/// @return the repository
public HMCLGameRepository repository() {
return repository;
}
/// Returns the held instance, if any.
///
/// @return the instance, or `null` when empty
@Contract(pure = true)
public @Nullable HMCLGameInstance instance() {
return instance;
}
/// Returns the held instance id, if any.
///
/// @return the instance id, or `null` when empty
@Contract(pure = true)
public @Nullable GameInstanceID instanceId() {
return instance != null ? instance.getId() : null;
}
/// Returns whether an instance is present.
///
/// @return whether [#instance()] is non-null
public boolean isPresent() {
return instance != null;
}
/// Returns whether no instance is present.
///
/// @return whether [#instance()] is null
public boolean isEmpty() {
return instance == null;
}
/// Re-resolves the held instance id from the repository's current snapshot.
///
/// @return this optional when empty; otherwise a fresh optional for the same id
public Optional refreshed() {
if (instance == null) {
return this;
}
return of(repository, instance.getId());
}
}
}
@@ -42,16 +42,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class HMCLGameLauncher extends DefaultLauncher {
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) {
this(repository, manifest, authInfo, options, null);
/// Creates a launcher with daemon process monitors.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(instance, manifest, 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, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(repository, manifest, authInfo, options, listener, daemon);
/// Creates a launcher for the given instance and launch plan.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
/// @param daemon whether monitors should be daemon threads
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(instance, manifest, authInfo, options, listener, daemon);
}
@Override
@@ -66,7 +77,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isDisableAutoGameOptions())
return;
Path runDir = repository.getRunDirectory(manifest.id());
Path runDir = instance.getRunDirectory();
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
@@ -91,8 +102,8 @@ 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(manifest));
if (gameVersion.compareTo("1.1") < 0)
GameVersionNumber gameVersion = instance.getVersion();
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
return;
String lang = normalizedLanguageTag(locale, gameVersion);
@@ -180,7 +191,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.artifact().getFileName();
Path agentPath = repository.getLibraryFile(manifest, library).toAbsolutePath().normalize();
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
/*
* 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.game;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
@NotNullByDefault
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
/// 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.
private 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.
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
public HMCLGameRepositoryLayout(Path baseDirectory) {
super(baseDirectory);
}
/// Returns the HMCL-managed metadata directory under the instance root.
///
/// This directory stores instance-scoped files owned by HMCL.
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(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
}
/// Returns the HMCL-managed state directory under the instance metadata 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.
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
}
}
@@ -0,0 +1,55 @@
/*
* 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.game;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Collection;
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
@NotNullByDefault
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
/// Creates an empty unsealed HMCL snapshot.
///
/// @param repository the owning repository
/// @param layout the HMCL layout for this snapshot
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
super(repository, layout);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
@Override
protected HMCLGameRepositorySnapshot newEmpty() {
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
}
@SuppressWarnings("unchecked")
@Override
public Collection<HMCLGameInstance> getInstances() {
return (Collection<HMCLGameInstance>) super.getInstances();
}
}
@@ -19,7 +19,7 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.GameBuilder;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
@@ -27,53 +27,110 @@ import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
/// Installs or updates an HMCL modpack by rebuilding its recognized game components.
@NotNullByDefault
public final class HMCLModpackInstallTask extends Task<Void> {
private final Path zipFile;
private final GameInstanceID instanceId;
/// Existing instance selecting update mode, or `null` for a new installation.
private final @Nullable DefaultGameInstance updateTarget;
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, GameInstanceID instanceId) {
/// Creates a task that installs a new HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the id of the new instance
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId) {
this(repository, zipFile, modpack, instanceId, null);
}
/// Creates a task that updates an existing HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instance the existing instance to update
/// @throws IllegalArgumentException if `instance` belongs to another repository, has no
/// modpack configuration, or records another provider type
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
DefaultGameInstance instance) {
this(repository, zipFile, modpack, instance.getId(), instance);
}
/// Creates an HMCL modpack task in the mode selected by `updateTarget`.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the target instance id
/// @param updateTarget the existing instance selecting update mode, or `null` for install
/// @throws IllegalArgumentException if an update target belongs to another repository or has
/// no compatible configuration
private HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget) {
this.repository = repository;
this.dependency = repository.getDependency();
this.zipFile = zipFile;
this.instanceId = instanceId;
this.updateTarget = updateTarget;
this.modpack = modpack;
if (this.updateTarget != null) {
dependency.validateGameInstance(this.updateTarget);
}
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");
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
if (this.updateTarget != null && Files.notExists(json))
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
dependents.add(dependency.newGameBuilder().name(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync());
onDone().register(event -> {
if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId);
});
ModpackConfiguration<Modpack> config = null;
@Nullable ModpackConfiguration<Modpack> config = null;
try {
if (Files.exists(json)) {
if (this.updateTarget != null && Files.exists(json)) {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
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(this.instanceId)).withStage("hmcl.modpack"));
onDone().register(event -> {
if (this.updateTarget == null && event.isFailed()) {
repository.removeInstanceFromDisk(this.instanceId);
}
});
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), List.of("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
}
@Override
@@ -86,20 +143,43 @@ public final class HMCLModpackInstallTask extends Task<Void> {
return dependents;
}
/// Reads the legacy pack manifest and schedules one builder for all recognized components.
///
/// The pack manifest is used only for component detection. The builder creates a new
/// patch-structured manifest instead of attempting to mutate the legacy flattened manifest.
@Override
public void execute() throws Exception {
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
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) {
if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId()))
continue;
libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion()));
}
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
.withId(instanceId)
.withJar(null);
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
try (GameBuilder builder = updateTarget == null
? dependency.newGameBuilder(instanceId)
: dependency.newGameBuilder(updateTarget)) {
builder.enableIsolation();
String gameVersion = modpack.getGameVersion();
builder.component(GameComponentType.GAME, gameVersion);
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) {
continue;
}
@Nullable String componentVersion = mark.version();
if (componentVersion != null) {
if (mark.componentType() == GameComponentType.OPTIFINE) {
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
if (matcher.matches()) {
componentVersion = matcher.group("optifine");
}
}
builder.component(mark.componentType(), componentVersion);
}
}
dependencies.add(builder.buildAsync());
}
}
}
@@ -28,10 +28,12 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.Set;
public final class HMCLModpackProvider implements ModpackProvider {
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
@@ -42,12 +44,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) {
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
return null;
}
@Override
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(
DefaultDependencyManager dependencyManager,
DefaultGameInstance instance,
Path zipFile,
Modpack modpack,
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -55,7 +62,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new HMCLModpackInstallTask(repository, zipFile, modpack, instanceId));
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
}
@Override
@@ -76,7 +83,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) {
public Task<?> getInstallTask(
DefaultDependencyManager dependencyManager,
Path zipFile,
GameInstanceID instanceId,
String iconUrl,
@Nullable Set<String> excludedFiles) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
}
}
@@ -25,8 +25,6 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.MaintainTask;
import org.jackhuang.hmcl.download.game.*;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
@@ -34,15 +32,14 @@ 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.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;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
import org.jackhuang.hmcl.util.*;
@@ -59,7 +56,6 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Path;
import java.time.Instant;
@@ -84,9 +80,8 @@ public final class LauncherHelper {
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
private final HMCLGameRepository repository;
private final HMCLGameInstance gameInstance;
private Account account;
private final GameInstanceID selectedInstanceId;
private Path scriptFile;
private final GameSettings.Effective setting;
private LauncherVisibility launcherVisibility;
@@ -94,16 +89,23 @@ public final class LauncherHelper {
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) {
this.repository = Objects.requireNonNull(repository);
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
this.gameInstance = Objects.requireNonNull(gameInstance);
this.account = Objects.requireNonNull(account);
this.selectedInstanceId = selectedInstanceId;
this.setting = repository.getEffectiveGameSettings(selectedInstanceId);
this.setting = gameInstance.getEffectiveSettings();
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("instance.launch"));
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
private HMCLGameRepository repository() {
return gameInstance.getRepository();
}
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
public Account getAccount() {
@@ -134,7 +136,7 @@ public final class LauncherHelper {
public void launch() {
FXUtils.checkFxUserThread();
LOG.info("Launching game version: " + selectedInstanceId);
LOG.info("Launching game instance: " + gameInstance.getId());
Controllers.dialog(launchingStepsPane);
launch0();
@@ -145,48 +147,55 @@ public final class LauncherHelper {
launch();
}
/// Builds and executes the launch pipeline for the captured game instance.
private void launch0() {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
HMCLGameRepository repository = repository();
DefaultDependencyManager dependencyManager = repository.getDependency();
AtomicReference<GameInstanceManifest> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedInstanceManifest(selectedInstanceId).launchManifest()));
Optional<String> gameVersion = repository.getGameVersion(version.get());
boolean integrityCheck = repository.unmarkInstanceLaunchedAbnormally(selectedInstanceId);
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest()));
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
CountDownLatch launchingLatch = new CountDownLatch(1);
List<String> javaAgents = new ArrayList<>(0);
List<String> javaArguments = new ArrayList<>(0);
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
TaskExecutor executor = checkGameState(repository, setting, version.get())
TaskExecutor executor = checkGameState(gameInstance, setting, launchManifest.get())
.thenComposeAsync(java -> {
javaVersionRef.set(Objects.requireNonNull(java));
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments));
if (setting.getInheritable(GameSettings::notCheckGameProperty))
return null;
return Task.allOf(
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.get(), integrityCheck),
Task.composeAsync(() -> {
try {
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedInstanceId));
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
@Nullable ModpackConfiguration<?> configuration =
gameInstance.readModpackConfiguration();
if (configuration == null) return null;
@Nullable ModpackProvider provider =
ModpackHelper.getProviderByType(configuration.getType());
if (provider == null) return null;
else return provider.createCompletionTask(dependencyManager, selectedInstanceId);
else return provider.createCompletionTask(
dependencyManager,
gameInstance);
} catch (IOException e) {
return null;
}
}),
Task.composeAsync(() -> {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer)
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|| renderer.mesaDriverName() == null)
return null;
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
@@ -194,7 +203,7 @@ public final class LauncherHelper {
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
.thenRunAsync(() -> javaAgents.add(agent));
} else {
@@ -204,18 +213,13 @@ public final class LauncherHelper {
})
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> {
if (gameVersion.isEmpty()) {
return null;
}
return new GameVerificationFixTask(dependencyManager, gameVersion.get(), version.get());
})
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get()))
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
|| !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) {
return Task.completed(null);
} else {
CompletableFuture<Void> future = new CompletableFuture<>();
@@ -234,8 +238,8 @@ public final class LauncherHelper {
})
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedInstanceId, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
@@ -275,16 +279,16 @@ public final class LauncherHelper {
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedInstanceId), 10));
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10));
return new HMCLGameLauncher(
repository,
version.get(),
gameInstance,
launchManifest.get(),
authInfo,
launchOptions,
launcherVisibility == LauncherVisibility.CLOSE
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
: new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent())
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
);
}).thenComposeAsync(launcher -> { // launcher is prev task's result
if (scriptFile == null) {
@@ -348,30 +352,28 @@ public final class LauncherHelper {
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
} else if (ex instanceof LibraryDownloadException) {
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
if (ex.getCause() instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException) {
URI uri = ((DownloadException) ex).getUri();
} else if (ex instanceof DownloadException de) {
String url = de.getUrl();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", uri);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
message = i18n("install.failed.downloading.timeout", url);
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -379,14 +381,13 @@ public final class LauncherHelper {
message = i18n("account.failed.injector_download_failure");
} else if (ex instanceof CharacterDeletedException) {
message = i18n("account.failed.character_deleted");
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
} else if (ex instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message = i18n("download.code.404", uri);
message = i18n("download.code.404", url);
else
message = i18n("download.failed", uri, responseCode);
message = i18n("download.failed", url, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
@@ -423,9 +424,9 @@ public final class LauncherHelper {
executor.start();
}
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));
private static Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
GameVersionNumber gameVersion = gameInstance.getVersion();
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
try {
@@ -456,9 +457,9 @@ public final class LauncherHelper {
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
GameJavaVersion minimumJavaVersion = null;
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
}
}
@@ -480,9 +481,9 @@ public final class LauncherHelper {
}
} else {
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
}
}
@@ -491,7 +492,7 @@ public final class LauncherHelper {
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
downloadJava(targetJavaVersion, repository)
downloadJava(targetJavaVersion, gameInstance.getRepository())
.whenCompleteAsync((downloadedJava, exception) -> {
if (exception == null) {
future.complete(downloadedJava);
@@ -554,10 +555,9 @@ public final class LauncherHelper {
} else {
GameJavaVersion gameJavaVersion;
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
.orElse("");
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
gameJavaVersion = !cleanroomVersion.isEmpty()
gameJavaVersion = cleanroomVersion != null
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
@@ -568,7 +568,7 @@ public final class LauncherHelper {
gameJavaVersion = null;
if (gameJavaVersion != null) {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
if (throwable == null) {
setting.setJavaAutoSelected();
future.complete(downloadedJava);
@@ -638,7 +638,7 @@ public final class LauncherHelper {
break;
case MODDED_JAVA_16:
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
suggestions.add(i18n("launch.advice.forge37_0_60"));
else
@@ -651,8 +651,8 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
break;
case CLEANROOM: {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
if (!cleanroomVersion.isEmpty())
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null)
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
else
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
@@ -681,7 +681,7 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
}
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE))
.map(VersionNumber::asVersion)
.orElse(null);
@@ -742,7 +742,7 @@ public final class LauncherHelper {
Throwable resolvedException = resolveException(exception);
LOG.warning("Failed to download java", exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
future.completeExceptionally(new CancellationException());
}
@@ -829,16 +829,12 @@ public final class LauncherHelper {
}
}
/**
* The managed process listener.
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
* Because every time we launched a game, we generates a new [HMCLProcessListener]
*/
/// The managed process listener.
/// Guarantee that one Java [Process], one [HMCLProcessListener].
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
private final class HMCLProcessListener implements ProcessListener {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final GameInstanceManifest manifest;
private final LaunchOptions launchOptions;
private ManagedProcess process;
private volatile boolean lwjgl;
@@ -850,9 +846,7 @@ public final class LauncherHelper {
private Thread submitLogThread;
private LinkedBlockingQueue<Log> logBuffer;
public HMCLProcessListener(HMCLGameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.repository = repository;
this.manifest = manifest;
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
@@ -979,7 +973,7 @@ public final class LauncherHelper {
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
if (showLogs) {
if (level == null)
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
lock.lock();
@@ -1037,8 +1031,8 @@ public final class LauncherHelper {
}
if (exitType != ExitType.NORMAL) {
repository.markInstanceLaunchedAbnormally(manifest.id());
runLater(() -> new GameCrashWindow(process, exitType, repository, manifest, launchOptions, logs).show());
gameInstance.markLaunchedAbnormally();
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
}
checkExit();
@@ -39,8 +39,6 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public String getApiBaseUrl() {
return getBackedRemoteModRepository().getApiBaseUrl();
@@ -52,15 +50,15 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(getType()).searchMod(searchFilter)) {
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(type).searchMod(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
@@ -71,7 +69,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
@@ -80,8 +78,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
SearchResult searchResult = null;
List<RemoteAddon> remoteAddons = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
remoteAddons = searchResult.getUnsortedResults().toList();
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
remoteAddons = searchResult.results().toList();
if (!remoteAddons.isEmpty()) {
break;
}
@@ -93,19 +91,19 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
continue;
}
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteAddon.slug());
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteAddon;
} else {
searchResultArray[englishIndex--] = remoteAddon;
}
}
totalPages = searchResult.getTotalPages();
totalPages = searchResult.totalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteMod.slug());
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
@@ -124,8 +122,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public Stream<Category> getCategories() throws IOException {
return getBackedRemoteModRepository().getCategories();
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
return getBackedRemoteModRepository().getCategories(type);
}
@Override
@@ -138,6 +136,11 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
}
@Override
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
}
@Override
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
@@ -17,14 +17,18 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -41,22 +45,24 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript,
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
PathMatcher logMatcher) {
Path runDirectory = repository.getRunDirectory(instanceId);
Path baseDirectory = repository.getBaseDirectory();
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
Path runDirectory = options.getGameDir();
List<GameInstanceID> instances = new ArrayList<>();
GameInstanceID currentInstanceId = instanceId;
GameInstanceID currentInstanceId = instance.id;
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
while (true) {
if (resolvedSoFar.contains(currentInstanceId)) break;
resolvedSoFar.add(currentInstanceId);
GameInstanceManifest currentVersion = repository.getInstanceManifest(currentInstanceId);
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
if (currentInstance == null)
break;
instances.add(currentInstanceId);
if (currentVersion.inheritsFrom() != null) {
currentInstanceId = currentVersion.inheritsFrom();
if (currentInstance.getManifest().inheritsFrom() != null) {
currentInstanceId = currentInstance.getManifest().inheritsFrom();
} else {
break;
}
@@ -74,9 +80,11 @@ public final class LogExporter {
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
for (GameInstanceID id : instances) {
Path instanceJson = repository.getInstanceJson(id);
if (Files.exists(instanceJson)) {
zipper.putFile(instanceJson, id + ".json");
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
if (currentInstance != null) {
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
}
}
}
} catch (IOException e) {
@@ -18,11 +18,9 @@
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.Unzipper;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -44,17 +42,14 @@ public class ManuallyCreatedModpackInstallTask extends Task<Path> {
@Override
public void execute() throws Exception {
Path subdirectory;
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
}
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
Path dest = Paths.get("externalgames").resolve(name);
setResult(dest);
new Unzipper(zipFile, dest)
.setSubDirectory(subdirectory.toString())
.setSubDirectory(subdirectory)
.setTerminateIfSubDirectoryNotExists()
.setEncoding(charset)
.unzip();
@@ -31,11 +31,7 @@ import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameWindowType;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
@@ -46,13 +42,13 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -60,10 +56,9 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import java.util.Set;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.toIterable;
import static org.jackhuang.hmcl.util.Pair.pair;
/// Utilities for reading, installing, and applying modpack-specific game settings.
@@ -114,8 +109,8 @@ public final class ModpackHelper {
} catch (IOException ignored) {
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
try {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
throw new ManuallyCreatedModpackException(file);
} catch (IOException e) {
// ignore it
@@ -124,28 +119,35 @@ public final class ModpackHelper {
throw new UnsupportedModpackException(file.toString());
}
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
Path root = fs.getPath("/");
if (isMinecraftDirectory(root)) return root;
try (Stream<Path> firstLayer = Files.list(root)) {
for (Path dir : toIterable(firstLayer)) {
if (isMinecraftDirectory(dir)) return dir;
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
throws IOException, UnsupportedModpackException {
try (Stream<Path> secondLayer = Files.list(dir)) {
for (Path subdir : toIterable(secondLayer)) {
if (isMinecraftDirectory(subdir)) return subdir;
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
if (isMinecraftDirectory(rootDir)) {
return "";
}
for (ArchiveFileTree.Dir<?> firstLayer : rootDir.getSubDirs().values()) {
if (isMinecraftDirectory(firstLayer)) {
return firstLayer.getName();
}
for (ArchiveFileTree.Dir<?> secondLayer : firstLayer.getSubDirs().values()) {
if (isMinecraftDirectory(secondLayer)) {
return firstLayer.getName() + "/" + secondLayer.getName();
}
} catch (IOException ignored) {
}
}
} catch (IOException ignored) {
}
throw new UnsupportedModpackException(modpackName);
}
private static boolean isMinecraftDirectory(Path path) {
return Files.isDirectory(path.resolve("versions")) &&
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
}
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
@@ -157,15 +159,9 @@ public final class ModpackHelper {
}
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
repository.markInstanceAsModpack(instanceId);
ExceptionalRunnable<?> success = () -> {
repository.refresh();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.undoMark(instanceId);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
repository.getInstance(instanceId).enableIsolation();
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -200,16 +196,30 @@ public final class ModpackHelper {
});
}
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, String iconUrl) {
repository.markInstanceAsModpack(instanceId);
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
}
/// Creates an install task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param instanceId the new instance id
/// @param modpack the parsed modpack
/// @param iconUrl the optional icon URL, or `null`
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the install task
public static Task<?> getInstallTask(
HMCLGameRepository repository,
Path zipFile,
GameInstanceID instanceId,
Modpack modpack,
@Nullable String iconUrl,
@Nullable Set<String> excludedFiles) {
ExceptionalRunnable<?> success = () -> {
repository.refresh();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.undoMark(instanceId);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
repository.getInstance(instanceId).enableIsolation();
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -220,44 +230,69 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.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, instanceId, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.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, instanceId, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.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, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
switch (configuration.getType()) {
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(repository, instanceId, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId))
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
HMCLGameInstance instance = repository.getInstance(instanceId);
return new ModpackUpdateTask(
instance,
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
.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, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
return getUpdateTask(repository, zipFile, charset, instanceId, configuration, null);
}
/// Creates an update task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param charset the archive encoding
/// @param instanceId the instance to update
/// @param configuration the existing modpack configuration
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the update task
public static Task<?> getUpdateTask(
HMCLGameRepository repository,
Path zipFile,
Charset charset,
GameInstanceID instanceId,
ModpackConfiguration<?> configuration,
@Nullable Set<String> excludedFiles)
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(), instanceId, zipFile, modpack)
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.thenComposeAsync(repository.refreshAsync());
else
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(repository.refreshAsync());
}
@@ -53,7 +53,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private String idToken;
private OAuthServer(int port) {
super(port);
super("localhost", port);
this.port = port;
@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.Skin;
import org.jackhuang.hmcl.auth.yggdrasil.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -99,7 +100,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(texture.url(), file).run();
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
LOG.info("Texture downloaded: " + texture.url());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -26,18 +26,13 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.Metadata;
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.GameInstanceManifest;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -321,7 +316,7 @@ public final class JavaManager {
@Nullable
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
LibraryAnalyzer analyzer = manifest != null ? LibraryAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null;
GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null;
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -438,14 +433,14 @@ public final class JavaManager {
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
}
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
@@ -499,7 +494,7 @@ public final class JavaManager {
it.isJDK() ? "JDK" : "JRE",
it.getVersion(),
it.getPlatform().getArchitecture().getDisplayName(),
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
it.getBinary()))
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
return searcher.javaRuntimes;
@@ -842,7 +837,7 @@ public final class JavaManager {
}
void searchJavaInProgramFiles(String env, String defaultValue) {
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
Path path;
try {
path = Path.of(programFiles);
@@ -47,13 +47,8 @@ import java.util.*;
import static java.util.stream.Collectors.toList;
import static javafx.collections.FXCollections.observableArrayList;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.SettingsManager.getAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.setting.SettingsManager.getUserAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -73,7 +68,7 @@ public final class Accounts {
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
// ==== login type / account factory mapping ====
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
@@ -18,44 +18,47 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.List;
import java.util.concurrent.CancellationException;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DownloadProviders {
private DownloadProviders() {
}
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
private static final DownloadProvider MOJANG_PROVIDER;
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
private static final DownloadProvider DEFAULT_PROVIDER;
private static final String DEFAULT_BMCLAPI_ROOT = "https://bmclapi2.bangbang93.com";
private static final String BMCLAPI_ROOT;
static {
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
MOJANG_PROVIDER = new MojangDownloadProvider();
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
@Nullable String bmclOverride = System.getProperty("hmcl.bmclapi.override");
if (StringUtils.isBlank(bmclOverride)) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
WebURL url;
try {
url = WebURL.parseBrowserInput(bmclOverride);
} catch (Exception e) {
url = null;
LOG.warning("Invalid BMCLAPI override URL: " + bmclOverride, e);
}
if (url == null) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
BMCLAPI_ROOT = StringUtils.removeSuffix(url.toString(), "/");
}
}
}
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(BMCLAPI_ROOT);
/// Initializes download provider settings and synchronizes download thread settings.
public static void init() {
InvalidationListener onChangeDownloadThreads = observable -> {
@@ -67,73 +70,15 @@ public final class DownloadProviders {
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
onChangeDownloadThreads.invalidated(null);
InvalidationListener onChangeDownloadSource = observable -> {
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
settings().versionListSourceProperty().get(),
settings().fileDownloadSourceProperty().get()));
};
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
}
/// Creates a download provider with independent version-list and file download preferences.
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
return new AutoDownloadProvider(
getCandidates(versionListSource),
getCandidates(fileDownloadSource));
}
/// Returns provider candidates ordered by the given source preference.
private static List<DownloadProvider> getCandidates(DownloadSource source) {
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
return switch (normalized) {
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
case OFFICIAL -> List.of(MOJANG_PROVIDER);
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
};
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return PROVIDER_WRAPPER;
return PROVIDER;
}
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", uri);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", uri);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
} else if (exception instanceof CancellationException) {
return i18n("message.cancelled");
}
return StringUtils.getStackTrace(exception);
}
}
@@ -17,15 +17,17 @@
*/
package org.jackhuang.hmcl.setting;
import javafx.beans.property.*;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
@@ -34,17 +36,10 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnknownNullability;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.*;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Manages the merged runtime view of local and user game directories.
@@ -141,13 +136,18 @@ public final class GameDirectoryManager {
/// The selected game repository, or `null` before the fallback game directory is resolved.
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
/// The selected instance ID projected from the selected repository.
private static final ReadOnlyObjectWrapper<GameInstanceID> selectedInstance = new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
/// The selected instance projected from the selected repository's current snapshot.
private static final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance =
new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
private static final ChangeListener<GameInstanceID> selectedRepositoryInstanceListener =
private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener =
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
/// Reacts when the selected repository publishes a new snapshot.
private static final ChangeListener<? super HMCLGameRepositorySnapshot> selectedRepositorySnapshotListener =
(observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged();
/// Initializes game directory state from the stores loaded by [SettingsManager].
///
/// This method creates the built-in local and user-home game directories when required, rebuilds
@@ -202,25 +202,32 @@ public final class GameDirectoryManager {
@Nullable HMCLGameRepository oldRepository = selectedRepository.get();
if (oldRepository != null) {
oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener);
oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener);
}
HMCLGameRepository repository = getOrCreateRepository(newValue);
selectedRepository.set(repository);
selectedInstance.set(repository.getSelectedInstance());
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
repository.snapshotProperty().addListener(selectedRepositorySnapshotListener);
if (repository.isLoaded()) {
onSelectedRepositorySnapshotChanged();
}
repository.refreshAsync().start();
});
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
}
EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> {
runInFX(() -> {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository != null && repository == event.getSource()) {
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners)
listener.accept(repository);
}
});
});
/// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot.
private static void onSelectedRepositorySnapshotChanged() {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository == null || !repository.isLoaded()) {
return;
}
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners) {
listener.accept(repository);
}
}
/// Creates the built-in game directories only when no game directory exists.
@@ -323,6 +330,7 @@ public final class GameDirectoryManager {
if (source == target) {
gameDirectory.setName(name);
gameDirectory.setPath(path);
rebuildGameDirectories();
return;
}
@@ -480,17 +488,26 @@ public final class GameDirectoryManager {
}
/// Returns the selected instance property projected from the selected repository.
public static ReadOnlyObjectProperty<@Nullable GameInstanceID> selectedInstanceProperty() {
///
/// The value is `null` when the selected repository has no registered selected instance.
///
/// @return the read-only selected-instance property
public static ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
return selectedInstance.getReadOnlyProperty();
}
/// Returns the selected instance ID for the selected repository.
public static @Nullable GameInstanceID getSelectedInstance() {
/// Returns the selected instance from the selected repository's current snapshot.
///
/// @return the selected instance, or `null` when none is registered
public static @Nullable HMCLGameInstance getSelectedInstance() {
return getSelectedRepository().getSelectedInstance();
}
/// Sets the selected instance ID for the selected repository.
public static void setSelectedInstance(@Nullable GameInstanceID instance) {
/// Sets the selected instance for the selected repository.
///
/// @param instance the instance to select, or `null` to clear the selection
/// @throws IllegalArgumentException if `instance` belongs to another repository
public static void setSelectedInstance(@Nullable HMCLGameInstance instance) {
getSelectedRepository().setSelectedInstance(instance);
}
@@ -19,7 +19,9 @@ package org.jackhuang.hmcl.setting;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.Nullable;
public enum GameInstanceIconType {
DEFAULT("/assets/img/grass.png"),
@@ -47,6 +49,7 @@ public enum GameInstanceIconType {
case FORGE -> GameInstanceIconType.FORGE;
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
case FABRIC -> GameInstanceIconType.FABRIC;
case LEGACY_FABRIC -> GameInstanceIconType.LEGACY_FABRIC;
case QUILT -> GameInstanceIconType.QUILT;
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
@@ -54,6 +57,22 @@ public enum GameInstanceIconType {
};
}
public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) {
return switch (componentType) {
case GAME -> GameInstanceIconType.GRASS;
case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC;
case LEGACY_FABRIC, LEGACY_FABRIC_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 NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
default -> null;
};
}
private final String resourceUrl;
GameInstanceIconType(String resourceUrl) {
@@ -813,15 +813,15 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Property name for using native GLFW.
public static final String PROPERTY_USE_NATIVE_GLFW = "useNativeGLFW";
public static final String PROPERTY_USE_NATIVE_GLFW_OR_SDL = "useNativeGLFW";
/// If `true`, HMCL will use native GLFW.
@SerializedName(PROPERTY_USE_NATIVE_GLFW)
private final InheritableProperty<Boolean> useNativeGLFW = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW, false);
@SerializedName(PROPERTY_USE_NATIVE_GLFW_OR_SDL)
private final InheritableProperty<Boolean> useNativeGLFWorSDL = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW_OR_SDL, false);
/// Returns the native GLFW property.
public InheritableProperty<Boolean> useNativeGLFWProperty() {
return useNativeGLFW;
public InheritableProperty<Boolean> useNativeGLFWorSDLProperty() {
return useNativeGLFWorSDL;
}
/// Property name for using native OpenAL.
@@ -132,12 +132,12 @@ public final class LegacyGameSettingsMigrator {
HMCLGameRepository repository,
GameInstanceID instanceId,
@Nullable GameSettingsPresetID parent) {
Path instanceRoot = repository.getInstanceRoot(instanceId);
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
if (!Files.exists(file)) {
return null;
}
Path receiptLocation = repository.getInstanceStateDirectory(instanceId)
Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId)
.resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME);
if (MigrationReceipt.matches(receiptLocation, file)) {
LOG.info("Skipping already migrated legacy version setting " + file);
@@ -271,7 +271,7 @@ public final class LegacyGameSettingsMigrator {
GameSettings.PROPERTY_NOT_PATCH_NATIVES,
GameSettings.PROPERTY_USE_CUSTOM_NATIVES,
GameSettings.PROPERTY_NATIVES_DIRECTORY,
GameSettings.PROPERTY_USE_NATIVE_GLFW,
GameSettings.PROPERTY_USE_NATIVE_GLFW_OR_SDL,
GameSettings.PROPERTY_USE_NATIVE_OPENAL
));
}
@@ -342,7 +342,7 @@ public final class LegacyGameSettingsMigrator {
boolean useCustomNatives = isLegacyCustomNativesDirectory(source);
target.useCustomNativesProperty().setValue(useCustomNatives);
target.nativesDirectoryProperty().setValue(useCustomNatives ? JsonUtils.getString(source, "nativesDir", "") : "");
target.useNativeGLFWProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
target.useNativeGLFWorSDLProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
target.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false));
}
@@ -153,6 +153,7 @@ public final class StyleSheets {
addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed());
addColor(builder, scheme, ColorRole.PRIMARY, 0.5);
addColor(builder, scheme, ColorRole.PRIMARY, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.8);
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
@@ -29,13 +30,12 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
@@ -50,13 +50,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaBundle {
private final Path root;
private final List<URI> links;
/// Candidate bundle URLs in attempt order.
private final List<WebURL> links;
private final FileDownloadTask.IntegrityCheck hash;
private final Map<String, FileDownloadTask.IntegrityCheck> files;
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
/// Creates a bundle using the supplied download candidates and archive/file integrity metadata.
/// The candidate list and file map are retained without copying.
public TerracottaBundle(Path root, List<WebURL> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
this.root = root;
this.links = links;
this.hash = hash;
@@ -22,6 +22,7 @@ import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.DownloadException;
@@ -42,7 +43,6 @@ import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -288,7 +288,7 @@ public final class TerracottaManager {
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
return new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR);
}
return Task.completed(null);
@@ -297,7 +297,7 @@ public final class TerracottaManager {
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return new TerracottaState.Waiting(-1, -1, null);
@@ -317,7 +317,7 @@ public final class TerracottaManager {
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
for (WebURL node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
@@ -338,7 +338,7 @@ public final class TerracottaManager {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
for (WebURL node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
@@ -35,10 +36,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -90,8 +91,8 @@ public final class TerracottaMetadata {
}
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> URI.create(options.replace(link)))
@Unmodifiable List<WebURL> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> WebURL.parse(options.replace(link)))
.toList();
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -26,9 +27,8 @@ import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -43,17 +43,19 @@ public final class TerracottaNodeList {
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
new URI(url);
} catch (URISyntaxException e) {
WebURL.parse(url);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
private static volatile List<URI> list;
/// Cached node URLs, or `null` before the first fetch completes.
private static volatile @Nullable @Unmodifiable List<WebURL> list;
public static List<URI> fetch() {
List<URI> list = TerracottaNodeList.list;
/// Returns the cached unmodifiable node URLs, fetching once and caching an empty list on failure.
public static @Unmodifiable List<WebURL> fetch() {
@Nullable @Unmodifiable List<WebURL> list = TerracottaNodeList.list;
if (list != null)
return list;
@@ -84,7 +86,7 @@ public final class TerracottaNodeList {
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
})
.map(it -> URI.create(it.url()))
.map(it -> WebURL.parse(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
@@ -26,6 +26,7 @@ import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorStyle;
import org.glavo.monetfx.Contrast;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.setting.LauncherSettings;
@@ -36,8 +37,8 @@ import org.jackhuang.hmcl.util.MathUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ContentEncoding;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNullByDefault;
@@ -48,7 +49,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
@@ -1497,28 +1497,28 @@ public final class ThemePackManager {
List<Path> temporaryFiles,
ResolvedBackground background) throws IOException {
String url = requireNonBlank(background.networkImageUrl(), "background.url");
URI uri = NetworkUtils.toURI(url);
if (!NetworkUtils.isHttpUri(uri)) {
WebURL webUrl = WebURL.parse(url);
if (!NetworkUtils.isHttpUri(webUrl)) {
throw new IOException("Theme background URL must be HTTP or HTTPS: " + url);
}
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(uri);
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(webUrl);
if (background.networkImageCachePolicy() == NetworkBackgroundImageCachePolicy.ENABLED) {
try {
@Nullable Path cachedFile = new CacheFileTask(uri).run();
@Nullable Path cachedFile = new CacheFileTask(webUrl).run();
if (cachedFile != null && Files.isRegularFile(cachedFile)) {
assets.add(new ThemePackAsset(cachedFile, entryName));
return new ThemeBackground.Image(entryName);
}
} catch (Exception e) {
LOG.warning("Failed to cache theme background, falling back to direct download: " + uri, e);
LOG.warning("Failed to cache theme background, falling back to direct download: " + webUrl, e);
}
}
Path temporaryFile = Files.createTempFile("hmcl-theme-background-", ".tmp");
boolean success = false;
try {
downloadNetworkBackground(uri, temporaryFile);
downloadNetworkBackground(webUrl, temporaryFile);
assets.add(new ThemePackAsset(temporaryFile, entryName));
temporaryFiles.add(temporaryFile);
success = true;
@@ -1535,12 +1535,12 @@ public final class ThemePackManager {
}
/// Downloads one network background into a temporary file without installing it into the persistent image cache.
private static void downloadNetworkBackground(URI uri, Path outputFile) throws IOException {
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(uri));
private static void downloadNetworkBackground(WebURL url, Path outputFile) throws IOException {
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(url));
try {
int responseCode = connection.getResponseCode();
if (responseCode / 100 != 2) {
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + uri);
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + url);
}
ContentEncoding contentEncoding = ContentEncoding.fromConnection(connection);
@@ -1554,8 +1554,8 @@ public final class ThemePackManager {
}
/// Returns a safe theme-pack asset file name for a downloaded network background.
private static String networkBackgroundAssetName(URI uri) {
String path = Objects.toString(uri.getPath(), "");
private static String networkBackgroundAssetName(WebURL url) {
String path = Objects.toString(url.getPath(), "");
@Nullable Path fileNamePath = path.isBlank() ? null : Path.of(path).getFileName();
String fileName = fileNamePath != null ? fileNamePath.toString() : "";
String sanitized = sanitizePathSegment(fileName);
@@ -24,8 +24,7 @@ import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.ObjectExpression;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.SetChangeListener;
@@ -80,6 +79,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -91,6 +91,9 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Provides the current launcher MonetFX theme and derived color bindings.
@NotNullByDefault
public final class Themes {
/// The stage property retaining the listener for native appearance updates and marking completed registration.
private static final String NATIVE_DARK_MODE_LISTENER = "Themes.applyNativeDarkMode.listener";
/// The seed color extracted from the last loaded wallpaper image.
private static final ReadOnlyObjectWrapper<@Nullable ThemeColor> wallpaperThemeColor = new ReadOnlyObjectWrapper<>();
@@ -283,6 +286,9 @@ public final class Themes {
colorScheme
);
/// Whether the current color scheme brightness is inherited from system.
private static final ReadOnlyBooleanWrapper autoBrightness = new ReadOnlyBooleanWrapper();
/// The current JavaFX launcher background and its node opacity.
private static final ReadOnlyObjectWrapper<LauncherBackground> background = new ReadOnlyObjectWrapper<>(
new LauncherBackground(
@@ -380,6 +386,35 @@ public final class Themes {
}
static {
Supplier<@Nullable Brightness> effectiveBrightnessSupplier = () -> {
try {
return ThemePackManager.resolveCurrentThemeBrightness(ThemePackManager.currentResolveContext());
} catch (IOException | RuntimeException e) {
return null;
}
};
InvalidationListener autoBrightnessListener = o -> {
boolean auto;
if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) {
String val = settings().themeBrightnessModeProperty().get();
auto = val == null || switch (val.trim().toLowerCase(Locale.ROOT)) {
case "light", "dark" -> false;
default -> true;
};
} else {
auto = effectiveBrightnessSupplier.get() == null;
}
autoBrightness.set(auto);
};
settings().selectedThemeProperty().addListener(autoBrightnessListener);
settings().getThemeAppearanceOverrides().addListener(autoBrightnessListener);
settings().themeBrightnessModeProperty().addListener(autoBrightnessListener);
settings().backgroundTypeProperty().addListener(autoBrightnessListener);
if (FXUtils.DARK_MODE != null) {
FXUtils.DARK_MODE.addListener(autoBrightnessListener);
}
autoBrightnessListener.invalidated(null);
ChangeListener<ResolvedTheme> listener = (observable, oldValue, newValue) -> {
if (!Objects.equals(oldValue, newValue)) {
colorScheme.set(newValue.toColorScheme());
@@ -433,7 +468,7 @@ public final class Themes {
}
String themeBrightnessMode = settings().themeBrightnessModeProperty().get();
return switch (Objects.toString(themeBrightnessMode, "").toLowerCase(Locale.ROOT).trim()) {
return switch (Objects.toString(themeBrightnessMode, "").trim().toLowerCase(Locale.ROOT)) {
case "light" -> Brightness.LIGHT;
case "dark" -> Brightness.DARK;
default -> getAutomaticBrightness();
@@ -444,24 +479,16 @@ public final class Themes {
///
/// @return the effective launcher brightness
public static Brightness getCurrentBrightness() {
Brightness contextBrightness = getThemeConditionBrightness();
if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) {
Brightness contextBrightness = getThemeConditionBrightness();
try {
return Objects.requireNonNullElse(
ThemePackManager.resolveCurrentThemeBrightness(ThemeResolveContext.current(contextBrightness)),
contextBrightness);
} catch (IOException | RuntimeException e) {
return contextBrightness;
} catch (IOException | RuntimeException ignored) {
}
}
String themeBrightnessMode = settings().themeBrightnessModeProperty().get();
return switch (Objects.toString(themeBrightnessMode, "").toLowerCase(Locale.ROOT).trim()) {
case "auto" -> getAutomaticBrightness();
case "dark" -> Brightness.DARK;
case "light" -> Brightness.LIGHT;
default -> getAutomaticBrightness();
};
return contextBrightness;
}
/// Returns the brightness requested by the current system or platform settings.
@@ -1119,8 +1146,22 @@ public final class Themes {
return darkMode;
}
/// Applies native dark-mode integration to a JavaFX stage where the platform supports it.
/// Returns whether the current color scheme brightness is inherited from system.
public static ReadOnlyBooleanProperty autoBrightnessProperty() {
return autoBrightness.getReadOnlyProperty();
}
/// Registers native dark-mode integration once per stage where the platform supports it.
///
/// Windows updates the stage's native frame; macOS updates the application appearance. The stage retains
/// its listener, which observes theme changes weakly. Repeated calls for an already registered stage do
/// nothing, including after hiding the stage or replacing its scene.
///
/// @param stage the stage retaining the registration, accessed on the JavaFX application thread
public static void applyNativeDarkMode(Stage stage) {
if (stage.getProperties().containsKey(NATIVE_DARK_MODE_LISTENER)) {
return;
}
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> {
if (stage.isShowing()) {
@@ -1137,7 +1178,7 @@ public final class Themes {
});
}
});
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
if (stage.isShowing()) {
listener.changed(null, false, Themes.darkModeProperty().get());
@@ -1154,7 +1195,7 @@ public final class Themes {
MacOSNativeUtils.setAppearance(darkModeProperty().get());
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance);
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
}
}
@@ -33,10 +33,15 @@ import javafx.stage.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.account.AccountListPage;
@@ -56,6 +61,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -64,6 +70,8 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
@@ -88,6 +96,7 @@ public final class Controllers {
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
/// 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();
@@ -202,6 +211,16 @@ public final class Controllers {
}
}
/// Configures the main stage's application identity, shutdown action, and taskbar relaunch information.
///
/// @param stage the unshown main stage
private static void configureMainStage(Stage stage) {
stage.setOnCloseRequest(event -> Launcher.stopApplication());
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(stage);
}
/// Initializes the main application stage, scene graph, and background services.
///
/// @param stage the primary application stage, which must not have been shown
@@ -222,9 +241,9 @@ public final class Controllers {
}
}
stage.setOnCloseRequest(e -> Launcher.stopApplication());
configureMainStage(stage);
decorator = new Decorator(getRootPage());
decorator = new Decorator(getRootPage(), Controllers::configureMainStage);
Scene mainScene = decorator.attachStage(stage);
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
getRootPage().getMainPage().showUpdateDialogProperty().bind(
@@ -243,9 +262,6 @@ public final class Controllers {
StyleSheets.init(mainScene);
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) {
Runnable continueAction = () -> {
UserState userState = userState();
@@ -371,6 +387,42 @@ public final class Controllers {
}, updateShowTips);
}, updateShowTips);
}
tryInstallBundledModpack(GameDirectoryManager.getSelectedRepository());
}
/// Offers automatic install when a package exists under `.hmcl/modpack/`.
///
/// Called from [Controllers#initialize] after the UI is ready. Install does not wait for repository
/// refresh: instance paths come from the selected repository layout. The package file itself is the
/// install signal; it is deleted after a successful install so later startups do not re-prompt.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile == null) {
return;
}
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Charset encoding = CompressingUtils.findSuitableEncoding(modpackFile);
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile, encoding);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
);
}
public static void dialog(Region content) {
@@ -554,7 +606,7 @@ public final class Controllers {
break;
case "hmcl://game/launch":
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep);
Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep);
break;
}
} else {
@@ -562,20 +614,20 @@ public final class Controllers {
}
}
public static void openUriOrCopy(@Nullable URI uri) {
if (uri == null) return;
openUriOrCopy(uri.toString());
public static void openUriOrCopy(@Nullable URI url) {
if (url == null) return;
openUriOrCopy(url.toString());
}
public static void openUriOrCopy(@Nullable String uri) {
if (uri == null) return;
public static void openUriOrCopy(@Nullable String url) {
if (url == null) return;
var dialog = new MessageDialogPane.Builder(
i18n("web.open_in_browser", uri),
i18n("web.open_in_browser", url),
i18n("message.confirm"),
MessageDialogPane.MessageType.QUESTION
)
.addAction(i18n("button.copy"), () -> FXUtils.copyText(uri))
.yesOrNo(() -> FXUtils.openLink(uri), null)
.addAction(i18n("button.copy"), () -> FXUtils.copyText(url))
.yesOrNo(() -> FXUtils.openLink(url), null)
.build();
dialog(dialog);
}
@@ -598,4 +650,12 @@ public final class Controllers {
FXUtils.shutdown();
}
public static final boolean AUTO_TRIM_HEAP = !"false".equalsIgnoreCase(System.getenv("HMCL_AUTO_TRIM_HEAP"));
public static void trimHeap() {
if (AUTO_TRIM_HEAP) {
System.gc();
}
}
}
@@ -72,6 +72,8 @@ public class CrashWindow extends Stage {
FXUtils.setIcon(this);
setTitle(i18n("message.error"));
FXUtils.addMacOSCloseWindowHandler(this, null);
setOnCloseRequest(e -> javafx.application.Platform.exit());
}
@@ -71,7 +71,7 @@ public final class DialogUtils {
private static void showInDecorator(Decorator decorator, StackPane dialogContainer, Node content) {
show(dialogContainer, content, dialog -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingWindow(dialog);
decorator.capableDraggingOverlay(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(dialogContainer);
});
@@ -58,13 +58,11 @@ import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.FileChooser;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.stage.*;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
@@ -76,11 +74,11 @@ import org.jackhuang.hmcl.ui.construct.MenuSeparator;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.image.ImageLoader;
import org.jackhuang.hmcl.ui.image.ImageUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.ResourceNotFoundError;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
@@ -110,10 +108,7 @@ import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.ToIntFunction;
import java.util.function.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -470,25 +465,31 @@ public final class FXUtils {
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
@FXThread
public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
checkFxUserThread();
tooltip.setShowDelay(showDelay);
tooltip.setShowDuration(showDuration);
tooltip.setHideDelay(hideDelay);
Tooltip.install(node, tooltip);
}
@FXThread
public static void installFastTooltip(Node node, Tooltip tooltip) {
runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip);
}
@FXThread
public static void installFastTooltip(Node node, String tooltip) {
installFastTooltip(node, new Tooltip(tooltip));
}
@FXThread
public static void installSlowTooltip(Node node, Tooltip tooltip) {
runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip);
}
@FXThread
public static void installSlowTooltip(Node node, String tooltip) {
installSlowTooltip(node, new Tooltip(tooltip));
}
@@ -613,21 +614,21 @@ public final class FXUtils {
if (link == null)
return;
String uri = NetworkUtils.encodeLocation(link);
String url = NetworkUtils.encodeLocation(link);
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri});
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", url});
return;
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Runtime.getRuntime().exec(new String[]{"open", uri});
Runtime.getRuntime().exec(new String[]{"open", url});
return;
} else {
for (String browser : linuxBrowsers) {
Path path = SystemUtils.which(browser);
if (path != null) {
try {
Runtime.getRuntime().exec(new String[]{path.toString(), uri});
Runtime.getRuntime().exec(new String[]{path.toString(), url});
return;
} catch (Throwable ignored) {
}
@@ -640,7 +641,7 @@ public final class FXUtils {
}
try {
java.awt.Desktop.getDesktop().browse(new URI(uri));
java.awt.Desktop.getDesktop().browse(new URI(url));
} catch (Throwable e) {
LOG.warning("Failed to open link: " + link, e);
}
@@ -728,116 +729,6 @@ public final class FXUtils {
}
}
private static final class EnumBidirectionalBinding<E extends Enum<E>> implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<E>> comboBoxRef;
private final WeakReference<Property<E>> propertyRef;
private final int hashCode;
private boolean updating = false;
private EnumBidirectionalBinding(JFXComboBox<E> comboBox, Property<E> property) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.propertyRef = new WeakReference<>(property);
this.hashCode = System.identityHashCode(comboBox) ^ System.identityHashCode(property);
}
@Override
public void invalidated(Observable sourceProperty) {
if (!updating) {
final JFXComboBox<E> comboBox = comboBoxRef.get();
final Property<E> property = propertyRef.get();
if (comboBox == null || property == null) {
if (comboBox != null) {
comboBox.getSelectionModel().selectedItemProperty().removeListener(this);
}
if (property != null) {
property.removeListener(this);
}
} else {
updating = true;
try {
if (property == sourceProperty) {
E newValue = property.getValue();
comboBox.getSelectionModel().select(newValue);
} else {
E newValue = comboBox.getSelectionModel().getSelectedItem();
property.setValue(newValue);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return comboBoxRef.get() == null || propertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof EnumBidirectionalBinding))
return false;
EnumBidirectionalBinding<?> that = (EnumBidirectionalBinding<?>) o;
final JFXComboBox<E> comboBox = this.comboBoxRef.get();
final Property<E> property = this.propertyRef.get();
final JFXComboBox<?> thatComboBox = that.comboBoxRef.get();
final Property<?> thatProperty = that.propertyRef.get();
if (comboBox == null || property == null || thatComboBox == null || thatProperty == null)
return false;
return comboBox == thatComboBox && property == thatProperty;
}
}
/**
* Bind combo box selection with given enum property bidirectionally.
* You should <b>only and always</b> use {@code bindEnum} as well as {@code unbindEnum} at the same time.
*
* @param comboBox the combo box being bound with {@code property}.
* @param property the property being bound with {@code combo box}.
* @see #unbindEnum(JFXComboBox, Property)
* @see ExtendedProperties#selectedItemPropertyFor(ComboBox)
*/
public static <T extends Enum<T>> void bindEnum(JFXComboBox<T> comboBox, Property<T> property) {
EnumBidirectionalBinding<T> binding = new EnumBidirectionalBinding<>(comboBox, property);
comboBox.getSelectionModel().selectedItemProperty().removeListener(binding);
property.removeListener(binding);
comboBox.getSelectionModel().select(property.getValue());
comboBox.getSelectionModel().selectedItemProperty().addListener(binding);
property.addListener(binding);
}
/**
* Unbind combo box selection with given enum property bidirectionally.
* You should <b>only and always</b> use {@code bindEnum} as well as {@code unbindEnum} at the same time.
*
* @param comboBox the combo box being bound with the property which can be inferred by {@code bindEnum}.
* @see #bindEnum(JFXComboBox, Property)
* @see ExtendedProperties#selectedItemPropertyFor(ComboBox)
*/
public static <T extends Enum<T>> void unbindEnum(JFXComboBox<T> comboBox, Property<T> property) {
EnumBidirectionalBinding<T> binding = new EnumBidirectionalBinding<>(comboBox, property);
comboBox.getSelectionModel().selectedItemProperty().removeListener(binding);
property.removeListener(binding);
}
private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<ColorPicker> colorPickerRef;
private final WeakReference<Property<Paint>> propertyRef;
@@ -932,146 +823,6 @@ public final class FXUtils {
property.addListener(binding);
}
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<String>> comboBoxRef;
private final WeakReference<IntegerProperty> widthPropertyRef;
private final WeakReference<IntegerProperty> heightPropertyRef;
private final int hashCode;
private boolean updating = false;
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
IntegerProperty widthProperty,
IntegerProperty heightProperty) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.widthPropertyRef = new WeakReference<>(widthProperty);
this.heightPropertyRef = new WeakReference<>(heightProperty);
this.hashCode = System.identityHashCode(comboBox)
^ System.identityHashCode(widthProperty)
^ System.identityHashCode(heightProperty);
}
@Override
public void invalidated(Observable observable) {
if (!updating) {
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null) {
if (comboBox != null) {
comboBox.focusedProperty().removeListener(this);
comboBox.sceneProperty().removeListener(this);
}
if (widthProperty != null)
widthProperty.removeListener(this);
if (heightProperty != null)
heightProperty.removeListener(this);
} else {
updating = true;
try {
int width = widthProperty.get();
int height = heightProperty.get();
if (observable instanceof ReadOnlyProperty<?>
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
String value = comboBox.valueProperty().get();
if (value == null)
value = "";
int idx = value.indexOf('x');
if (idx < 0)
idx = value.indexOf('*');
if (idx < 0) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
String widthStr = value.substring(0, idx).trim();
String heightStr = value.substring(idx + 1).trim();
int newWidth;
int newHeight;
try {
newWidth = Integer.parseInt(widthStr);
newHeight = Integer.parseInt(heightStr);
} catch (NumberFormatException e) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
widthProperty.set(newWidth);
heightProperty.set(newHeight);
} else {
comboBox.setValue(width + "x" + height);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return this.comboBoxRef.get() == null
|| this.widthPropertyRef.get() == null
|| this.heightPropertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof WindowsSizeBidirectionalBinding))
return false;
var that = (WindowsSizeBidirectionalBinding) obj;
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
var thatComboBox = that.comboBoxRef.get();
var thatWidthProperty = that.widthPropertyRef.get();
var thatHeightProperty = that.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
return false;
}
return comboBox == thatComboBox
&& widthProperty == thatWidthProperty
&& heightProperty == thatHeightProperty;
}
}
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().addListener(binding);
comboBox.sceneProperty().addListener(binding);
widthProperty.addListener(binding);
heightProperty.addListener(binding);
}
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().removeListener(binding);
comboBox.sceneProperty().removeListener(binding);
widthProperty.removeListener(binding);
heightProperty.removeListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
int itemCount = children.length;
int childSelectedCount = 0;
@@ -1251,8 +1002,9 @@ public final class FXUtils {
.setSignificance(Task.TaskSignificance.MINOR);
}
public static Task<Image> getRemoteImageTask(List<URI> uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uris)
/// Creates a task that caches an image from HTTP(S) candidates and loads it with the requested sizing options.
public static Task<Image> getRemoteImageTask(DownloadCandidates candidates, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(candidates)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
@@ -1393,20 +1145,6 @@ public final class FXUtils {
};
}
public static <T> Callback<ListView<T>, ListCell<T>> jfxListCellFactory(Function<T, Node> graphicBuilder) {
return view -> new JFXListCell<T>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
if (!empty) {
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
setGraphic(graphicBuilder.apply(item));
}
}
};
}
public static ColumnConstraints getColumnFillingWidth() {
ColumnConstraints constraint = new ColumnConstraints();
constraint.setFillWidth(true);
@@ -1459,6 +1197,18 @@ public final class FXUtils {
});
}
public static void addMacOSCloseWindowHandler(Window window, @Nullable Supplier<Boolean> restriction) {
window.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
&& event.isMetaDown()
&& event.getCode() == KeyCode.W
&& (restriction == null || restriction.get())) {
window.fireEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSE_REQUEST));
event.consume();
}
});
}
public static <N extends Parent> N prepareNode(N node) {
Scene dummyScene = new Scene(node);
StyleSheets.init(dummyScene);
@@ -34,7 +34,6 @@ import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.setting.StyleSheets;
@@ -44,7 +43,6 @@ import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
@@ -73,17 +71,15 @@ 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 GameInstanceManifest manifest;
private final HMCLGameInstance gameInstance;
private final String memory;
private final String total_memory;
private final String java;
private final LibraryAnalyzer analyzer;
private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown")));
private final BooleanProperty loading = new SimpleBooleanProperty();
private final TextFlow feedbackTextFlow = new TextFlow();
private final ManagedProcess managedProcess;
private final DefaultGameRepository repository;
private final ProcessListener.ExitType exitType;
private final LaunchOptions launchOptions;
private final View view;
@@ -91,16 +87,14 @@ public class GameCrashWindow extends Stage {
private final List<Log> logs;
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, GameInstanceManifest manifest, LaunchOptions launchOptions, List<Log> logs) {
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List<Log> logs) {
Themes.applyNativeDarkMode(this);
this.managedProcess = managedProcess;
this.exitType = exitType;
this.repository = repository;
this.manifest = manifest;
this.gameInstance = gameInstance;
this.launchOptions = launchOptions;
this.logs = logs;
this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null));
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-");
@@ -120,6 +114,8 @@ public class GameCrashWindow extends Stage {
setTitle(i18n("game.crash.title"));
FXUtils.setIcon(this);
FXUtils.addMacOSCloseWindowHandler(this, null);
analyzeCrashReport();
}
@@ -142,7 +138,8 @@ public class GameCrashWindow extends Stage {
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
}), Task.supplyAsync(() -> {
Path latestLog = repository.getRunDirectory(manifest.id()).resolve("logs/latest.log");
Path runDirectory = gameInstance.getRunDirectory();
Path latestLog = runDirectory.resolve("logs/latest.log");
if (!Files.isReadable(latestLog)) {
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
}
@@ -291,7 +288,7 @@ public class GameCrashWindow extends Stage {
}
});
return LogExporter.exportLogs(logFile, repository, launchOptions.getInstanceId(), logs,
return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs,
new CommandBuilder().addAll(managedProcess.getCommands()).toString(),
path -> {
try {
@@ -342,10 +339,10 @@ public class GameCrashWindow extends Stage {
launcher.setTitle(i18n("launcher"));
launcher.setSubtitle(Metadata.VERSION);
TwoLineListItem version = new TwoLineListItem();
version.getStyleClass().setAll("two-line-item-second-large");
version.setTitle(i18n("game.version"));
version.setSubtitle(GameCrashWindow.this.manifest.id().toString());
TwoLineListItem instance = new TwoLineListItem();
instance.getStyleClass().setAll("two-line-item-second-large");
instance.setTitle(i18n("game.version"));
instance.setSubtitle(GameCrashWindow.this.gameInstance.getId().toString());
TwoLineListItem total_memory = new TwoLineListItem();
total_memory.getStyleClass().setAll("two-line-item-second-large");
@@ -365,14 +362,14 @@ public class GameCrashWindow extends Stage {
TwoLineListItem os = new TwoLineListItem();
os.getStyleClass().setAll("two-line-item-second-large");
os.setTitle(i18n("system.operating_system"));
os.setSubtitle(Lang.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME));
os.setSubtitle(Objects.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME));
TwoLineListItem arch = new TwoLineListItem();
arch.getStyleClass().setAll("two-line-item-second-large");
arch.setTitle(i18n("system.architecture"));
arch.setSubtitle(Architecture.SYSTEM_ARCH.getDisplayName());
infoPane.getChildren().setAll(launcher, version, total_memory, memory, java, os, arch);
infoPane.getChildren().setAll(launcher, instance, total_memory, memory, java, os, arch);
}
HBox moddedPane = new HBox(8);
@@ -380,15 +377,13 @@ public class GameCrashWindow extends Stage {
moddedPane.setPadding(new Insets(8));
moddedPane.setAlignment(Pos.CENTER_LEFT);
for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) {
if (!type.getPatchId().isEmpty()) {
analyzer.getVersion(type).ifPresent(ver -> {
TwoLineListItem item = new TwoLineListItem();
item.getStyleClass().setAll("two-line-item-second-large");
item.setTitle(i18n("install.installer." + type.getPatchId()));
item.setSubtitle(ver);
moddedPane.getChildren().add(item);
});
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
if (mark.version() != null) {
TwoLineListItem item = new TwoLineListItem();
item.getStyleClass().setAll("two-line-item-second-large");
item.setTitle(i18n("install.installer." + mark.componentType().getPatchId()));
item.setSubtitle(mark.version());
moddedPane.getChildren().add(item);
}
}
}
@@ -170,7 +170,7 @@ public final class HTMLRenderer {
private void applyStyle(Text text) {
if (code) {
text.getStyleClass().add("html-code");
text.setStyle("-fx-font-family: \"%s\";".formatted(Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)));
text.setStyle("-fx-font-family: \"%s\";".formatted(Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)));
return;
}
@@ -34,26 +34,22 @@ import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
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;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.*;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author huangyuhui
*/
public class InstallerItem extends Control {
private final String id;
private final GameComponentType type;
private final GameInstanceIconType iconType;
private final Style style;
private final ObjectProperty<InstalledState> versionProperty = new SimpleObjectProperty<>(this, "version", null);
@@ -83,30 +79,14 @@ public class InstallerItem extends Control {
CARD,
}
public InstallerItem(LibraryAnalyzer.LibraryType id, Style style) {
this(id.getPatchId(), style);
}
public InstallerItem(String id, Style style) {
this.id = id;
public InstallerItem(GameComponentType type, Style style) {
this.type = type;
this.style = style;
iconType = switch (id) {
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;
};
this.iconType = GameInstanceIconType.getIconType(type);
}
public String getLibraryId() {
return id;
public GameComponentType getComponentType() {
return type;
}
public ObjectProperty<InstalledState> versionProperty() {
@@ -149,7 +129,7 @@ public class InstallerItem extends Control {
public final static class InstallerItemGroup {
private final InstallerItem game;
private final InstallerItem[] libraries;
private final InstallerItem[] components;
private Set<InstallerItem> getIncompatibles(Map<InstallerItem, Set<InstallerItem>> incompatibleMap, InstallerItem item) {
return incompatibleMap.computeIfAbsent(item, it -> new HashSet<>());
@@ -175,19 +155,19 @@ public class InstallerItem extends Control {
}
}
public InstallerItemGroup(String gameVersion, Style style) {
game = new InstallerItem(MINECRAFT, style);
InstallerItem fabric = new InstallerItem(FABRIC, style);
InstallerItem fabricApi = new InstallerItem(FABRIC_API, style);
InstallerItem forge = new InstallerItem(FORGE, style);
InstallerItem cleanroom = new InstallerItem(CLEANROOM, style);
InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style);
InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style);
InstallerItem neoForge = new InstallerItem(NEO_FORGE, style);
InstallerItem liteLoader = new InstallerItem(LITELOADER, style);
InstallerItem optiFine = new InstallerItem(OPTIFINE, style);
InstallerItem quilt = new InstallerItem(QUILT, style);
InstallerItem quiltApi = new InstallerItem(QUILT_API, style);
public InstallerItemGroup(GameVersionNumber gameVersion, Style style) {
game = new InstallerItem(GameComponentType.GAME, style);
InstallerItem fabric = new InstallerItem(GameComponentType.FABRIC, style);
InstallerItem fabricApi = new InstallerItem(GameComponentType.FABRIC_API, style);
InstallerItem forge = new InstallerItem(GameComponentType.FORGE, style);
InstallerItem cleanroom = new InstallerItem(GameComponentType.CLEANROOM, style);
InstallerItem legacyfabric = new InstallerItem(GameComponentType.LEGACY_FABRIC, style);
InstallerItem legacyfabricApi = new InstallerItem(GameComponentType.LEGACY_FABRIC_API, style);
InstallerItem neoForge = new InstallerItem(GameComponentType.NEO_FORGE, style);
InstallerItem liteLoader = new InstallerItem(GameComponentType.LITELOADER, style);
InstallerItem optiFine = new InstallerItem(GameComponentType.OPTIFINE, style);
InstallerItem quilt = new InstallerItem(GameComponentType.QUILT, style);
InstallerItem quiltApi = new InstallerItem(GameComponentType.QUILT_API, style);
Map<InstallerItem, Set<InstallerItem>> incompatibleMap = new HashMap<>();
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric);
@@ -217,7 +197,7 @@ public class InstallerItem extends Control {
for (InstallerItem other : incompatibleItems) {
InstalledState otherVersion = other.versionProperty.get();
if (otherVersion != null) {
return new IncompatibleState(other.id, otherVersion.version);
return new IncompatibleState(other.type.getPatchId(), otherVersion.version);
}
}
@@ -226,7 +206,7 @@ public class InstallerItem extends Control {
}
if (gameVersion != null) {
game.versionProperty.set(new InstalledState(gameVersion, false, false));
game.versionProperty.set(new InstalledState(gameVersion.toString(), false, false));
}
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom};
@@ -235,22 +215,19 @@ public class InstallerItem extends Control {
if (!item.resolvedStateProperty.isBound()) {
item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> {
InstalledState itemVersion = item.versionProperty.get();
if (itemVersion != null) {
return itemVersion;
}
return InstallableState.INSTANCE;
return Objects.requireNonNullElse(itemVersion, InstallableState.INSTANCE);
}, item.versionProperty));
}
}
if (gameVersion == null) {
this.libraries = all;
} else if (gameVersion.equals("1.12.2")) {
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine};
} else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) {
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi};
this.components = all;
} else if (gameVersion.compareTo("1.12.2") == 0) {
this.components = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine};
} else if (gameVersion.compareTo("1.13.2") <= 0) {
this.components = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi};
} else {
this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi};
this.components = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi};
}
}
@@ -258,8 +235,8 @@ public class InstallerItem extends Control {
return game;
}
public InstallerItem[] getLibraries() {
return libraries;
public InstallerItem[] getComponents() {
return components;
}
}
@@ -309,7 +286,7 @@ public class InstallerItem extends Control {
nameLabel.getStyleClass().add("installer-item-name");
nameLabel.setMouseTransparent(true);
pane.getChildren().add(nameLabel);
nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.id) ? i18n("install.installer." + control.id) : control.id);
nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.type.getPatchId()) ? i18n("install.installer." + control.type.getPatchId()) : control.type.getPatchId());
HBox.setMargin(nameLabel, new Insets(0, 4, 0, 4));
Label statusLabel = new Label();
@@ -355,7 +332,7 @@ public class InstallerItem extends Control {
pane.getChildren().add(buttonsContainer);
JFXButton removeButton = FXUtils.newToggleButton4(SVG.CLOSE);
if (control.id.equals(MINECRAFT.getPatchId())) {
if (control.type == GameComponentType.GAME) {
removeButton.setVisible(false);
} else {
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
@@ -40,7 +40,6 @@ import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.CircularArrayList;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
@@ -52,10 +51,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
@@ -104,6 +100,8 @@ public final class LogWindow extends Stage {
}
this.gameProcess = gameProcess;
FXUtils.addMacOSCloseWindowHandler(this, () -> !gameProcess.isRunning());
}
public void logLine(Log log) {
@@ -324,7 +322,7 @@ public final class LogWindow extends Stage {
listView.scrollTo(listView.getItems().size() - 1);
});
listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)
listView.setStyle("-fx-font-family: \"" + Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)
+ "\"; -fx-font-size: " + settings().logFontSizeProperty().get() + "px;");
listView.setCellFactory(x -> new ListCell<>() {
{
@@ -18,6 +18,8 @@
package org.jackhuang.hmcl.ui;
import com.sun.jna.Pointer;
import javafx.beans.property.ReadOnlyBooleanProperty;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.platform.macos.ObjectiveCRuntime;
import org.jetbrains.annotations.Nullable;
@@ -56,8 +58,30 @@ public final class MacOSNativeUtils {
return pointer == null || Pointer.nativeValue(pointer) == 0;
}
private static final ReadOnlyBooleanProperty autoBrightness = Themes.autoBrightnessProperty();
static {
FXUtils.onChange(autoBrightness, b -> setAppearance(Themes.darkModeProperty().get()));
}
public static void setAppearance(boolean dark) {
setAppearance(dark, false);
if (autoBrightness.get()) {
clearAppearance();
} else {
setAppearance(dark, false);
}
}
public static void clearAppearance() {
if (nsApp == null) return;
try {
var objc = ObjectiveCRuntime.INSTANCE;
Pointer setSel = objc.sel_registerName("setAppearance:");
objc.objc_msgSend(nsApp, setSel, Pointer.NULL);
} catch (Throwable t) {
LOG.warning("Failed to set macOS appearance", t);
}
}
public static void setAppearance(boolean dark, boolean highContrast) {
@@ -18,13 +18,7 @@
package org.jackhuang.hmcl.ui.account;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
@@ -47,7 +41,6 @@ import org.jackhuang.hmcl.ui.construct.ClassTitle;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
@@ -56,7 +49,6 @@ import java.util.Locale;
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage {
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
@@ -173,13 +165,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
String host = "";
try {
host = NetworkUtils.toURI(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
}
item.subtitleProperty().set(host);
item.setSubtitle(server.getDisplayHostUrl());
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
FXUtils.installFastTooltip(item, tooltip);
@@ -67,11 +67,10 @@ import static java.util.Collections.emptyList;
import static java.util.Collections.unmodifiableList;
import static javafx.beans.binding.Bindings.bindContent;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor;
public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
private static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$");
@@ -388,20 +387,22 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
add(lblServers, 0, rowIndex);
cboServers = new JFXComboBox<>();
cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl())));
cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName));
cboServers.setConverter(stringConverter(it -> String.format("%s (%s)", it.getName(), it.getDisplayHostUrl())));
bindContent(cboServers.getItems(), getAuthlibInjectorServers());
cboServers.getItems().addListener(onInvalidating(
() -> Platform.runLater( // the selection will not be updated as expected if we call it immediately
cboServers.getSelectionModel()::selectFirst)));
cboServers.getSelectionModel().selectFirst();
cboServers.setPromptText(i18n("account.injector.empty"));
Label noServersLabel = new Label(i18n("account.injector.empty"));
BooleanBinding noServers = createBooleanBinding(cboServers.getItems()::isEmpty, cboServers.getItems());
classPropertyFor(cboServers, "jfx-combo-box-warning").bind(noServers);
classPropertyFor(cboServers, "jfx-combo-box").bind(noServers.not());
HBox.setHgrow(cboServers, Priority.ALWAYS);
HBox.setMargin(cboServers, new Insets(0, 10, 0, 0));
cboServers.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(noServersLabel, Priority.ALWAYS);
HBox.setMargin(noServersLabel, new Insets(0, 10, 0, 0));
noServersLabel.setMaxWidth(Double.MAX_VALUE);
HBox linksContainer = new HBox();
linksContainer.setAlignment(Pos.CENTER);
@@ -419,7 +420,13 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
Controllers.dialog(new AddAuthlibInjectorServerPane());
});
HBox boxServers = new HBox(cboServers, linksContainer, btnAddServer);
noServersLabel.visibleProperty().bind(noServers);
noServersLabel.managedProperty().bind(noServers);
cboServers.visibleProperty().bind(noServers.not());
cboServers.managedProperty().bind(noServers.not());
HBox boxServers = new HBox(cboServers, noServersLabel, linksContainer, btnAddServer);
boxServers.setAlignment(Pos.CENTER_LEFT);
add(boxServers, 1, rowIndex);
rowIndex++;
@@ -29,9 +29,7 @@ import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.control.Label;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.layout.*;
import javafx.scene.shape.SVGPath;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthInfo;
@@ -49,10 +47,10 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.QrCodeUtils;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
@@ -141,20 +139,22 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
setBody(rootContainer);
rootContainer.setAlignment(Pos.TOP_CENTER);
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
var snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
StackPane warningPane = new StackPane();
if (!IntegrityChecker.isOfficial()) {
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHintPane.setSegment(i18n("unofficial.hint"));
warningPane.getChildren().add(unofficialHintPane);
} else if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
snapshotHint.setSegment(i18n("account.methods.microsoft.snapshot"));
rootContainer.getChildren().add(snapshotHint);
warningPane.getChildren().add(snapshotHint);
btnLogin.setDisable(true);
loginButtonSpinner.setLoading(false);
return;
}
if (!IntegrityChecker.isOfficial()) {
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHintPane.setSegment(i18n("unofficial.hint"));
rootContainer.getChildren().add(unofficialHintPane);
}
rootContainer.getChildren().add(warningPane);
if (currentStep instanceof Step.Init) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
@@ -198,6 +198,20 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
} else if (currentStep instanceof Step.WaitForScanQrCode wait) {
loginButtonSpinner.setLoading(true);
VBox leftVBox = new VBox(6);
leftVBox.setAlignment(Pos.CENTER);
VBox rightVBox = new VBox(6);
HBox hbox = new HBox(leftVBox, rightVBox);
leftVBox.prefWidthProperty().bind(hbox.widthProperty().divide(2));
rightVBox.prefWidthProperty().bind(hbox.widthProperty().divide(2));
leftVBox.setMaxWidth(Double.MAX_VALUE);
rightVBox.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(leftVBox, Priority.ALWAYS);
HBox.setHgrow(rightVBox, Priority.ALWAYS);
String scanUri = "https://www.microsoft.com/link".equals(wait.verificationUri())
? "https://www.microsoft.com/link?otc=" + wait.userCode()
: wait.verificationUri();
@@ -217,7 +231,7 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
var lblCode = new Label(wait.userCode());
lblCode.getStyleClass().add("code-label");
lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
lblCode.setStyle("-fx-font-family: \"" + Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
var codeBox = new StackPane(lblCode);
codeBox.getStyleClass().add("code-box");
@@ -225,7 +239,10 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
FXUtils.onClicked(codeBox, () -> FXUtils.copyText(wait.userCode()));
codeBox.setMaxWidth(USE_PREF_SIZE);
rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox);
leftVBox.getChildren().setAll(new Group(qrCode), codeBox);
rightVBox.getChildren().setAll(warningPane, deviceHint);
rootContainer.getChildren().setAll(hbox);
} else if (currentStep instanceof Step.DeviceLoginCompleted) {
loginButtonSpinner.setLoading(true);
@@ -19,15 +19,11 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXProgressBar;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -38,18 +34,11 @@ public class DialogPane extends JFXDialogLayout {
protected final SpinnerPane acceptPane = new SpinnerPane();
protected final JFXButton cancelButton = new JFXButton();
protected final Label warningLabel = new Label();
private final JFXProgressBar progressBar = new JFXProgressBar();
public DialogPane() {
Label titleLabel = new Label();
titleLabel.textProperty().bind(title);
setHeading(titleLabel);
getChildren().add(progressBar);
progressBar.setVisible(false);
StackPane.setMargin(progressBar, new Insets(-24.0D, -24.0D, -16.0D, -24.0D));
StackPane.setAlignment(progressBar, Pos.TOP_CENTER);
progressBar.setMaxWidth(Double.MAX_VALUE);
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
acceptButton.setOnAction(e -> onAccept());
@@ -66,10 +55,6 @@ public class DialogPane extends JFXDialogLayout {
setActions(warningLabel, acceptPane, cancelButton);
}
protected JFXProgressBar getProgressBar() {
return progressBar;
}
public String getTitle() {
return title.get();
}
@@ -39,7 +39,7 @@ public final class FontComboBox extends JFXComboBox<String> {
styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\""));
setCellFactory(listView -> new JFXListCell<String>() {
setCellFactory(listView -> new JFXListCell<>() {
@Override
public void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
@@ -21,15 +21,18 @@ import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.DoubleBinding;
import javafx.css.PseudoClass;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
public abstract class MDListCell<T> extends ListCell<T> {
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private static final PseudoClass LAST = PseudoClass.getPseudoClass("last");
private final StackPane container = new StackPane();
private final StackPane root = new StackPane();
private final RipplerContainer ripplerContainer = new RipplerContainer(container);
public MDListCell(JFXListView<T> listView) {
@@ -37,7 +40,6 @@ public abstract class MDListCell<T> extends ListCell<T> {
setGraphic(null);
root.getStyleClass().add("md-list-cell");
RipplerContainer ripplerContainer = new RipplerContainer(container);
root.getChildren().setAll(ripplerContainer);
Region clippedContainer = (Region) listView.lookup(".clipped-container");
@@ -58,14 +60,32 @@ public abstract class MDListCell<T> extends ListCell<T> {
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
if (oldItem == item && oldEmpty == empty) {
updateLastPseudoClass(empty);
return;
}
ripplerContainer.releaseRippleImmediately();
updateControl(item, empty);
if (empty) {
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(root);
}
updateLastPseudoClass(empty || item == null);
}
/// Updates the `:last` pseudo-class so the trailing divider can be suppressed in CSS.
///
/// @param empty whether this cell is empty
private void updateLastPseudoClass(boolean empty) {
ListView<T> listView = getListView();
boolean last = !empty
&& listView != null
&& getIndex() >= 0
&& getIndex() == listView.getItems().size() - 1;
root.pseudoClassStateChanged(LAST, last);
}
protected StackPane getContainer() {
@@ -78,5 +98,9 @@ public abstract class MDListCell<T> extends ListCell<T> {
});
}
protected void onClicked(Runnable action) {
FXUtils.onClicked(ripplerContainer, action);
}
protected abstract void updateControl(T item, boolean empty);
}
@@ -205,6 +205,10 @@ public class RipplerContainer extends StackPane {
ripplerFillProperty().set(ripplerFill);
}
public void releaseRippleImmediately() {
buttonRippler.releaseRippleImmediately();
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
@@ -27,6 +27,7 @@ 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.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -113,7 +114,15 @@ public class TaskExecutorDialogPane extends BorderPane {
executor.addTaskListener(new TaskListener() {
@Override
public void onStop(boolean success, TaskExecutor executor) {
Platform.runLater(() -> fireEvent(new DialogCloseEvent()));
Platform.runLater(() -> {
fireEvent(new DialogCloseEvent());
if (Controllers.AUTO_TRIM_HEAP) {
// Trim heap memory when tasks finish, allowing GC to return memory to the OS faster,
// prevent excessive peak memory from consuming system resources.
Platform.runLater(Controllers::trimHeap);
}
});
}
});
}
@@ -33,45 +33,16 @@ import javafx.scene.control.ListCell;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameInstallTask;
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltInstallTask;
import org.jackhuang.hmcl.game.HMCLModpackInstallTask;
import org.jackhuang.hmcl.java.JavaInstallTask;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask;
import org.jackhuang.hmcl.modpack.curse.CurseInstallTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthInstallTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackLocalInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.util.Collection;
@@ -162,58 +133,16 @@ public final class TaskListPane extends StackPane {
if (!task.getSignificance().shouldShow() || task.getName() == null)
return;
if (task instanceof GameAssetDownloadTask) {
task.setName(i18n("assets.download_all"));
} else if (task instanceof GameInstallTask) {
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
return;
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
} else if (task instanceof CleanroomInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
} else if (task instanceof LegacyFabricInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric")));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.neoforge")));
} else if (task instanceof LiteLoaderInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.liteloader")));
} else if (task instanceof OptiFineInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.optifine")));
} else if (task instanceof FabricInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.fabric")));
} else if (task instanceof FabricAPIInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.fabric-api")));
} else if (task instanceof QuiltInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.quilt")));
} else if (task instanceof QuiltAPIInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.quilt-api")));
} else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) {
task.setName(i18n("modpack.completion"));
} else if (task instanceof ModpackInstallTask) {
task.setName(i18n("modpack.installing"));
} else if (task instanceof ModpackUpdateTask) {
task.setName(i18n("modpack.update"));
} else if (task instanceof CurseInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.curse")));
} else if (task instanceof MultiMCModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.multimc")));
} else if (task instanceof ModrinthInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.modrinth")));
} else if (task instanceof ServerModpackLocalInstallTask) {
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
task.setName(i18n("modpack.export"));
} else if (task instanceof MinecraftInstanceTask) {
task.setName(i18n("modpack.scan"));
} else if (task instanceof MojangJavaDownloadTask) {
task.setName(i18n("download.java"));
} else if (task instanceof JavaInstallTask) {
task.setName(i18n("java.installing"));
if (task instanceof GameInstallTask
&& task.getInheritedStage() != null
&& task.getInheritedStage().startsWith("hmcl.install.game")) {
return;
}
@Nullable String localizedName = I18n.translateTaskName(task);
if (localizedName != null)
task.setName(localizedName);
Platform.runLater(() -> {
ProgressListNode node = new ProgressListNode(task);
nodes.put(task, node);
@@ -24,12 +24,21 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.OverrunStyle;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.stream.Collectors;
/// Displays a title with optional tags and a subtitle.
public class TwoLineListItem extends VBox {
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
@@ -39,14 +48,28 @@ public class TwoLineListItem extends VBox {
private final Label lblTitle;
private Label lblSubtitle;
/// Creates an empty item whose children do not receive mouse events.
public TwoLineListItem() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
setMouseTransparent(true);
lblTitle = new Label();
lblTitle.getStyleClass().add("title");
lblTitle.setTextOverrun(OverrunStyle.ELLIPSIS);
this.firstLine = new HBox(lblTitle);
this.firstLine = new HBox(lblTitle) {
/// Includes the tags' natural width when requesting space from the parent.
@Override
protected double computePrefWidth(double height) {
double width = super.computePrefWidth(height);
for (Node child : getManagedChildren()) {
if (child instanceof TagsBox tagsBox) {
width += snapSizeX(tagsBox.computeNaturalPrefWidth(height));
}
}
return width;
}
};
firstLine.getStyleClass().add("first-line");
firstLine.setAlignment(Pos.CENTER_LEFT);
@@ -158,17 +181,29 @@ public class TwoLineListItem extends VBox {
return lblSubtitle;
}
private ObservableList<Label> tags;
private static Label createTag(String tag, PseudoClass pseudoClass) {
var tagLabel = new Label(tag);
tagLabel.getStyleClass().add("tag");
tagLabel.setMinWidth(Label.USE_PREF_SIZE);
if (pseudoClass != null)
tagLabel.pseudoClassStateChanged(pseudoClass, true);
return tagLabel;
}
/// Stores the mutable tag list once tag support has been initialized.
private @Nullable ObservableList<Label> tags;
/// Returns the mutable list of tags displayed after the title.
public ObservableList<Label> getTags() {
if (tags == null) {
tags = FXCollections.observableArrayList();
var tagsBox = new HBox(8);
var tagsBox = new TagsBox();
tagsBox.getStyleClass().add("tags");
tagsBox.setAlignment(Pos.CENTER_LEFT);
tagsBox.setMinWidth(0);
HBox.setHgrow(tagsBox, Priority.ALWAYS);
HBox.setHgrow(tagsBox, Priority.SOMETIMES);
Bindings.bindContent(tagsBox.getChildren(), tags);
var isNotEmpty = Bindings.isNotEmpty(tags);
tagsBox.managedProperty().bind(isNotEmpty);
@@ -176,25 +211,53 @@ public class TwoLineListItem extends VBox {
FXUtils.setOverflowHidden(tagsBox);
lblTitle.setMinWidth(Label.USE_PREF_SIZE);
HBox.setHgrow(lblTitle, Priority.ALWAYS);
lblTitle.setMinWidth(0);
firstLine.getChildren().setAll(lblTitle, tagsBox);
}
return tags;
}
/// Reports its natural width separately from the width used to allocate space within the title row.
@NotNullByDefault
private static final class TagsBox extends HBox {
/// Creates a tag container with zero minimum and preferred allocation widths.
private TagsBox() {
super(8);
// Start at zero during HBox allocation so tags do not compete with the title for space.
setPrefWidth(0);
setMinWidth(0);
}
/// Returns the computed preferred width without applying the zero preferred-width override.
///
/// @param height the available height, or -1 if unspecified
/// @return the natural preferred width of the tags and their container
private double computeNaturalPrefWidth(double height) {
return super.computePrefWidth(height);
}
}
public void addTag(String tag, PseudoClass pseudoClass) {
var tagLabel = new Label(tag);
tagLabel.getStyleClass().add("tag");
tagLabel.setMinWidth(Label.USE_PREF_SIZE);
if (pseudoClass != null)
tagLabel.pseudoClassStateChanged(pseudoClass, true);
getTags().add(tagLabel);
getTags().add(createTag(tag, pseudoClass));
}
public void addTag(String tag) {
addTag(tag, null);
}
public void addTags(Collection<String> tags) {
getTags().addAll(tags.stream().map(tag -> createTag(tag, null)).toList());
}
public void addTagsIfNotExist(Collection<String> tags) {
var current = getTags().stream().map(Label::getText).collect(Collectors.toSet());
var target = new LinkedHashSet<>(tags);
target.removeAll(current);
addTags(target);
}
private static final PseudoClass WARNING_PSEUDO_CLASS = PseudoClass.getPseudoClass("warning");
public void addTagWarning(String tag) {
@@ -23,6 +23,7 @@ import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
@@ -61,6 +62,7 @@ import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogUtils;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -78,6 +80,9 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
/// Coordinates the main window's visual tree, navigation, dialogs, and native window behavior.
@@ -129,6 +134,29 @@ public final class Decorator {
/// The navigation stack rendered in the main window.
private final Navigator navigator = new Navigator();
/// Reflective access to system decoration, or `null` on unsupported runtimes.
private final @Nullable NativeWindowDecoration nativeDecoration = NativeWindowDecoration.create();
/// Whether the attached stage delegates window decoration and gestures to the platform.
private boolean nativeDecorationEnabled;
/// Installs application-specific identity and lifecycle integration on replacement stages before attachment.
private final Consumer<Stage> stageInitializer;
/// Whether an appearance change has already queued a window-style check.
private boolean windowStyleUpdateQueued;
/// Coalesces appearance changes so stage replacement runs after theme bindings have updated.
private final ChangeListener<Boolean> windowStyleListener = (observable, oldValue, newValue) -> {
if (!windowStyleUpdateQueued) {
windowStyleUpdateQueued = true;
Platform.runLater(() -> {
windowStyleUpdateQueued = false;
updateWindowStyle();
});
}
};
/// The clipped main-window content hosted inside [#shadowContainer].
private final MainWindowPane mainWindowPane;
@@ -141,15 +169,18 @@ public final class Decorator {
/// Whether the navigation close action should use the home icon.
private final BooleanProperty showCloseAsHome = new SimpleBooleanProperty(this, "showCloseAsHome");
/// The width of the visible window content, excluding custom decoration insets.
/// The width of the visible window content, excluding native borders and custom shadow padding.
private final DoubleProperty contentWidth = new SimpleDoubleProperty(this, "contentWidth");
/// The height of the visible window content, excluding custom decoration insets.
/// The height of the visible window content, excluding native borders and custom shadow padding.
private final DoubleProperty contentHeight = new SimpleDoubleProperty(this, "contentHeight");
/// The stage currently controlled by this decorator, or `null` while detached.
private @Nullable Stage stage;
/// Tracks the attached stage's content geometry, or `null` while detached.
private @Nullable WindowBounds windowBounds;
/// The title-bar state supplied by the current page, or `null` before navigation is initialized.
private final ObjectProperty<DecoratorPage.@Nullable State> state = new SimpleObjectProperty<>(this, "state");
@@ -198,49 +229,7 @@ public final class Decorator {
private final InvalidationListener stageDecorationListener = observable -> {
@Nullable Stage currentStage = stage;
if (currentStage != null) {
updateWindowDecoration(currentStage.isMaximized() || currentStage.isFullScreen());
}
};
/// Updates the changed visible-content bound and persists normal, non-iconified stage bounds.
private final InvalidationListener stageBoundsListener = observable -> {
@Nullable Stage currentStage = stage;
if (currentStage == null) {
return;
}
Insets insets = getWindowInsets();
boolean saveBounds = !currentStage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|| !currentStage.isFullScreen() && !currentStage.isMaximized());
if (observable == currentStage.xProperty()) {
if (saveBounds) {
double currentContentX = currentStage.getX() + insets.getLeft();
SettingsManager.state().setX(currentContentX / PRIMARY_SCREEN_BOUNDS.getWidth());
}
} else if (observable == currentStage.yProperty()) {
if (saveBounds) {
double currentContentY = currentStage.getY() + insets.getTop();
SettingsManager.state().setY(currentContentY / PRIMARY_SCREEN_BOUNDS.getHeight());
}
} else if (observable == currentStage.widthProperty()) {
double currentContentWidth = Math.max(
MIN_CONTENT_WIDTH,
currentStage.getWidth() - insets.getLeft() - insets.getRight());
contentWidth.set(currentContentWidth);
if (saveBounds) {
SettingsManager.state().setWidth(currentContentWidth);
}
} else if (observable == currentStage.heightProperty()) {
double currentContentHeight = Math.max(
MIN_CONTENT_HEIGHT,
currentStage.getHeight() - insets.getTop() - insets.getBottom());
contentHeight.set(currentContentHeight);
if (saveBounds) {
SettingsManager.state().setHeight(currentContentHeight);
}
updateWindowDecoration(WindowState.isMaximized(currentStage) || currentStage.isFullScreen());
}
};
@@ -251,9 +240,13 @@ public final class Decorator {
///
/// The decorator initially enables its custom window shadow. No scene is created until
/// [#attachStage(Stage)] is called or [#getRoot()] is otherwise installed in a scene.
/// Appearance changes replace the attached stage when required, preserving its retained scene and window state.
///
/// @param mainPage the permanent root page of the navigation stack
public Decorator(Node mainPage) {
/// @param stageInitializer configures each replacement stage on the JavaFX application thread before attachment;
/// it must not show the stage and is not called for stages supplied to [#attachStage(Stage)]
public Decorator(Node mainPage, Consumer<Stage> stageInitializer) {
this.stageInitializer = Objects.requireNonNull(stageInitializer);
navigator.setOnNavigated(this::onNavigated);
mainWindowPane = new MainWindowPane(this);
@@ -285,21 +278,21 @@ public final class Decorator {
return root;
}
/// Returns the insets reserved outside the main-window content.
/// Returns the space reserved inside the scene for custom window shadows.
///
/// @return the root's shadow insets for a normal window, or [Insets#EMPTY] while maximized or full-screen
/// @return the custom shadow insets, or [Insets#EMPTY] with native decoration, supported maximization, or full-screen
public Insets getWindowInsets() {
return root.getPadding();
}
/// Returns the visible window-content width property, excluding custom decoration insets.
/// Returns the visible window-content width property, excluding native borders and custom shadow padding.
///
/// @return the current content-width property
public ReadOnlyDoubleProperty contentWidthProperty() {
return contentWidth;
}
/// Returns the visible window-content height property, excluding custom decoration insets.
/// Returns the visible window-content height property, excluding native borders and custom shadow padding.
///
/// @return the current content-height property
public ReadOnlyDoubleProperty contentHeightProperty() {
@@ -316,11 +309,12 @@ public final class Decorator {
/// Updates the shadow, outer insets, and content corner shape for an edge-to-edge window state.
///
/// @param edgeToEdge whether the attached stage is maximized or full-screen
/// @param edgeToEdge whether the attached stage is maximized under [WindowState]'s policy or full-screen
private void updateWindowDecoration(boolean edgeToEdge) {
root.setPadding(edgeToEdge ? Insets.EMPTY : SHADOW_INSETS);
shadowContainer.setEffect(edgeToEdge ? null : windowShadow);
mainWindowPane.setWindowEdgeToEdge(edgeToEdge);
boolean customDecoration = !nativeDecorationEnabled && !edgeToEdge;
root.setPadding(customDecoration ? SHADOW_INSETS : Insets.EMPTY);
shadowContainer.setEffect(customDecoration ? windowShadow : null);
mainWindowPane.setWindowCornersRounded(customDecoration);
}
/// Returns the pane on which application dialogs are stacked.
@@ -405,7 +399,14 @@ public final class Decorator {
///
/// @param node the drag-enabled node
public void capableDraggingWindow(Node node) {
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> allowMove = true);
if (nativeDecoration != null) {
nativeDecoration.setDraggable(node, true);
}
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
if (!nativeDecorationEnabled) {
allowMove = true;
}
});
node.addEventHandler(MouseEvent.MOUSE_EXITED, event -> {
if (!dragging) {
allowMove = false;
@@ -413,6 +414,19 @@ public final class Decorator {
});
}
/// Configures window dragging through an overlay while retaining ordinary mouse picking.
///
/// Native decoration permits dragging only over underlying draggable header areas. Custom decoration
/// permits dragging from the overlay itself. Interactive content must be excluded with [#forbidDraggingWindow(Node)].
///
/// @param overlay the overlay above the window content and title bar
public void capableDraggingOverlay(Node overlay) {
capableDraggingWindow(overlay);
if (nativeDecoration != null) {
nativeDecoration.setDragTransparent(overlay);
}
}
/// Registers `node` as the title-bar area used to restore and maximize the attached stage.
///
/// @param node the title-bar node
@@ -425,9 +439,14 @@ public final class Decorator {
///
/// @param node the node that must consume drag eligibility
public void forbidDraggingWindow(Node node) {
if (nativeDecoration != null) {
nativeDecoration.setDraggable(node, false);
}
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
allowMove = false;
event.consume();
if (!nativeDecorationEnabled) {
allowMove = false;
event.consume();
}
});
}
@@ -436,14 +455,15 @@ public final class Decorator {
/// @param event the title-bar click event
private void onTitleBarDoubleClick(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
if (nativeDecorationEnabled
|| currentStage == null
|| !WindowState.supportsMaximization(currentStage)
|| event.getButton() != MouseButton.PRIMARY
|| event.getClickCount() != 2) {
return;
}
currentStage.setMaximized(!currentStage.isMaximized());
currentStage.setMaximized(!WindowState.isMaximized(currentStage));
event.consume();
}
@@ -452,7 +472,7 @@ public final class Decorator {
/// @param event the title-bar drag event
private void onTitleBarDragged(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (currentStage == null || dragging || !currentStage.isMaximized()) {
if (nativeDecorationEnabled || currentStage == null || dragging || !WindowState.isMaximized(currentStage)) {
return;
}
@@ -531,10 +551,10 @@ public final class Decorator {
/// @param event the pointer movement event
private void onMouseMoved(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (currentStage == null
if (nativeDecorationEnabled || currentStage == null
|| currentStage.isIconified()
|| currentStage.isFullScreen()
|| currentStage.isMaximized()
|| WindowState.isMaximized(currentStage)
|| !currentStage.isResizable()) {
root.setCursor(Cursor.DEFAULT);
return;
@@ -597,10 +617,10 @@ public final class Decorator {
/// @param event the primary-button drag event
private void onMouseDragged(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (currentStage == null
if (nativeDecorationEnabled || currentStage == null
|| currentStage.isIconified()
|| currentStage.isFullScreen()
|| currentStage.isMaximized()
|| WindowState.isMaximized(currentStage)
|| !event.isPrimaryButtonDown()
|| event.isStillSincePress()) {
return;
@@ -673,13 +693,12 @@ public final class Decorator {
return root.getScene();
}
/// Applies the persisted content bounds and current decoration insets to `targetStage`.
/// Returns the persisted normal content rectangle without native borders or custom shadow padding.
///
/// An off-screen persisted position is replaced with a position centered on the primary screen.
///
/// @param targetStage the stage receiving the initial outer bounds and minimum dimensions
private void initializeStageBounds(Stage targetStage) {
Insets insets = getWindowInsets();
/// @return the initial content bounds in JavaFX screen coordinates
private Rectangle2D initialContentBounds() {
double initialContentWidth = Math.max(MIN_CONTENT_WIDTH, SettingsManager.state().getWidth());
double initialContentHeight = Math.max(MIN_CONTENT_HEIGHT, SettingsManager.state().getHeight());
double initialContentX = SettingsManager.state().getX() * PRIMARY_SCREEN_BOUNDS.getWidth();
@@ -702,14 +721,68 @@ public final class Decorator {
initialContentY = (PRIMARY_SCREEN_BOUNDS.getHeight() - initialContentHeight) / 2;
}
targetStage.setX(initialContentX - insets.getLeft());
targetStage.setY(initialContentY - insets.getTop());
targetStage.setWidth(initialContentWidth + insets.getLeft() + insets.getRight());
targetStage.setHeight(initialContentHeight + insets.getTop() + insets.getBottom());
targetStage.setMinWidth(MIN_CONTENT_WIDTH + insets.getLeft() + insets.getRight());
targetStage.setMinHeight(MIN_CONTENT_HEIGHT + insets.getTop() + insets.getBottom());
contentWidth.set(initialContentWidth);
contentHeight.set(initialContentHeight);
return new Rectangle2D(initialContentX, initialContentY, initialContentWidth, initialContentHeight);
}
/// Persists a normal content rectangle independently of the window's decoration style.
///
/// @param bounds the content bounds in JavaFX screen coordinates
private void saveContentBounds(Rectangle2D bounds) {
SettingsManager.state().setX(bounds.getMinX() / PRIMARY_SCREEN_BOUNDS.getWidth());
SettingsManager.state().setY(bounds.getMinY() / PRIMARY_SCREEN_BOUNDS.getHeight());
SettingsManager.state().setWidth(bounds.getWidth());
SettingsManager.state().setHeight(bounds.getHeight());
}
/// Returns the preferred style for the configured native-decoration policy and current theme.
///
/// @return the system style when available and permitted by the policy; otherwise the custom transparent style
private StageStyle preferredStageStyle() {
return nativeDecoration != null && nativeDecoration.isPreferred(
Themes.windowTransparentProperty().get(), Themes.darkModeProperty().get())
? nativeDecoration.style : StageStyle.TRANSPARENT;
}
/// Replaces the attached stage when transparency or theme brightness requires a different decoration style.
///
/// Preserves the scene, navigation, dialogs, normal bounds, supported window states, and focus owner.
/// Hidden windows remain hidden. Does nothing while detached or when the current style is still suitable.
/// Must run on the JavaFX application thread after theme bindings have updated.
private void updateWindowStyle() {
FXUtils.checkFxUserThread();
@Nullable Stage previousStage = stage;
if (previousStage == null || previousStage.getStyle() == preferredStageStyle()) {
return;
}
boolean showing = previousStage.isShowing();
boolean maximized = WindowState.isMaximized(previousStage);
boolean fullScreen = previousStage.isFullScreen();
boolean iconified = previousStage.isIconified();
@Nullable Node focusOwner = previousStage.getScene().getFocusOwner();
Stage replacement = new Stage();
stageInitializer.accept(replacement);
replacement.setTitle(previousStage.getTitle());
replacement.getIcons().setAll(previousStage.getIcons());
replacement.setOnCloseRequest(previousStage.getOnCloseRequest());
replacement.setResizable(previousStage.isResizable());
replacement.setAlwaysOnTop(previousStage.isAlwaysOnTop());
replacement.setFullScreenExitHint(previousStage.getFullScreenExitHint());
replacement.setFullScreenExitKeyCombination(previousStage.getFullScreenExitKeyCombination());
detachStage();
previousStage.hide();
attachStage(replacement);
replacement.setMaximized(maximized && WindowState.supportsMaximization(replacement));
replacement.setFullScreen(fullScreen);
replacement.setIconified(iconified);
if (showing) {
replacement.show();
}
if (focusOwner != null) {
focusOwner.requestFocus();
}
}
/// Attaches the retained scene and native window behavior to `newStage`.
@@ -717,23 +790,40 @@ public final class Decorator {
/// If the root has no scene, this method reuses `newStage`'s scene and replaces its root, or creates a
/// transparent scene when the stage has none. If the retained scene belongs to another stage, it is detached
/// from that stage before being installed on `newStage`. A newly attached stage receives the persisted normal
/// content bounds and minimum size adjusted for the normal decoration insets. The stage and retained scene use
/// transparent styles required by the custom decoration. Any active window animation is cancelled and reset.
/// When window animations are enabled, each subsequent showing starts the opening animation. This method does
/// not show the stage.
/// content bounds. Native frame offsets and minimum size are resolved when the stage is shown.
/// The native-decoration policy selects the system or custom transparent decoration. Automatic mode uses
/// system decoration for supported opaque windows, except for dark windows on Windows; explicit enablement
/// bypasses the brightness exclusion. Transparent windows always use custom decoration.
/// Any active window animation is cancelled and reset. Custom window
/// animations run only with custom decoration. Window-style listeners remain active until [#detachStage()].
/// On macOS, native application appearance updates are installed once per stage and retained across detachment.
/// This method does not show the stage or invoke the replacement-stage initializer.
///
/// @param newStage the stage to attach, which must be accessed on the JavaFX application thread
/// @return the scene installed on `newStage`
/// @throws IllegalStateException if `newStage` has already been shown with a non-transparent style, or if the
/// @throws IllegalStateException if `newStage` has already been shown with a different style, or if the
/// retained root or scene cannot be transferred from its current owner
public Scene attachStage(Stage newStage) {
FXUtils.checkFxUserThread();
stopWindowAnimation();
if (newStage.getStyle() != StageStyle.TRANSPARENT) {
newStage.initStyle(StageStyle.TRANSPARENT);
if (stage != null && stage != newStage) {
detachStage();
}
StageStyle style = preferredStageStyle();
if (newStage.getStyle() != style) {
newStage.initStyle(style);
}
nativeDecorationEnabled = nativeDecoration != null && style == nativeDecoration.style;
if (nativeDecoration != null) {
nativeDecoration.setContent(null);
if (nativeDecorationEnabled) {
nativeDecoration.configureStage(newStage);
}
}
mainWindowPane.setNativeDecoration(nativeDecorationEnabled ? nativeDecoration : null);
@Nullable Scene retainedScene = root.getScene();
Scene scene;
if (retainedScene == null) {
@@ -759,37 +849,37 @@ public final class Decorator {
}
}
scene.setFill(Color.TRANSPARENT);
if (nativeDecorationEnabled) {
scene.fillProperty().bind(Themes.colorSchemeProperty().getSurfaceContainer());
} else {
scene.fillProperty().unbind();
scene.setFill(Color.TRANSPARENT);
}
if (newStage.getScene() != scene) {
newStage.setScene(scene);
}
if (stage != newStage) {
playRestoreMinimizeAnimation = false;
if (stage != null) {
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
stage.iconifiedProperty().removeListener(iconifiedListener);
stage.maximizedProperty().removeListener(stageDecorationListener);
stage.fullScreenProperty().removeListener(stageDecorationListener);
stage.xProperty().removeListener(stageBoundsListener);
stage.yProperty().removeListener(stageBoundsListener);
stage.widthProperty().removeListener(stageBoundsListener);
stage.heightProperty().removeListener(stageBoundsListener);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Themes.applyNativeDarkMode(newStage);
}
playRestoreMinimizeAnimation = false;
updateWindowDecoration(false);
initializeStageBounds(newStage);
stage = newStage;
updateWindowDecoration(newStage.isMaximized() || newStage.isFullScreen());
newStage.addEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
newStage.iconifiedProperty().addListener(iconifiedListener);
newStage.maximizedProperty().addListener(stageDecorationListener);
newStage.fullScreenProperty().addListener(stageDecorationListener);
newStage.xProperty().addListener(stageBoundsListener);
newStage.yProperty().addListener(stageBoundsListener);
newStage.widthProperty().addListener(stageBoundsListener);
newStage.heightProperty().addListener(stageBoundsListener);
windowBounds = new WindowBounds(newStage, root, initialContentBounds(),
MIN_CONTENT_WIDTH, MIN_CONTENT_HEIGHT, this::saveContentBounds);
contentWidth.bind(windowBounds.contentWidthProperty());
contentHeight.bind(windowBounds.contentHeightProperty());
updateWindowDecoration(WindowState.isMaximized(newStage) || newStage.isFullScreen());
Themes.windowTransparentProperty().addListener(windowStyleListener);
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Themes.darkModeProperty().addListener(windowStyleListener);
}
}
root.setCursor(Cursor.DEFAULT);
dragging = false;
@@ -801,20 +891,27 @@ public final class Decorator {
///
/// The root, scene, navigation state, dialogs, and snackbar remain owned by this decorator and may subsequently
/// be attached to another stage. Calling this method while detached has no effect beyond resetting root transforms.
/// Removes window-style listeners; a queued style check does nothing unless another stage has been attached.
/// Hiding a stage that will later be shown again does not require detachment.
public void detachStage() {
FXUtils.checkFxUserThread();
stopWindowAnimation();
if (windowBounds != null) {
windowBounds.close();
contentWidth.unbind();
contentHeight.unbind();
windowBounds = null;
}
if (stage != null) {
Themes.windowTransparentProperty().removeListener(windowStyleListener);
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Themes.darkModeProperty().removeListener(windowStyleListener);
}
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
stage.iconifiedProperty().removeListener(iconifiedListener);
stage.maximizedProperty().removeListener(stageDecorationListener);
stage.fullScreenProperty().removeListener(stageDecorationListener);
stage.xProperty().removeListener(stageBoundsListener);
stage.yProperty().removeListener(stageBoundsListener);
stage.widthProperty().removeListener(stageBoundsListener);
stage.heightProperty().removeListener(stageBoundsListener);
if (stage.getScene() == root.getScene()) {
stage.setScene(null);
}
@@ -880,8 +977,9 @@ public final class Decorator {
/// Calling this method replaces any active minimize, restore, close, or earlier opening animation. If window
/// animations are disabled, it restores the stable root transform without starting an animation.
private void playOpenAnimation() {
if (!AnimationUtils.playWindowAnimation()) {
if (nativeDecorationEnabled || !AnimationUtils.playWindowAnimation()) {
stopWindowAnimation();
Controllers.trimHeap();
return;
}
@@ -896,7 +994,13 @@ public final class Decorator {
new KeyValue(root.scaleXProperty(), 1, Motion.EASE),
new KeyValue(root.scaleYProperty(), 1, Motion.EASE),
new KeyValue(root.scaleZProperty(), 1, Motion.EASE)));
playWindowAnimation(timeline, this::resetRootTransform);
playWindowAnimation(timeline, () -> {
resetRootTransform();
// Proactively triggering GC after the window open animation typically
// reduces physical memory usage after HMCL startup.
Controllers.trimHeap();
});
// Apply the initial frame before WINDOW_SHOWING returns so the window cannot flash at full opacity.
timeline.jumpTo(Duration.ZERO);
}
@@ -908,7 +1012,7 @@ public final class Decorator {
return;
}
if (AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
@@ -939,7 +1043,7 @@ public final class Decorator {
/// Closes the application, using the configured window animation when enabled.
void closeWindow() {
if (AnimationUtils.playWindowAnimation()) {
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation()) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
@@ -1018,6 +1122,13 @@ public final class Decorator {
return;
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
&& event.getEventType() == KeyEvent.KEY_PRESSED
&& event.isMetaDown()
&& event.getCode() == KeyCode.Q) {
return;
}
Node newTarget;
@Nullable JFXDialogPane currentDialogPane =
(JFXDialogPane) getDialogContainer().getProperties()
@@ -1060,6 +1171,11 @@ public final class Decorator {
/// Restores the root node's transform after an animated minimization.
private void playRestoreAnimation() {
if (nativeDecorationEnabled) {
stopWindowAnimation();
return;
}
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(root.opacityProperty(), 0, Motion.EASE),
@@ -63,7 +63,7 @@ final class MainWindowPane extends StackPane {
/// 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.
/// The content clip, rounded only for normal windows with custom decoration.
private final Rectangle clip = new Rectangle();
/// The frame containing the title bar and current navigation page.
@@ -72,6 +72,9 @@ final class MainWindowPane extends StackPane {
/// The title bar containing page navigation state.
private final BorderPane titleBar;
/// The custom help, minimize, and close buttons shared by both decoration styles.
private final HBox windowButtons;
/// The transition container used when the title-bar state changes.
private final TransitionPane navBarPane;
@@ -112,14 +115,15 @@ final class MainWindowPane extends StackPane {
center.getChildren().setAll(decorator.getNavigator());
frame.setCenter(center);
HBox rightButtonsContainer = createWindowButtons();
windowButtons = createWindowButtons();
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(rightButtonsContainer);
titleBar.setRight(windowButtons);
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
decorator.capableDraggingWindow(navBarPane);
frame.setTop(titleBar);
updateTitleBarBackground();
@@ -136,11 +140,27 @@ final class MainWindowPane extends StackPane {
getChildren().setAll(backgroundNode, frame);
}
/// Updates the content-corner shape for an edge-to-edge window state.
/// Moves the custom title bar into or out of the native dragging header.
///
/// @param edgeToEdge whether the attached window is maximized or full-screen
void setWindowEdgeToEdge(boolean edgeToEdge) {
double arc = edgeToEdge ? 0.0 : ARC;
/// @param decoration the native header support, or `null` for custom decoration
void setNativeDecoration(@Nullable NativeWindowDecoration decoration) {
frame.setTop(null);
if (decoration == null) {
frame.setTop(titleBar);
} else {
decoration.setContent(titleBar);
decoration.headerBar.backgroundProperty().bind(titleBar.backgroundProperty());
frame.setTop(decoration.headerBar);
}
}
/// Enables or disables custom rounding of the content corners.
///
/// Native decoration uses square content bounds so the platform can shape the window outline.
///
/// @param rounded whether to round the content corners
void setWindowCornersRounded(boolean rounded) {
double arc = rounded ? ARC : 0.0;
clip.setArcWidth(arc);
clip.setArcHeight(arc);
}
@@ -171,6 +191,7 @@ final class MainWindowPane extends StackPane {
private HBox createWindowButtons() {
HBox buttons = new HBox();
buttons.setAlignment(Pos.TOP_RIGHT);
decorator.capableDraggingWindow(buttons);
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton helpButton = new JFXButton();
@@ -247,6 +268,7 @@ final class MainWindowPane extends StackPane {
private Node createNavBar(DecoratorPage.State state) {
HBox navBar = new HBox();
navBar.setAlignment(Pos.CENTER_LEFT);
decorator.capableDraggingWindow(navBar);
// Left navigation buttons
if (state.backable()) {
@@ -275,6 +297,7 @@ final class MainWindowPane extends StackPane {
StackPane titleArea = new StackPane();
titleArea.setAlignment(Pos.CENTER_LEFT);
titleArea.setMinWidth(0);
decorator.capableDraggingWindow(titleArea);
HBox.setHgrow(titleArea, Priority.ALWAYS);
if (state.titleNode() != null) {
titleArea.getChildren().setAll(state.titleNode());
@@ -284,6 +307,7 @@ final class MainWindowPane extends StackPane {
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
titleLabel.getStyleClass().add("jfx-decorator-title");
titleLabel.setMinWidth(0);
titleLabel.setMouseTransparent(true);
titleArea.getChildren().setAll(titleLabel);
}
@@ -0,0 +1,245 @@
/*
* 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 javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.collections.ListChangeListener;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.layout.Region;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Accesses the JavaFX 27 extended-window APIs without linking them on older runtimes.
@NotNullByDefault
final class NativeWindowDecoration {
/// Marks nodes whose title-content descendants are already observed for native hit testing.
private static final Object DRAG_CONFIGURED = new Object();
/// Whether the user explicitly enabled native decoration regardless of automatic platform or brightness exclusions.
private final boolean forced;
/// The platform-supported extended stage style.
final StageStyle style;
/// The header bar that supplies native dragging around the custom title content.
final Region headerBar;
/// Assigns the header bar's center content.
private final MethodHandle setCenter;
/// Assigns a node's native header hit-test behavior.
private final MethodHandle setDragType;
/// Reads explicit native drag exclusions on title nodes.
private final MethodHandle getDragType;
/// Sets the system button height to zero to hide the platform-provided buttons.
private final MethodHandle setSystemButtonHeight;
/// Marks only the specified node as draggable, preserving child control interaction.
private final Object draggable;
/// Excludes a node and its descendants from native dragging.
private final Object notDraggable;
/// Ignores an overlay and its descendants when testing underlying native header areas.
private final Object transparentSubtree;
/// Resolves all required public APIs and creates a detached header bar.
///
/// @param forced whether to bypass the automatic theme brightness exclusion
/// @throws Throwable if a required API cannot be resolved or invoked
private NativeWindowDecoration(boolean forced) throws Throwable {
this.forced = forced;
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
style = (StageStyle) lookup.findStaticGetter(StageStyle.class, "EXTENDED", StageStyle.class).invokeExact();
Class<?> headerClass = Class.forName("javafx.scene.layout.HeaderBar");
Class<?> dragClass = Class.forName("javafx.scene.layout.HeaderDragType");
// Adapt runtime-only types once so cached handles can be invoked with exact, statically known signatures.
setCenter = lookup.findVirtual(headerClass, "setCenter", MethodType.methodType(void.class, Node.class))
.asType(MethodType.methodType(void.class, Region.class, Node.class));
setDragType = lookup.findStatic(headerClass, "setDragType", MethodType.methodType(void.class, Node.class, dragClass))
.asType(MethodType.methodType(void.class, Node.class, Object.class));
getDragType = lookup.findStatic(headerClass, "getDragType", MethodType.methodType(dragClass, Node.class))
.asType(MethodType.methodType(Object.class, Node.class));
setSystemButtonHeight = lookup.findStatic(headerClass, "setSystemButtonHeight",
MethodType.methodType(void.class, Stage.class, double.class));
draggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "DRAGGABLE", dragClass).invoke());
notDraggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "NONE", dragClass).invoke());
transparentSubtree = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "TRANSPARENT_SUBTREE", dragClass).invoke());
headerBar = (Region) lookup.findConstructor(headerClass, MethodType.methodType(void.class)).invoke();
}
/// Creates native decoration support according to the configured preference and runtime capabilities.
///
/// The `hmcl.nativeDecoration` system property takes precedence over `HMCL_NATIVE_DECORATION`.
/// Values are case-insensitive and trimmed: `false` disables native decoration, `true` enables it on
/// any supported platform, and `auto` enables it only on macOS or Windows 11 and later. Missing or
/// unrecognized values use `auto`. All modes require JavaFX 27 or later and extended-window support.
/// The preference is read when this method is called and retained by the returned instance.
///
/// @return the resolved support, or `null` when disabled by policy or unavailable on the runtime
static @Nullable NativeWindowDecoration create() {
@Nullable String preference = System.getProperty("hmcl.nativeDecoration", System.getenv("HMCL_NATIVE_DECORATION"));
if (preference != null) {
preference = preference.trim();
}
if ("false".equalsIgnoreCase(preference)) {
return null;
}
boolean forced = "true".equalsIgnoreCase(preference);
if (!forced && preference != null && !"auto".equalsIgnoreCase(preference)) {
LOG.warning("Invalid native decoration preference: " + preference + "; using auto");
}
if (!forced && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS
&& !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11)) {
return null;
}
try {
int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]);
if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) {
return null;
}
return new NativeWindowDecoration(forced);
} catch (Throwable e) {
LOG.warning("Native window decoration is unavailable; using custom decoration", e);
return null;
}
}
/// Returns whether to use native decoration with the current transparency and theme brightness.
///
/// Transparent windows always use custom decoration. Automatic mode also excludes dark windows on Windows;
/// explicitly enabling native decoration bypasses only that brightness exclusion.
///
/// @param transparent whether the theme requests window transparency
/// @param dark whether the current theme is dark
/// @return whether this instance permits native decoration for the supplied theme settings
boolean isPreferred(boolean transparent, boolean dark) {
return !transparent && (forced || !(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && dark));
}
/// Sets or removes the header content; the caller must first detach it from any other parent.
///
/// Configures native dragging on non-interactive descendants and observes later child additions.
/// Controls other than labels and nodes explicitly excluded from dragging retain ordinary input handling.
///
/// @param content the title-bar content, or `null` to detach it
void setContent(@Nullable Node content) {
try {
if (content != null) {
configureTitleDragging(content);
}
setCenter.invokeExact(headerBar, content);
} catch (Throwable e) {
throw new IllegalStateException("Cannot update native header content", e);
}
}
/// Marks a node as draggable or explicitly non-draggable during native header hit testing.
///
/// Draggability does not extend to descendants, so interactive title content retains its behavior.
///
/// @param node the node to configure
/// @param enabled whether the node itself may move the window
void setDraggable(Node node, boolean enabled) {
try {
setDragType.invokeExact(node, enabled ? draggable : notDraggable);
} catch (Throwable e) {
throw new IllegalStateException("Cannot configure native header dragging", e);
}
}
/// Lets native header hit testing pass through an overlay and its descendants.
///
/// Ordinary mouse picking is unchanged. This does not create draggable areas outside the underlying
/// header. Descendants excluded with [#setDraggable(Node, boolean)] block native dragging instead.
///
/// @param overlay the overlay above the header
void setDragTransparent(Node overlay) {
try {
setDragType.invokeExact(overlay, transparentSubtree);
} catch (Throwable e) {
throw new IllegalStateException("Cannot configure native header overlay", e);
}
}
/// Configures native hit testing for title content, including subsequently added descendants.
///
/// Containers and label graphics may move the window. Other controls and nodes explicitly excluded with
/// [#setDraggable(Node, boolean)] retain ordinary input handling, including all of their descendants.
///
/// @param node the title content to configure
private void configureTitleDragging(Node node) {
if (node.getProperties().putIfAbsent(DRAG_CONFIGURED, Boolean.TRUE) != null) {
return;
}
try {
if ((Object) getDragType.invokeExact(node) == notDraggable) {
return;
}
} catch (Throwable e) {
throw new IllegalStateException("Cannot read native header dragging", e);
}
if (node instanceof Control && !(node instanceof Label)) {
setDraggable(node, false);
return;
}
// Native hit testing starts at the deepest picked node; marking only its parent is insufficient.
setDraggable(node, true);
if (node instanceof Parent parent) {
parent.getChildrenUnmodifiable().addListener((ListChangeListener<Node>) change -> {
while (change.next()) {
for (Node child : change.getAddedSubList()) {
configureTitleDragging(child);
}
}
});
for (Node child : parent.getChildrenUnmodifiable()) {
configureTitleDragging(child);
}
}
}
/// Hides platform-provided window buttons.
///
/// @param stage the extended stage to configure
void configureStage(Stage stage) {
try {
setSystemButtonHeight.invokeExact(stage, 0.0);
} catch (Throwable e) {
throw new IllegalStateException("Cannot hide native window buttons", e);
}
}
}
@@ -0,0 +1,307 @@
/*
* 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 javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.layout.Region;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.WindowEvent;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Objects;
import java.util.function.Consumer;
/// Tracks content bounds independently of the stage's native frame and the root's shadow padding.
///
/// All access must occur on the JavaFX application thread. The scene and root must remain attached to the stage
/// until [#close()] is called. Normal bounds are sampled after layout, and once more before hiding or detaching.
/// Maximized bounds under [WindowState]'s policy, full-screen bounds, and iconified bounds are not reported
/// as normal bounds. Transparent macOS windows retain normal bounds despite native zoom notifications.
/// Minimum sizes are configured separately after showing or a display-scale or resizability change;
/// ordinary geometry tracking does not write window constraints.
@NotNullByDefault
final class WindowBounds implements AutoCloseable {
/// The stage whose outer geometry is managed.
private final Stage stage;
/// The scene providing client dimensions and offsets within the native window.
private final Scene scene;
/// The root whose padding reserves space for custom shadows.
private final Region root;
/// The padding used in the normal window state, even when maximized padding is empty.
private final Insets normalInsets;
/// The minimum content width in JavaFX screen coordinates.
private final double minWidth;
/// The minimum content height in JavaFX screen coordinates.
private final double minHeight;
/// Receives normal content bounds in JavaFX screen coordinates.
private final Consumer<Rectangle2D> saveBounds;
/// The latest normal content rectangle, retained through hiding and non-normal window states.
private Rectangle2D normalBounds;
/// The content width excluding both native borders and custom shadow padding.
private final ReadOnlyDoubleWrapper contentWidth = new ReadOnlyDoubleWrapper();
/// The content height excluding both native borders and custom shadow padding.
private final ReadOnlyDoubleWrapper contentHeight = new ReadOnlyDoubleWrapper();
/// Whether the current show operation is still resolving native client offsets.
private boolean restoringPosition;
/// Whether native geometry can be sampled, including during WINDOW_HIDING.
private boolean shown;
/// Whether geometry must be sampled after the next layout pulse.
private boolean dirty;
/// Whether the native minimum needs configuration once normal geometry has stopped changing between pulses.
private boolean minimumSizePending;
/// Whether this tracker has released its listeners.
private boolean closed;
/// Marks geometry for sampling after layout without assuming an order for stage and scene notifications.
private final ChangeListener<Object> boundsListener = (observable, oldValue, newValue) -> invalidate();
/// Compensates for native offsets discovered while creating the window peer.
private final InvalidationListener offsetListener = observable -> restorePosition();
/// Requests new native minimum dimensions when the display scale or resizability changes.
private final ChangeListener<Object> minimumSizeListener = (observable, oldValue, newValue) -> invalidateMinimumSize();
/// Establishes the normal restore rectangle before the native peer becomes visible.
private final EventHandler<WindowEvent> showingHandler = event -> restoreBounds();
/// Completes initial positioning and enables geometry sampling.
private final EventHandler<WindowEvent> shownHandler = event -> {
restorePosition();
restoringPosition = false;
shown = true;
invalidateMinimumSize();
};
/// Samples geometry before JavaFX clears the hidden scene's native offsets.
private final EventHandler<WindowEvent> hidingHandler = event -> {
updateBounds();
shown = false;
restoringPosition = false;
};
/// Samples geometry and separately processes pending minimum-size configuration after layout.
private final Runnable pulseListener = this::onPulse;
/// The properties observed for geometry, state, and padding changes.
private final ObservableValue<?> @Unmodifiable [] observed;
/// The properties that can change the normal native frame dimensions.
private final ObservableValue<?> @Unmodifiable [] minimumSizeObserved;
/// Initializes client sizing and installs listeners on an attached stage and scene.
///
/// The root's current padding must be its normal-state shadow padding. Initial bounds exclude that padding
/// and native decoration. [Stage#sizeToScene()] lets JavaFX determine the required native outer size.
///
/// @param stage the stage with `root` installed as its scene root
/// @param root the scene root, whose minimum and preferred sizes are managed by this tracker
/// @param initialBounds the normal content rectangle to restore
/// @param minWidth the minimum content width
/// @param minHeight the minimum content height
/// @param saveBounds the callback receiving sampled normal content bounds
WindowBounds(Stage stage, Region root, Rectangle2D initialBounds,
double minWidth, double minHeight, Consumer<Rectangle2D> saveBounds) {
this.stage = stage;
this.scene = Objects.requireNonNull(stage.getScene());
this.root = root;
this.normalInsets = root.getPadding();
this.normalBounds = initialBounds;
this.minWidth = minWidth;
this.minHeight = minHeight;
this.saveBounds = saveBounds;
// Enforce minimum content size on the stage, independently of the current page's layout minimum.
root.setMinSize(0, 0);
contentWidth.set(initialBounds.getWidth());
contentHeight.set(initialBounds.getHeight());
observed = new ObservableValue<?>[] {
stage.xProperty(), stage.yProperty(), stage.widthProperty(), stage.heightProperty(),
stage.maximizedProperty(), stage.fullScreenProperty(), stage.iconifiedProperty(),
scene.xProperty(), scene.yProperty(), scene.widthProperty(), scene.heightProperty(),
root.paddingProperty()
};
for (ObservableValue<?> observable : observed) {
observable.addListener(boundsListener);
}
scene.xProperty().addListener(offsetListener);
scene.yProperty().addListener(offsetListener);
minimumSizeObserved = new ObservableValue<?>[] {
stage.outputScaleXProperty(), stage.outputScaleYProperty(), stage.resizableProperty()
};
for (ObservableValue<?> observable : minimumSizeObserved) {
observable.addListener(minimumSizeListener);
}
scene.addPostLayoutPulseListener(pulseListener);
stage.addEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
stage.addEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
stage.addEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight());
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom());
restoreBounds();
if (stage.isShowing()) {
restoringPosition = false;
shown = true;
invalidateMinimumSize();
}
}
/// Returns the content width, excluding native borders and the root's current padding.
ReadOnlyDoubleProperty contentWidthProperty() {
return contentWidth.getReadOnlyProperty();
}
/// Returns the content height, excluding native borders and the root's current padding.
ReadOnlyDoubleProperty contentHeightProperty() {
return contentHeight.getReadOnlyProperty();
}
/// Requests one layout pulse to coalesce geometry notifications.
private void invalidate() {
dirty = true;
Platform.requestNextPulse();
}
/// Schedules native minimum-size configuration independently of ordinary geometry sampling.
private void invalidateMinimumSize() {
minimumSizePending = true;
invalidate();
}
/// Samples dirty geometry, then configures pending minimums on a subsequent quiet layout pulse.
private void onPulse() {
if (closed) {
return;
}
if (dirty) {
updateBounds();
if (minimumSizePending && isNormal()) {
Platform.requestNextPulse();
}
} else if (minimumSizePending && isNormal()) {
updateMinimumSize();
}
}
/// Returns whether the visible stage can supply normal window geometry.
private boolean isNormal() {
return shown && !restoringPosition && !stage.isIconified()
&& !WindowState.isMaximized(stage) && !stage.isFullScreen();
}
/// Requests the normal client size and starts compensating for native client offsets.
private void restoreBounds() {
restoringPosition = true;
root.setPrefSize(normalBounds.getWidth() + normalInsets.getLeft() + normalInsets.getRight(),
normalBounds.getHeight() + normalInsets.getTop() + normalInsets.getBottom());
restorePosition();
stage.sizeToScene();
}
/// Aligns the content origin while the native peer is being initialized.
private void restorePosition() {
if (restoringPosition) {
stage.setX(normalBounds.getMinX() - scene.getX() - normalInsets.getLeft());
stage.setY(normalBounds.getMinY() - scene.getY() - normalInsets.getTop());
}
}
/// Updates content dimensions and saves normal bounds when native geometry is available.
private void updateBounds() {
dirty = false;
if (!shown || restoringPosition || stage.isIconified()) {
return;
}
Insets insets = root.getPadding();
double width = scene.getWidth() - insets.getLeft() - insets.getRight();
double height = scene.getHeight() - insets.getTop() - insets.getBottom();
if (width <= 0 || height <= 0) {
return;
}
contentWidth.set(width);
contentHeight.set(height);
if (WindowState.isMaximized(stage) || stage.isFullScreen()) {
return;
}
normalBounds = new Rectangle2D(
stage.getX() + scene.getX() + insets.getLeft(),
stage.getY() + scene.getY() + insets.getTop(),
Math.max(minWidth, width), Math.max(minHeight, height));
saveBounds.accept(normalBounds);
}
/// Measures the normal native frame and applies content minimums including frame and shadow padding.
private void updateMinimumSize() {
// Stage and Scene sizes arrive separately. Only measure after pending geometry notifications
// have been processed, never from an individual size listener or an ordinary resize pulse.
boolean hasFrame = stage.getStyle() != StageStyle.TRANSPARENT && stage.getStyle() != StageStyle.UNDECORATED;
double frameWidth = hasFrame ? Math.max(0, stage.getWidth() - scene.getWidth()) : 0;
double frameHeight = hasFrame ? Math.max(0, stage.getHeight() - scene.getHeight()) : 0;
minimumSizePending = false;
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight() + frameWidth);
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom() + frameHeight);
}
/// Saves the last available bounds and removes all listeners without hiding or detaching the stage.
/// Repeated calls have no effect.
@Override
public void close() {
if (closed) {
return;
}
updateBounds();
closed = true;
for (ObservableValue<?> observable : observed) {
observable.removeListener(boundsListener);
}
scene.xProperty().removeListener(offsetListener);
scene.yProperty().removeListener(offsetListener);
for (ObservableValue<?> observable : minimumSizeObserved) {
observable.removeListener(minimumSizeListener);
}
scene.removePostLayoutPulseListener(pulseListener);
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
stage.removeEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
stage.removeEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
}
}
@@ -0,0 +1,53 @@
/*
* 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 javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
/// Applies the main window's platform-specific maximization policy.
/// All methods must be called on the JavaFX application thread.
@NotNullByDefault
public final class WindowState {
/// Prevents instantiation of this utility class.
private WindowState() {
}
/// Returns whether the launcher honors maximization for the stage's platform and style.
///
/// Transparent windows on macOS do not support maximization in the launcher: native zoom notifications
/// can mark an ordinary borderless window as maximized, including during initialization.
/// This policy does not suppress full-screen or iconified states.
///
/// @param stage the main stage to inspect
/// @return `false` for transparent stages on macOS; `true` otherwise
public static boolean supportsMaximization(Stage stage) {
// https://github.com/HMCL-dev/HMCL/issues/4290
return OperatingSystem.CURRENT_OS != OperatingSystem.MACOS || stage.getStyle() != StageStyle.TRANSPARENT;
}
/// Returns whether the stage is maximized under the launcher's platform policy.
///
/// @param stage the main stage to inspect
/// @return `true` if maximization is supported and the stage reports that it is maximized
public static boolean isMaximized(Stage stage) {
return supportsMaximization(stage) && stage.isMaximized();
}
}
@@ -22,7 +22,10 @@ import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.RequiredFieldValidator;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
@@ -40,6 +43,7 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
@@ -117,10 +121,12 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
setMessage(i18n("game_directory.already_exists"));
}
/// Rejects names used by other game directories, excluding the entry being edited.
@Override
protected void eval() {
JFXTextField control = (JFXTextField) this.getSrcControl();
hasErrors.set(GameDirectoryManager.getGameDirectories().stream()
.filter(existingGameDirectory -> existingGameDirectory != gameDirectory)
.anyMatch(existingGameDirectory -> Objects.equals(
GameDirectoryManager.getGameDirectoryCustomName(existingGameDirectory), control.getText())));
}
@@ -162,46 +168,56 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
StackPane.setAlignment(saveButton, Pos.BOTTOM_RIGHT);
saveButton.setPrefSize(100, 40);
saveButton.setOnAction(e -> onSave());
String initialLocation = getLocation();
boolean initialUseRelativePath = toggleUseRelativePath.isSelected();
saveButton.disableProperty().bind(Bindings.createBooleanBinding(
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation()),
txtGameDirectoryName.textProperty(), location));
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation())
|| (gameDirectory != null
&& Objects.equals(txtGameDirectoryName.getText(), gameDirectoryDisplayName)
&& Objects.equals(getLocation(), initialLocation)
&& toggleUseRelativePath.isSelected() == initialUseRelativePath),
txtGameDirectoryName.textProperty(), location, toggleUseRelativePath.selectedProperty()));
}
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
Path newPath;
try {
newPath = FileUtils.toAbsolute(Path.of(newValue));
} catch (InvalidPathException ignored) {
return;
}
if (!".minecraft".equals(FileUtils.getName(newPath)))
return;
Path parent = newPath.getParent();
if (parent == null)
return;
String suggestedName = FileUtils.getName(parent);
if (!suggestedName.isBlank()) {
txtGameDirectoryName.setText(suggestedName);
}
};
locationProperty().addListener(locationChangeListener);
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (txtGameDirectoryName.isFocused()) {
txtGameDirectoryName.textProperty().removeListener(this);
locationProperty().removeListener(locationChangeListener);
if (gameDirectory == null) {
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
Path newPath;
try {
newPath = FileUtils.toAbsolute(Path.of(newValue));
} catch (InvalidPathException ignored) {
return;
}
}
});
if (!".minecraft".equals(FileUtils.getName(newPath)))
return;
@Nullable Path parent = newPath.getParent();
if (parent == null)
return;
String suggestedName = FileUtils.getName(parent);
if (!suggestedName.isBlank()) {
txtGameDirectoryName.setText(suggestedName);
}
};
locationChangeListener.changed(locationProperty(), this.location.get(), this.location.get());
locationProperty().addListener(locationChangeListener);
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
/// Stops automatic name suggestions after the user edits the name.
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (txtGameDirectoryName.isFocused()) {
txtGameDirectoryName.textProperty().removeListener(this);
locationProperty().removeListener(locationChangeListener);
}
}
});
}
}
/// Saves the edited game directory or adds a new entry to the appropriate game directory store.
private void onSave() {
@FXThread
private void doSave() {
if (gameDirectory != null) {
LocalizedText name = LocalizedText.plain(txtGameDirectoryName.getText());
PortablePath path = StringUtils.isNotBlank(getLocation()) ? createPortableLocation() : gameDirectory.getPath();
@@ -228,27 +244,39 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
SettingsManager.forceOverwriteUserGameDirectories();
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
} else {
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
SettingsManager.forceOverwriteLocalGameDirectories();
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
}
}
fireEvent(new PageCloseEvent());
}
/// Saves the edited game directory or adds a new entry to the appropriate game directory store.
@FXThread
private void onSave() {
if (Objects.equals(Path.of(getLocation()).getRoot(), Path.of(getLocation()))) {
Controllers.confirm(i18n("game_directory.root"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING, this::doSave, null);
} else doSave();
}
/// Creates the portable path for the current location according to the relative-path toggle.
private PortablePath createPortableLocation() {
if (toggleUseRelativePath.isSelected()) {
@@ -28,9 +28,8 @@ 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.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -41,6 +40,7 @@ import org.jackhuang.hmcl.ui.wizard.Navigation;
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.versioning.GameVersionNumber;
import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -56,39 +56,39 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
protected BooleanProperty installable = new SimpleBooleanProperty();
public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) {
public AbstractInstallersPage(WizardController controller, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
this.controller = controller;
this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle());
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId();
if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue;
library.setOnInstall(() -> {
for (InstallerItem component : group.getComponents()) {
GameComponentType type = component.getComponentType();
if (type == GameComponentType.GAME) continue;
component.setOnInstall(() -> {
if (!Boolean.TRUE.equals(state().getShownTips().get(FABRIC_QUILT_API_TIP))
&& (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId)
|| LibraryAnalyzer.LibraryType.QUILT_API.getPatchId().equals(libraryId)
|| LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId().equals(libraryId))) {
&& (type == GameComponentType.FABRIC_API
|| type == GameComponentType.QUILT_API
|| type == GameComponentType.LEGACY_FABRIC_API)) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + libraryId)),
i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + type.getPatchId())),
i18n("message.warning"),
MessageDialogPane.MessageType.WARNING
).ok(null).addCancel(i18n("button.do_not_show_again"), () -> state().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build());
}
if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState))
if (!(component.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState))
controller.onNext(
new VersionsPage(
controller,
i18n("install.installer.choose", i18n("install.installer." + libraryId)),
i18n("install.installer.choose", i18n("install.installer." + type.getPatchId())),
gameVersion,
downloadProvider,
libraryId,
type,
() -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS)
), Navigation.NavigationDirection.NEXT
);
});
library.setOnRemove(() -> {
controller.getSettings().remove(libraryId);
component.setOnRemove(() -> {
controller.getSettings().remove(type.getPatchId());
reload();
});
}
@@ -166,16 +166,16 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
}
{
InstallerItem[] libraries = control.group.getLibraries();
InstallerItem[] components = control.group.getComponents();
FlowPane libraryPane = new FlowPane(16, 16, libraries);
FlowPane libraryPane = new FlowPane(16, 16, components);
ScrollPane scrollPane = new ScrollPane(libraryPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
root.setCenter(scrollPane);
if (libraries.length <= 8)
if (components.length <= 8)
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
}
@@ -20,46 +20,47 @@ package org.jackhuang.hmcl.ui.download;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.Optional;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
class AdditionalInstallersPage extends AbstractInstallersPage {
protected final BooleanProperty compatible = new SimpleBooleanProperty();
protected final HMCLGameRepository repository;
protected final String gameVersion;
protected final GameVersionNumber gameVersion;
protected final GameInstanceManifest manifest;
protected final HMCLGameInstance instance;
public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
public AdditionalInstallersPage(HMCLGameInstance instance, GameVersionNumber gameVersion, WizardController controller, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
this.instance = instance;
this.gameVersion = gameVersion;
this.manifest = manifest;
this.repository = repository;
this.manifest = instance.getManifest();
txtName.setText(manifest.id().toString());
txtName.setText(instance.getId().id());
txtName.setEditable(false);
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId();
if (libraryId.equals("game")) continue;
library.setOnRemove(() -> {
controller.getSettings().put(libraryId, new UpdateInstallerWizardProvider.RemoveVersionAction(libraryId));
for (InstallerItem component : group.getComponents()) {
if (component.getComponentType() == GameComponentType.GAME) continue;
component.setOnRemove(() -> {
controller.getSettings().put(
component.getComponentType().getPatchId(),
new UpdateInstallerWizardProvider.RemoveComponentAction(component.getComponentType()));
reload();
});
}
installable.bind(Bindings.createBooleanBinding(() -> compatible.get() && txtName.validate(), txtName.textProperty(), compatible));
installable.bind(Bindings.createBooleanBinding(() -> txtName.validate(), txtName.textProperty()));
}
@Override
@@ -69,42 +70,25 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
@Override
public String getTitle() {
return i18n("settings.tabs.installers");
return i18n("install.change_version.title", instance.getId().id());
}
private String getVersion(String id) {
return Optional.ofNullable(controller.getSettings().get(id))
.flatMap(it -> Lang.tryCast(it, RemoteVersion.class))
.map(RemoteVersion::getSelfVersion).orElse(null);
private String getVersion(GameComponentType type) {
return Optional.ofNullable(controller.getSettings().get(type.getPatchId()))
.flatMap(it -> Lang.tryCast(it, ComponentRemoteVersion.class))
.map(ComponentRemoteVersion::getSelfVersion).orElse(null);
}
@Override
protected void reload() {
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);
boolean compatible = true;
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId();
String version = analyzer.getVersion(libraryId).orElse(null);
String libraryVersion = Lang.requireNonNullElse(getVersion(libraryId), version);
boolean alreadyInstalled = version != null && !(controller.getSettings().get(libraryId) instanceof UpdateInstallerWizardProvider.RemoveVersionAction);
if (!"game".equals(libraryId) && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) {
// For third-party libraries, if game version is being changed, and the library is not being reinstalled,
// warns the user that we should update the library.
library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true));
compatible = false;
} else if (alreadyInstalled || getVersion(libraryId) != null) {
library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false));
for (InstallerItem component : group.getComponents()) {
GameComponentType componentType = component.getComponentType();
if (controller.getSettings().containsKey(componentType.getPatchId())) {
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(componentType), false, false));
} else {
library.versionProperty().set(null);
component.versionProperty().set(null);
}
}
this.compatible.set(compatible);
}
@Override
@@ -25,7 +25,11 @@ 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.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLDependencyManager;
import org.jackhuang.hmcl.game.HMCLGameBuilder;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -45,24 +49,22 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
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;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -94,8 +96,8 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
public DownloadPage(GameInstanceID uploadInstance) {
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
"game", versionPageNavigator::onGameSelected)));
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), GameVersionNumber.unknown(), DownloadProviders.getDownloadProvider(),
GameComponentType.GAME, versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file);
@@ -110,7 +112,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteAddonRepository.WORLDS)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance())));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
GameDirectoryManager.registerVersionsListener(this::loadVersions);
@@ -135,53 +137,47 @@ 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 GameInstancePage.GameInstanceLoadable loadable) {
loadable.loadInstance(GameDirectoryManager.getSelectedRepository(), null);
if (node instanceof DownloadListPage page) {
page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()));
}
return node;
};
}
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
if (instanceId == null) {
instanceId = repository.getSelectedInstance();
@Nullable HMCLGameInstance instance = instanceId != null
? repository.findInstance(instanceId)
: repository.getSelectedInstance();
Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory();
var targetPath = runDirectory.resolve(subdirectoryName);
FileNameSet existingPaths;
try {
existingPaths = FileNameSet.list(targetPath, null);
} catch (Exception e) {
LOG.warning("Failed to list folders in " + targetPath, e);
existingPaths = new FileNameSet(false);
}
Path runDirectory = instanceId != null && repository.hasInstance(instanceId) ? repository.getRunDirectory(instanceId) : repository.getBaseDirectory();
Set<String> existingFiles;
try (var list = Files.list(runDirectory.resolve(subdirectoryName))) {
existingFiles = list.map(Path::getFileName)
.map(Path::toString)
.collect(Collectors.toSet());
} catch (IOException e) {
LOG.warning("Failed to list files in " + runDirectory.resolve(subdirectoryName), e);
existingFiles = Set.of();
}
Set<String> finalExistingFiles = existingFiles;
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Path dest = targetPath.resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.file().url()), dest);
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
task.setName(file.name());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else {
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
if (!(exception instanceof CancellationException)) {
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
}
} else {
Controllers.showToast(i18n("install.success"));
}
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
handler.resolve();
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), (it) -> !finalExistingFiles.contains(it)));
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), existingPaths::notContains));
}
@@ -191,19 +187,19 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
if (repository.getGameDirectory() == GameDirectoryManager.getSelectedGameDirectory()) {
listenerHolder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), version -> {
if (modTab.isInitialized()) {
modTab.getNode().loadInstance(repository, null);
modTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (modpackTab.isInitialized()) {
modpackTab.getNode().loadInstance(repository, null);
modpackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (resourcePackTab.isInitialized()) {
resourcePackTab.getNode().loadInstance(repository, null);
resourcePackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (shaderTab.isInitialized()) {
shaderTab.getNode().loadInstance(repository, null);
shaderTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (worldTab.isInitialized()) {
worldTab.getNode().loadInstance(repository, null);
worldTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
}));
}
@@ -290,7 +286,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
private static class VanillaInstallWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependencyManager;
private final HMCLDependencyManager dependencyManager;
private final DownloadProvider downloadProvider;
private final GameRemoteVersion gameVersion;
@@ -305,27 +301,44 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
public void start(SettingsMap settings) {
settings.put(ModpackPage.GAME_DIRECTORY, repository.getGameDirectory());
settings.put(ModpackPage.REPOSITORY, repository);
settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion);
settings.put(GameComponentType.GAME.getPatchId(), gameVersion);
}
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
GameBuilder builder = dependencyManager.newGameBuilder();
/// Builds the selected instance and selects it after successful completion.
///
/// @param settings the installer selections and target instance id
/// @return the builder task with its stage hints preserved as the outermost task wrapper
private Task<?> finishVersionDownloadingAsync(SettingsMap settings) {
@Nullable GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
if (instanceId == null) {
throw new IllegalStateException("Instance ID is not set");
}
GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
builder.name(instanceId);
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) {
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion().toString());
settings.asStringMap().forEach((key, value) -> {
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key)
&& value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
settings.asStringMap().forEach((key, value) -> {
if (!GameComponentType.GAME.getPatchId().equals(key)
&& value instanceof ComponentRemoteVersion remoteVersion)
builder.component(remoteVersion);
});
repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion());
return builder.buildAsync().whenComplete(any -> {
repository.refresh();
repository.applyDefaultIsolationSetting(instanceId);
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
boolean modded = GameComponentType.MOD_LOADERS.stream()
.anyMatch(componentType ->
settings.get(componentType.getPatchId()) instanceof ComponentRemoteVersion);
if (repository.shouldIsolateNewInstance(modded)) {
builder.enableIsolation();
}
Task<?> buildTask = builder.buildAsync();
settings.put(TaskCleanup.KEY, builder::abort);
buildTask.onDone().register(event -> {
if (!event.isFailed()) {
runInFX(() -> repository.setSelectedInstance(repository.getInstance(instanceId)));
}
});
return buildTask;
}
}
@Override
@@ -341,7 +354,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new InstallersPage(controller, repository, ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider);
return new InstallersPage(controller, repository, ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider);
default:
throw new IllegalStateException("error step " + step + ", settings: " + settings + ", pages: " + controller.getPages());
}
@@ -18,8 +18,8 @@
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.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.ui.Controllers;
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -38,7 +39,7 @@ public class InstallersPage extends AbstractInstallersPage {
private boolean isNameModifiedByUser = false;
public InstallersPage(WizardController controller, HMCLGameRepository repository, String gameVersion, DownloadProvider downloadProvider) {
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
txtName.getValidators().addAll(
@@ -52,20 +53,20 @@ public class InstallersPage extends AbstractInstallersPage {
@Override
public String getTitle() {
return ((RemoteVersion) controller.getSettings().get("game")).getGameVersion();
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
}
private String getVersion(String id) {
return I18n.getDisplayVersion((RemoteVersion) controller.getSettings().get(id));
return I18n.getDisplayVersion((ComponentRemoteVersion) controller.getSettings().get(id));
}
protected void reload() {
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId();
for (InstallerItem component : group.getComponents()) {
String libraryId = component.getComponentType().getPatchId();
if (controller.getSettings().containsKey(libraryId)) {
library.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
} else {
library.versionProperty().set(null);
component.versionProperty().set(null);
}
}
if (!isNameModifiedByUser) {
@@ -115,30 +116,25 @@ public class InstallersPage extends AbstractInstallersPage {
private void setTxtNameWithLoaders() {
StringBuilder nameBuilder = new StringBuilder(getTitle());
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), "");
if (!controller.getSettings().containsKey(libraryId)) {
for (InstallerItem component : group.getComponents()) {
if (component.getComponentType() == GameComponentType.GAME
|| !controller.getSettings().containsKey(component.getComponentType().getPatchId()))
continue;
}
LibraryAnalyzer.LibraryType libraryType = LibraryAnalyzer.LibraryType.fromPatchId(libraryId);
String loaderName = switch (component.getComponentType()) {
case FORGE -> "Forge";
case NEO_FORGE -> "NeoForge";
case CLEANROOM -> "Cleanroom";
case LEGACY_FABRIC -> "LegacyFabric";
case FABRIC -> "Fabric";
case LITELOADER -> "LiteLoader";
case QUILT -> "Quilt";
case OPTIFINE -> "OptiFine";
default -> null;
};
if (libraryType != null) {
String loaderName = switch (libraryType) {
case FORGE -> "Forge";
case NEO_FORGE -> "NeoForge";
case CLEANROOM -> "Cleanroom";
case LEGACY_FABRIC -> "LegacyFabric";
case FABRIC -> "Fabric";
case LITELOADER -> "LiteLoader";
case QUILT -> "Quilt";
case OPTIFINE -> "OptiFine";
default -> null;
};
if (loaderName != null)
nameBuilder.append('-').append(loaderName);
}
if (loaderName != null)
nameBuilder.append('-').append(loaderName);
}
txtName.setText(nameBuilder.toString());
@@ -20,11 +20,18 @@ package org.jackhuang.hmcl.ui.download;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableSet;
import javafx.collections.transformation.FilteredList;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackFile;
import org.jackhuang.hmcl.modpack.ModpackManifest;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -39,9 +46,15 @@ import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -51,10 +64,22 @@ public final class LocalModpackPage extends ModpackPage {
private final BooleanProperty installAsVersion = new SimpleBooleanProperty(true);
private Modpack manifest = null;
private Charset charset;
private final ObservableList<ModpackFile> allFiles = FXCollections.observableList(new ArrayList<>());
private final ObservableSet<String> excludedFiles = FXCollections.observableSet(new HashSet<>());
private final BooleanProperty loadingOptionalFiles = new SimpleBooleanProperty(false);
private final BooleanProperty loadedOptionalFiles = new SimpleBooleanProperty(true);
public LocalModpackPage(WizardController controller) {
super(controller);
btnOptionalFiles.setOnAction(ev -> controller.onNext(new OptionalFilesPage(
this::onInstall,
this::loadOptionalFiles,
loadingOptionalFiles,
loadedOptionalFiles,
new FilteredList<>(allFiles, ModpackFile::optional),
excludedFiles)));
HMCLGameRepository repository = controller.getSettings().get(ModpackPage.REPOSITORY);
String name = controller.getSettings().get(MODPACK_NAME);
@@ -103,8 +128,7 @@ public final class LocalModpackPage extends ModpackPage {
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile))
.thenApplyAsync(encoding -> {
charset = encoding;
manifest = ModpackHelper.readModpackManifest(selectedFile, encoding);
return manifest;
return ModpackHelper.readModpackManifest(selectedFile, encoding);
})
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
if (exception instanceof ManuallyCreatedModpackException) {
@@ -126,7 +150,11 @@ public final class LocalModpackPage extends ModpackPage {
LOG.warning("Failed to read modpack manifest", exception);
Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
Platform.runLater(controller::onEnd);
} else if (manifest == null) {
Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
Platform.runLater(controller::onEnd);
} else {
this.manifest = manifest;
hideSpinner();
controller.getSettings().put(MODPACK_MANIFEST, manifest);
nameProperty.set(manifest.getName());
@@ -139,6 +167,33 @@ public final class LocalModpackPage extends ModpackPage {
}
btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription()));
if (manifest.getManifest() instanceof ModpackManifest.SupportOptional supportOptional) {
allFiles.setAll(supportOptional.getFiles());
if (allFiles.stream().anyMatch(ModpackFile::optional)) {
loadOptionalFiles();
btnOptionalFiles.setVisible(true);
btnOptionalFiles.setManaged(true);
}
}
}
}).start();
}
private void loadOptionalFiles() {
Objects.requireNonNull(manifest);
loadingOptionalFiles.set(true);
loadedOptionalFiles.set(false);
Task.supplyAsync(() -> manifest.getManifest().getProvider().loadFiles(DownloadProviders.getDownloadProvider(), manifest.getManifest()))
.whenComplete(Schedulers.javafx(), (manifest1, exception) -> {
loadingOptionalFiles.set(false);
List<? extends ModpackFile> files = ((ModpackManifest.SupportOptional) manifest1).getFiles();
manifest.setManifest(manifest1);
allFiles.setAll(files);
if (files.stream().anyMatch(s -> s.optional() && (!s.addonQueried() || s.fileName() == null))) {
LOG.warning("Failed to load optional files");
} else {
loadedOptionalFiles.set(true);
}
}).start();
}
@@ -157,21 +212,28 @@ public final class LocalModpackPage extends ModpackPage {
i18n("install.name.invalid"),
i18n("message.warning"),
MessageDialogPane.MessageType.QUESTION)
.yesOrNo(() -> {
controller.getSettings().put(MODPACK_NAME, name);
controller.getSettings().put(MODPACK_CHARSET, charset);
controller.onFinish();
}, () -> {
.yesOrNo(() -> finishInstall(name), () -> {
// The user selects Cancel and does nothing.
})
.build());
} else {
controller.getSettings().put(MODPACK_NAME, name);
controller.getSettings().put(MODPACK_CHARSET, charset);
controller.onFinish();
finishInstall(name);
}
}
private void finishInstall(String name) {
controller.getSettings().put(MODPACK_NAME, name);
controller.getSettings().put(MODPACK_CHARSET, charset);
controller.getSettings().put(MODPACK_EXCLUDED_FILES, getExcludedFiles());
controller.onFinish();
}
private @Nullable Set<String> getExcludedFiles() {
if (allFiles.isEmpty())
return null;
return Set.copyOf(excludedFiles);
}
protected void onDescribe() {
if (manifest != null)
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
@@ -183,4 +245,5 @@ public final class LocalModpackPage extends ModpackPage {
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
public static final SettingsMap.Key<String> MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL");
public static final SettingsMap.Key<Set<String>> MODPACK_EXCLUDED_FILES = new SettingsMap.Key<>("MODPACK_EXCLUDED_FILES");
}
@@ -18,10 +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;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
@@ -93,6 +90,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
String iconUrl = settings.get(LocalModpackPage.MODPACK_ICON_URL);
Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET);
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);
var excludedFiles = settings.get(LocalModpackPage.MODPACK_EXCLUDED_FILES);
if (isManuallyCreated) {
return ModpackHelper.getInstallManuallyCreatedModpackTask(selected, name, charset);
@@ -109,9 +107,9 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
}
try {
if (serverModpackManifest != null) {
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)));
} else {
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)), excludedFiles);
}
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
@@ -124,10 +122,10 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
} else {
if (serverModpackManifest != null) {
return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
} else {
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl, excludedFiles)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
}
}
}
@@ -22,6 +22,7 @@ import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.StringProperty;
import javafx.geometry.Pos;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameDirectory;
@@ -49,6 +50,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
protected final JFXTextField txtModpackName;
protected final JFXButton btnInstall;
protected final JFXButton btnDescription;
protected final JFXButton btnOptionalFiles;
protected ModpackPage(WizardController controller) {
this.controller = controller;
@@ -66,7 +68,6 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
txtModpackName = new JFXTextField();
txtModpackName.setPrefWidth(300);
// BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32));
BorderPane.setAlignment(txtModpackName, Pos.CENTER_RIGHT);
archiveNamePane.setRight(txtModpackName);
}
@@ -95,10 +96,19 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
btnDescription.setOnAction(e -> onDescribe());
descriptionPane.setLeft(btnDescription);
var installHBox = new HBox(8);
btnOptionalFiles = FXUtils.newRaisedButton(i18n("modpack.optional_files"));
btnOptionalFiles.setVisible(false);
btnOptionalFiles.setManaged(false);
installHBox.getChildren().add(btnOptionalFiles);
btnInstall = FXUtils.newRaisedButton(i18n("button.install"));
btnInstall.setOnAction(e -> onInstall());
btnInstall.disableProperty().bind(createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty()));
descriptionPane.setRight(btnInstall);
var nameInvalid = createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty());
btnInstall.disableProperty().bind(nameInvalid);
btnOptionalFiles.disableProperty().bind(nameInvalid);
installHBox.getChildren().add(btnInstall);
descriptionPane.setRight(installHBox);
}
componentList.getContent().setAll(
@@ -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.glavo.url.WebURL;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
@@ -125,7 +126,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
try {
if (url.endsWith("server-manifest.json")) {
// if urlString ends with .json, we assume that the url is server-manifest.json
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
Controllers.taskDialog(new GetTask(WebURL.parse(url)).whenComplete(Schedulers.javafx(), (result, e) -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
handler.reject(i18n("modpack.type.server.malformed"));
@@ -0,0 +1,322 @@
/*
* 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.download;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableBooleanValue;
import javafx.collections.ObservableList;
import javafx.collections.ObservableSet;
import javafx.collections.SetChangeListener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.modpack.ModpackFile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.construct.MDListCell;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Wizard page that lets the user choose which optional modpack files to install.
@NotNullByDefault
public class OptionalFilesPage extends SpinnerPane implements WizardPage {
/// Fixed row height used to size [#body] to its items.
private static final double CELL_HEIGHT = 48;
/// Maximum list viewport height before scrolling.
private static final double MAX_LIST_HEIGHT = 320;
/// Keys of files the user unchecked; empty means all optional files will be installed.
private final ObservableSet<String> excludedFiles;
/// Per-file selected state mirrored from [#excludedFiles] (`true` means install / not excluded).
private final Map<String, BooleanProperty> selectedByKey = new HashMap<>();
/// Whether [#selectedByKey] is being updated from an [#excludedFiles] set change.
private boolean updatingFromSet;
/// List view showing optional files.
private final JFXListView<ModpackFile> body = new JFXListView<>();
/// Creates the optional-files selection page.
///
/// @param install callback that finishes the install wizard
/// @param retry callback that reloads optional-file metadata
/// @param loading whether remote metadata is still loading
/// @param successful whether remote metadata loaded successfully
/// @param optionalFiles optional files to display
/// @param excludedFiles mutable set of unchecked optional file keys
public OptionalFilesPage(
Runnable install,
Runnable retry,
ObservableBooleanValue loading,
ObservableBooleanValue successful,
ObservableList<ModpackFile> optionalFiles,
ObservableSet<String> excludedFiles) {
this.excludedFiles = excludedFiles;
excludedFiles.addListener((SetChangeListener<String>) change -> {
updatingFromSet = true;
try {
if (change.wasRemoved()) {
BooleanProperty selected = selectedByKey.get(change.getElementRemoved());
if (selected != null)
selected.set(true);
}
if (change.wasAdded()) {
BooleanProperty selected = selectedByKey.get(change.getElementAdded());
if (selected != null)
selected.set(false);
}
} finally {
updatingFromSet = false;
}
});
VBox borderPane = new VBox();
borderPane.setAlignment(Pos.CENTER);
borderPane.setMaxHeight(Region.USE_PREF_SIZE);
FXUtils.setLimitWidth(borderPane, 500);
ComponentList componentList = new ComponentList();
Label lblRetry = new Label(i18n("modpack.retry_optional_files"));
lblRetry.setOnMouseClicked(e -> retry.run());
VBox tail = new VBox();
{
var descPane = new BorderPane();
HBox selectionButtons = new HBox(8);
JFXButton btnSelectAll = FXUtils.newBorderButton(i18n("button.select_all"));
btnSelectAll.setOnAction(e -> excludedFiles.clear());
JFXButton btnClear = FXUtils.newBorderButton(i18n("button.clear"));
btnClear.setOnAction(e -> {
excludedFiles.clear();
for (ModpackFile file : optionalFiles) {
excludedFiles.add(file.key());
}
});
selectionButtons.getChildren().setAll(btnSelectAll, btnClear);
descPane.setLeft(selectionButtons);
var btnInstall = FXUtils.newRaisedButton(i18n("button.install"));
descPane.setRight(btnInstall);
btnInstall.setOnAction(e -> install.run());
tail.getChildren().add(descPane);
}
// ListView defaults to a large preferred height (~400) regardless of item count, which
// leaves empty viewport space when few optional files exist. Size exactly to content —
// do not add extra pixels, or a blank strip appears under the last row.
body.setFixedCellSize(CELL_HEIGHT);
body.prefHeightProperty().bind(Bindings.createDoubleBinding(
() -> Math.min(MAX_LIST_HEIGHT, optionalFiles.size() * CELL_HEIGHT + 2),
optionalFiles));
body.setMaxHeight(Region.USE_PREF_SIZE);
body.setCellFactory(it -> new OptionalFileEntry(body));
body.setItems(optionalFiles);
Runnable refreshContent = () -> {
if (successful.get()) {
componentList.getContent().setAll(body, tail);
} else {
componentList.getContent().setAll(lblRetry, body, tail);
}
};
refreshContent.run();
successful.addListener((obs, oldVal, newVal) -> refreshContent.run());
borderPane.getChildren().setAll(componentList);
setContent(borderPane);
loadingProperty().bind(loading);
}
/// Returns the selected property for `key`, creating and wiring it on first use.
///
/// @param key the file exclusion key
/// @return a property that is `true` when the file is selected for install
private BooleanProperty selectedProperty(String key) {
return selectedByKey.computeIfAbsent(key, k -> {
BooleanProperty selected = new SimpleBooleanProperty(!excludedFiles.contains(k));
selected.addListener((obs, wasSelected, isSelected) -> {
if (updatingFromSet)
return;
if (isSelected) {
excludedFiles.remove(k);
} else {
excludedFiles.add(k);
}
});
return selected;
});
}
/// List cell for one optional file with checkbox and detail button.
private final class OptionalFileEntry extends MDListCell<ModpackFile> {
private final JFXCheckBox checkBox = new JFXCheckBox();
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton infoButton = new JFXButton();
private final HBox container = new HBox(8);
/// Currently bidirectionally bound selected property, or `null` when unbound.
private @Nullable BooleanProperty boundSelected = null;
/// Creates a cell bound to `listView`.
///
/// @param listView the owning list view
public OptionalFileEntry(JFXListView<ModpackFile> listView) {
super(listView);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(content, Priority.ALWAYS);
content.setMouseTransparent(true);
setSelectable();
container.getChildren().setAll(checkBox, content);
infoButton.getStyleClass().add("toggle-icon4");
infoButton.setGraphic(SVG.INFO.createIcon());
container.getChildren().add(infoButton);
getContainer().getChildren().setAll(container);
}
@Override
protected void updateControl(ModpackFile item, boolean empty) {
if (empty) {
if (boundSelected != null) {
checkBox.selectedProperty().unbindBidirectional(boundSelected);
boundSelected = null;
}
return;
}
String name = item.fileName();
if (name != null) {
content.setTitle(name);
} else {
content.setTitle(i18n("modpack.unknown_optional_file"));
}
RemoteAddon addon = item.remoteAddon();
if (addon != null) {
content.setSubtitle(addon.title());
infoButton.setOnMouseClicked(e -> Controllers.dialog(new ModInfo(addon)));
infoButton.setManaged(true);
infoButton.setVisible(true);
} else {
content.setSubtitle("");
infoButton.setOnMouseClicked(null);
infoButton.setManaged(false);
infoButton.setVisible(false);
}
BooleanProperty selected = OptionalFilesPage.this.selectedProperty(item.key());
if (boundSelected != selected) {
if (boundSelected != null)
checkBox.selectedProperty().unbindBidirectional(boundSelected);
checkBox.selectedProperty().bindBidirectional(selected);
boundSelected = selected;
}
}
}
/// Dialog showing remote addon details for an optional file.
private static final class ModInfo extends JFXDialogLayout {
/// Creates a detail dialog for `addon`.
///
/// @param addon the remote addon
public ModInfo(RemoteAddon addon) {
HBox container = new HBox(8);
SpinnerPane spinnerPane = new SpinnerPane();
ImageView imageView = new ImageView();
imageView.setFitHeight(32);
imageView.setFitWidth(32);
spinnerPane.setContent(imageView);
spinnerPane.setPrefSize(32, 32);
spinnerPane.setLoading(true);
CompletableFuture.supplyAsync(() -> {
try {
return FXUtils.getRemoteImageTask(addon.iconUrl(), 32, 32, true, true).run();
} catch (Exception e) {
throw new RuntimeException(e);
}
}, Schedulers.io()).thenAcceptAsync((image) -> {
imageView.setImage(image);
spinnerPane.setLoading(false);
}, Schedulers.javafx());
container.getChildren().add(spinnerPane);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(addon.title());
title.setSubtitle(addon.author());
container.getChildren().add(title);
setHeading(container);
Label description = new Label(addon.description());
description.setWrapText(true);
description.setPadding(new Insets(8, 0, 0, 0));
setBody(description);
JFXHyperlink pageButton = new JFXHyperlink(i18n("mods.url"));
pageButton.setOnAction(e -> FXUtils.openLink(addon.pageUrl()));
getActions().add(pageButton);
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
@Override
public String getTitle() {
return i18n("modpack.optional_files");
}
@Override
public void cleanup(SettingsMap settings) {
}
}
@@ -20,9 +20,11 @@ package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.game.LibraryDownloadException;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.Task;
@@ -34,11 +36,11 @@ import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.util.ArrayList;
import java.util.concurrent.CancellationException;
import java.util.zip.ZipException;
@@ -46,66 +48,71 @@ import java.util.zip.ZipException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class UpdateInstallerWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final HMCLGameInstance gameInstance;
private final DefaultDependencyManager dependencyManager;
private final String gameVersion;
private final GameInstanceManifest manifest;
private final String libraryId;
private final GameComponentType componentType;
private final String oldLibraryVersion;
private final DownloadProvider downloadProvider;
public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull GameInstanceManifest manifest, @NotNull String libraryId, @Nullable String oldLibraryVersion) {
this.repository = repository;
this.gameVersion = gameVersion;
this.manifest = manifest;
this.libraryId = libraryId;
public UpdateInstallerWizardProvider(@NotNull HMCLGameInstance gameInstance, @NotNull GameComponentType componentType, @Nullable String oldLibraryVersion) {
this.gameInstance = gameInstance;
this.componentType = componentType;
this.oldLibraryVersion = oldLibraryVersion;
this.downloadProvider = DownloadProviders.getDownloadProvider();
this.dependencyManager = repository.getDependency(downloadProvider);
this.dependencyManager = gameInstance.getRepository().getDependency(downloadProvider);
}
@Override
public void start(SettingsMap settings) {
}
/// Creates a task that updates components and synchronizes the client JAR with the resulting manifest.
@Override
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.change_version.process"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next));
// We remove library but not save it,
// so if installation failed will not break down current version.
Task<GameInstanceManifest> ret = Task.supplyAsync(() -> manifest);
var hints = new ArrayList<Task.StagesHint>();
for (Object value : settings.asStringMap().values()) {
if (value instanceof RemoteVersion remoteVersion) {
ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion));
hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion())));
if ("game".equals(remoteVersion.getLibraryId())) {
hints.add(new Task.StagesHint("hmcl.install.libraries"));
if (value instanceof ComponentRemoteVersion remoteVersion) {
hints.add(new Task.StagesHint("hmcl.install.libraries"));
hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getComponentType().getPatchId(), remoteVersion.getSelfVersion())));
if (remoteVersion.getComponentType() == GameComponentType.GAME) {
hints.add(new Task.StagesHint("hmcl.install.assets"));
}
} else if (value instanceof RemoveVersionAction removeVersionAction) {
ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.libraryId));
}
}
return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshAsync()).withStagesHints(hints);
return dependencyManager.updateInstanceAsync(gameInstance.getId(), publishedInstance -> {
Task<GameInstanceManifest> update = Task.supplyAsync(publishedInstance::getManifest);
for (Object value : settings.asStringMap().values()) {
if (value instanceof ComponentRemoteVersion remoteVersion) {
update = update.thenComposeAsync(manifest ->
dependencyManager.installComponentRemoteAsync(publishedInstance, manifest, remoteVersion));
} else if (value instanceof RemoveComponentAction removeComponentAction) {
update = update.thenApplyAsync(manifest ->
manifest.removeComponent(removeComponentAction.componentType));
}
}
return update;
}).withStagesHints(hints);
}
@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." + libraryId)), gameVersion, downloadProvider, libraryId, () -> {
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + componentType.getPatchId())), gameInstance.getVersion(), downloadProvider, componentType, () -> {
if (oldLibraryVersion == null) {
controller.onFinish();
} else if ("game".equals(libraryId)) {
String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion();
controller.onNext(new AdditionalInstallersPage(newGameVersion, manifest, controller, repository, downloadProvider));
} else if (componentType == GameComponentType.GAME) {
GameVersionNumber newGameVersion = ((GameRemoteVersion) settings.get(componentType.getPatchId())).getGameVersion();
controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider));
} else {
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()),
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType.getPatchId()), oldLibraryVersion, ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()),
i18n("install.change_version"), controller::onFinish, controller::onCancel);
}
});
@@ -129,40 +136,38 @@ 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().name()) + "\n";
if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
if (exception.getCause() instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(exception.getCause());
}
Controllers.dialog(message, i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
} else if (exception instanceof DownloadException de) {
String url = de.getUrl();
if (exception.getCause() instanceof SocketTimeoutException) {
Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
Controllers.dialog(i18n("install.failed.downloading.timeout", url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception.getCause() instanceof ResponseCodeException responseCodeException) {
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else if (exception instanceof UnsupportedInstallationException) {
switch (((UnsupportedInstallationException) exception).getReason()) {
case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2:
} else if (exception instanceof UnsupportedInstallationException unsupportedInstallationException) {
switch (unsupportedInstallationException.getReason()) {
case UnsupportedInstallationException.CLEANROOM_NOT_COMPATIBLE_WITH_FORGE ->
Controllers.dialog(i18n("install.failed.cleanroom_not_compatible_with_forge"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2 ->
Controllers.dialog(i18n("install.failed.optifine_forge_1.17"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
break;
default:
default ->
Controllers.dialog(i18n("install.failed.optifine_conflict"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
break;
}
} else if (exception instanceof DefaultDependencyManager.UnsupportedLibraryInstallerException) {
Controllers.dialog(i18n("install.failed.install_online"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
@@ -170,8 +175,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
Controllers.dialog(i18n("install.failed.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
Controllers.dialog(i18n("assets.index.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception instanceof VersionMismatchException) {
VersionMismatchException e = ((VersionMismatchException) exception);
} else if (exception instanceof VersionMismatchException e) {
Controllers.dialog(i18n("install.failed.version_mismatch", e.getExpect(), e.getActual()), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception instanceof CancellationException) {
// Ignore cancel
@@ -180,11 +184,6 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
}
public static class RemoveVersionAction {
private final String libraryId;
public RemoveVersionAction(String libraryId) {
this.libraryId = libraryId;
}
public record RemoveComponentAction(GameComponentType componentType) {
}
}
@@ -31,8 +31,7 @@ import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.VersionList;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
@@ -45,6 +44,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.game.GameComponentType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -65,6 +65,7 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.regex.Pattern;
@@ -76,31 +77,29 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class VersionsPage extends Control implements WizardPage, Refreshable {
private final String gameVersion;
private final String libraryId;
private final @Nullable GameVersionNumber gameVersion;
private final GameComponentType componentType;
private final String title;
private final Navigation navigation;
private final DownloadProvider downloadProvider;
private final VersionList<?> versionList;
private final Runnable callback;
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
private final ObservableList<ComponentRemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
public VersionsPage(Navigation navigation,
String title, String gameVersion,
String title, GameVersionNumber gameVersion,
DownloadProvider downloadProvider,
String libraryId,
GameComponentType componentType,
Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.gameVersion = componentType == GameComponentType.GAME ? null : gameVersion;
this.componentType = componentType;
this.navigation = navigation;
this.downloadProvider = downloadProvider;
this.versionList = downloadProvider.getVersionListById(libraryId);
this.callback = callback;
refresh();
load(false);
}
@Override
@@ -110,9 +109,12 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
@Override
public void refresh() {
load(true);
}
private void load(boolean refresh) {
status.set(Status.LOADING);
Task<?> task = versionList.refreshAsync(gameVersion)
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
Task<?> task = downloadProvider.getVersionsAsync(componentType, gameVersion, refresh)
.whenComplete(Schedulers.javafx(), (items, exception) -> {
if (exception == null) {
versions.setAll(items);
@@ -152,12 +154,13 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
OLD
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
private static class RemoteVersionListCell extends ListCell<ComponentRemoteVersion> {
private final VersionsPage control;
private final TwoLineListItem twoLineListItem = new TwoLineListItem();
private final ImageView imageView = new ImageView();
private final StackPane pane = new StackPane();
private final RipplerContainer ripplerContainer;
RemoteVersionListCell(VersionsPage control) {
this.control = control;
@@ -172,7 +175,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
HBox actions = new HBox(8);
actions.setAlignment(Pos.CENTER);
{
if ("game".equals(control.libraryId)) {
if (control.componentType == GameComponentType.GAME) {
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> onOpenWiki());
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
@@ -188,22 +191,22 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(new RipplerContainer(hbox));
pane.getChildren().setAll(ripplerContainer = new RipplerContainer(hbox));
FXUtils.onClicked(this, this::onAction);
}
private void onAction() {
RemoteVersion item = getItem();
ComponentRemoteVersion item = getItem();
if (item == null)
return;
control.navigation.getSettings().put(control.libraryId, item);
control.navigation.getSettings().put(control.componentType.getPatchId(), item);
control.callback.run();
}
private void onOpenWiki() {
RemoteVersion item = getItem();
ComponentRemoteVersion item = getItem();
if (!(item instanceof GameRemoteVersion))
return;
@@ -211,19 +214,22 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
@Override
public void updateItem(RemoteVersion remoteVersion, boolean empty) {
RemoteVersion oldRemoteVersion = getItem();
public void updateItem(ComponentRemoteVersion remoteVersion, boolean empty) {
ComponentRemoteVersion oldRemoteVersion = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(remoteVersion, empty);
if (oldRemoteVersion == remoteVersion && oldEmpty == empty) return;
ripplerContainer.releaseRippleImmediately();
if (empty) {
setGraphic(null);
return;
}
setGraphic(pane);
if (oldRemoteVersion == remoteVersion) return;
twoLineListItem.setTitle(I18n.getDisplayVersion(remoteVersion));
if (remoteVersion.getReleaseDate() != null) {
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
@@ -234,8 +240,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
twoLineListItem.getTags().clear();
if (remoteVersion instanceof GameRemoteVersion) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
ComponentRemoteVersion.Type versionType = remoteVersion.getVersionType();
GameVersionNumber gameVersion = remoteVersion.getGameVersion();
switch (versionType) {
case RELEASE -> {
@@ -243,8 +249,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
}
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT && gameVersion.isAprilFools()) {
twoLineListItem.addTag(i18n("instance.game.april_fools"));
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
} else {
@@ -284,7 +289,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
iconType = GameInstanceIconType.COMMAND;
imageView.setImage(iconType.getIcon());
String displayGameVersion = I18n.getDisplayVersion(GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()));
String displayGameVersion = I18n.getDisplayVersion(remoteVersion.getGameVersion());
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(displayGameVersion);
@@ -295,7 +300,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
private static final class VersionsPageSkin extends SkinBase<VersionsPage> {
private final JFXListView<RemoteVersion> list;
private final JFXListView<ComponentRemoteVersion> list;
private final TransitionPane transitionPane;
private final JFXSpinner spinner;
@@ -321,7 +326,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
column2.setMaxWidth(150);
ColumnConstraints column3 = new ColumnConstraints();
if (control.versionList.hasType())
if (control.downloadProvider.hasType(control.componentType))
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
else
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
@@ -337,7 +342,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
nameField.setPromptText(i18n("instance.search.prompt"));
nameField.textProperty().addListener(o -> updateList());
if ("game".equals(control.libraryId)) {
if (control.componentType == GameComponentType.GAME) {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
@@ -360,7 +365,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
refreshButton.setOnAction(event -> control.onRefresh());
if (control.versionList.hasType()) {
if (control.downloadProvider.hasType(control.componentType)) {
searchPane.addRow(rowIndex++,
new Label(i18n("instance.search")), nameField,
new Label(i18n("instance.game.type")), categoryField,
@@ -453,20 +458,20 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
private void updateList() {
Stream<RemoteVersion> versions = getSkinnable().versions.stream();
Stream<ComponentRemoteVersion> versions = getSkinnable().versions.stream();
VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem();
if (filter != null)
versions = versions.filter(it -> {
RemoteVersion.Type versionType = it.getVersionType();
ComponentRemoteVersion.Type versionType = it.getVersionType();
return switch (filter) {
case RELEASE -> versionType == RemoteVersion.Type.RELEASE;
case SNAPSHOTS -> versionType == RemoteVersion.Type.SNAPSHOT
|| versionType == RemoteVersion.Type.PENDING
|| versionType == RemoteVersion.Type.UNOBFUSCATED;
case APRIL_FOOLS -> versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
case OLD -> versionType == RemoteVersion.Type.OLD;
case RELEASE -> versionType == ComponentRemoteVersion.Type.RELEASE;
case SNAPSHOTS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
|| versionType == ComponentRemoteVersion.Type.PENDING
|| versionType == ComponentRemoteVersion.Type.UNOBFUSCATED;
case APRIL_FOOLS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
&& it.getGameVersion().isAprilFools();
case OLD -> versionType == ComponentRemoteVersion.Type.OLD;
// case ALL,
default -> true;
};
@@ -19,24 +19,21 @@ 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.game.HMCLGameInstance;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.setting.*;
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.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -47,12 +44,10 @@ import java.util.List;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
public final class ExportWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final GameInstanceID instanceId;
private final HMCLGameInstance gameInstance;
public ExportWizardProvider(HMCLGameRepository repository, GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
public ExportWizardProvider(HMCLGameInstance gameInstance) {
this.gameInstance = gameInstance;
}
@Override
@@ -71,11 +66,11 @@ public final class ExportWizardProvider implements WizardProvider {
}
private Task<?> exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, Path modpackFile) {
Path launcherJar = JarUtils.thisJarPath();
@Nullable Path launcherJar = JarUtils.thisJarPath();
boolean packWithLauncher = exportInfo.isPackWithLauncher() && launcherJar != null;
return new Task<>() {
Path tempModpack;
Task<?> exportTask;
@Nullable Path tempModpack;
@Nullable Task<?> exportTask;
{
setSignificance(TaskSignificance.MODERATE);
@@ -99,9 +94,6 @@ public final class ExportWizardProvider implements WizardProvider {
case ModpackTypeSelectionPage.MODPACK_TYPE_MCBBS:
exportTask = exportAsMcbbs(exportInfo, dest);
break;
case ModpackTypeSelectionPage.MODPACK_TYPE_MULTIMC:
exportTask = exportAsMultiMC(exportInfo, dest);
break;
case ModpackTypeSelectionPage.MODPACK_TYPE_SERVER:
exportTask = exportAsServer(exportInfo, dest);
break;
@@ -136,9 +128,12 @@ public final class ExportWizardProvider implements WizardProvider {
zip.putTextFile(
JsonUtils.GSON.toJson(exportedServers, AuthlibInjectorServerList.class),
".hmcl/config/authlib-injector-servers.json");
zip.putFile(tempModpack, ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType)
// Bundled package under .hmcl/modpack/ (not the process workdir).
String packageName = ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType)
? "modpack.mrpack"
: "modpack.zip");
: "modpack.zip";
zip.putFile(tempModpack, ".hmcl/" + Metadata.BUNDLED_MODPACK_DIRECTORY_NAME + "/" + packageName);
for (String extension : FontManager.FONT_EXTENSIONS) {
String fileName = "font." + extension;
@@ -150,6 +145,13 @@ public final class ExportWizardProvider implements WizardProvider {
}
zip.putFile(launcherJar, launcherJar.getFileName().toString());
} finally {
if (tempModpack != null) {
try {
Files.deleteIfExists(tempModpack);
} catch (Exception ignored) {
}
}
}
}
};
@@ -157,7 +159,7 @@ public final class ExportWizardProvider implements WizardProvider {
private Task<?> exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency = null;
@Nullable Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
@@ -165,55 +167,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile);
}
@Override
public Collection<Task<?>> getDependencies() {
return Collections.singleton(dependency);
}
};
}
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId);
dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(),
new MultiMCInstanceConfiguration(
"OneSix",
exportInfo.getName() + "-" + exportInfo.getVersion(),
null,
Lang.toIntOrNull(setting.getInheritable(GameSettings::permSizeProperty)),
setting.getInheritable(GameSettings::commandWrapperProperty),
setting.getInheritable(GameSettings::preLaunchCommandProperty),
null,
exportInfo.getDescription(),
null,
exportInfo.getJavaArguments(),
setting.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN,
setting.getWidth(),
setting.getHeight(),
null,
exportInfo.getMinMemory(),
setting.getInheritable(GameSettings::showLogsProperty),
/* showConsoleOnError */ true,
/* autoCloseConsole */ false,
/* overrideMemory */ true,
/* overrideJavaLocation */ false,
/* overrideJavaArgs */ true,
/* overrideConsole */ true,
/* overrideCommands */ true,
/* overrideWindow */ true,
/* iconKey */ null // TODO
), modpackFile);
dependency = new McbbsModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
}
@Override
@@ -225,7 +179,7 @@ public final class ExportWizardProvider implements WizardProvider {
private Task<?> exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
@Nullable Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
@@ -233,7 +187,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new ServerModpackExportTask(repository, instanceId, exportInfo, modpackFile);
dependency = new ServerModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
}
@Override
@@ -245,7 +199,7 @@ public final class ExportWizardProvider implements WizardProvider {
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
@Nullable Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
@@ -254,8 +208,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
repository,
instanceId,
resolveCurrentGameInstance(),
exportInfo,
modpackFile
);
@@ -268,12 +221,19 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
/// Returns the current registered snapshot for the instance selected by this wizard.
///
/// @return the current registered instance
private HMCLGameInstance resolveCurrentGameInstance() {
return gameInstance.getRepository().getInstance(gameInstance.getId());
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
return switch (step) {
case 0 -> new ModpackTypeSelectionPage(controller);
case 1 -> new ModpackInfoPage(controller, repository, instanceId);
case 2 -> new ModpackFileSelectionPage(controller, repository, instanceId, ModAdviser::suggestMod);
case 1 -> new ModpackInfoPage(controller, gameInstance);
case 2 -> new ModpackFileSelectionPage(controller, gameInstance, ModAdviser::suggestMod);
default -> throw new IllegalArgumentException("step");
};
}
@@ -18,18 +18,18 @@
package org.jackhuang.hmcl.ui.export;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXCheckTreeCell;
import com.jfoenix.controls.JFXTreeView;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.CheckBoxTreeItem;
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.game.HMCLGameInstance;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -41,6 +41,7 @@ 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.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -62,16 +63,18 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class ModpackFileSelectionPage extends BorderPane implements WizardPage {
private final WizardController controller;
private final GameInstanceID instanceId;
private final HMCLGameInstance gameInstance;
private final ModAdviser adviser;
private @Nullable ModpackFileTreeItem rootNode;
public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId, ModAdviser adviser) {
/// Creates a file-selection page and starts loading the instance's exportable files.
public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) {
this.controller = controller;
this.instanceId = instanceId;
this.gameInstance = gameInstance;
this.adviser = adviser;
JFXTreeView<String> treeView = new JFXTreeView<>();
treeView.setCellFactory(__ -> new ModpackFileTreeCell());
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
onEscPressed(treeView, () -> controller.onPrev(true));
@@ -97,17 +100,17 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
btnNext.setOnAction(e -> onNext());
nextPane.getChildren().setAll(btnNext);
loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext);
spinnerPane.setOnFailedAction((__) -> loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext));
loadRoot(treeView, placeholderPane, spinnerPane, btnNext);
spinnerPane.setOnFailedAction((__) -> loadRoot(treeView, placeholderPane, spinnerPane, btnNext));
this.setBottom(nextPane);
}
private void loadRoot(HMCLGameRepository repository, JFXTreeView<String> treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) {
private void loadRoot(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())
.supplyAsync(() -> getTreeItem(gameInstance.getRunDirectory(), "minecraft", 0), Schedulers.io())
.whenCompleteAsync((root, throwable) -> {
if (throwable == null) {
if (root != null) {
@@ -145,12 +148,12 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
if (fileName.startsWith("._")) // macOS system file
state = ModAdviser.ModSuggestion.HIDDEN;
if (FileUtils.getNameWithoutExtension(file).equals(instanceId.toString()))
if (FileUtils.getNameWithoutExtension(file).equals(gameInstance.getId().toString()))
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (isDirectory) {
if (fileName.equals(instanceId + "-natives")) { // Ignore <version>-natives
if (fileName.equals(gameInstance.getId() + "-natives")) { // Ignore <version>-natives
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (level == 1 && fileName.startsWith("natives-")) { // Ignore natives-os-arch
@@ -161,7 +164,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
return null;
}
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? instanceId.toString() : StringUtils.substringAfterLast(basePath, '/'), basePath);
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? gameInstance.getId().toString() : StringUtils.substringAfterLast(basePath, '/'), basePath);
if (state == ModAdviser.ModSuggestion.SUGGESTED)
node.setSelected(true);
@@ -195,14 +198,16 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
return node;
}
private void getFilesNeeded(ModpackFileTreeItem node, String basePath, List<String> list) {
/// Appends selected paths relative to `minecraft/`, traversing partially selected directories.
/// A `null` node contributes no paths.
private void getFilesNeeded(@Nullable ModpackFileTreeItem node, String basePath, List<String> list) {
if (node == null) return;
if (node.isSelected() || node.isIndeterminate()) {
if (basePath.length() > "minecraft/".length())
list.add(StringUtils.substringAfter(basePath, "minecraft/"));
for (TreeItem<String> child : node.getChildren()) {
if (child instanceof ModpackFileTreeItem mChild) {
getFilesNeeded(mChild, basePath + "/" + mChild.getFileName(), list);
getFilesNeeded(mChild, basePath + "/" + mChild.getValue(), list);
}
}
}
@@ -243,39 +248,69 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
pair("minecraft/scripts", i18n("modpack.files.scripts"))
);
/// Stores a file name, optional explanation, and hierarchical selection state.
@NotNullByDefault
private static final class ModpackFileTreeItem extends CheckBoxTreeItem<String> {
private final String fileName;
/// The localized explanation for a recognized path, or `null` if none exists.
private final @Nullable String comment;
/// Creates an item whose value is the file name; only the `minecraft` root starts expanded.
public ModpackFileTreeItem(String fileName, String basePath) {
this.fileName = fileName;
HBox graphic = new HBox();
JFXCheckBox checkBox = new JFXCheckBox();
checkBox.selectedProperty().bindBidirectional(this.selectedProperty());
checkBox.indeterminateProperty().bindBidirectional(this.indeterminateProperty());
graphic.getChildren().add(checkBox);
{
Label text = new Label(fileName);
text.setMouseTransparent(true);
graphic.getChildren().add(text);
}
if (TRANSLATION.containsKey(basePath)) {
Label comment = new Label(TRANSLATION.get(basePath));
comment.setStyle("-fx-text-fill: -monet-on-surface-variant;");
comment.setMouseTransparent(true);
graphic.getChildren().add(comment);
}
graphic.setPickOnBounds(false);
super(fileName);
this.comment = TRANSLATION.get(basePath);
this.setExpanded("minecraft".equals(basePath));
this.setValue(""); // To disable the default display of text
this.setGraphic(graphic);
}
}
/// Renders file names and localized explanations alongside the bound checkbox.
@NotNullByDefault
private static final class ModpackFileTreeCell extends JFXCheckTreeCell<String> {
/// Contains the inherited checkbox graphic followed by the file labels.
private final HBox content = new HBox();
/// Displays the current file name.
private final Label fileName = new Label();
/// Displays the current path's optional explanation.
private final Label comment = new Label();
/// Creates reusable labels whose mouse events pass through to the cell.
private ModpackFileTreeCell() {
fileName.setMouseTransparent(true);
comment.setStyle("-fx-text-fill: -monet-on-surface-variant;");
comment.setMouseTransparent(true);
content.setAlignment(Pos.CENTER_LEFT);
content.setPickOnBounds(false);
}
public String getFileName() {
return fileName;
}
/// Refreshes the file labels and removes content when the cell is cleared.
@Override
protected void updateDisplay(@Nullable String item, boolean empty) {
content.getChildren().clear();
super.updateDisplay(item, empty);
fileName.setText(null);
comment.setText(null);
if (empty || item == null) {
return;
}
// Updating the text makes the skin reattach the current graphic to the cell.
// Clear it before moving the checkbox graphic into the content container.
setText(null);
@Nullable Node graphic = getGraphic();
if (graphic != null) {
content.getChildren().add(graphic);
}
fileName.setText(item);
content.getChildren().add(fileName);
if (getTreeItem() instanceof ModpackFileTreeItem treeItem && treeItem.comment != null) {
comment.setText(treeItem.comment);
content.getChildren().add(comment);
}
setGraphic(content);
}
}
}
@@ -35,8 +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.game.HMCLGameInstance;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.setting.Accounts;
@@ -65,9 +64,8 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackInfoPage extends Control implements WizardPage {
private final WizardController controller;
private final HMCLGameRepository repository;
private final HMCLGameInstance gameInstance;
private final ModpackExportInfo.Options options;
private final GameInstanceID instanceId;
private final boolean canIncludeLauncher;
private final ModpackExportInfo exportInfo = new ModpackExportInfo();
@@ -88,19 +86,18 @@ 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 repository, GameInstanceID instanceId) {
public ModpackInfoPage(WizardController controller, HMCLGameInstance gameInstance) {
this.controller = controller;
this.repository = repository;
this.gameInstance = gameInstance;
this.options = controller.getSettings().get(MODPACK_INFO_OPTION);
this.instanceId = instanceId;
if (this.options == null)
throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required");
name.set(instanceId.toString());
name.set(gameInstance.getId().toString());
author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse(""));
GameSettings.Effective versionSetting = repository.getEffectiveGameSettings(this.instanceId);
GameSettings.Effective versionSetting = gameInstance.getEffectiveSettings();
minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0));
launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty));
javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty));
@@ -213,7 +210,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.instanceId.toString());
instanceNamePane.setText(skinnable.gameInstance.getId().toString());
list.getContent().add(instanceNamePane);
}
@@ -26,7 +26,6 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.ui.SVG;
@@ -55,7 +54,6 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
this.getChildren().setAll(
title,
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MULTIMC, MultiMCModpackExportTask.OPTION),
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION)
);
@@ -98,7 +96,6 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
public static final SettingsMap.Key<String> MODPACK_TYPE = new SettingsMap.Key<>("modpack.type");
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
public static final String MODPACK_TYPE_MULTIMC = "multimc";
public static final String MODPACK_TYPE_SERVER = "server";
public static final String MODPACK_TYPE_MODRINTH = "modrinth";
}
@@ -27,6 +27,7 @@ import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
@@ -76,7 +77,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// @author Glavo
@NotNullByDefault
public final class GameSettingsPage<S extends GameSettings> extends StackPane
implements DecoratorPage, GameInstancePage.GameInstanceLoadable, PageAware {
implements DecoratorPage, PageAware {
private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object();
private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden");
@@ -88,20 +89,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
private final ObjectProperty<State> state = new SimpleObjectProperty<>(this, "state", new State("", null, false, false, false));
private final WeakListenerHolder holder = new WeakListenerHolder();
/// The selected game directory.
private @Nullable GameDirectory gameDirectory;
/// The selected repository.
private @Nullable HMCLGameRepository repository;
/// The current instance ID.
private @Nullable GameInstanceID instanceId;
/// The current game instance when editing instance settings, or `null` for preset settings.
private final ObjectProperty<@Nullable HMCLGameInstance> gameInstance =
new SimpleObjectProperty<>(this, "gameInstance");
/// 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;
@@ -129,12 +123,24 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
private final InvalidationListener javaListener = o -> refreshJavaSettings();
private final InvalidationListener weakJavaListener = holder.weak(javaListener);
public GameSettingsPage(Class<S> settingType) {
/// Creates a settings page.
///
/// @param settingType [GameSettings.Instance] or [GameSettings.Preset]
/// @param instanceContext parent instance property for instance settings; ignored for presets and may be `null`
public GameSettingsPage(
Class<S> settingType,
@Nullable ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
assert settingType == GameSettings.Preset.class || settingType == GameSettings.Instance.class;
this.isPresetSetting = settingType == GameSettings.Preset.class;
if (!isPresetSetting) {
bindActiveParentSetting();
Objects.requireNonNull(instanceContext, "instanceContext");
holder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
this.scrollPane = new ScrollPane();
@@ -790,21 +796,23 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
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;
InvalidationListener updateGraphicsVisibility = o -> {
GameVersionNumber version = currentGameVersion();
boolean showBackendChoose = isPresetSetting || version.compareTo("26.2-snapshot-2") >= 0;
graphicsBackendPane.setVisible(showBackendChoose);
graphicsBackendPane.setManaged(showBackendChoose);
boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(newValue);
boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(version);
openGLRendererPane.setVisible(showOpenGL);
openGLRendererPane.setManaged(showOpenGL);
boolean showVulkan = GraphicsAPI.VULKAN.isSupported(newValue);
boolean showVulkan = isPresetSetting || GraphicsAPI.VULKAN.isSupported(version);
vulkanRendererPane.setVisible(showVulkan);
vulkanRendererPane.setManaged(showVulkan);
});
};
this.gameInstance.addListener(updateGraphicsVisibility);
updateGraphicsVisibility.invalidated(this.gameInstance);
}
var nativeLibrarySettings = new ComponentList();
@@ -821,20 +829,24 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
nativeLibrarySettings.getContent().add(nativesDirPane);
nativesDirPane.setTitle(i18n("settings.advanced.natives_directory"));
{
var txtNativesDir = new JFXTextField();
txtNativesDir.setPrefWidth(400);
nativesDirPane.setRight(txtNativesDir);
bindIndependentTextField(nativesDirPane, txtNativesDir, GameSettings::nativesDirectoryProperty);
var nativesDirSelector = new FileSelector()
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
nativesDirSelector.setPrefWidth(400);
nativesDirPane.setRight(nativesDirSelector);
bindInheritableStringProperty(
nativesDirPane,
nativesDirSelector.valueProperty(),
GameSettings::nativesDirectoryProperty);
}
var noNativesPatchPane = createIndependentBooleanButton(GameSettings::notPatchNativesProperty);
nativeLibrarySettings.getContent().add(noNativesPatchPane);
noNativesPatchPane.setTitle(i18n("settings.advanced.dont_patch_natives"));
var useNativeGLFWPane = createIndependentBooleanButton(GameSettings::useNativeGLFWProperty);
nativeLibrarySettings.getContent().add(useNativeGLFWPane);
useNativeGLFWPane.setTitle(i18n("settings.advanced.use_native_glfw"));
useNativeGLFWPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only"));
var useNativeGLFWORsdlPane = createIndependentBooleanButton(GameSettings::useNativeGLFWorSDLProperty);
nativeLibrarySettings.getContent().add(useNativeGLFWORsdlPane);
useNativeGLFWORsdlPane.setTitle(i18n("settings.advanced.use_native_glfw_or_sdl"));
useNativeGLFWORsdlPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only"));
var useNativeOpenALPane = createIndependentBooleanButton(GameSettings::useNativeOpenALProperty);
nativeLibrarySettings.getContent().add(useNativeOpenALPane);
@@ -1860,16 +1872,20 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
private boolean isCurrentInstanceModpack() {
return repository != null && instanceId != null && repository.isModpack(instanceId);
HMCLGameInstance gameInstance = this.gameInstance.get();
return gameInstance != null && gameInstance.isModpack();
}
/// Returns the current instance version root displayed for modpack running directories.
private String getCurrentInstanceVersionRoot() {
if (repository == null || instanceId == null) {
return "";
}
HMCLGameInstance gameInstance = this.gameInstance.get();
return gameInstance != null ? gameInstance.getInstanceRoot().toString() : "";
}
return repository.getInstanceRoot(instanceId).toString();
/// Returns the Minecraft version of the loaded instance, or [GameVersionNumber#unknown()] for presets.
private GameVersionNumber currentGameVersion() {
HMCLGameInstance gameInstance = this.gameInstance.get();
return gameInstance != null ? gameInstance.getVersion() : GameVersionNumber.unknown();
}
/// Keeps a listener attached to the current instance's parent preset property.
@@ -2589,8 +2605,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
/// Returns the runtime parent preset for an instance, including the game directory's migrated preset fallback.
private GameSettings.Preset getEffectiveParentGameSettings(GameSettings.Instance instance) {
if (repository != null) {
return repository.getParentGameSettings(instance);
HMCLGameInstance gameInstance = this.gameInstance.get();
if (gameInstance != null) {
return gameInstance.getRepository().getParentGameSettings(instance);
}
return getExplicitParentGameSettings(instance);
@@ -2629,26 +2646,21 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
@SuppressWarnings("unchecked")
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.gameDirectory = repository.getGameDirectory();
this.repository = repository;
this.instanceId = instanceId;
public void loadInstance(HMCLGameInstance.Optional instance) {
HMCLGameInstance gameInstance = instance.instance();
this.gameInstance.set(gameInstance);
assert isPresetSetting == (instanceId == null);
assert isPresetSetting == (gameInstance == null);
if (instanceId != null) {
this.currentGameVersionNumber.set(GameVersionNumber.asGameVersion(repository.getGameVersion(instanceId)));
@Nullable GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
if (gameInstance != null) {
@Nullable GameSettings.Instance setting = gameInstance.getSettingsOrCreate();
this.currentSetting.set((S) setting);
setSettingsReadOnly(
setting == null || repository.isInstanceGameSettingsReadOnly(instanceId),
setting == null || gameInstance.isSettingsReadOnly(),
i18n("settings.game.instance_settings.unsupported"),
setting != null ? this::forceOverwriteInstanceGameSettings : null);
loadIcon();
} else {
this.currentGameVersionNumber.set(GameVersionNumber.unknown());
this.currentSetting.set((S) SettingsManager.getDefaultGameSettingsPresetOrCreate());
setSettingsReadOnly(
SettingsManager.isGameSettingsReadOnly(),
@@ -2657,11 +2669,6 @@ 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
@@ -2701,13 +2708,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() {
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null) {
HMCLGameInstance gameInstance = this.gameInstance.get();
if (gameInstance == null) {
return;
}
Controllers.confirmBackupAndOverwrite(i18n("settings.game.instance_settings.unsupported"), () -> {
repository.forceOverwriteInstanceGameSettings(loadedInstanceId);
gameInstance.forceOverwriteSettings();
setSettingsReadOnly(false, "");
});
}
@@ -2721,11 +2728,12 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
private void loadIcon() {
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null)
HMCLGameInstance gameInstance = this.gameInstance.get();
if (gameInstance == null) {
return;
}
iconPickerItem.setImage(repository.getInstanceIconImage(loadedInstanceId));
iconPickerItem.setImage(gameInstance.getIconImage());
}
/// Refreshes Java selection controls and keeps inherited parent Java properties observed.
@@ -2789,17 +2797,19 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
private void initJavaSubtitle() {
S setting = currentSetting.get();
if (setting == null || gameDirectory == null)
if (setting == null) {
return;
}
initializeSelectedJava();
JavaVersionType javaVersionType = setting.javaTypeProperty().getValue();
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
GameSettings.Effective effectiveSetting = loadedInstanceId != null && repository != null ? repository.getEffectiveGameSettings(loadedInstanceId) : null;
HMCLGameInstance gameInstance = this.gameInstance.get();
@Nullable GameSettings.Effective effectiveSetting =
gameInstance != null ? gameInstance.getEffectiveSettings() : null;
JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getInheritable(GameSettings::javaTypeProperty) : javaVersionType;
boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION;
if (instanceId == null && autoSelected) {
if (gameInstance == null && autoSelected) {
javaSublist.setDescription(i18n("settings.game.java_directory.auto"));
return;
}
@@ -2811,13 +2821,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
if (JavaManager.isInitialized()) {
GameVersionNumber gameVersionNumber = this.currentGameVersionNumber.get();
GameInstanceManifest manifest;
if (this.instanceId == null) {
manifest = null;
} else {
manifest = repository != null && loadedInstanceId != null ? repository.getResolvedInstanceManifest(loadedInstanceId).launchManifest() : null;
}
GameVersionNumber gameVersionNumber = currentGameVersion();
GameInstanceManifest manifest = gameInstance != null ? gameInstance.getResolvedManifest() : null;
try {
JavaRuntime java = effectiveSetting != null
@@ -2837,19 +2842,21 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
private void onExploreIcon() {
if (repository == null || instanceId == null)
HMCLGameInstance gameInstance = this.gameInstance.get();
if (gameInstance == null) {
return;
Controllers.dialog(new GameInstanceIconDialog(repository, instanceId, this::loadIcon));
}
Controllers.dialog(new GameInstanceIconDialog(gameInstance, this::loadIcon));
}
private void onDeleteIcon() {
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null)
HMCLGameInstance gameInstance = this.gameInstance.get();
if (gameInstance == null) {
return;
}
repository.deleteIconFile(loadedInstanceId);
GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(loadedInstanceId);
gameInstance.deleteIconFile();
GameSettings.Instance localGameSettings = gameInstance.getSettingsOrCreate();
if (localGameSettings != null) {
localGameSettings.iconProperty().setValue(GameInstanceIconType.DEFAULT);
}
@@ -51,6 +51,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -84,38 +85,37 @@ public final class ImageUtils {
LoaderParameters parameters = new LoaderParameters();
parameters.autoStartAnimations = false;
@Nullable SVGImage image;
Supplier<Image> supplier = () -> {
SVGImage image = SVGLoader.load(content, parameters);
var snapshotParameters = new SnapshotParameters();
snapshotParameters.setFill(Color.TRANSPARENT);
if (requestedWidth <= 0. || requestedHeight <= 0.) {
return image.toImage(snapshotParameters);
}
double scaleX = requestedWidth / image.getScaledWidth();
double scaleY = requestedHeight / image.getScaledHeight();
if (preserveRatio || scaleX == scaleY) {
double scale = Math.min(scaleX, scaleY);
return image.scale(scale).toImage(snapshotParameters);
} else {
// FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS
return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY);
}
};
@Nullable Image image;
if (Platform.isFxApplicationThread()) {
image = SVGLoader.load(content, parameters);
image = supplier.get();
} else {
// TODO: Currently, SVGLoader.load(...) requires the javafx.swing module if it operates on a non-JavaFX thread.
image = CompletableFuture.supplyAsync(
() -> SVGLoader.load(content, parameters),
Schedulers.javafx()
).get();
image = CompletableFuture.supplyAsync(supplier, Schedulers.javafx()).get();
}
if (image == null)
throw new IOException("Failed to load SVG image");
var snapshotParameters = new SnapshotParameters();
snapshotParameters.setFill(Color.TRANSPARENT);
if (requestedWidth <= 0. || requestedHeight <= 0.) {
return image.toImage(snapshotParameters);
}
double scaleX = requestedWidth / image.getScaledWidth();
double scaleY = requestedHeight / image.getScaledHeight();
if (preserveRatio || scaleX == scaleY) {
double scale = Math.min(scaleX, scaleY);
return image.scale(scale).toImage(snapshotParameters);
} else {
// FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS
return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY);
}
return image;
};
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
@@ -342,7 +342,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
RemoteAddonRepository repo = object.data.source().getRepository();
Task.supplyAsync(() -> {
if (object.changelog != null) {
return object.changelog;
@@ -368,7 +368,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
Task.supplyAsync(() -> {
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
RemoteAddonRepository repo = object.data.source().getRepository();
return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion());
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
@@ -27,7 +27,6 @@ import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.transformation.FilteredList;
@@ -46,7 +45,6 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -55,15 +53,13 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.IntStream;
@@ -263,11 +259,13 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final BooleanProperty activeProperty;
private final DataPack.Pack packInfo;
private SoftReference<CompletableFuture<Image>> iconCache;
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
DataPackInfoObject(DataPack.Pack packInfo) {
this.packInfo = packInfo;
this.activeProperty = packInfo.activeProperty();
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
String getTitle() {
@@ -282,7 +280,11 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
return packInfo;
}
Image loadIcon() {
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image image = null;
Path imagePath;
if (this.getPackInfo().isDirectory()) {
@@ -291,7 +293,6 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
} else {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(getPackInfo().getPath())) {
@@ -301,7 +302,6 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
}
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
}
@@ -309,36 +309,9 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
} else {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
return getDefaultIcon();
}
}
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<DataPackInfoObject>> current) {
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
CompletableFuture<Image> imageFuture;
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
Image image = imageFuture.getNow(null);
if (image != null) {
imageContainer.setImage(image);
return;
}
} else {
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
this.iconCache = new SoftReference<>(imageFuture);
}
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
imageFuture.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<DataPackInfoObject> infoObjectProperty = current.get();
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
// The current ListCell has already switched to another object
return;
}
}
imageContainer.setImage(image);
}, Schedulers.javafx());
}
}
private final class DataPackInfoListCell extends MDListCell<DataPackInfoObject> {
@@ -377,7 +350,7 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.activeProperty);
dataItem.loadIcon(imageContainer, new WeakReference<>(this.itemProperty()));
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
}
}
@@ -37,13 +37,12 @@ import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -59,7 +58,6 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.util.*;
import java.util.stream.Collectors;
@@ -68,12 +66,12 @@ import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
public class DownloadListPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable {
public class DownloadListPage extends Control implements DecoratorPage {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final boolean instanceSelection;
private final ObjectProperty<HMCLGameRepository.InstanceReference> instanceReference = new SimpleObjectProperty<>();
private final ObjectProperty<HMCLGameInstance.Optional> instanceReference = new SimpleObjectProperty<>();
private final IntegerProperty pageOffset = new SimpleIntegerProperty(0);
private final IntegerProperty pageCount = new SimpleIntegerProperty(-1);
private final ListProperty<RemoteAddon> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
@@ -87,16 +85,18 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
protected final StringProperty downloadSource = new SimpleStringProperty();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private int searchID = 0;
protected RemoteAddonRepository repository;
protected final RemoteAddonRepository repository;
protected final @NotNull RemoteAddon.Type type;
private final DownloadProvider downloadProvider;
private Runnable retrySearch;
public DownloadListPage(RemoteAddonRepository repository) {
this(repository, null, false);
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository) {
this(type, repository, null, false);
}
public DownloadListPage(RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
this.type = Objects.requireNonNull(type);
this.repository = repository;
this.callback = callback;
this.instanceSelection = instanceSelection;
@@ -111,23 +111,24 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
return actions;
}
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId));
public void loadInstance(HMCLGameInstance.Optional instance) {
this.instanceReference.set(instance);
setLoading(false);
setFailed(false);
if (!searchInitialized) {
searchInitialized = true;
search("", null, 0, "", RemoteAddonRepository.SortType.POPULARITY);
search("", null, 0, "", RemoteAddonRepository.SortType.RELEVANCY);
}
if (instanceSelection) {
instances.setAll(repository.getDisplayInstanceManifests()
.map(GameInstanceManifest::id)
HMCLGameRepository repository = instance.repository();
instances.setAll(repository.getDisplayInstances()
.map(DefaultGameInstance::getId)
.toList());
selectedInstance.set(repository.getSelectedInstance());
@Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance();
selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null);
}
}
@@ -159,6 +160,10 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
}
public DownloadPage.DownloadCallback getCallback() {
return callback;
}
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
retrySearch = null;
setLoading(true);
@@ -166,14 +171,16 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
int currentSearchID = searchID = searchID + 1;
Task.supplyAsync(() -> {
HMCLGameRepository.InstanceReference instanceReference = this.instanceReference.get();
if (instanceReference.instanceId() == null) {
HMCLGameInstance.Optional instanceReference = this.instanceReference.get();
@Nullable HMCLGameInstance instance = instanceReference.instance();
if (instance == null) {
return userGameVersion;
} else {
return instanceReference.repository().getGameVersion(instanceReference.instanceId()).orElse("");
GameVersionNumber version = instance.getVersion();
return version != GameVersionNumber.unknown() ? version.toString() : "";
}
}).thenApplyAsync(
gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
gameVersion -> repository.search(downloadProvider, type, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (searchID != currentSearchID) {
return;
@@ -181,8 +188,8 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
setLoading(false);
if (exception == null) {
items.setAll(result.getResults().collect(Collectors.toList()));
pageCount.set(result.getTotalPages());
items.setAll(result.results().collect(Collectors.toList()));
pageCount.set(result.totalPages());
failed.set(false);
} else {
failed.set(true);
@@ -217,10 +224,10 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
}
}
protected HMCLGameRepository.InstanceReference getInstanceReference() {
protected HMCLGameInstance.Optional getInstanceOptional() {
if (instanceSelection) {
@Nullable GameInstanceID instanceId = selectedInstance.get();
return new HMCLGameRepository.InstanceReference(instanceReference.get().repository(), instanceId);
return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId);
} else {
return instanceReference.get();
}
@@ -247,8 +254,8 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
iconLoader = new RemoteImageLoader(control.downloadProvider) {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris) {
return FXUtils.getRemoteImageTask(uris, 80, 80, true, true);
protected @NotNull Task<Image> createLoadTask(@NotNull DownloadCandidates urls) {
return FXUtils.getRemoteImageTask(urls, 80, 80, true, true);
}
};
@@ -349,7 +356,7 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryComboBox.getSelectionModel().select(0);
Task.supplyAsync(() -> getSkinnable().repository.getCategories())
Task.supplyAsync(() -> getSkinnable().repository.getCategories(getSkinnable().type))
.thenAcceptAsync(Schedulers.javafx(), categories -> {
if (!Objects.equals(getSkinnable().downloadSource.get(), downloadSource)) {
return;
@@ -370,7 +377,7 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
sortStackPane.getChildren().setAll(sortComboBox);
sortComboBox.prefWidthProperty().bind(sortStackPane.widthProperty());
sortComboBox.getStyleClass().add("fit-width");
sortComboBox.setConverter(stringConverter(sortType -> i18n("curse.sort." + sortType.name().toLowerCase(Locale.ROOT))));
sortComboBox.setConverter(stringConverter(sortType -> i18n("addon.sort." + sortType.name().toLowerCase(Locale.ROOT))));
sortComboBox.getItems().setAll(RemoteAddonRepository.SortType.values());
sortComboBox.getSelectionModel().select(0);
searchPane.addRow(rowIndex++, new Label(i18n("addon.category")), categoryStackPane, new Label(i18n("search.sort")), sortStackPane);
@@ -570,7 +577,7 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
FXUtils.onClicked(wrapper, () -> {
RemoteAddon item = getItem();
if (item != null)
Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceReference(), getSkinnable().callback));
Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceOptional(), getSkinnable().callback));
});
setPrefWidth(0);
@@ -581,7 +588,15 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
@Override
protected void updateItem(RemoteAddon item, boolean empty) {
RemoteAddon oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.releaseRippleImmediately();
if (empty || item == null) {
setGraphic(null);
} else {
@@ -591,7 +606,7 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
: PADDING
);
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().type).getModByCurseForgeId(item.slug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.title());
String description = item.description();
if (description != null) {
@@ -29,11 +29,9 @@ import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
@@ -53,6 +51,7 @@ import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
@@ -69,18 +68,18 @@ public class DownloadPage extends Control implements DecoratorPage {
private final ModTranslations translations;
private final RemoteAddon addon;
private final ModTranslations.Mod mod;
private final HMCLGameRepository.InstanceReference instanceReference;
private final HMCLGameInstance.Optional instanceReference;
private final DownloadCallback callback;
private final DownloadListPage page;
private final RemoteAddon.Type type;
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> versions;
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference, @Nullable DownloadCallback callback) {
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) {
this.page = page;
this.repository = page.repository;
this.addon = addon;
this.type = Objects.requireNonNullElse(addon.type(), repository.getType());
this.type = Objects.requireNonNullElse(addon.type(), page.type);
this.translations = ModTranslations.getTranslationsByAddonType(this.type);
this.mod = translations.getModByCurseForgeId(addon.slug());
this.instanceReference = instanceReference;
@@ -130,7 +129,7 @@ public class DownloadPage extends Control implements DecoratorPage {
return addon;
}
public HMCLGameRepository.InstanceReference getInstanceReference() {
public HMCLGameInstance.Optional getInstanceOptional() {
return instanceReference;
}
@@ -271,21 +270,20 @@ public class DownloadPage extends Control implements DecoratorPage {
FXUtils.onChangeAndOperate(control.loaded, loaded -> {
if (control.versions == null) return;
if (control.instanceReference.repository() != null && control.instanceReference.instanceId() != null) {
HMCLGameRepository repository = control.instanceReference.repository();
GameInstanceManifest.Resolved resolvedManifest = repository.getResolvedInstanceManifest(control.instanceReference.instanceId());
String gameVersion = repository.getGameVersion(resolvedManifest.unresolved()).orElse(null);
if (gameVersion != null && control.versions.containsKey(gameVersion)) {
@Nullable HMCLGameInstance instance = control.instanceReference.instance();
if (instance != null) {
String gameVersion = instance.getVersion().toString();
if (!GameVersionNumber.unknown().equals(instance.getVersion())
&& control.versions.containsKey(gameVersion)) {
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
if (addonVersions != null && !addonVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders();
Set<ModLoaderType> targetLoaders = instance.getModLoaders();
resolve:
for (RemoteAddon.Version addonVersion : addonVersions) {
if (getSkinnable().type == RemoteAddon.Type.MOD) {
for (ModLoaderType loader : addonVersion.loaders()) {
if (targetLoaders.contains(loader)) {
for (AddonLoader loader : addonVersion.loaders()) {
if (loader.type() instanceof ModLoaderType modLoaderType && targetLoaders.contains(modLoaderType)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
@@ -375,7 +373,7 @@ public class DownloadPage extends Control implements DecoratorPage {
public final RemoteAddon addon;
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference) {
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) {
this.addon = addon;
HBox pane = new HBox(8);
@@ -401,21 +399,15 @@ public class DownloadPage extends Control implements DecoratorPage {
});
setNode(IDX_LEADING, pane);
if (addon != RemoteAddon.BROKEN) {
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(addon.slug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title());
content.setSubtitle(addon.description());
for (String category : addon.categories()) {
if (page.shouldDisplayCategory(category))
content.addTag(page.getLocalizedCategory(category, null));
}
if (StringUtils.isNotBlank(addon.iconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
}
} else {
content.setTitle(i18n("addon.broken_dependency.title"));
content.setSubtitle(i18n("addon.broken_dependency.desc"));
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/icon@4x.png"));
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(addon.slug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title());
content.setSubtitle(addon.description());
for (String category : addon.categories()) {
if (page.shouldDisplayCategory(category))
content.addTag(page.getLocalizedCategory(category, null));
}
if (StringUtils.isNotBlank(addon.iconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
}
}
}
@@ -469,31 +461,11 @@ public class DownloadPage extends Control implements DecoratorPage {
graphicPane.getChildren().setAll(icon);
}
for (ModLoaderType modLoaderType : dataItem.loaders()) {
switch (modLoaderType) {
case FORGE:
content.addTag(i18n("install.installer.forge"));
break;
case CLEANROOM:
content.addTag(i18n("install.installer.cleanroom"));
break;
case NEO_FORGE:
content.addTag(i18n("install.installer.neoforge"));
break;
case FABRIC:
content.addTag(i18n("install.installer.fabric"));
break;
case LITE_LOADER:
content.addTag(i18n("install.installer.liteloader"));
break;
case QUILT:
content.addTag(i18n("install.installer.quilt"));
break;
case LEGACY_FABRIC:
content.addTag(i18n("install.installer.legacyfabric"));
break;
}
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : dataItem.loaders()) {
tags.add(I18n.translateLoaderName(loader));
}
content.addTags(tags);
descPane.getChildren().setAll(graphicPane, content);
}
@@ -549,6 +521,7 @@ public class DownloadPage extends Control implements DecoratorPage {
scrollPane.setContent(dependenciesList);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
FXUtils.onChangeAndOperate(scrollPane.widthProperty(), d -> FXUtils.setLimitWidth(dependenciesList, d.doubleValue()));
FXUtils.smoothScrolling(scrollPane);
FXUtils.setOverflowHidden(scrollPane, 8);
spinnerPane.setContent(scrollPane);
@@ -599,9 +572,12 @@ public class DownloadPage extends Control implements DecoratorPage {
Task.composeAsync(() -> {
// TODO: Massive tasks may cause OOM.
EnumMap<RemoteAddon.DependencyType, Pair<Label, List<DependencyAddonItem>>> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class);
AtomicBoolean hasBroken = new AtomicBoolean(false);
List<Task<?>> queue = new ArrayList<>(version.dependencies().size());
for (RemoteAddon.Dependency dependency : version.dependencies()) {
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE || dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE) continue;
if (dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
hasBroken.set(true);
continue;
}
@@ -616,24 +592,39 @@ public class DownloadPage extends Control implements DecoratorPage {
.setSignificance(Task.TaskSignificance.MINOR)
.thenAcceptAsync(Schedulers.javafx(), dep -> {
if (dep == RemoteAddon.BROKEN) {
hasBroken.set(true);
return;
}
DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference);
var listener = FXUtils.onWeakChangeAndOperate(dependenciesList.widthProperty(), d -> FXUtils.setLimitWidth(dependencyAddonItem, d.doubleValue()));
dependencyAddonItem.getProperties().put("DependencyAddonItem.width", listener);
dependencies.get(dependency.getType()).value().add(dependencyAddonItem);
})
.setSignificance(Task.TaskSignificance.MINOR));
}
return Task.allOf(queue).thenSupplyAsync(() ->
dependencies.values().stream().flatMap(types ->
Stream.concat(
Stream.of(types.key()),
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)))
).toList()
);
return Task.allOf(queue).thenSupplyAsync(() -> {
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
if (types.value().isEmpty()) return Stream.empty();
return Stream.concat(
Stream.of(types.key()),
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
});
if (!hasBroken.get()) {
return dependenciesStream;
} else {
Label warning = new Label(i18n("addon.dependencies.has_broken"));
warning.setWrapText(true);
warning.setPadding(new Insets(0, 8, 0, 8));
return Stream.concat(
Stream.of(warning),
dependenciesStream
);
}
});
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
dependenciesList.getContent().setAll(result);
dependenciesList.getContent().setAll(result.toList());
spinnerPane.setFailedReason(null);
} else {
dependenciesList.getContent().setAll();
@@ -17,35 +17,35 @@
*/
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.geometry.Pos;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import java.util.function.Consumer;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameAdvancedListItem extends AdvancedListItem {
private final ImageContainer imageContainer;
private final WeakListenerHolder holder = new WeakListenerHolder();
private HMCLGameRepository repository;
@SuppressWarnings("unused")
private Consumer<Event> onInstanceIconChangedListener;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private Consumer<RefreshedGameInstancesEvent> onRefreshedInstancesListener;
/// Strongly held so [WeakChangeListener] keeps delivering icon updates.
private final ChangeListener<Image> iconListener;
private @Nullable WeakChangeListener<Image> weakIconListener;
private @Nullable ReadOnlyObjectProperty<Image> observedIcon;
public GameAdvancedListItem() {
this.imageContainer = new ImageContainer(LEFT_GRAPHIC_SIZE);
this.iconListener = (observable, oldImage, newImage) -> imageContainer.setImage(newImage);
imageContainer.setMouseTransparent(true);
AdvancedListItem.setAlignment(imageContainer, Pos.CENTER);
setLeftGraphic(imageContainer);
@@ -53,31 +53,28 @@ public class GameAdvancedListItem extends AdvancedListItem {
holder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), this::loadInstance));
}
private void loadInstance(GameInstanceID instanceId) {
if (GameDirectoryManager.getSelectedRepository() != repository) {
repository = GameDirectoryManager.getSelectedRepository();
if (repository != null) {
onInstanceIconChangedListener = repository.onInstanceIconChanged.registerWeak(event -> {
FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance()));
});
if (!repository.isLoaded()) {
onRefreshedInstancesListener = EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class)
.registerWeak(event -> FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance())));
return;
}
}
}
if (instanceId != null && repository != null) {
if (repository.hasInstance(instanceId)) {
setTitle(i18n("instance.manage.manage"));
setSubtitle(instanceId.toString());
imageContainer.setImage(repository.getInstanceIconImage(instanceId));
return;
}
private void loadInstance(@Nullable HMCLGameInstance instance) {
unbindIcon();
if (instance != null) {
setTitle(i18n("instance.manage.manage"));
setSubtitle(instance.getId().toString());
observedIcon = instance.iconImageProperty();
weakIconListener = new WeakChangeListener<>(iconListener);
observedIcon.addListener(weakIconListener);
imageContainer.setImage(instance.getIconImage());
return;
}
setTitle(i18n("instance.empty"));
setSubtitle(i18n("instance.empty.add"));
imageContainer.setImage(GameInstanceIconType.DEFAULT.getIcon());
}
private void unbindIcon() {
if (observedIcon != null && weakIconListener != null) {
observedIcon.removeListener(weakIconListener);
}
observedIcon = null;
weakIconListener = null;
}
}
@@ -17,43 +17,43 @@
*/
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.FlowPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.DialogPane;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameInstanceIconDialog extends DialogPane {
private final HMCLGameRepository repository;
private final GameInstanceID instanceId;
public class GameInstanceIconDialog extends JFXDialogLayout {
private final HMCLGameInstance gameInstance;
private final Runnable onFinish;
private final GameSettings.Instance setting;
private final GameSettings.@Nullable Instance setting;
public GameInstanceIconDialog(HMCLGameRepository repository, GameInstanceID instanceId, Runnable onFinish) {
this.repository = repository;
this.instanceId = instanceId;
public GameInstanceIconDialog(HMCLGameInstance gameInstance, Runnable onFinish) {
this.gameInstance = gameInstance;
this.onFinish = onFinish;
this.setting = repository.getInstanceGameSettingsOrCreate(this.instanceId);
this.setting = gameInstance.getSettingsOrCreate();
setHeading(new Label(i18n("settings.icon")));
setTitle(i18n("settings.icon"));
FlowPane pane = new FlowPane();
setBody(pane);
pane.getChildren().setAll(
createCustomIcon(),
createIcon(GameInstanceIconType.GRASS),
@@ -71,6 +71,14 @@ public class GameInstanceIconDialog extends DialogPane {
createIcon(GameInstanceIconType.FURNACE),
createIcon(GameInstanceIconType.QUILT)
);
setBody(pane);
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(event -> fireEvent(new DialogCloseEvent()));
onEscPressed(this, cancelButton::fire);
setActions(cancelButton);
}
private void exploreIcon() {
@@ -79,7 +87,7 @@ public class GameInstanceIconDialog extends DialogPane {
Path selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile != null) {
try {
repository.setInstanceIconFile(instanceId, selectedFile);
gameInstance.setIconFile(selectedFile);
if (setting != null) {
setting.iconProperty().setValue(GameInstanceIconType.DEFAULT);
@@ -117,10 +125,9 @@ public class GameInstanceIconDialog extends DialogPane {
return container;
}
@Override
protected void onAccept() {
repository.onInstanceIconChanged.fireEvent(new Event(this));
// Icon file / settings.iconProperty updates already invalidate iconImageProperty.
onFinish.run();
super.onAccept();
fireEvent(new DialogCloseEvent());
}
}
@@ -21,16 +21,15 @@ import com.jfoenix.controls.JFXPopup;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.scene.Node;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.EventPriority;
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -48,8 +47,6 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Supplier;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -65,10 +62,19 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
private final TabHeader.Tab<ResourcePackListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
private final TransitionPane transitionPane = new TransitionPane();
private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty();
private final ObjectProperty<HMCLGameRepository.InstanceReference> instanceReference = new SimpleObjectProperty<>();
private final ObjectProperty<HMCLGameInstance.@Nullable Optional> instance =
new SimpleObjectProperty<>(this, "instance");
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private GameInstanceID preferredInstanceId = null;
/// Re-resolves the page context when its repository publishes a new snapshot.
private final ChangeListener<? super HMCLGameRepositorySnapshot> repositorySnapshotListener =
(observable, oldValue, newValue) -> checkSelectedInstance();
/// Repository currently observed for snapshot publications.
private @Nullable HMCLGameRepository observedRepository;
/// Last concrete instance displayed by this page.
private @Nullable GameInstanceID preferredInstanceId;
public static class WorkingDirChangedEvent extends Event {
public static final EventType<WorkingDirChangedEvent> EVENT_TYPE = new EventType<>(Event.ANY, "WORKING_DIR_CHANGED");
@@ -79,12 +85,13 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}
public GameInstancePage() {
gameSettingsTab.setNodeSupplier(loadInstanceFor(() -> new GameSettingsPage<>(GameSettings.Instance.class)));
installerListTab.setNodeSupplier(loadInstanceFor(InstallerListPage::new));
modListTab.setNodeSupplier(loadInstanceFor(ModListPage::new));
resourcePackTab.setNodeSupplier(loadInstanceFor(ResourcePackListPage::new));
worldListTab.setNodeSupplier(loadInstanceFor(WorldListPage::new));
schematicsTab.setNodeSupplier(loadInstanceFor(SchematicsPage::new));
// Child tabs subscribe to instanceProperty() themselves and reload on change.
gameSettingsTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance));
installerListTab.setNodeSupplier(() -> new InstallerListPage(instance));
modListTab.setNodeSupplier(() -> new ModListPage(instance));
resourcePackTab.setNodeSupplier(() -> new ResourcePackListPage(instance));
worldListTab.setNodeSupplier(() -> new WorldListPage(instance));
schematicsTab.setNodeSupplier(() -> new SchematicsPage(instance));
tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab);
tab.select(gameSettingsTab);
@@ -92,31 +99,64 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
addEventHandler(WorkingDirChangedEvent.EVENT_TYPE, event -> {
if (this.instanceReference.get() != null) {
if (installerListTab.isInitialized())
installerListTab.getNode().loadInstance(getRepository(), getInstanceId());
if (modListTab.isInitialized())
modListTab.getNode().loadInstance(getRepository(), getInstanceId());
if (resourcePackTab.isInitialized())
resourcePackTab.getNode().loadInstance(getRepository(), getInstanceId());
if (worldListTab.isInitialized())
worldListTab.getNode().loadInstance(getRepository(), getInstanceId());
if (schematicsTab.isInitialized())
schematicsTab.getNode().loadInstance(getRepository(), getInstanceId());
HMCLGameInstance.Optional current = this.instance.get();
if (current != null) {
// Re-resolve so subscribed tabs reload from the current snapshot.
this.instance.set(current.refreshed());
}
});
listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> checkSelectedInstance(), EventPriority.HIGHEST));
// Page chrome that depends on the current instance.
listenerHolder.add(FXUtils.onWeakChange(instance, current -> {
observeRepository(current);
if (current == null) {
return;
}
HMCLGameInstance gameInstance = current.instance();
currentInstanceUpgradable.set(gameInstance != null && gameInstance.isModpack());
if (gameInstance != null) {
preferredInstanceId = gameInstance.getId();
}
}));
}
/// Observes snapshot publications for the repository associated with the current page context.
///
/// @param current the current page context, or `null` when the page has no context
private void observeRepository(HMCLGameInstance.@Nullable Optional current) {
@Nullable HMCLGameRepository repository = current != null ? current.repository() : null;
if (repository == observedRepository) {
return;
}
if (observedRepository != null) {
observedRepository.snapshotProperty().removeListener(repositorySnapshotListener);
}
observedRepository = repository;
if (repository != null) {
repository.snapshotProperty().addListener(repositorySnapshotListener);
}
}
/// Returns the current instance context for this page and its tabs.
///
/// Child tabs subscribe to this property and reload when it changes. The page only publishes
/// context; it does not push `loadInstance` into children.
///
/// @return the observable instance context
public ReadOnlyObjectProperty<HMCLGameInstance.@Nullable Optional> instanceProperty() {
return instance;
}
private void checkSelectedInstance() {
runInFX(() -> {
if (this.instanceReference.get() == null) return;
HMCLGameRepository repository = this.instanceReference.get().repository();
@Nullable GameInstanceID instanceId = this.instanceReference.get().instanceId();
if (instanceId == null || !repository.hasInstance(instanceId)) {
HMCLGameInstance.Optional current = this.instance.get();
if (current == null) return;
current = current.refreshed();
this.instance.set(current);
if (current.isEmpty()) {
if (preferredInstanceId != null) {
loadInstance(preferredInstanceId, repository);
loadInstance(preferredInstanceId, current.repository());
} else {
fireEvent(new PageCloseEvent());
}
@@ -124,56 +164,28 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
});
}
private <T extends Node> Supplier<T> loadInstanceFor(Supplier<T> nodeSupplier) {
return () -> {
T node = nodeSupplier.get();
if (instanceReference.get() != null) {
if (node instanceof GameInstancePage.GameInstanceLoadable loadable) {
@Nullable GameInstanceID instanceId = instanceReference.get().instanceId();
loadable.loadInstance(instanceReference.get().repository(), instanceId);
}
}
return node;
};
}
public void showInstanceSettings() {
tab.select(gameSettingsTab, false);
}
public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) {
this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId));
this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId));
}
public void loadInstance(GameInstanceID instanceId, HMCLGameRepository repository) {
// If we jumped to game list page and deleted this version
// and back to this page, we should return to main page.
if (this.instanceReference.get() != null && (!getRepository().isLoaded() ||
if (this.instance.get() != null && (!getRepository().isLoaded() ||
!getRepository().hasInstance(instanceId))) {
Platform.runLater(() -> fireEvent(new PageCloseEvent()));
return;
}
setInstance(instanceId, repository);
preferredInstanceId = instanceId;
if (gameSettingsTab.isInitialized())
gameSettingsTab.getNode().loadInstance(repository, instanceId);
if (installerListTab.isInitialized())
installerListTab.getNode().loadInstance(repository, instanceId);
if (modListTab.isInitialized())
modListTab.getNode().loadInstance(repository, instanceId);
if (resourcePackTab.isInitialized())
resourcePackTab.getNode().loadInstance(repository, instanceId);
if (worldListTab.isInitialized())
worldListTab.getNode().loadInstance(repository, instanceId);
if (schematicsTab.isInitialized())
schematicsTab.getNode().loadInstance(repository, instanceId);
currentInstanceUpgradable.set(repository.isModpack(instanceId));
this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId));
}
private void onNavigated(Navigator.NavigationEvent event) {
if (this.instanceReference.get() == null)
if (this.instance.get() == null)
throw new IllegalStateException();
// If we jumped to game list page and deleted this version
@@ -188,11 +200,26 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}
private void onBrowse(String sub) {
FXUtils.openFolder(getRepository().getRunDirectory(getInstanceId()).resolve(sub));
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance == null) {
return;
}
FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub));
}
private void onBrowseSchematics() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance == null) {
return;
}
FXUtils.openFolder(gameInstance.getSchematicsDirectory());
}
private void redownloadAssetIndex() {
Instances.updateGameAssets(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.updateGameAssets(gameInstance);
}
}
private void clearLibraries() {
@@ -209,9 +236,9 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
private void clearAssets() {
Path assetsDir = getRepository().getBaseDirectory().resolve("assets");
HMCLGameRepository.InstanceReference currentInstanceReference = instanceReference.get();
Path resourcesDir = currentInstanceReference != null
? getRepository().getRunDirectory(currentInstanceReference.instanceId()).resolve("resources")
HMCLGameInstance.Optional current = instance.get();
Path resourcesDir = current != null && current.isPresent()
? current.instance().getRunDirectory().resolve("resources")
: null;
Task.runAsync(Schedulers.io(), () -> {
@@ -227,46 +254,79 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}
private void clearJunkFiles() {
Instances.cleanInstance(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.cleanInstance(gameInstance);
}
}
private void testGame() {
Instances.testGame(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.testGame(gameInstance);
}
}
private void updateGame() {
Instances.updateInstance(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.updateInstance(gameInstance);
}
}
private void generateLaunchScript() {
Instances.generateLaunchScript(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.generateLaunchScript(gameInstance);
}
}
private void export() {
Instances.exportInstance(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.exportInstance(gameInstance);
}
}
private void rename() {
Instances.renameInstance(getRepository(), getInstanceId())
.thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId));
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.renameInstance(gameInstance)
.thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId));
}
}
private void remove() {
Instances.deleteInstance(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.deleteInstance(gameInstance);
}
}
private void duplicate() {
Instances.duplicateInstance(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.duplicateInstance(gameInstance);
}
}
private @Nullable HMCLGameInstance requireGameInstance() {
HMCLGameInstance.Optional current = instance.get();
return current != null ? current.instance() : null;
}
public HMCLGameRepository getRepository() {
return Optional.ofNullable(instanceReference.get()).map(HMCLGameRepository.InstanceReference::repository).orElse(null);
HMCLGameInstance.Optional current = instance.get();
return current != null ? current.repository() : null;
}
public @Nullable GameInstanceID getInstanceId() {
return Optional.ofNullable(instanceReference.get())
.map(HMCLGameRepository.InstanceReference::instanceId)
.orElse(null);
HMCLGameInstance.Optional current = instance.get();
return current != null ? current.instanceId() : null;
}
public HMCLGameInstance.Optional getInstance() {
return instance.get();
}
@Override
@@ -306,7 +366,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> control.onBrowse("mods"), browsePopup),
new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> control.onBrowse("resourcepacks"), browsePopup),
new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), () -> control.onBrowse("schematics"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), control::onBrowseSchematics, browsePopup),
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup),
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup),
new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup),
@@ -350,7 +410,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
control.state.bind(Bindings.createObjectBinding(() ->
State.fromTitle(i18n("instance.manage.manage.title", getSkinnable().getInstanceId()), -1),
getSkinnable().instanceReference));
getSkinnable().instance));
//control.transitionPane.getStyleClass().add("gray-background");
//FXUtils.setOverflowHidden(control.transitionPane, 8);
@@ -358,12 +418,4 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}
}
/// Loads page content for a game instance in a repository.
public interface GameInstanceLoadable {
/// Loads page content for the given repository and game instance.
///
/// @param repository the repository containing the game instance
/// @param instanceId the game instance ID, or `null` when only repository context is available
void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId);
}
}
@@ -19,23 +19,20 @@ package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -43,9 +40,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameItem {
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
protected final HMCLGameRepository repository;
protected final String id;
protected final GameInstanceID instanceId;
protected final HMCLGameInstance gameInstance;
private boolean initialized = false;
private StringProperty title;
@@ -53,22 +48,28 @@ public class GameItem {
private StringProperty subtitle;
private ObjectProperty<Image> image;
public GameItem(HMCLGameRepository repository, GameInstanceID instanceId) {
this.repository = repository;
this.id = instanceId.toString();
this.instanceId = instanceId;
public GameItem(HMCLGameInstance gameInstance) {
this.gameInstance = gameInstance;
}
public GameDirectory getGameDirectory() {
return repository.getGameDirectory();
return gameInstance.getRepository().getGameDirectory();
}
public HMCLGameRepository getRepository() {
return repository;
return gameInstance.getRepository();
}
public GameInstanceID getInstanceId() {
return gameInstance.getId();
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
public String getId() {
return id;
return gameInstance.getId().toString();
}
private void init() {
@@ -86,15 +87,16 @@ public class GameItem {
CompletableFuture.supplyAsync(() -> {
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
Optional<String> gameVersion = repository.getGameVersion(instanceId);
String modPackVersion = null;
GameVersionNumber version = gameInstance.getVersion();
@Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString();
@Nullable String modPackVersion = null;
try {
ModpackConfiguration<?> config = repository.readModpackConfiguration(instanceId);
@Nullable ModpackConfiguration<?> config = gameInstance.readModpackConfiguration();
modPackVersion = config != null ? config.getVersion() : null;
} catch (IOException e) {
LOG.warning("Failed to read modpack configuration from " + id, e);
LOG.warning("Failed to read modpack configuration from " + getId(), e);
}
return new Result(gameVersion.orElse(null), modPackVersion);
return new Result(gameVersion, modPackVersion);
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
if (exception == null) {
if (result.tag != null) {
@@ -102,26 +104,25 @@ public class GameItem {
}
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), result.gameVersion);
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
String libraryId = mark.getLibraryId();
String libraryVersion = mark.getLibraryVersion();
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
if (I18n.hasKey("install.installer." + libraryId)) {
libraries.append(", ").append(i18n("install.installer." + libraryId));
if (libraryVersion != null)
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
GameComponentAnalyzer analyzer = gameInstance.getAnalyzer();
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) continue;
if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) {
libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId()));
if (mark.version() != null)
libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), ""));
}
}
subtitle.set(libraries.toString());
} else {
LOG.warning("Failed to read version info from " + id, exception);
LOG.warning("Failed to read version info from " + getId(), exception);
}
}, Schedulers.javafx());
title.set(id);
image.set(repository.getInstanceIconImage(instanceId));
title.set(getId());
image.set(gameInstance.getIconImage());
}
public ReadOnlyStringProperty titleProperty() {
@@ -29,7 +29,6 @@ import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
@@ -40,7 +39,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class GameListCell extends ListCell<GameListItem> {
private final Region graphic;
private final RipplerContainer graphic;
private final ImageContainer imageView;
private final TwoLineListItem content;
@@ -70,7 +69,7 @@ public final class GameListCell extends ListCell<GameListItem> {
fireEvent(new ActionEvent());
GameListItem item = GameListCell.this.getItem();
if (item != null) {
item.getRepository().setSelectedInstance(new GameInstanceID(item.getId()));
item.getRepository().setSelectedInstance(item.getGameInstance());
}
}
}
@@ -162,8 +161,15 @@ public final class GameListCell extends ListCell<GameListItem> {
@Override
public void updateItem(GameListItem item, boolean empty) {
GameListItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.releaseRippleImmediately();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
@@ -22,22 +22,25 @@ import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import java.util.Objects;
import org.jetbrains.annotations.Nullable;
public class GameListItem extends GameItem {
private final boolean isModpack;
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) {
super(repository, instanceId);
this.isModpack = repository.isModpack(instanceId);
public GameListItem(HMCLGameInstance gameInstance) {
super(gameInstance);
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
this.isModpack = gameInstance.isModpack();
selected.bind(Bindings.createBooleanBinding(
() -> {
if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false;
return Objects.equals(repository.getSelectedInstance(), instanceId);
@Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance();
return selectedInstance != null && selectedInstance.getId().equals(instanceId);
},
GameDirectoryManager.selectedGameDirectoryProperty(),
GameDirectoryManager.selectedInstanceProperty()));
@@ -48,39 +51,39 @@ public class GameListItem extends GameItem {
}
public void rename() {
Instances.renameInstance(repository, instanceId);
Instances.renameInstance(gameInstance);
}
public void duplicate() {
Instances.duplicateInstance(repository, instanceId);
Instances.duplicateInstance(gameInstance);
}
public void remove() {
Instances.deleteInstance(repository, instanceId);
Instances.deleteInstance(gameInstance);
}
public void export() {
Instances.exportInstance(repository, instanceId);
Instances.exportInstance(gameInstance);
}
public void browse() {
Instances.openFolder(repository, instanceId);
Instances.openFolder(gameInstance);
}
public void testGame() {
Instances.testGame(repository, instanceId);
Instances.testGame(gameInstance);
}
public void launch() {
Instances.launch(repository, instanceId);
Instances.launch(gameInstance);
}
public void modifyGameSettings() {
Instances.modifyGameSettings(repository, instanceId);
Instances.modifyGameSettings(gameInstance);
}
public void generateLaunchScript() {
Instances.generateLaunchScript(repository, instanceId);
Instances.generateLaunchScript(gameInstance);
}
public boolean canUpdate() {
@@ -88,6 +91,6 @@ public class GameListItem extends GameItem {
}
public void update() {
Instances.updateInstance(repository, instanceId);
Instances.updateInstance(gameInstance);
}
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
@@ -38,10 +39,7 @@ import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ModpackHelper;
@@ -49,10 +47,7 @@ import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.directory.GameDirectoryListItem;
@@ -84,6 +79,16 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
gameDirectoryListItems = MappedObservableList.create(GameDirectoryManager.getGameDirectories(), gameDirectory -> {
GameDirectoryListItem item = new GameDirectoryListItem(gameDirectory);
FXUtils.setLimitWidth(item, 200);
FXUtils.onSecondaryButtonClicked(item, () -> {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().add(new IconedMenuItem(
SVG.EDIT,
i18n("button.edit"),
() -> Controllers.navigate(new GameDirectoryPage(gameDirectory)),
popup));
popup.show(item, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, item.getWidth(), 0);
});
return item;
});
@@ -156,11 +161,13 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
setLoading(true);
setFailedReason(null);
List<GameListItem> versionItems = repository.getDisplayInstanceManifests().map(instance -> new GameListItem(repository, instance.id())).toList();
List<GameListItem> instanceItems = repository.getDisplayInstances()
.map(GameListItem::new)
.toList();
sourceList.setAll(versionItems);
sourceList.setAll(instanceItems);
if (versionItems.isEmpty()) {
if (instanceItems.isEmpty()) {
setFailedReason(i18n("instance.empty.hint"));
}
@@ -176,12 +183,12 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
String regex = searchText.substring("regex:".length());
try {
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
return item -> pattern.matcher(item.id).find();
return item -> pattern.matcher(item.getId()).find();
} catch (PatternSyntaxException e) {
return item -> false;
}
} else {
return item -> item.id.toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
}
}
@@ -34,9 +34,7 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
@@ -47,6 +45,8 @@ import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Displays game instances in a popup selection list.
///
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
@@ -63,20 +63,36 @@ public final class GameListPopupMenu extends StackPane {
}
/// Shows an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
HMCLGameRepository repository, List<GameInstanceManifest> versions) {
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, repository, versions);
List<HMCLGameInstance> instances) {
if (!hideShowing(owner)) {
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances);
}
}
/// Shows and returns an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
/// @return the shown popup
public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
HMCLGameRepository repository, List<GameInstanceManifest> versions) {
List<HMCLGameInstance> instances) {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream()
.filter(it -> repository.hasInstance(it.id()))
.map(it -> new GameItem(repository, it.id()))
menu.getItems().setAll(instances.stream()
.map(GameItem::new)
.toList());
JFXPopup popup = new JFXPopup(menu);
owner.getProperties().put(KEY, popup);
@@ -103,9 +119,7 @@ public final class GameListPopupMenu extends StackPane {
Label placeholder = new Label(i18n("instance.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> {
getChildren().setAll(empty ? placeholder : listView);
});
FXUtils.onChangeAndOperate(isEmpty, empty -> getChildren().setAll(empty ? placeholder : listView));
}
public ObservableList<GameItem> getItems() {
@@ -115,6 +129,7 @@ public final class GameListPopupMenu extends StackPane {
private static final class Cell extends ListCell<GameItem> {
private final Region graphic;
private final RipplerContainer ripplerContainer;
private final ImageContainer imageView;
private final TwoLineListItem content;
@@ -143,7 +158,7 @@ public final class GameListPopupMenu extends StackPane {
container.setLeft(imageView);
container.setCenter(content);
RipplerContainer ripplerContainer = new RipplerContainer(container);
this.ripplerContainer = new RipplerContainer(container);
StackPane rootPane = new StackPane();
rootPane.getStyleClass().add("advanced-list-item");
@@ -153,7 +168,7 @@ public final class GameListPopupMenu extends StackPane {
FXUtils.onClicked(rootPane, () -> {
GameItem item = getItem();
if (item != null) {
item.getRepository().setSelectedInstance(new GameInstanceID(item.getId()));
item.getRepository().setSelectedInstance(item.getGameInstance());
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
@@ -164,8 +179,14 @@ public final class GameListPopupMenu extends StackPane {
@Override
protected void updateItem(GameItem item, boolean empty) {
GameItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.ripplerContainer.releaseRippleImmediately();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
Loaded 100 of 413 files, more files were not shown because too many files have changed in this diff. Show more