Compare commits

...
112 Commits
Author SHA1 Message Date
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
328 changed files with 20466 additions and 9893 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());
@@ -57,8 +57,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 +70,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 +82,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();
@@ -308,8 +313,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 +401,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 +458,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 +484,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,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,6 +28,7 @@ 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;
@@ -42,12 +43,12 @@ 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) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -55,7 +56,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
@@ -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;
@@ -84,9 +82,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 +91,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 +138,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 +149,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 +205,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 +215,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 +240,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 +281,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,8 +354,7 @@ 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();
if (responseCode == 404)
@@ -363,8 +368,7 @@ public final class LauncherHelper {
URI uri = ((DownloadException) ex).getUri();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", uri);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
@@ -379,8 +383,7 @@ 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();
if (responseCode == 404)
@@ -423,9 +426,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 +459,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 +483,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 +494,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 +557,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 +570,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 +640,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 +653,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 +683,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);
@@ -829,16 +831,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 +848,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 +975,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 +1033,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();
@@ -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, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
remoteAddons = searchResult.results().toList();
if (!remoteAddons.isEmpty()) {
break;
}
@@ -100,7 +98,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
searchResultArray[englishIndex--] = remoteAddon;
}
}
totalPages = searchResult.getTotalPages();
totalPages = searchResult.totalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
@@ -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,8 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
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 +108,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 +118,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 +158,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 +195,10 @@ 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) {
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 -> {
@@ -237,10 +226,14 @@ public final class ModpackHelper {
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();
}
@@ -253,11 +246,11 @@ public final class ModpackHelper {
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)
.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)
.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;
@@ -26,10 +26,10 @@ 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.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.game.JavaVersionConstraint;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -37,7 +37,6 @@ 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 +320,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,7 +437,7 @@ 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);
@@ -499,7 +498,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 +841,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<>();
@@ -22,10 +22,9 @@ 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;
@@ -44,7 +43,6 @@ import java.util.UUID;
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 +139,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 +205,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.
@@ -480,17 +490,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);
@@ -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;
@@ -283,6 +283,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 +383,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 +465,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 +476,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,6 +1143,11 @@ public final class Themes {
return darkMode;
}
/// Returns whether the current color scheme brightness is inherited from system.
public static ReadOnlyBooleanProperty autoBrightnessProperty() {
return autoBrightness.getReadOnlyProperty();
}
/// Applies native dark-mode integration to a JavaFX stage where the platform supports it.
public static void applyNativeDarkMode(Stage stage) {
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
@@ -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;
@@ -371,6 +379,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 +598,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 {
@@ -598,4 +642,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());
}
@@ -58,10 +58,7 @@ 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;
@@ -76,11 +73,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 +107,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 +464,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));
}
@@ -728,116 +728,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 +822,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;
@@ -1393,20 +1143,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 +1195,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);
@@ -30,6 +30,7 @@ public abstract class MDListCell<T> extends ListCell<T> {
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 +38,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");
@@ -60,8 +60,10 @@ public abstract class MDListCell<T> extends ListCell<T> {
if (oldItem == item && oldEmpty == empty) return;
ripplerContainer.releaseRippleImmediately();
updateControl(item, empty);
if (empty) {
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(root);
@@ -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);
}
});
}
});
}
@@ -60,7 +60,6 @@ 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;
@@ -204,7 +203,7 @@ public final class TaskListPane extends StackPane {
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
} else if (task instanceof McbbsModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
task.setName(i18n("modpack.export"));
} else if (task instanceof MinecraftInstanceTask) {
task.setName(i18n("modpack.scan"));
@@ -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) {
@@ -882,6 +882,7 @@ public final class Decorator {
private void playOpenAnimation() {
if (!AnimationUtils.playWindowAnimation()) {
stopWindowAnimation();
Controllers.trimHeap();
return;
}
@@ -896,7 +897,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);
}
@@ -1018,6 +1025,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()
@@ -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;
@@ -38,8 +41,13 @@ import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
import org.jackhuang.hmcl.ui.construct.LineToggleButton;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
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;
@@ -187,6 +195,7 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
txtGameDirectoryName.setText(suggestedName);
}
};
locationChangeListener.changed(null, this.location.get(), this.location.get());
locationProperty().addListener(locationChangeListener);
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
@@ -200,8 +209,8 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
});
}
/// 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();
@@ -249,6 +258,14 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
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;
@@ -58,37 +58,37 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) {
this.controller = controller;
this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle());
this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(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,11 +20,11 @@ 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;
@@ -32,34 +32,34 @@ import org.jackhuang.hmcl.util.SettingsMap;
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 GameInstanceManifest manifest;
protected final HMCLGameInstance instance;
public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
public AdditionalInstallersPage(HMCLGameInstance instance, String 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 +69,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,20 @@ 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.io.FileUtils;
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;
@@ -95,7 +95,7 @@ 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)));
GameComponentType.GAME, versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file);
@@ -135,35 +135,31 @@ 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);
@@ -171,9 +167,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else {
if (!(exception instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
}
} else {
@@ -181,7 +175,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
}), 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 +185,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 +284,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 +299,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());
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 +352,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;
@@ -52,20 +52,20 @@ public class InstallersPage extends AbstractInstallersPage {
@Override
public String getTitle() {
return ((RemoteVersion) controller.getSettings().get("game")).getGameVersion();
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion();
}
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 +115,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());
@@ -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;
@@ -109,9 +106,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)));
}
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
@@ -124,10 +121,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));
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
}
}
}
@@ -21,8 +21,9 @@ import javafx.scene.Node;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
import org.jackhuang.hmcl.download.game.LibraryDownloadException;
import org.jackhuang.hmcl.game.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;
@@ -46,66 +47,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().toString(), 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) {
String newGameVersion = ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion();
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,8 +135,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
public static void alertFailureMessage(Exception exception, Runnable next) {
if (exception instanceof LibraryDownloadException) {
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().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();
if (responseCode == 404)
@@ -145,8 +150,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
URI uri = ((DownloadException) exception).getUri();
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();
} 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);
} else {
@@ -155,14 +159,14 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\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 +174,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 +183,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,8 @@ 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.ComponentVersionList;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
@@ -45,6 +45,7 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -77,27 +78,27 @@ 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 GameComponentType componentType;
private final String title;
private final Navigation navigation;
private final DownloadProvider downloadProvider;
private final VersionList<?> versionList;
private final ComponentVersionList<?> 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,
DownloadProvider downloadProvider,
String libraryId,
GameComponentType componentType,
Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.componentType = componentType;
this.navigation = navigation;
this.downloadProvider = downloadProvider;
this.versionList = downloadProvider.getVersionListById(libraryId);
this.versionList = downloadProvider.getVersionList(componentType);
this.callback = callback;
refresh();
@@ -152,12 +153,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 +174,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 +190,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 +213,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,7 +239,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
twoLineListItem.getTags().clear();
if (remoteVersion instanceof GameRemoteVersion) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
ComponentRemoteVersion.Type versionType = remoteVersion.getVersionType();
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
switch (versionType) {
@@ -243,7 +248,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
}
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("instance.game.april_fools"));
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
@@ -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;
@@ -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,
@@ -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
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
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
case OLD -> versionType == RemoteVersion.Type.OLD;
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();
@@ -831,10 +839,10 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
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 +1868,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 +2601,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 +2642,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 +2665,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 +2704,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 +2724,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 +2793,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 +2817,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 +2838,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) -> {
@@ -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()));
}
}
@@ -38,9 +38,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.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
@@ -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());
@@ -111,23 +109,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);
}
}
@@ -166,11 +165,13 @@ 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)
@@ -181,8 +182,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 +218,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();
}
@@ -370,7 +371,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 +571,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 +582,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 {
@@ -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,14 +68,14 @@ 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;
@@ -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.of();
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,18 @@ 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 redownloadAssetIndex() {
Instances.updateGameAssets(getRepository(), getInstanceId());
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.updateGameAssets(gameInstance);
}
}
private void clearLibraries() {
@@ -209,9 +228,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 +246,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
@@ -350,7 +402,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 +410,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();
@@ -102,15 +102,6 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
}
}
@Override
protected SortType getBackedRemoteModRepositorySortOrder() {
if ("addon.modrinth".equals(downloadSource.get())) {
return SortType.NAME;
} else {
return SortType.POPULARITY;
}
}
@Override
public RemoteAddon.Type getType() {
return type;
@@ -17,13 +17,12 @@
*/
package org.jackhuang.hmcl.ui.instances;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -36,25 +35,30 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.*;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InstallerListPage extends ListPageBase<InstallerItem> implements GameInstancePage.GameInstanceLoadable {
private HMCLGameRepository repository;
private GameInstanceID instanceId;
private GameInstanceManifest manifest;
private String gameVersion;
public class InstallerListPage extends ListPageBase<InstallerItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
{
FXUtils.applyDragListener(this, it -> Arrays.asList("jar", "exe").contains(FileUtils.getExtension(it)), mods -> {
/// Creates an installer list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> Set.of("jar", "exe").contains(FileUtils.getExtension(it)), mods -> {
if (!mods.isEmpty())
doInstallOffline(mods.get(0));
});
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
@@ -62,78 +66,80 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ga
return new InstallerListPageSkin();
}
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
this.manifest = repository.getInstanceManifest(instanceId);
this.gameVersion = null;
CompletableFuture.supplyAsync(() -> {
gameVersion = repository.getGameVersion(manifest).orElse(null);
return LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion);
}).thenAcceptAsync(analyzer -> {
/// Rebuilds the component controls for the selected instance, or clears them if none is selected.
public void loadInstance(HMCLGameInstance.Optional instance) {
@Nullable HMCLGameInstance gameInstance = instance.instance();
this.gameInstance = gameInstance;
if (gameInstance == null) {
itemsProperty().clear();
return;
}
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameVersion, InstallerItem.Style.LIST_ITEM);
HMCLGameRepository repository = gameInstance.getRepository();
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
for (InstallerItem item : group.getLibraries()) {
String libraryId = item.getLibraryId();
itemsProperty().clear();
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM);
// Skip fabric-api and quilt-api and legacyfabric-api
if (libraryId.endsWith("-api")) {
continue;
}
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
for (InstallerItem component : group.getComponents()) {
String libraryVersion = analyzer.getVersion(libraryId).orElse(null);
if (libraryVersion != null) {
item.versionProperty().set(new InstallerItem.InstalledState(
libraryVersion,
analyzer.getLibraryStatus(libraryId) != LibraryAnalyzer.LibraryMark.LibraryStatus.CLEAR,
false
));
} else {
item.versionProperty().set(null);
}
item.setOnInstall(() -> {
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(repository, gameVersion, manifest, libraryId, libraryVersion));
});
item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId)
.thenComposeAsync(repository::saveAsync)
.withComposeAsync(repository.refreshAsync())
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId))
.start());
itemsProperty().add(item);
// Skip fabric-api and quilt-api and legacyfabric-api
if (component.getComponentType().getPatchId().endsWith("-api")) {
continue;
}
// other third-party libraries which are unable to manage.
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
String libraryId = mark.getLibraryId();
String libraryVersion = mark.getLibraryVersion();
if ("mcbbs".equals(libraryId))
continue;
@Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType());
// we have done this library above.
if (LibraryAnalyzer.LibraryType.fromPatchId(libraryId) != null)
continue;
InstallerItem installerItem = new InstallerItem(libraryId, InstallerItem.Style.LIST_ITEM);
installerItem.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false));
installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId)
.thenComposeAsync(repository::saveAsync)
.withComposeAsync(repository.refreshAsync())
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId))
.start());
itemsProperty().add(installerItem);
if (libraryVersion != null) {
component.versionProperty().set(new InstallerItem.InstalledState(
libraryVersion,
!gameInstance.getAnalyzer().isClear(component.getComponentType()),
false
));
} else {
component.versionProperty().set(null);
}
}, Platform::runLater);
component.setOnInstall(() -> {
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion));
});
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
component.getComponentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(component);
}
var currentItems = getItems().stream().map(InstallerItem::getComponentType).toList();
// other third-party libraries which are unable to manage.
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
if (currentItems.contains(mark.componentType())) continue;
// we have done this library above.
InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM);
installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false));
installerItem.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
mark.componentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(installerItem);
}
}
private void reloadCurrentInstance() {
if (gameInstance != null) {
loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId()));
}
}
public void installOffline() {
@@ -143,17 +149,23 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ga
if (file != null) doInstallOffline(file);
}
/// Runs a local component installer for the current modifiable instance and displays its result.
private void doInstallOffline(Path file) {
Task<?> task = repository.getDependency().installLibraryAsync(manifest, file)
.thenComposeAsync(repository::saveAsync)
.thenComposeAsync(repository.refreshAsync());
if (gameInstance == null || !gameInstance.getManifest().isModifiable()) {
return;
}
HMCLGameRepository repository = gameInstance.getRepository();
Task<?> task = repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().installComponentLocalAsync(publishedInstance, file));
task.setName(i18n("install.installer.install_offline"));
TaskExecutor executor = task.executor(new TaskListener() {
@Override
public void onStop(boolean success, TaskExecutor executor) {
runInFX(() -> {
if (success) {
loadInstance(repository, instanceId);
reloadCurrentInstance();
Controllers.dialog(i18n("install.success"));
} else {
if (executor.getException() == null)
@@ -28,11 +28,7 @@ import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.QuickPlayOption;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
@@ -52,6 +48,7 @@ import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
@@ -60,6 +57,7 @@ import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -106,9 +104,7 @@ public final class Instances {
if (StringUtils.isNotBlank(mod.iconUrl()))
installWizardProvider.setIconUrl(mod.iconUrl());
Controllers.getDecorator().startWizard(installWizardProvider);
} else if (e instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else {
} else if (!(e instanceof CancellationException)) {
Controllers.dialog(
i18n("install.failed.downloading.detail", file.file().url()) + "\n" + StringUtils.getStackTrace(e),
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
@@ -119,9 +115,11 @@ public final class Instances {
);
}
public static void deleteInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
boolean isIndependent = repository.getRunDirectory(instanceId).toAbsolutePath().normalize()
.equals(repository.getInstanceRoot(instanceId).toAbsolutePath().normalize());
public static void deleteInstance(HMCLGameInstance gameInstance) {
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
boolean isIndependent = gameInstance.getRunDirectory().toAbsolutePath().normalize()
.equals(gameInstance.getInstanceRoot().toAbsolutePath().normalize());
String message = isIndependent ? i18n("instance.manage.remove.confirm.independent", instanceId) :
i18n("instance.manage.remove.confirm.trash", instanceId, instanceId + "_removed");
@@ -139,7 +137,9 @@ public final class Instances {
Controllers.confirmAction(message, i18n("message.warning"), MessageDialogPane.MessageType.WARNING, deleteButton);
}
public static CompletableFuture<String> renameInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
public static CompletableFuture<String> renameInstance(HMCLGameInstance gameInstance) {
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
return Controllers.prompt(i18n("instance.manage.rename.message"), (newName, handler) -> {
if (newName.equals(instanceId.toString())) {
handler.resolve();
@@ -152,7 +152,7 @@ public final class Instances {
repository.refreshAsync()
.thenRunAsync(Schedulers.javafx(), () -> {
if (repository.hasInstance(newInstanceId)) {
repository.setSelectedInstance(newInstanceId);
repository.setSelectedInstance(repository.getInstance(newInstanceId));
}
}).start();
} else {
@@ -163,22 +163,24 @@ public final class Instances {
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString())));
}
public static void exportInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
Controllers.getDecorator().startWizard(new ExportWizardProvider(repository, instanceId), i18n("modpack.wizard"));
public static void exportInstance(HMCLGameInstance gameInstance) {
Controllers.getDecorator().startWizard(new ExportWizardProvider(gameInstance), i18n("modpack.wizard"));
}
public static void openFolder(HMCLGameRepository repository, GameInstanceID instanceId) {
FXUtils.openFolder(repository.getRunDirectory(instanceId));
public static void openFolder(HMCLGameInstance gameInstance) {
FXUtils.openFolder(gameInstance.getRunDirectory());
}
public static void installFromJson(HMCLGameRepository repository, Path file) {
GameInstanceManifest manifest;
try {
manifest = JsonUtils.fromJsonFile(file, GameInstanceManifest.class);
if (manifest == null) {
if (manifest == null)
throw new IllegalArgumentException("Missing game manifest");
}
if (manifest.inheritsFrom() != null)
throw new IllegalArgumentException("Game manifest inherits from another manifest");
} catch (Exception e) {
LOG.warning("Failed to read game manifest from " + file, e);
Controllers.dialog(i18n("install.new_game.malformed_json"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
return;
}
@@ -189,21 +191,48 @@ public final class Instances {
GameInstanceID instanceId = new GameInstanceID(result);
DefaultDependencyManager dependencyManager = repository.getDependency();
GameInstanceManifest newVersion = manifest.withId(instanceId).withJar(instanceId);
GameInstanceManifest newManifest = manifest.withId(instanceId).withJar(instanceId);
GameDownloadTask gameDownloadTask = new GameDownloadTask(
dependencyManager,
newManifest);
AtomicReference<GameRepositoryDraft> activeDraft = new AtomicReference<>();
Controllers.taskDialog(
Task.allOf(new GameDownloadTask(dependencyManager, null, newVersion),
Task.supplyAsync(() -> {
GameRepositoryDraft draft = repository.openDraft();
activeDraft.set(draft);
return draft;
})
.thenComposeAsync(draft -> Task.allOf(
gameDownloadTask,
Task.allOf(
new GameAssetDownloadTask(dependencyManager, newVersion, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true),
new GameLibrariesTask(dependencyManager, newVersion, true)
).withRunAsync(() -> {
// ignore failure
}))
.thenComposeAsync(repository.saveAsync(newVersion))
.thenRunAsync(repository::refresh)
new GameAssetDownloadTask(
dependencyManager,
newManifest,
GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY,
true),
new GameLibrariesTask(dependencyManager, newManifest, true))
.withRunAsync(() -> {
// ignore failure
})))
.thenAcceptAsync(ignored -> {
GameRepositoryDraft draft = activeDraft.get();
if (draft == null) {
throw new IllegalStateException("Game repository draft is unavailable");
}
draft.put(newManifest);
draft.putPrimaryJar(instanceId, gameDownloadTask.getResult());
draft.commit();
})
.whenComplete(exception -> {
GameRepositoryDraft draft = activeDraft.getAndSet(null);
if (draft != null && draft.isOpen()) {
draft.abort();
}
})
.whenComplete(Schedulers.javafx(), (exception) -> {
if (exception == null) {
repository.setSelectedInstance(new GameInstanceID(result));
repository.setSelectedInstance(repository.getInstance(instanceId));
} else {
Controllers.dialog(
DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
@@ -212,7 +241,9 @@ public final class Instances {
}, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName)));
}
public static void duplicateInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
public static void duplicateInstance(HMCLGameInstance gameInstance) {
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
Controllers.prompt(
new PromptDialogPane.Builder(i18n("instance.manage.duplicate.prompt"), (res, handler) -> {
String newInstanceName = ((PromptDialogPane.Builder.StringQuestion) res.get(1)).getValue();
@@ -238,33 +269,35 @@ public final class Instances {
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
}
public static void updateInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository, instanceId));
public static void updateInstance(HMCLGameInstance gameInstance) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(gameInstance.getRepository(), gameInstance.getId()));
}
public static void updateGameAssets(HMCLGameRepository repository, GameInstanceID instanceId) {
TaskExecutor executor = new GameAssetDownloadTask(repository.getDependency(), repository.getInstanceManifest(instanceId), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true)
.executor();
public static void updateGameAssets(HMCLGameInstance gameInstance) {
TaskExecutor executor = new GameAssetDownloadTask(
gameInstance.getRepository().getDependency(),
gameInstance.getResolvedManifest(),
GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY,
true).executor();
Controllers.taskDialog(executor, i18n("instance.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL);
executor.start();
}
public static void cleanInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
public static void cleanInstance(HMCLGameInstance gameInstance) {
try {
repository.clean(instanceId);
gameInstance.getRepository().clean(gameInstance.getId());
} catch (IOException e) {
LOG.warning("Unable to clean game directory", e);
}
}
@SafeVarargs
public static void generateLaunchScript(HMCLGameRepository repository, GameInstanceID instanceId, Consumer<LauncherHelper>... injecters) {
if (!checkVersionForLaunching(repository, instanceId))
return;
public static void generateLaunchScript(HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
ensureSelectedAccount(account -> {
Path runDirectory = gameInstance.getRunDirectory();
FileChooser chooser = new FileChooser();
if (Files.isDirectory(repository.getRunDirectory(instanceId)))
chooser.setInitialDirectory(repository.getRunDirectory(instanceId).toFile());
if (Files.isDirectory(runDirectory))
chooser.setInitialDirectory(runDirectory.toFile());
chooser.setTitle(i18n("instance.launch_script.save"));
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
chooser.getExtensionFilters().add(
@@ -282,7 +315,7 @@ public final class Instances {
file = file.resolveSibling(file.getFileName().toString() + "." + defaultExt);
}
LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId);
LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account);
for (Consumer<LauncherHelper> injecter : injecters) {
injecter.accept(launcherHelper);
}
@@ -306,42 +339,16 @@ public final class Instances {
};
}
/// Launches the given instance after ensuring that an account is selected.
///
/// If `gameInstance` is `null`, an error dialog is shown and no account selection or launch is
/// attempted.
///
/// @param gameInstance the instance to launch, or `null` when no instance is available
/// @param injecters callbacks that configure the launcher before launch
@SafeVarargs
public static void launch(HMCLGameRepository repository, GameInstanceID instanceId, Consumer<LauncherHelper>... injecters) {
if (!checkVersionForLaunching(repository, instanceId))
return;
ensureSelectedAccount(account -> {
LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId);
for (Consumer<LauncherHelper> injecter : injecters) {
injecter.accept(launcherHelper);
}
launcherHelper.launch();
});
}
public static void testGame(HMCLGameRepository repository, GameInstanceID instanceId) {
launch(repository, instanceId, LauncherHelper::setTestMode);
}
public static void launchAndEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) {
launch(repository, instanceId, launcherHelper ->
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
}
public static void generateLaunchScriptForQuickEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) {
generateLaunchScript(repository, instanceId, launcherHelper ->
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
}
private static boolean checkVersionForLaunching(HMCLGameRepository repository, GameInstanceID instanceId) {
boolean unavailable;
if (instanceId == null || !repository.isLoaded()) {
unavailable = true;
} else {
unavailable = !repository.hasInstance(instanceId);
}
if (unavailable) {
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
if (gameInstance == null) {
JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download"));
gotoDownload.getStyleClass().add("dialog-accept");
gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
@@ -350,10 +357,30 @@ public final class Instances {
MessageDialogPane.MessageType.ERROR,
gotoDownload,
null);
return false;
} else {
return true;
return;
}
ensureSelectedAccount(account -> {
LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account);
for (Consumer<LauncherHelper> injecter : injecters) {
injecter.accept(launcherHelper);
}
launcherHelper.launch();
});
}
public static void testGame(HMCLGameInstance gameInstance) {
launch(gameInstance, LauncherHelper::setTestMode);
}
public static void launchAndEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) {
launch(gameInstance, launcherHelper ->
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
}
public static void generateLaunchScriptForQuickEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) {
generateLaunchScript(gameInstance, launcherHelper ->
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
}
private static void ensureSelectedAccount(Consumer<Account> action) {
@@ -391,10 +418,11 @@ public final class Instances {
Controllers.navigate(Controllers.getSettingsPage());
}
public static void modifyGameSettings(HMCLGameRepository repository, GameInstanceID instanceId) {
Controllers.getGameInstancePage().setInstance(instanceId, repository);
public static void modifyGameSettings(HMCLGameInstance gameInstance) {
Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository());
Controllers.getGameInstancePage().showInstanceSettings();
// VersionPage.loadVersion will be invoked after navigation
Controllers.navigate(Controllers.getGameInstancePage());
}
}
@@ -17,51 +17,93 @@
*/
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.control.Skin;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
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.stage.FileChooser;
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 javafx.util.Duration;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.mod.ModManager;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageAware;
import org.jackhuang.hmcl.util.TaskCancellationAction;
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.*;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObject> implements GameInstancePage.GameInstanceLoadable, PageAware {
public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> implements PageAware {
private final ReentrantLock lock = new ReentrantLock();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private ModManager modManager;
private HMCLGameRepository repository;
private GameInstanceID instanceId;
private @Nullable HMCLGameInstance gameInstance;
private String gameVersion;
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
public ModListPage() {
/// Creates a mod list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ModListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> {
mods.forEach(it -> {
try {
@@ -72,6 +114,12 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
});
loadMods(modManager);
});
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
@@ -83,15 +131,15 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
loadMods(modManager);
}
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
return;
}
GameInstanceManifest resolved = repository.getResolvedInstanceManifest(instanceId).standaloneManifest();
this.gameVersion = repository.getGameVersion(resolved).orElse(null);
this.gameVersion = gameInstance.getVersion().toString();
loadMods(repository.getModManager(instanceId));
loadMods(gameInstance.getModManager());
}
private void loadMods(ModManager modManager) {
@@ -105,7 +153,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
lock.lock();
try {
modManager.refresh();
return modManager.getLocalFiles().stream().map(ModListPageSkin.ModInfoObject::new).toList();
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
@@ -131,13 +179,13 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
private void updateSupportedLoaders(ModManager modManager) {
supportedLoaders.clear();
LibraryAnalyzer analyzer = modManager.getLibraryAnalyzer();
GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer();
if (analyzer == null) {
Collections.addAll(supportedLoaders, ModLoaderType.values());
return;
}
for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) {
for (GameComponentType type : GameComponentType.MOD_LOADERS) {
if (type.isModLoader() && analyzer.has(type)) {
ModLoaderType modLoaderType = type.getModLoaderType();
if (modLoaderType != null) {
@@ -149,26 +197,26 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}
}
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
supportedLoaders.add(ModLoaderType.FORGE);
}
if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) {
if (analyzer.has(GameComponentType.QUILT)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) {
if (analyzer.has(GameComponentType.LEGACY_FABRIC)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
supportedLoaders.add(ModLoaderType.FORGE);
supportedLoaders.add(ModLoaderType.NEO_FORGE);
}
// Sinytra Connector
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE))
|| "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE))
|| "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
}
@@ -176,7 +224,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.litemod"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null) return;
@@ -208,11 +256,11 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}).start();
}
void removeSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
try {
modManager.removeMods(selectedItems.stream()
.filter(Objects::nonNull)
.map(ModListPageSkin.ModInfoObject::getModInfo)
.map(ModInfoObject::getModInfo)
.toArray(LocalModFile[]::new));
loadMods(modManager);
} catch (IOException ignore) {
@@ -220,34 +268,40 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}
}
void enableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
void enableSelected(ObservableList<ModInfoObject> selectedItems) {
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModListPageSkin.ModInfoObject::getModInfo)
.map(ModInfoObject::getModInfo)
.forEach(info -> info.setActive(true));
}
void disableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
void disableSelected(ObservableList<ModInfoObject> selectedItems) {
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModListPageSkin.ModInfoObject::getModInfo)
.map(ModInfoObject::getModInfo)
.forEach(info -> info.setActive(false));
}
public void openModFolder() {
FXUtils.openFolder(repository.getRunDirectory(instanceId).resolve("mods"));
if (gameInstance != null) {
FXUtils.openFolder(gameInstance.getModsDirectory());
}
}
public void checkUpdates(Collection<LocalModFile> mods) {
Objects.requireNonNull(mods);
if (isLoading()) {
if (isLoading() || gameInstance == null) {
return;
}
HMCLGameInstance gameInstance = this.gameInstance;
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
Optional<String> gameVersion = repository.getGameVersion(instanceId);
return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, mods)).orElse(null);
GameVersionNumber version = gameInstance.getVersion();
return version != GameVersionNumber.unknown()
? new AddonCheckUpdatesTask<>(
DownloadProviders.getDownloadProvider(), version.toString(), mods)
: null;
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception instanceof CancellationException) return;
@@ -262,7 +316,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (repository.isModpack(instanceId)) {
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("mods.update_modpack_mod.warning"), null,
MessageDialogPane.MessageType.WARNING,
@@ -273,7 +327,10 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}
public void download() {
Controllers.getDownloadPage().showModDownloads().selectInstance(instanceId);
if (gameInstance == null) {
return;
}
Controllers.getDownloadPage().showModDownloads().selectInstance(gameInstance.getId());
Controllers.navigate(Controllers.getDownloadPage());
}
@@ -287,14 +344,555 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}
public GameDirectory getGameDirectory() {
return this.repository.getGameDirectory();
return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null;
}
public HMCLGameRepository getRepository() {
return this.repository;
return gameInstance != null ? gameInstance.getRepository() : null;
}
public GameInstanceID getInstanceId() {
return this.instanceId;
return gameInstance != null ? gameInstance.getId() : null;
}
@NotNullByDefault
private static final class ModListPageSkin extends SkinBase<ModListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
private final HBox toolbarSelecting;
private final JFXListView<ModListPage.ModInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
public ModListPageSkin(ModListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
pane.getChildren().setAll(root);
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
toolbarSelecting = new HBox();
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
// Toolbar Normal
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
// Toolbar Selecting
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> {
selectAll.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
};
listView.getSelectionModel().getSelectedItems().addListener(listener);
listView.getItems().addListener(listener);
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
selectAll,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new ModListPage.ModInfoListCell(listView, skinnable));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("mods.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("mods.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super ModListPage.ModInfoObject>) c -> {
if (isSearching.get()) {
search();
}
});
listView.setOnContextMenuRequested(event -> {
ModListPage.ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ModListPage.ModInfoDialog(selectedItem));
}
});
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
// So we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ModListPage.ModInfoObject item : getSkinnable().getItems()) {
LocalModFile modInfo = item.getModInfo();
if (predicate.test(modInfo.getFileName())
|| predicate.test(modInfo.getName())
|| predicate.test(modInfo.getVersion())
|| predicate.test(modInfo.getGameVersion())
|| predicate.test(modInfo.getId())
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|| predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) {
listView.getItems().add(item);
}
}
}
}
}
public static final class ModInfoObject {
private final BooleanProperty active;
private final LocalModFile localModFile;
private final @Nullable ModTranslations.Mod modTranslations;
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.active = localModFile.activeProperty();
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public LocalModFile getModInfo() {
return localModFile;
}
public @Nullable ModTranslations.Mod getModTranslations() {
return modTranslations;
}
private Image getDefaultIcon() {
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
private Image loadIcon() {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
iconPaths.add(this.localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load mod icons", e);
}
return getDefaultIcon();
}
}
private static final class ModInfoDialog extends JFXDialogLayout {
ModInfoDialog(ModInfoObject modInfo) {
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
titleContainer.setPadding(new Insets(0, 0, 12, 0));
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
prefWidthProperty().bind(widthBinding);
maxWidthProperty().bind(widthBinding);
var imageContainer = new ImageContainer(40);
titleContainer.setAlignment(Pos.CENTER_LEFT);
modInfo.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.getTitleLabel().setWrapText(true);
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
title.setTitle(modInfo.getModTranslations().getDisplayName());
else
title.setTitle(modInfo.getModInfo().getName());
StringJoiner subtitle = new StringJoiner("\n");
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
}
title.setSubtitle(subtitle.toString());
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(modInfo.getModInfo().getDescription().toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
description.heightProperty().addListener((obs, oldVal, newVal) -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.MODS),
pair("addon.modrinth", ModrinthRemoteAddonRepository.MODS)
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : versionOptional.get().loaders()) {
String tag = I18n.translateLoaderName(loader);
if (tag != null) tags.add(tag);
}
title.addTagsIfNotExist(tags);
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
getActions().add(officialPageButton);
}
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
pair("key", modInfo.getModInfo().getName()),
pair("site", "all"),
pair("filter", "0")
)));
getActions().add(searchButton);
} else {
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
mcmodButton.setExternalLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
getActions().add(mcmodButton);
}
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);
}
}
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ModListPage page;
private final JFXCheckBox checkBox = new JFXCheckBox();
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
private BooleanProperty booleanProperty;
private Tooltip warningTooltip;
ModInfoListCell(JFXListView<ModInfoObject> listView, ModListPage page) {
super(listView);
this.page = page;
this.getStyleClass().add("mod-info-list-cell");
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(content, Priority.ALWAYS);
content.setMouseTransparent(true);
setSelectable();
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton);
StackPane.setMargin(container, new Insets(8));
getContainer().getChildren().setAll(container);
}
@Override
protected void updateControl(ModInfoObject dataItem, boolean empty) {
pseudoClassStateChanged(WARNING, false);
if (warningTooltip != null) {
Tooltip.uninstall(this, warningTooltip);
warningTooltip = null;
}
if (empty) return;
List<String> warning = new ArrayList<>();
content.getTags().clear();
LocalModFile modInfo = dataItem.getModInfo();
ModTranslations.Mod modTranslations = dataItem.getModTranslations();
ModLoaderType modLoaderType = modInfo.getModLoaderType();
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
String displayName = modInfo.getName();
if (modTranslations != null && I18n.isUseChinese()) {
String chineseName = modTranslations.getName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.containsEmoji(chineseName)) {
StringBuilder builder = new StringBuilder();
chineseName.codePoints().forEach(ch -> {
if (ch < 0x1F300 || ch > 0x1FAFF)
builder.appendCodePoint(ch);
});
chineseName = builder.toString().trim();
}
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName)) {
displayName = displayName + " (" + chineseName + ")";
}
}
}
content.setTitle(displayName);
StringJoiner joiner = new StringJoiner(" | ");
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
joiner.add(modInfo.getId());
joiner.add(FileUtils.getName(modInfo.getFile()));
content.setSubtitle(joiner.toString());
if (modLoaderType == ModLoaderType.UNKNOWN) {
content.addTagWarning(i18n("mods.unknown"));
} else if (!page.supportedLoaders.contains(modLoaderType)) {
warning.add(i18n("mods.warning.loader_mismatch"));
content.addTagWarning(I18n.translateLoaderType(dataItem.getModInfo().getModLoaderType()));
}
String modVersion = modInfo.getVersion();
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
content.addTag(modVersion);
}
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
restoreButton.setOnAction(e -> {
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
() -> page.rollback(modInfo, localModFile),
popup.get()))
.toList()
);
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
});
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
if (!warning.isEmpty()) {
pseudoClassStateChanged(WARNING, true);
//noinspection ConstantValue
this.warningTooltip = warning.size() == 1
? new Tooltip(warning.get(0))
: new Tooltip(String.join("\n", warning));
FXUtils.installFastTooltip(this, warningTooltip);
}
}
}
}
@@ -1,661 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.ListChangeListener;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
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.util.Duration;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
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.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
final class ModListPageSkin extends SkinBase<ModListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
private final HBox toolbarSelecting;
private final JFXListView<ModInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
ModListPageSkin(ModListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
pane.getChildren().setAll(root);
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
toolbarSelecting = new HBox();
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
// Toolbar Normal
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getItems().stream()
.map(ModInfoObject::getModInfo)
.toList()
)
),
createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
// Toolbar Selecting
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> {
selectAll.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
};
listView.getSelectionModel().getSelectedItems().addListener(listener);
listView.getItems().addListener(listener);
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream()
.map(ModInfoObject::getModInfo)
.toList()
)
),
selectAll,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new ModInfoListCell(listView));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("mods.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("mods.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super ModInfoObject>) c -> {
if (isSearching.get()) {
search();
}
});
listView.setOnContextMenuRequested(event -> {
ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ModInfoDialog(selectedItem));
}
});
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
// So we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ModInfoObject item : getSkinnable().getItems()) {
LocalModFile modInfo = item.getModInfo();
if (predicate.test(modInfo.getFileName())
|| predicate.test(modInfo.getName())
|| predicate.test(modInfo.getVersion())
|| predicate.test(modInfo.getGameVersion())
|| predicate.test(modInfo.getId())
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|| predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) {
listView.getItems().add(item);
}
}
}
}
static final class ModInfoObject {
private final BooleanProperty active;
private final LocalModFile localModFile;
private final @Nullable ModTranslations.Mod modTranslations;
private SoftReference<CompletableFuture<Image>> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.active = localModFile.activeProperty();
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
}
LocalModFile getModInfo() {
return localModFile;
}
public @Nullable ModTranslations.Mod getModTranslations() {
return modTranslations;
}
@FXThread
private Image loadIcon() {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
iconPaths.add(this.localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load mod icons", e);
}
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<ModInfoObject>> 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(GameInstanceIconType.getIconType(localModFile.getModLoaderType()).getIcon());
imageFuture.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<ModInfoObject> infoObjectProperty = current.get();
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
// The current ListCell has already switched to another object
return;
}
}
imageContainer.setImage(image);
}, Schedulers.javafx());
}
}
final class ModInfoDialog extends JFXDialogLayout {
ModInfoDialog(ModInfoObject modInfo) {
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
titleContainer.setPadding(new Insets(0, 0, 12, 0));
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
prefWidthProperty().bind(widthBinding);
maxWidthProperty().bind(widthBinding);
var imageContainer = new ImageContainer(40);
titleContainer.setAlignment(Pos.CENTER_LEFT);
modInfo.loadIcon(imageContainer, null);
TwoLineListItem title = new TwoLineListItem();
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
title.setTitle(modInfo.getModTranslations().getDisplayName());
else
title.setTitle(modInfo.getModInfo().getName());
StringJoiner subtitle = new StringJoiner("\n");
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
}
title.setSubtitle(subtitle.toString());
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(modInfo.getModInfo().getDescription().toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
description.heightProperty().addListener((obs, oldVal, newVal) -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.MODS),
pair("addon.modrinth", ModrinthRemoteAddonRepository.MODS)
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
for (ModLoaderType modLoaderType : versionOptional.get().loaders()) {
String loaderName = switch (modLoaderType) {
case FORGE -> i18n("install.installer.forge");
case CLEANROOM -> i18n("install.installer.cleanroom");
case LEGACY_FABRIC -> i18n("install.installer.legacyfabric");
case NEO_FORGE -> i18n("install.installer.neoforge");
case FABRIC -> i18n("install.installer.fabric");
case LITE_LOADER -> i18n("install.installer.liteloader");
case QUILT -> i18n("install.installer.quilt");
default -> null;
};
if (loaderName == null)
continue;
if (title.getTags()
.stream()
.noneMatch(it -> it.getText().equals(loaderName))) {
title.addTag(loaderName);
}
}
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
getActions().add(officialPageButton);
}
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
pair("key", modInfo.getModInfo().getName()),
pair("site", "all"),
pair("filter", "0")
)));
getActions().add(searchButton);
} else {
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
mcmodButton.setExternalLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
getActions().add(mcmodButton);
}
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);
}
}
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
final class ModInfoListCell extends MDListCell<ModInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
JFXCheckBox checkBox = new JFXCheckBox();
ImageContainer imageContainer = new ImageContainer(32);
TwoLineListItem content = new TwoLineListItem();
JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
BooleanProperty booleanProperty;
Tooltip warningTooltip;
ModInfoListCell(JFXListView<ModInfoObject> listView) {
super(listView);
this.getStyleClass().add("mod-info-list-cell");
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(content, Priority.ALWAYS);
content.setMouseTransparent(true);
setSelectable();
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton);
StackPane.setMargin(container, new Insets(8));
getContainer().getChildren().setAll(container);
}
@Override
protected void updateControl(ModInfoObject dataItem, boolean empty) {
pseudoClassStateChanged(WARNING, false);
if (warningTooltip != null) {
Tooltip.uninstall(this, warningTooltip);
warningTooltip = null;
}
if (empty) return;
List<String> warning = new ArrayList<>();
content.getTags().clear();
LocalModFile modInfo = dataItem.getModInfo();
ModTranslations.Mod modTranslations = dataItem.getModTranslations();
ModLoaderType modLoaderType = modInfo.getModLoaderType();
dataItem.loadIcon(imageContainer, new WeakReference<>(this.itemProperty()));
String displayName = modInfo.getName();
if (modTranslations != null && I18n.isUseChinese()) {
String chineseName = modTranslations.getName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.containsEmoji(chineseName)) {
StringBuilder builder = new StringBuilder();
chineseName.codePoints().forEach(ch -> {
if (ch < 0x1F300 || ch > 0x1FAFF)
builder.appendCodePoint(ch);
});
chineseName = builder.toString().trim();
}
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName)) {
displayName = displayName + " (" + chineseName + ")";
}
}
}
content.setTitle(displayName);
StringJoiner joiner = new StringJoiner(" | ");
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
joiner.add(modInfo.getId());
joiner.add(FileUtils.getName(modInfo.getFile()));
content.setSubtitle(joiner.toString());
if (modLoaderType == ModLoaderType.UNKNOWN) {
content.addTagWarning(i18n("mods.unknown"));
} else if (!ModListPageSkin.this.getSkinnable().supportedLoaders.contains(modLoaderType)) {
warning.add(i18n("mods.warning.loader_mismatch"));
switch (dataItem.getModInfo().getModLoaderType()) {
case FORGE -> content.addTagWarning(i18n("install.installer.forge"));
case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric"));
case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom"));
case NEO_FORGE -> content.addTagWarning(i18n("install.installer.neoforge"));
case FABRIC -> content.addTagWarning(i18n("install.installer.fabric"));
case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader"));
case QUILT -> content.addTagWarning(i18n("install.installer.quilt"));
}
}
String modVersion = modInfo.getVersion();
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
content.addTag(modVersion);
}
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
restoreButton.setOnAction(e -> {
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
() -> getSkinnable().rollback(modInfo, localModFile),
popup.get()))
.toList()
);
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
});
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
if (!warning.isEmpty()) {
pseudoClassStateChanged(WARNING, true);
//noinspection ConstantValue
this.warningTooltip = warning.size() == 1
? new Tooltip(warning.get(0))
: new Tooltip(String.join("\n", warning));
FXUtils.installFastTooltip(this, warningTooltip);
}
}
}
}
@@ -23,6 +23,7 @@ import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -43,8 +44,7 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
@@ -53,18 +53,21 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Path;
import java.util.*;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Stream;
@@ -76,7 +79,7 @@ import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> implements GameInstancePage.GameInstanceLoadable {
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> {
private static final String TIP_KEY = "resourcePackWarning";
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
@@ -90,16 +93,26 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
};
}
private HMCLGameRepository repository;
private GameInstanceID instanceId;
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
private Path resourcePackDirectory;
private ResourcePackManager resourcePackManager;
private final ReentrantLock lock = new ReentrantLock();
public ResourcePackListPage() {
/// Creates a resource-pack list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ResourcePackListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles);
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
@@ -107,11 +120,16 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
return new ResourcePackListPageSkin(this);
}
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
this.resourcePackManager = new ResourcePackManager(repository, instanceId);
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
this.resourcePackManager = null;
this.resourcePackDirectory = null;
getItems().clear();
return;
}
this.resourcePackManager = gameInstance.getResourcePackManager();
this.resourcePackDirectory = this.resourcePackManager.getDirectory();
refresh();
@@ -185,7 +203,10 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
private void onDownload() {
Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(instanceId);
if (gameInstance == null) {
return;
}
Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(gameInstance.getId());
Controllers.navigate(Controllers.getDownloadPage());
}
@@ -232,10 +253,18 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
public void checkUpdates(Collection<ResourcePackFile> resourcePacks) {
HMCLGameInstance gameInstance = this.gameInstance;
if (gameInstance == null) {
return;
}
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
Optional<String> gameVersion = repository.getGameVersion(instanceId);
return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null);
GameVersionNumber version = gameInstance.getVersion();
return version != GameVersionNumber.unknown()
? new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(),
version.toString(), resourcePacks)
: null;
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || result == null) {
@@ -249,7 +278,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (repository.isModpack(instanceId)) {
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("resourcepack.update_in_modpack.warning"), null,
MessageDialogPane.MessageType.WARNING,
@@ -404,7 +433,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ResourcePackInfoDialog(control, selectedItem));
Controllers.dialog(new ResourcePackInfoDialog(selectedItem));
}
});
@@ -465,13 +494,18 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
}
public static class ResourcePackInfoObject {
public static final class ResourcePackInfoObject {
private final ResourcePackFile file;
private final BooleanProperty enabled;
private final ItemPropertyAsyncCache<Image, ResourcePackInfoObject> iconCache;
public ResourcePackInfoObject(Pair<ResourcePackFile, Boolean> pair) {
this.file = pair.key();
this.enabled = new SimpleBooleanProperty(this, "enabled", pair.value());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public ResourcePackFile getFile() {
@@ -482,13 +516,16 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
return enabled;
}
Image getIcon() {
Image image = file.getIcon();
if (image == null || image.isError() || image.getWidth() <= 0 || image.getHeight() <= 0 ||
(Math.abs(image.getWidth() - image.getHeight()) >= 1)) {
image = FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image icon = file.loadIcon();
if (icon != null && !icon.isError() && icon.getWidth() > 0 && icon.getHeight() > 0 && Math.abs(icon.getWidth() - icon.getHeight()) < 1) {
return icon;
}
return image;
return getDefaultIcon();
}
}
@@ -503,8 +540,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton btnInfo = FXUtils.newToggleButton4(SVG.INFO);
private ResourcePackInfoObject object = null;
private BooleanProperty booleanProperty = null;
public ResourcePackListCell(JFXListView<ResourcePackInfoObject> listView, ResourcePackListPage page) {
@@ -520,7 +555,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
checkBox = new JFXCheckBox() {
@Override
public void fire() {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && object != null && !object.getFile().isCompatible()) {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && getItem() != null && !getItem().getFile().isCompatible()) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.info"),
@@ -535,7 +570,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
};
checkBox.setOnAction(e -> {
if (object != null) object.file.setEnabled(checkBox.isSelected());
if (getItem() != null) getItem().getFile().setEnabled(checkBox.isSelected());
});
HBox.setHgrow(content, Priority.ALWAYS);
@@ -555,9 +590,8 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
if (empty || item == null) return;
this.object = item;
ResourcePackFile file = item.getFile();
imageContainer.setImage(item.getIcon());
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(itemProperty()));
content.getTags().clear();
content.setTitle(file.getFileName());
@@ -569,7 +603,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile()));
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(this.page, item)));
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(item)));
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
@@ -588,7 +622,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
private static final class ResourcePackInfoDialog extends JFXDialogLayout {
ResourcePackInfoDialog(ResourcePackListPage page, ResourcePackInfoObject packInfoObject) {
ResourcePackInfoDialog(ResourcePackInfoObject packInfoObject) {
ResourcePackFile pack = packInfoObject.getFile();
HBox titleContainer = new HBox();
@@ -597,7 +631,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
imageContainer.setImage(packInfoObject.getIcon());
packInfoObject.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(pack.getFileName());
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -34,13 +35,13 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.schematic.LitematicFile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -54,6 +55,7 @@ import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.*;
import java.util.Objects;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
@@ -63,7 +65,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> implements GameInstancePage.GameInstanceLoadable {
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
private static String translateAuthorName(String author) {
if (I18n.isUseChinese() && "hsds".equals(author)) {
@@ -72,14 +74,25 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
return author;
}
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private Path schematicsDirectory;
private DirItem currentDirectory;
public SchematicsPage() {
/// Creates a schematics list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public SchematicsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this,
file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"),
this::addFiles
);
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
@@ -87,9 +100,9 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
return new SchematicsPageSkin();
}
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.schematicsDirectory = repository.getSchematicsDirectory(instanceId);
public void loadInstance(HMCLGameInstance.Optional instance) {
HMCLGameInstance gameInstance = instance.instance();
this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null;
refresh();
}
@@ -161,10 +174,20 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
return;
Path parent = currentDirectory.path;
FileNameSet existingPaths;
try {
existingPaths = FileNameSet.list(parent, null);
} catch (Exception e) {
LOG.warning("Failed to list folders in " + parent, e);
existingPaths = new FileNameSet(false);
}
Controllers.prompt(
i18n("schematics.create_directory.prompt"),
(result, handler) -> {
Path targetDir = parent.resolve(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("schematics.create_directory.failed.already_exists"));
return;
@@ -178,7 +201,11 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
LOG.warning("Failed to create directory: " + targetDir, e);
handler.reject(i18n("schematics.create_directory.failed", targetDir));
}
}, "", new RequiredValidator(), new Validator(i18n("schematics.create_directory.failed.invalid_name"), FileUtils::isNameValid));
},
"",
new RequiredValidator(),
new Validator(i18n("schematics.create_directory.failed.invalid_name"), FileUtils::isNameValid),
new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains));
}
private DirItem loadAll(Path dir, @Nullable DirItem parent) {
@@ -598,8 +625,14 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
@Override
protected void updateItem(Item item, boolean empty) {
Item oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
graphics.releaseRippleImmediately();
iconImageView.setImage(null);
if (empty || item == null) {
@@ -24,6 +24,7 @@ import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -36,8 +37,7 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -56,7 +56,7 @@ import java.nio.file.Path;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
@@ -64,23 +64,33 @@ import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldListPage extends ListPageBase<World> implements GameInstancePage.GameInstanceLoadable {
public final class WorldListPage extends ListPageBase<World> {
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private Path savesDir;
private List<World> worlds;
private HMCLGameRepository repository;
private GameInstanceID instanceId;
private @Nullable HMCLGameInstance gameInstance;
private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false);
private int refreshCount = 0;
public WorldListPage() {
/// Creates a world list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public WorldListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
installWorld(modpacks.get(0));
});
showAll.addListener(e -> updateWorldList());
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
@@ -88,21 +98,19 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
return new WorldListPageSkin();
}
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
this.savesDir = repository.getSavesDirectory(instanceId);
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null;
refresh();
}
private void updateWorldList() {
if (worlds == null) {
if (worlds == null || gameInstance == null) {
getItems().clear();
} else if (showAll.get()) {
getItems().setAll(worlds);
} else {
GameVersionNumber gameVersion = repository.getGameVersion(instanceId).map(GameVersionNumber::asGameVersion).orElse(null);
GameVersionNumber gameVersion = gameInstance.getVersion();
getItems().setAll(worlds.stream()
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
.toList());
@@ -110,15 +118,16 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
}
public void refresh() {
if (repository == null || instanceId == null)
if (gameInstance == null || savesDir == null)
return;
int currentRefresh = ++refreshCount;
HMCLGameInstance gameInstance = this.gameInstance;
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
// Ensure the game version number is parsed
repository.getGameVersion(instanceId);
gameInstance.getVersion();
return World.getWorlds(savesDir);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (refreshCount != currentRefresh) {
@@ -126,8 +135,7 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
return;
}
Optional<String> gameVersion = repository.getGameVersion(instanceId);
supportQuickPlay.set(World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)));
supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion()));
worlds = result;
updateWorldList();
@@ -180,7 +188,9 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
}
private void showManagePage(World world) {
Controllers.navigate(new WorldManagePage(world, repository, instanceId));
if (gameInstance != null) {
Controllers.navigate(new WorldManagePage(world, gameInstance));
}
}
public void export(World world) {
@@ -200,11 +210,15 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
}
public void launch(World world) {
Instances.launchAndEnterWorld(repository, instanceId, world.getFileName());
if (gameInstance != null) {
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
}
public void generateLaunchScript(World world) {
Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
if (gameInstance != null) {
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
}
public BooleanProperty showAllProperty() {
@@ -330,6 +344,7 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
if (oldWorld == world && oldEmpty == empty) return;
this.graphic.releaseRippleImmediately();
this.content.getTags().clear();
if (empty || world == null) {
@@ -24,8 +24,7 @@ import javafx.geometry.Insets;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -36,13 +35,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.util.Optional;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -54,8 +51,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
private final World world;
private final Path backupsDir;
private final HMCLGameRepository repository;
private final GameInstanceID instanceId;
private final HMCLGameInstance gameInstance;
private final boolean supportQuickPlay;
private FileChannel sessionLockChannel;
@@ -70,11 +66,10 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
private final TabHeader.Tab<WorldBackupsPage> worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage");
private final TabHeader.Tab<DataPackListPage> dataPackTab = new TabHeader.Tab<>("dataPackListPage");
public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceID instanceId) {
public WorldManagePage(World world, HMCLGameInstance gameInstance) {
this.world = world;
this.backupsDir = repository.getBackupsDirectory(instanceId);
this.repository = repository;
this.instanceId = instanceId;
this.gameInstance = gameInstance;
this.backupsDir = gameInstance.getBackupsDirectory();
updateSessionLockChannel();
@@ -91,8 +86,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true));
Optional<String> gameVersion = repository.getGameVersion(instanceId);
supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion));
supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion());
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
@@ -151,11 +145,11 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
public void launch() {
fireEvent(new PageCloseEvent());
Instances.launchAndEnterWorld(repository, instanceId, world.getFileName());
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
public void generateLaunchScript() {
Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
@Override
@@ -21,7 +21,6 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -184,7 +183,10 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
@FXThread
private void loadJava(Collection<JavaRuntime> javaRuntimes) {
if (javaRuntimes != null) {
this.setItems(FXCollections.observableArrayList(javaRuntimes));
// JavaRuntime equality is path-based. setAll can therefore retain a stale item when the
// Java installation at an existing path has been upgraded.
this.getItems().clear();
this.getItems().addAll(javaRuntimes);
this.setLoading(false);
} else {
this.setLoading(true);
@@ -227,7 +229,7 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
}
private static final class JavaItemCell extends ListCell<JavaRuntime> {
private final Node graphic;
private final RipplerContainer graphic;
private final Label label = new Label();
private final TwoLineListItem content;
@@ -298,7 +300,14 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
@Override
protected void updateItem(JavaRuntime item, boolean empty) {
JavaRuntime 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 {
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.main;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -48,7 +49,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
private final TransitionPane transitionPane = new TransitionPane();
public LauncherSettingsPage() {
gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class));
gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class, null));
javaManagementTab.setNodeSupplier(JavaManagementPage::new);
settingsTab.setNodeSupplier(SettingsPage::new);
personalizationTab.setNodeSupplier(PersonalizationPage::new);
@@ -59,7 +60,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
tab = new TabHeader(transitionPane, gameTab, javaManagementTab, settingsTab, personalizationTab, downloadTab, helpTab, feedbackTab, aboutTab);
tab.select(gameTab);
addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(GameDirectoryManager.getSelectedRepository(), null));
addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())));
AdvancedListBox sideBar = new AdvancedListBox()
.addNavigationDrawerTab(tab, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL)
@@ -89,7 +90,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
}
public void showGameSettings(HMCLGameRepository repository) {
gameTab.getNode().loadInstance(repository, null);
gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
tab.select(gameTab, false);
}
@@ -24,6 +24,7 @@ import javafx.animation.KeyValue;
import javafx.animation.RotateTransition;
import javafx.animation.Timeline;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
@@ -43,14 +44,10 @@ import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.VersionList;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -76,30 +73,40 @@ 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 org.jetbrains.annotations.UnmodifiableView;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.download.RemoteVersion.Type.RELEASE;
import static org.jackhuang.hmcl.download.ComponentRemoteVersion.Type.RELEASE;
import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.ui.FXUtils.SINE;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays the launcher home controls for the currently selected game repository.
public final class MainPage extends StackPane implements DecoratorPage {
private static final String ANNOUNCEMENT = "announcement";
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final ObjectProperty<@Nullable GameInstanceID> currentGame = new SimpleObjectProperty<>(this, "currentGame");
private final ObjectProperty<@Nullable HMCLGameInstance> currentGame = new SimpleObjectProperty<>(this, "currentGame");
private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
private final BooleanProperty showUpdateDialog = new SimpleBooleanProperty(this, "showUpdateDialog");
private final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>(this, "latestVersion");
private final ObservableList<GameInstanceManifest> versions = FXCollections.observableArrayList();
private HMCLGameRepository repository;
/// Mutable storage for visible instances from the selected repository's current snapshot.
private final ObservableList<HMCLGameInstance> mutableInstances = FXCollections.observableArrayList();
/// Read-only observable view of [#mutableInstances].
private final @UnmodifiableView ObservableList<HMCLGameInstance> instances =
FXCollections.unmodifiableObservableList(mutableInstances);
/// Current snapshot of the repository selected by [GameDirectoryManager].
private final ObservableValue<HMCLGameRepositorySnapshot> selectedRepositorySnapshot =
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
.flatMap(HMCLGameRepository::snapshotProperty);
private TransitionPane announcementPane;
private final StackPane updatePane;
@@ -211,10 +218,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
HBox launchPane = new HBox();
launchPane.getStyleClass().add("launch-pane");
FXUtils.onScroll(launchPane, versions, list -> {
GameInstanceID currentId = getCurrentGame();
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
}, it -> repository.setSelectedInstance(it.id()));
FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> mutableInstances.setAll(GameDirectoryManager.getSelectedRepository().getDisplayInstances().toList()));
FXUtils.onScroll(launchPane, instances, list -> {
@Nullable HMCLGameInstance currentGame = getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
@@ -233,7 +242,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
private Tooltip tooltip;
@Override
public void accept(@Nullable GameInstanceID currentGame) {
public void accept(@Nullable HMCLGameInstance currentGame) {
if (currentGame == null) {
launchLabel.setText(i18n("instance.launch.empty"));
currentLabel.setText(null);
@@ -244,7 +253,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
FXUtils.installFastTooltip(launchButton, tooltip);
} else {
launchLabel.setText(i18n("instance.launch"));
currentLabel.setText(currentGame.toString());
currentLabel.setText(currentGame.getId().toString());
graphic.getChildren().setAll(launchLabel, currentLabel);
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch);
if (tooltip != null)
@@ -269,7 +278,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
JFXPopup.PopupHPosition.RIGHT,
0,
-menuButton.getHeight(),
repository, versions
instances
);
Node graphic = menuButton.getGraphic();
@@ -346,12 +355,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
private void launch() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository, repository.getSelectedInstance());
Instances.launch(repository.getSelectedInstance());
}
private void launchNoGame() {
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
VersionList<?> versionList = downloadProvider.getVersionListById("game");
ComponentVersionList<?> versionList = downloadProvider.getVersionList(GameComponentType.GAME);
Holder<GameInstanceID> instanceHolder = new Holder<>();
Task<?> task = versionList.refreshAsync("")
@@ -363,26 +372,25 @@ public final class MainPage extends StackPane implements DecoratorPage {
.orElseThrow(() -> new IOException("No versions found")))
.thenComposeAsync(version -> {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
DefaultDependencyManager dependency = repository.getDependency();
HMCLDependencyManager dependency = repository.getDependency();
String gameVersion = version.getGameVersion();
GameInstanceID instanceId = new GameInstanceID(gameVersion);
instanceHolder.value = instanceId;
return dependency.newGameBuilder()
.name(instanceId)
.gameVersion(gameVersion)
.buildAsync();
try (HMCLGameBuilder builder = dependency.newGameBuilder(instanceId)) {
return builder
.component(GameComponentType.GAME, gameVersion)
.buildAsync();
}
})
.whenComplete(any -> GameDirectoryManager.getSelectedRepository().refresh())
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
GameDirectoryManager.getSelectedRepository().setSelectedInstance(instanceHolder.value);
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
repository.setSelectedInstance(repository.getInstance(instanceHolder.value));
launch();
} else if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else {
} else if (!(exception instanceof CancellationException)) {
LOG.warning("Failed to install game", exception);
Controllers.dialog(StringUtils.getStackTrace(exception),
i18n("install.failed"),
@@ -410,28 +418,35 @@ public final class MainPage extends StackPane implements DecoratorPage {
return state;
}
public GameDirectory getGameDirectory() {
return repository.getGameDirectory();
}
public HMCLGameRepository getRepository() {
return repository;
}
public GameInstanceID getCurrentGame() {
/// Returns the instance shown by the launch controls.
///
/// @return the current instance, or `null` when no instance is selected
public @Nullable HMCLGameInstance getCurrentGame() {
return currentGame.get();
}
public ObjectProperty<@Nullable GameInstanceID> currentGameProperty() {
/// Returns the property for the instance shown by the launch controls.
///
/// @return the current-instance property
public ObjectProperty<@Nullable HMCLGameInstance> currentGameProperty() {
return currentGame;
}
public void setCurrentGame(@Nullable GameInstanceID currentGame) {
/// Sets the instance shown by the launch controls.
///
/// @param currentGame the instance to show, or `null` to show the empty state
public void setCurrentGame(@Nullable HMCLGameInstance currentGame) {
this.currentGame.set(currentGame);
}
public ObservableList<GameInstanceManifest> getVersions() {
return versions;
/// Returns the observable instances displayed by launch-selection controls.
///
/// The list is updated from the selected repository's published snapshot and contains no hidden
/// instances. The returned view cannot be mutated.
///
/// @return the observable launch-menu instances
public @UnmodifiableView ObservableList<HMCLGameInstance> getInstances() {
return instances;
}
public boolean isShowUpdate() {
@@ -470,9 +485,4 @@ public final class MainPage extends StackPane implements DecoratorPage {
this.latestVersion.set(latestVersion);
}
public void initVersions(HMCLGameRepository repository, List<GameInstanceManifest> versions) {
FXUtils.checkFxUserThread();
this.repository = repository;
this.versions.setAll(versions);
}
}
@@ -25,19 +25,15 @@ import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.scene.control.*;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.css.PseudoClass;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
import javafx.stage.FileChooser;
@@ -45,23 +41,13 @@ import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorStyle;
import org.glavo.uuid.UUIDs;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
import org.jackhuang.hmcl.theme.Theme;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.ThemeColorSource;
import org.jackhuang.hmcl.theme.ThemePackExporter;
import org.jackhuang.hmcl.theme.ThemePackManifest;
import org.jackhuang.hmcl.theme.ThemePackManager;
import org.jackhuang.hmcl.theme.ThemeReference;
import org.jackhuang.hmcl.theme.*;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
@@ -449,7 +435,7 @@ public class PersonalizationPage extends StackPane {
LineSelectButton<String> brightnessPane = new LineSelectButton<>();
brightnessPane.setTitle(i18n("settings.launcher.brightness"));
brightnessPane.setConverter(name -> i18n("settings.launcher.brightness."
+ Objects.requireNonNullElse(name, "auto")));
+ Objects.requireNonNullElse(name, "auto").trim().toLowerCase(Locale.ROOT)));
brightnessPane.setItems(Arrays.asList("auto", "light", "dark"));
bindThemeAppearanceLineSelectButton(
brightnessPane,
@@ -1271,7 +1257,7 @@ public class PersonalizationPage extends StackPane {
Label lblLogFontDisplay = new Label("[23:33:33] [Client Thread/INFO] [WaterPower]: Loaded mod WaterPower.");
lblLogFontDisplay.fontProperty().bind(Bindings.createObjectBinding(
() -> Font.font(Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT), settings().logFontSizeProperty().get()),
() -> Font.font(Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT), settings().logFontSizeProperty().get()),
settings().logFontFamilyProperty(), settings().logFontSizeProperty()));
logFontPane.getChildren().add(lblLogFontDisplay);
@@ -20,18 +20,11 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.scene.layout.Region;
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.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -45,29 +38,21 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.ui.nbt.NBTFileType;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.ui.nbt.NBTFileType;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -75,13 +60,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
private MainPage mainPage = null;
public RootPage() {
EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class)
.register(event -> onRefreshedVersions((HMCLGameRepository) event.getSource()));
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (repository.isLoaded())
onRefreshedVersions(GameDirectoryManager.getSelectedRepository());
getStyleClass().remove("gray-background");
getLeft().getStyleClass().add("gray-background");
}
@@ -122,20 +100,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
FXUtils.onChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), mainPage::setCurrentGame);
mainPage.latestVersionProperty().bind(UpdateChecker.latestVersionProperty());
GameDirectoryManager.registerVersionsListener(repository -> {
GameDirectory gameDirectory = repository.getGameDirectory();
List<GameInstanceManifest> children = repository.getInstanceManifests().parallelStream()
.filter(version -> !version.isHidden())
.sorted(Comparator
.comparing((GameInstanceManifest manifest) -> Lang.requireNonNullElse(manifest.releaseTime(), Instant.EPOCH))
.thenComparing(manifest -> VersionNumber.asVersion(repository.getGameVersion(manifest).orElse(manifest.id().toString()))))
.collect(Collectors.toList());
runInFX(() -> {
if (gameDirectory == GameDirectoryManager.getSelectedGameDirectory())
mainPage.initVersions(repository, children);
});
});
this.mainPage = mainPage;
}
return mainPage;
@@ -155,17 +119,18 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
// second item in left sidebar
GameAdvancedListItem gameListItem = new GameAdvancedListItem();
gameListItem.setOnAction(e -> {
GameInstanceID instanceId = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instanceId == null) {
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Controllers.navigate(Controllers.getGameListPage());
} else {
Instances.modifyGameSettings(GameDirectoryManager.getSelectedRepository(), instanceId);
Instances.modifyGameSettings(instance);
}
});
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> {
GameInstanceID currentId = getSkinnable().getMainPage().getCurrentGame();
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
}, it -> getSkinnable().getMainPage().getRepository().setSelectedInstance(it.id()));
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getInstances(), list -> {
@Nullable HMCLGameInstance currentGame = getSkinnable().getMainPage().getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
if (AnimationUtils.isAnimationEnabled()) {
FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareGameInstancePage);
}
@@ -182,10 +147,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
AdvancedListItem downloadItem = new AdvancedListItem();
downloadItem.setLeftIcon(SVG.DOWNLOAD);
downloadItem.setTitle(i18n("download"));
downloadItem.setOnAction(e -> {
Controllers.getDownloadPage().showGameDownloads();
Controllers.navigate(Controllers.getDownloadPage());
});
downloadItem.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
if (AnimationUtils.isAnimationEnabled()) {
FXUtils.prepareOnMouseEnter(downloadItem, Controllers::prepareDownloadPage);
}
@@ -252,44 +214,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
JFXPopup.PopupHPosition.LEFT,
gameListItem.getWidth(),
0,
getSkinnable().getMainPage().getRepository(),
getSkinnable().getMainPage().getVersions());
getSkinnable().getMainPage().getInstances());
}
}
private boolean checkedModpack = false;
private void onRefreshedVersions(HMCLGameRepository repository) {
runInFX(() -> {
if (!checkedModpack) {
checkedModpack = true;
if (repository.getInstanceCount() == 0) {
Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip");
Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack");
Path modpackFile;
if (Files.exists(zipModpack)) {
modpackFile = zipModpack;
} else if (Files.exists(mrpackModpack)) {
modpackFile = mrpackModpack;
} else {
modpackFile = null;
}
if (modpackFile != null) {
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile))
.thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding))
.thenApplyAsync(modpack -> ModpackHelper
.getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null)
.executor())
.thenAcceptAsync(Schedulers.javafx(), executor -> {
Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL);
executor.start();
}).start();
}
}
}
});
}
}
@@ -37,10 +37,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.upgrade.*;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -121,10 +118,18 @@ public final class SettingsPage extends ScrollPane {
updateButton.setManaged(outdated);
updatePane.pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), outdated);
if (UpdateChecker.isOutdated()) {
if (outdated) {
lblUpdateSubProperty.set(i18n("update.newest_version", UpdateChecker.getLatestVersion().version()));
} else if (UpdateChecker.isCheckingUpdate()) {
lblUpdateSubProperty.set(i18n("update.checking"));
} else if (UpdateChecker.errorProperty().get() != null) {
Throwable t = UpdateChecker.errorProperty().get();
if (t instanceof SelfVerificationException) {
lblUpdateSubProperty.set(i18n("update.unverified"));
} else {
String msg = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
lblUpdateSubProperty.set(i18n("update.check_failed", msg));
}
} else {
lblUpdateSubProperty.set(i18n("update.latest"));
}
@@ -132,6 +137,7 @@ public final class SettingsPage extends ScrollPane {
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.errorProperty().addListener(new WeakInvalidationListener(updateListener));
updateListener.invalidated(null);
}
@@ -632,7 +632,7 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
private final ThemePackManagementPage page;
/// Root graphic reused by this cell.
private final Region graphic;
private final RipplerContainer graphic;
/// The text content shown for the current theme pack.
private final TwoLineListItem content = new TwoLineListItem();
@@ -714,19 +714,21 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
@Override
protected void updateItem(ThemePackManager.@Nullable InstalledThemePack themePack, boolean empty) {
var currentItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(themePack, empty);
if (Objects.equals(getItem(), currentItem)) return;
if (Objects.equals(getItem(), currentItem) && oldEmpty == empty) return;
this.graphic.releaseRippleImmediately();
content.getTags().clear();
iconImage.setImage(null);
iconFallback.setVisible(false);
if (empty || themePack == null) {
setGraphic(null);
return;
}
setGraphic(graphic);
ThemePackManifest manifest = themePack.manifest();
@@ -221,7 +221,7 @@ public class TerracottaControllerPage extends StackPane {
MessageDialogPane.MessageType.QUESTION
).addAction(i18n("instance.launch"), () -> {
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> {
Instances.launch(repository.getSelectedInstance(), launcherHelper -> {
launcherHelper.setKeep();
launcherHelper.setDisableOfflineSkin();
});
@@ -26,6 +26,7 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
import org.jackhuang.hmcl.ui.Controllers;
@@ -41,6 +42,7 @@ import org.jackhuang.hmcl.ui.main.MainPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.Lang;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.setting.SettingsManager.userState;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -54,7 +56,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
private final TransitionPane transitionPane = new TransitionPane();
@SuppressWarnings("unused")
private ChangeListener<GameInstanceID> instanceChangeListenerHolder;
private @Nullable ChangeListener<@Nullable HMCLGameInstance> instanceChangeListenerHolder;
public TerracottaPage() {
statusPage.setNodeSupplier(TerracottaControllerPage::new);
@@ -79,27 +81,28 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
.add(accountListItem)
.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> {
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> {
Instances.launch(repository.getSelectedInstance(), launcherHelper -> {
launcherHelper.setKeep();
launcherHelper.setDisableOfflineSkin();
});
}, item -> {
instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(),
instanceName -> item.setSubtitle(instanceName != null ? instanceName.toString() : i18n("instance.empty"))
instance -> item.setSubtitle(instance != null ? instance.getId().toString() : i18n("instance.empty"))
);
MainPage mainPage = Controllers.getRootPage().getMainPage();
FXUtils.onScroll(item, mainPage.getVersions(), list -> {
GameInstanceID currentId = mainPage.getCurrentGame();
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
}, it -> mainPage.getRepository().setSelectedInstance(it.id()));
FXUtils.onScroll(item, mainPage.getInstances(), list -> {
@Nullable HMCLGameInstance currentGame = mainPage.getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.LEFT,
item.getWidth(),
0,
mainPage.getRepository(), mainPage.getVersions()));
mainPage.getInstances()));
})
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff. Show more