Compare commits

..
Author SHA1 Message Date
辞庐 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
270 changed files with 15599 additions and 8652 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:
+1
View File
@@ -17,6 +17,7 @@ NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
terracotta-log-*
hmcl-mod-update-list-*
*.hmcl-theme
/.java/
/.local/
@@ -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;
}
@@ -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();
@@ -138,6 +138,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);
@@ -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();
}
}
}
@@ -61,7 +61,6 @@ import javafx.scene.text.TextFlow;
import javafx.stage.FileChooser;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.glavo.url.WebURL;
@@ -76,11 +75,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;
@@ -470,25 +469,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 +733,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 +827,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 +1148,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);
@@ -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("-");
@@ -142,7 +136,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 +286,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 +337,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 +360,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 +375,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;
@@ -324,7 +320,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,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);
@@ -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);
}
});
}
});
}
@@ -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,29 +32,29 @@ 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();
});
}
@@ -69,38 +69,34 @@ 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 gameVersionChanged = !instance.getVersion().toString().equals(getVersion(GameComponentType.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 (InstallerItem component : group.getComponents()) {
GameComponentType componentType = component.getComponentType();
String version = instance.getComponentVersion(component.getComponentType());
String libraryVersion = Lang.requireNonNullElse(getVersion(componentType), version);
boolean alreadyInstalled = version != null && !(controller.getSettings().get(componentType.getPatchId()) instanceof UpdateInstallerWizardProvider.RemoveComponentAction);
if (component.getComponentType() != GameComponentType.GAME && gameVersionChanged && getVersion(componentType) == 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));
component.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));
} else if (alreadyInstalled || getVersion(componentType) != null) {
component.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false));
} else {
library.versionProperty().set(null);
component.versionProperty().set(null);
}
}
@@ -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,43 @@ 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) {
GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
builder.name(instanceId);
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
if (instanceId == null) {
throw new IllegalStateException("Instance ID is not set");
}
settings.asStringMap().forEach((key, value) -> {
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key)
&& value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) {
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion());
repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion());
return builder.buildAsync().whenComplete(any -> {
repository.refresh();
repository.applyDefaultIsolationSetting(instanceId);
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
settings.asStringMap().forEach((key, value) -> {
if (!GameComponentType.GAME.getPatchId().equals(key)
&& value instanceof ComponentRemoteVersion remoteVersion)
builder.component(remoteVersion);
});
boolean modded = GameComponentType.MOD_LOADERS.stream()
.anyMatch(componentType ->
settings.get(componentType.getPatchId()) instanceof ComponentRemoteVersion);
if (repository.shouldIsolateNewInstance(modded)) {
builder.enableIsolation();
}
Task<?> buildTask = builder.buildAsync();
buildTask.onDone().register(event -> {
if (!event.isFailed()) {
runInFX(() -> repository.setSelectedInstance(repository.getInstance(instanceId)));
}
});
return buildTask;
}
}
@Override
@@ -341,7 +351,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,22 +47,18 @@ 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
@@ -74,38 +71,46 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
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 gameInstance.getRepository().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 +134,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 +149,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 +158,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 +173,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 +182,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,8 +19,7 @@ package org.jackhuang.hmcl.ui.export;
import javafx.scene.Node;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
@@ -37,6 +36,7 @@ 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 +47,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 +69,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);
@@ -136,9 +134,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 +151,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 +165,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,7 +173,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile);
dependency = new McbbsModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
}
@Override
@@ -177,7 +185,7 @@ public final class ExportWizardProvider implements WizardProvider {
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
@Nullable Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
@@ -185,8 +193,9 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId);
dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(),
HMCLGameInstance instance = resolveCurrentGameInstance();
GameSettings.Effective setting = instance.getEffectiveSettings();
dependency = new MultiMCModpackExportTask(instance, exportInfo.getWhitelist(),
new MultiMCInstanceConfiguration(
"OneSix",
exportInfo.getName() + "-" + exportInfo.getVersion(),
@@ -225,7 +234,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 +242,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 +254,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 +263,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
repository,
instanceId,
resolveCurrentGameInstance(),
exportInfo,
modpackFile
);
@@ -268,12 +276,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");
};
}
@@ -29,7 +29,7 @@ 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;
@@ -62,14 +62,15 @@ 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) {
public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) {
this.controller = controller;
this.instanceId = instanceId;
this.gameInstance = gameInstance;
this.adviser = adviser;
GameInstanceID instanceId = gameInstance.getId();
JFXTreeView<String> treeView = new JFXTreeView<>();
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
@@ -97,17 +98,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 +146,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 +162,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);
@@ -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);
}
@@ -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) -> {
@@ -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,9 +109,8 @@ 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);
@@ -124,10 +121,12 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
}
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)
@@ -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();
}
@@ -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 {
@@ -30,10 +30,7 @@ import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
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 +50,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 +67,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 +128,7 @@ public class DownloadPage extends Control implements DecoratorPage {
return addon;
}
public HMCLGameRepository.InstanceReference getInstanceReference() {
public HMCLGameInstance.Optional getInstanceOptional() {
return instanceReference;
}
@@ -271,15 +269,14 @@ 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) {
@@ -375,7 +372,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 +398,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));
}
}
}
@@ -549,6 +540,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 +591,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 +611,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;
}
}
@@ -21,9 +21,7 @@ import javafx.scene.Node;
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;
@@ -31,6 +29,7 @@ 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.RipplerContainer;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
@@ -39,16 +38,14 @@ 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;
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();
setTitle(i18n("settings.icon"));
FlowPane pane = new FlowPane();
@@ -79,7 +76,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);
@@ -119,7 +116,7 @@ public class GameInstanceIconDialog extends DialogPane {
@Override
protected void onAccept() {
repository.onInstanceIconChanged.fireEvent(new Event(this));
// Icon file / settings.iconProperty updates already invalidate iconImageProperty.
onFinish.run();
super.onAccept();
}
@@ -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);
}
}
@@ -156,11 +156,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 +178,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,34 @@ 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) {
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);
@@ -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();
@@ -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,79 @@ 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 -> {
public void loadInstance(HMCLGameInstance.Optional instance) {
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.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.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() {
@@ -144,16 +149,21 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ga
}
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.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.ObjectProperty;
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.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.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
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.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,597 @@ 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 SoftReference<CompletableFuture<Image>> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.active = localModFile.activeProperty();
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
}
public 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());
}
}
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.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()));
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.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 (!page.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(),
() -> 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.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
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));
}
});
@@ -503,8 +532,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 +547,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 +562,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,7 +582,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
if (empty || item == null) return;
this.object = item;
ResourcePackFile file = item.getFile();
imageContainer.setImage(item.getIcon());
@@ -569,7 +595,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 +614,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();
@@ -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;
@@ -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();
}
}
}
});
}
}
@@ -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));
@@ -159,9 +159,7 @@ public final class UpdateHandler {
} else {
Exception e = executor.getException();
LOG.warning("Failed to update to " + version, e);
if (e instanceof CancellationException) {
Platform.runLater(() -> Controllers.showToast(i18n("message.cancelled")));
} else {
if (!(e instanceof CancellationException)) {
Platform.runLater(() -> Controllers.dialog(e.toString(), i18n("update.failed"), MessageType.ERROR));
}
}
@@ -17,13 +17,13 @@
*/
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModManager;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
@@ -73,13 +73,14 @@ public final class NativePatcher {
);
}
public static GameInstanceManifest patchNative(DefaultGameRepository repository,
GameInstanceManifest manifest, String gameVersion,
public static GameInstanceManifest patchNative(DefaultGameInstance instance,
GameInstanceManifest manifest,
JavaRuntime javaVersion,
GameSettings.Effective settings,
List<String> javaArguments) {
GameVersionNumber gameVersion = instance.getVersion();
if (settings.getInheritable(GameSettings::useCustomNativesProperty)) {
if (gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") < 0)
if (gameVersion.compareTo("1.19") < 0)
return manifest;
ArrayList<Library> newLibraries = new ArrayList<>();
@@ -96,17 +97,17 @@ public final class NativePatcher {
return manifest.withLibraries(newLibraries);
}
final boolean useNativeGLFW = settings.getInheritable(GameSettings::useNativeGLFWProperty);
final boolean useNativeGLFWorSDL = settings.getInheritable(GameSettings::useNativeGLFWorSDLProperty);
final boolean useNativeOpenAL = settings.getInheritable(GameSettings::useNativeOpenALProperty);
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && (useNativeGLFW || useNativeOpenAL)
&& gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") >= 0) {
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()
&& (useNativeGLFWorSDL || useNativeOpenAL) && gameVersion.compareTo("1.19") >= 0) {
manifest = manifest.withLibraries(manifest.getLibraries().stream()
.filter(library -> {
if (library.classifier() != null && library.classifier().startsWith("natives")
&& "org.lwjgl".equals(library.groupId())) {
if ((useNativeGLFW && "lwjgl-glfw".equals(library.artifactId()))
if ((useNativeGLFWorSDL && ("lwjgl-glfw".equals(library.artifactId()) || library.artifactId().contains("sdl")))
|| (useNativeOpenAL && "lwjgl-openal".equals(library.artifactId()))) {
LOG.info("Filter out " + library.name());
return false;
@@ -122,7 +123,6 @@ public final class NativePatcher {
OperatingSystem os = javaVersion.getPlatform().getOperatingSystem();
Architecture arch = javaVersion.getArchitecture();
GameVersionNumber gameVersionNumber = gameVersion != null ? GameVersionNumber.asGameVersion(gameVersion) : null;
if (settings.getInheritable(GameSettings::notPatchNativesProperty))
return manifest;
@@ -131,8 +131,7 @@ public final class NativePatcher {
return manifest;
if (arch == Architecture.ARM64 && (os == OperatingSystem.MACOS || os == OperatingSystem.WINDOWS)
&& gameVersionNumber != null
&& gameVersionNumber.compareTo("1.19") >= 0)
&& gameVersion.compareTo("1.19") >= 0)
return manifest;
Map<String, Library> replacements = getNatives(javaVersion.getPlatform());
@@ -172,7 +171,7 @@ public final class NativePatcher {
}
if (lwjglVersionChanged) {
ModManager modManager = repository.getModManager(manifest.id());
ModManager modManager = instance.getModManager();
try {
for (LocalModFile mod : modManager.getLocalFiles()) {
if ("sodium".equals(mod.getId())) {
@@ -17,7 +17,7 @@
*/
package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.util.i18n.translator.Translator;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -77,7 +77,7 @@ public final class I18n {
return getTranslator().formatSpeed(bytes);
}
public static String getDisplayVersion(RemoteVersion version) {
public static String getDisplayVersion(ComponentRemoteVersion version) {
return getTranslator().getDisplayVersion(version);
}
@@ -17,7 +17,7 @@
*/
package org.jackhuang.hmcl.util.i18n.translator;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -44,7 +44,7 @@ public class Translator {
return displayLocale;
}
public String getDisplayVersion(RemoteVersion remoteVersion) {
public String getDisplayVersion(ComponentRemoteVersion remoteVersion) {
return remoteVersion.getSelfVersion();
}
@@ -17,7 +17,7 @@
*/
package org.jackhuang.hmcl.util.i18n.translator;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import java.io.IOException;
@@ -73,7 +73,7 @@ public class Translator_en_Qabs extends Translator {
}
@Override
public String getDisplayVersion(RemoteVersion remoteVersion) {
public String getDisplayVersion(ComponentRemoteVersion remoteVersion) {
return translate(remoteVersion.getSelfVersion());
}
@@ -17,7 +17,7 @@
*/
package org.jackhuang.hmcl.util.i18n.translator;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -221,7 +221,7 @@ public class Translator_lzh extends Translator {
}
@Override
public String getDisplayVersion(RemoteVersion remoteVersion) {
public String getDisplayVersion(ComponentRemoteVersion remoteVersion) {
if (remoteVersion instanceof GameRemoteVersion)
return translateGameVersion(GameVersionNumber.asGameVersion(remoteVersion.getSelfVersion()));
else
@@ -57,7 +57,7 @@ fi
if [ -n "${HMCL_JAVA_OPTS+x}" ]; then
_HMCL_VM_OPTIONS=${HMCL_JAVA_OPTS}
else
_HMCL_VM_OPTIONS="-XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=15"
_HMCL_VM_OPTIONS="-XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=15 -XX:G1PeriodicGCInterval=900000 -XX:G1PeriodicGCSystemLoadThreshold=0.6 -XX:+UseStringDeduplication"
fi
function show_warning_console() {
@@ -50,6 +50,7 @@
-monet-surface-tint: #4858AB;
-monet-primary-seed: #5C6BC0;
-monet-primary-transparent-50: #4352A580;
-monet-primary-transparent-80: #4352A5CC;
-monet-secondary-container-transparent-50: #D0D5FD80;
-monet-surface-transparent-50: #FBF8FF80;
-monet-surface-transparent-80: #FBF8FFCC;
+3 -67
View File
@@ -70,15 +70,6 @@
-fx-fill: -monet-on-primary-fixed;
}
.hint.success {
-fx-background-color: #d1e7dd;
-fx-border-color: -monet-outline;
}
.hint.success Text, .hint.success .svg {
-fx-fill: #0a3622;
}
.hint.error {
-fx-background-color: -monet-error-container;
-fx-border-color: -monet-outline;
@@ -480,7 +471,7 @@
-fx-padding: 0;
}
.launch-pane > .jfx-button, .jfx-button * {
.launch-pane > .launch-button .label {
-fx-text-fill: -monet-on-primary-container;
-fx-font-size: 14px;
}
@@ -1237,69 +1228,14 @@
-fx-font-size: 14.0px;
}
/* unused */
.toggle-icon1 .icon {
-fx-fill: #4285F4;
-fx-padding: 20.0;
}
.toggle-icon1 {
-fx-pref-width: 80px;
-fx-background-radius: 80px;
-fx-pref-height: 80px;
-fx-background-color: transparent;
-jfx-toggle-color: rgba(66.0, 133.0, 244.0, 0.29885056614875793);
-jfx-untoggle-color: transparent;
}
.toggle-icon1 .jfx-rippler {
-jfx-rippler-fill: rgba(66.0, 133.0, 244.0, 0.29885056614875793);
-jfx-mask-type: CIRCLE;
}
.toggle-icon2, .toggle-icon3, .jfx-decorator-button {
.jfx-decorator-button {
-fx-pref-width: 50px;
-fx-background-radius: 50px;
-fx-pref-height: 50px;
-fx-background-color: transparent;
}
/* unused */
.toggle-icon2 .icon {
-fx-fill: RED;
}
.toggle-icon2 .jfx-rippler {
-jfx-rippler-fill: RED;
-jfx-mask-type: CIRCLE;
}
/* unused */
.toggle-icon3 {
-fx-toggle-icon4-size: 35px;
-fx-pref-width: -fx-toggle-icon4-size;
-fx-max-width: -fx-toggle-icon4-size;
-fx-min-width: -fx-toggle-icon4-size;
-fx-pref-height: -fx-toggle-icon4-size;
-fx-max-height: -fx-toggle-icon4-size;
-fx-min-height: -fx-toggle-icon4-size;
-fx-background-radius: 50px;
-fx-background-color: transparent;
-jfx-toggle-color: white;
-jfx-untoggle-color: transparent;
}
.toggle-icon3 .icon {
-fx-fill: rgb(204.0, 204.0, 51.0);
-fx-padding: 10.0;
}
.toggle-icon3 .jfx-rippler {
-jfx-rippler-fill: white;
-jfx-mask-type: CIRCLE;
}
/* used */
.toggle-icon4 {
-fx-toggle-icon4-size: 30px;
-fx-pref-width: -fx-toggle-icon4-size;
@@ -2003,7 +1939,7 @@
}
.line-select-button .menu-container:selected .subtitle-label {
-fx-text-fill: -monet-primary-container;
-fx-text-fill: -monet-primary-transparent-80;
}
.line-toggle-button .jfx-toggle-button {
@@ -22,7 +22,6 @@
about=About
about.copyright=Copyright
about.copyright.statement=Copyright © 2013-2026 huangyuhui and contributors.
about.author=Author
about.author.statement=bilibili @huanghongxun
about.claim=EULA
about.claim.statement=Click this link for full text.
@@ -80,7 +79,6 @@ account.failed.ssl=An SSL error occurred while connecting to the server. Please
account.failed.dns=An SSL error occurred while connecting to the server. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service.
account.failed.wrong_account=You have logged in to the wrong account.
account.storage.read_only=The account file was saved by a newer version of HMCL. The current version of HMCL cannot modify accounts.
account.hmcl.hint=You need to click "Log in" and complete the process in the opened browser window.
account.injector.add=New Auth Server
account.injector.empty=None (You can click "+" to add one)
account.injector.http=Warning: This server uses the unsafe HTTP protocol. Anyone intercepting your connection would be able to see your credentials in plaintext.
@@ -97,19 +95,15 @@ account.login.retry=Retry
account.login.refresh=Log in again
account.login.refresh.microsoft.hint=You need to log in to your Microsoft account again because the account authorization is invalid.
account.login.restricted=Sign in to your Microsoft account to enable this feature
account.logout=Logout
account.register=Register
account.manage=Account List
account.copy_uuid=Copy UUID of the Account
account.methods=Login Type
account.methods.authlib_injector=authlib-injector
account.methods.microsoft=Microsoft
account.methods.microsoft.birth=How to Change Your Account Birth Date
account.methods.microsoft.code=Code (Copied to Clipboard)
account.methods.microsoft.close_page=Microsoft account authorization is now completed.\n\
\n\
There are some extra work for us, but you can safely close this tab for now.
account.methods.microsoft.deauthorize=Deauthorize
account.methods.microsoft.error.add_family=Please click <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">here</a> to change your account birth date to be over 18 years old, or add your account to a family.
account.methods.microsoft.error.country_unavailable=XBOX Live is not available in your current country/region.
account.methods.microsoft.error.missing_xbox_account=Please confirm that you have purchased Minecraft: Java Edition. If you have, please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create your game profile.
@@ -119,8 +113,6 @@ account.methods.microsoft.error.banned=Your account may have been banned by XBOX
You can click <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">here</a> to check the ban status of your account.
account.methods.microsoft.error.unknown=Failed to log in, error code: %d.
account.methods.microsoft.error.wrong_verify_method=Failed to log in. Please try logging into your account using PASSWORD instead of other login methods.
account.methods.microsoft.logging_in=Logging in...
account.methods.microsoft.makegameidsettings=Create Profile / Edit Profile Name
account.methods.microsoft.hint=Click the "Log in" button to start adding your Microsoft account.
account.methods.microsoft.methods.device=Log In with QR Code
account.methods.microsoft.methods.device.hint=Scan QR code or visit <a href="%s">%s</a> to complete login, enter <b>%s</b> in the opened page.
@@ -132,7 +124,6 @@ account.methods.microsoft.profile=Account Profile
account.methods.microsoft.purchase=Buy Minecraft
account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the <a href="https://hmcl.huangyuhui.net/download">official build</a> to log in.
account.methods.microsoft.snapshot.tooltip=You are using an unofficial build of HMCL. Please download the official build to refresh the account.
account.methods.microsoft.snapshot.website=Official Website
account.methods.offline=Offline
account.methods.offline.name.special_characters=Use only letters, numbers, and underscores (max 16 chars)
account.methods.offline.name.invalid=It is recommended to use only English letters, numbers and underscores for the username, and the length should not exceed 16 characters.\n\
@@ -146,12 +137,10 @@ account.methods.offline.uuid.hint=UUID is a unique identifier for Minecraft play
\n\
This option is for advanced users only. We do not recommend changing this option unless you know what you are doing.
account.methods.offline.uuid.malformed=Invalid format.
account.methods.ban_query=Ban Query
account.missing=No Accounts
account.missing.add=Click here to add one.
account.move_to_global=Convert to Global Account\nThe account information will be saved in a config file of the current user directory of the system.
account.move_to_portable=Convert to Portable Account\nThe account information will be saved in a config file in the same directory as HMCL.
account.not_logged_in=Not Logged in
account.password=Password
account.portable=Portable
account.skin=Skin
@@ -172,8 +161,6 @@ account.skin.upload.failed=Failed to upload skin.
account.skin.invalid_skin=Invalid skin file.
account.username=Username
addon.broken_dependency.title=Broken dependency
addon.broken_dependency.desc=This dependency existed before, but it does not exist anymore. Try using another download source.
addon.category=Category
addon.changelog=Changelog
addon.changelog.empty=Currently no changelog
@@ -198,6 +185,7 @@ addon.dependency.tool=Required Dependencies (Must be downloaded separately. Miss
addon.dependency.include=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately)
addon.dependency.incompatible=Incompatible Addons (Installing these addons at the same time will prevent the game from launching)
addon.dependency.broken=Broken Dependencies (This addon existed before, but it does not exist anymore. Try using another download source.)
addon.dependencies.has_broken=This version has broken dependencies. Please try another download source, or installation could be unsuccessful.
addon.download.recommend=Recommended Version for Minecraft %1s
addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (Snapshots)
@@ -232,7 +220,6 @@ button.reveal_dir=Reveal
button.refresh=Refresh
button.remove=Remove
button.remove.confirm=Are you sure you want to permanently remove it? This action cannot be undone!
button.retry=Retry
button.save=Save
button.save_as=Save As
button.select_all=Select All
@@ -397,9 +384,6 @@ download=Download
download.code.404=File "%s" not found on the remote server.
download.content=Addons
download.shader=Shaders
download.curseforge.unavailable=This HMCL build does not support access to CurseForge. Please use the official build to access CurseForge.
download.existing=The file cannot be saved because it already exists. You can click "Save As" to save the file elsewhere.
download.external_link=Visit Download Website
download.failed=Failed to download "%1$s", response code: %2$d.
download.failed.empty=No versions are available. Please click here to go back.
download.failed.no_code=Failed to download
@@ -482,10 +466,6 @@ fatal.deprecated_java_version=HMCL will require Java 17 or later to run in the f
It is recommended to install the latest version of Java to ensure that HMCL works properly.\n\
\n\
You can continue to keep the old version of Java. HMCL can recognize and manage multiple Java versions and will automatically select the appropriate Java for you based on the game version.
fatal.deprecated_java_version.update=HMCL will require Java 17 or later to run in the future. Please install the latest version of Java to ensure that HMCL can complete the upgrade.\n\
\n\
You can continue to keep the old version of Java.\
HMCL can recognize and manage multiple Java versions and will automatically select the appropriate Java for you based on the game version.
fatal.deprecated_java_version.download_link=Download Java %d
fatal.samba=If you opened Hello Minecraft! Launcher from a Samba network drive, some features might not be working. Please try updating your Java or moving the launcher to another directory.
fatal.illegal_char=Your working directory contains an illegal character "=". You will not be able to use authlib-injector or change the skin of your offline account.
@@ -749,12 +729,10 @@ game_directory.default=Current
game_directory.home=Minecraft Launcher
game_directory.instance_directory=Game Directory
game_directory.instance_directory.choose=Choose game directory
game_directory.manage=Instance Directory List
game_directory.name=Name
game_directory.new=New Directory
game_directory.title=Game Directories
game_directory.selected=Selected
game_directory.use_relative_path=Use a relative path for the game directory if possible
game_directory.root=Using the disk root directory as the game folder may cause the game to fail to launch and will create multiple files and folders in the disk root directory.\n\nAre you sure you want to continue?
help=Help
help.doc=Hello Minecraft! Launcher Documentation
@@ -767,6 +745,7 @@ input.url=The input must be a valid URL.
install=New Instance
install.change_version=Change Version
install.change_version.title=Change Version - %1s
install.change_version.confirm=Are you sure you want to switch %1$s from version %2$s to %3$s?
install.change_version.process=Change Version Process
install.failed=Failed to install
@@ -775,13 +754,13 @@ install.failed.downloading.detail=Failed to download file: %s
install.failed.downloading.timeout=Download timeout when fetching: %s
install.failed.install_online=Failed to identify the provided file. If you are installing a mod, navigate to the "Mods" page.
install.failed.malformed=The downloaded files are corrupted. You can try resolving this problem by switching to another download source in "Settings → Download → Download Source".
install.failed.cleanroom_not_compatible_with_forge=Cleanroom is not compatible with Forge.
install.failed.optifine_conflict=Cannot install both OptiFine and Fabric on Minecraft 1.13 or later.
install.failed.optifine_forge_1.17=For Minecraft 1.17.1, Forge is only compatible with OptiFine H1 pre2 or later. You can install them by checking "Snapshots" when choosing an OptiFine version in HMCL.
install.failed.version_mismatch=This loader requires the game version %1$s, but the installed one is %2$s.
install.installer.change_version=%s Incompatible
install.installer.choose=Choose Your %s Version
install.installer.cleanroom=Cleanroom
install.installer.depend=Requires %s
install.installer.do_not_install=Do not install
install.installer.fabric=Fabric
install.installer.fabric-api=Fabric API
@@ -794,9 +773,6 @@ install.installer.game=Minecraft
install.installer.incompatible=Incompatible with %s
install.installer.install=Installing %s
install.installer.install_offline=Install/Update from Local File
install.installer.install_offline.tooltip=We support using the local (Neo)Forge, Cleanroom, and OptiFine installer.
install.installer.install_online=Online Install
install.installer.install_online.tooltip=We currently support Forge, NeoForge, Cleanroom, OptiFine, Fabric, Legacy Fabric, Quilt, and LiteLoader.
install.installer.liteloader=LiteLoader
install.installer.not_installed=Not installed
install.installer.optifine=OptiFine
@@ -810,11 +786,9 @@ install.modpack.installation=Modpack Installation
install.name.invalid=The name contains special characters (such as emoji or CJK characters).\nIt is recommended to change the name to include only English letters, numbers, and underscores to avoid potential issues when launching the game.\nDo you want to proceed with the installation?
install.new_game=Install Instance
install.new_game.already_exists=This instance name already exists. Please use another name.
install.new_game.current_game_version=Current Instance Version
install.new_game.installation=Instance Installation
install.new_game.malformed=Invalid name.
install.new_game.malformed_json=Invalid Minecraft instance JSON file.
install.select=Choose operation
install.success=Successfully installed.
instance=Games
@@ -908,16 +882,14 @@ launch.advice.multi=The following problems were detected:\n\n%s\n\nThese problem
launch.advice.java.auto=The current Java version is not compatible with the instance.\n\nClick "Yes" to automatically choose the most compatible Java version. Or, you can navigate to "Global/Instance-specific Settings → Java" to choose one yourself.
launch.advice.java.modded_java_7=Minecraft 1.7.2 and previous versions require Java 7 or earlier.
launch.advice.cleanroom=Cleanroom %2$s can only be run on Java %1$s or later. Please use Java %1$s or later versions.
launch.advice.corrected=We have resolved the Java problem. If you still want to use your choice of Java version, you can disable "Do not check JVM compatibility" in "Global/Instance-specific Settings → Advanced Settings".
launch.advice.uncorrected=If you still want to use your choice of Java version, you can disable "Do not check JVM compatibility" in "Global/Instance-specific Settings → Advanced Settings".
launch.advice.different_platform=The 64-bit Java version is recommended for your device, but you have installed a 32-bit one.
launch.advice.forge2760_liteloader=Forge version 2760 is not compatible with LiteLoader. Please consider upgrading Forge to version 2773 or later.
launch.advice.forge28_2_2_optifine=Forge version 28.2.2 or later is not compatible with OptiFine. Please consider downgrading Forge to version 28.2.1 or earlier.
launch.advice.forge37_0_60=Forge versions prior to 37.0.60 are not compatible with Java 17. Please update Forge to 37.0.60 or later, or launch the game with Java 16.
launch.advice.java8_1_13=Minecraft 1.13 and later can only be run on Java 8 or later. Please use Java 8 or later versions.
launch.advice.java8_51_1_13=Minecraft 1.13 may crash on Java 8 versions prior to 1.8.0_51. Please install the latest Java 8 version.
launch.advice.java9=You cannot launch Minecraft 1.12 or earlier with Java 9 or later. Please use Java 8 instead.
launch.advice.lwjgl_3_4_1=The current game version has a known issue that may cause stuttering in the game.\nHMCL can automatically optimize the game performance (using a Java Agent).\nDo you allow HMCL to automatically optimize the game?\nIf you encounter issues, you can disable the "Allow HMCL to modify the game" option in "Settings → General".
launch.advice.lwjgl_3_4_1=The current game version has a known issue that may cause stuttering in the game.\nHMCL can automatically optimize the game performance (using a Java Agent).\nDo you allow HMCL to automatically optimize the game?\nIf you encounter issues, you can disable the "Allow HMCL to modify the game" option in instance settings.
launch.advice.lwjgl_3_4_1.title=Allow HMCL to automatically optimize the game?
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %1$s to launch Minecraft %2$s.
launch.advice.modlauncher8=The Forge version you are using is not compatible with the current Java version. Please try updating Forge.
@@ -972,8 +944,6 @@ launcher.background.load_policy.wait_for_background=Wait for Background
launcher.background.loading=Background Loading
launcher.background.network=From URL
launcher.background.network.cache=Cache URL Images
launcher.background.network.cache.disabled=Disabled
launcher.background.network.cache.enabled=Enabled
launcher.background.paint=Solid Color
launcher.background.theme=Follow Theme
launcher.background.theme_color=Follow Theme Color
@@ -991,7 +961,6 @@ launcher.update_java=Please update your Java version.
libraries.download=Downloading Libraries
login.empty_username=You have not set your username yet!
login.enter_password=Please enter your password.
logwindow.always_on_top=Always On Top
@@ -1005,7 +974,6 @@ logwindow.export_game_crash_logs=Export Crash Logs
logwindow.export_dump=Export Game Stack Dump
logwindow.export_dump.no_dependency=Your Java does not contain the dependencies to create the stack dump. Please join our Discord or QQ group for help.
main_page=Home
message.cancelled=Operation Canceled
message.confirm=Confirm
@@ -1035,7 +1003,6 @@ modpack.desc=Describe your modpack, including an introduction and probably some
modpack.description=Modpack Description
modpack.download=Download Modpacks
modpack.download.title=Download Modpack - %1s
modpack.enter_name=Enter a name for this modpack.
modpack.export=Export as Modpack
modpack.export.as=Export Modpack As...
modpack.file_api=Modpack URL Prefix
@@ -1056,11 +1023,9 @@ modpack.files.scripts=MineTweaker Configuration File
modpack.files.servers_dat=Server List File
modpack.installing=Installing modpack
modpack.installing.given=Installing %s modpack
modpack.introduction=Curse, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
modpack.invalid=Invalid modpack, you can try downloading it again.
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type.
modpack.name=Modpack Name
modpack.not_a_valid_name=Invalid modpack name.
modpack.origin=Source
modpack.origin.url=Official Website
modpack.origin.mcbbs=MCBBS
@@ -1102,6 +1067,7 @@ modpack.wizard.step.initialization.warning=Before making a modpack, please make
Keep in mind that you are not allowed to add mods and resource packs that explicitly state they could not to be distributed or put in a modpack.
modpack.wizard.step.initialization.server=Click here for more information on how to make a server modpack that can be automatically updated.
# Translation primarily from modrinth.
modrinth.category.adventure=Adventure
modrinth.category.atmosphere=Atmosphere
modrinth.category.audio=Audio
@@ -1137,7 +1103,7 @@ modrinth.category.high=High
modrinth.category.iris=Iris
modrinth.category.items=Items
modrinth.category.java-agent=Java Agent
modrinth.category.kitchen-sink=Kitchen-Sink
modrinth.category.kitchen-sink=Kitchen Sink
modrinth.category.legacy-fabric=Legacy Fabric
modrinth.category.library=Library
modrinth.category.lightweight=Lightweight
@@ -1153,7 +1119,7 @@ modrinth.category.misc=Misc
modrinth.category.mobs=Mobs
modrinth.category.modded=Modded
modrinth.category.models=Models
modrinth.category.modloader=Modloader
modrinth.category.modloader=Risugami's ModLoader
modrinth.category.multiplayer=Multiplayer
modrinth.category.neoforge=NeoForge
modrinth.category.nilloader=NilLoader
@@ -1183,13 +1149,12 @@ modrinth.category.themed=Themed
modrinth.category.transportation=Transportation
modrinth.category.tweaks=Tweaks
modrinth.category.utility=Utility
modrinth.category.vanilla=Vanilla
modrinth.category.vanilla=Vanilla Shader
modrinth.category.vanilla-like=Vanilla-like
modrinth.category.velocity=Velocity
modrinth.category.waterfall=Waterfall
modrinth.category.worldgen=Worldgen
modrinth.category.8x-=8x or lower
modrinth.category.16x=16x
modrinth.category.32x=32x
modrinth.category.48x=48x
modrinth.category.64x=64x
@@ -1214,7 +1179,6 @@ mods.mcmod=MCMod
mods.mcmod.page=MCMod Page
mods.mcmod.search=Search in MCMod
mods.name=Name
mods.not_modded=You must install a modloader (Forge, NeoForge, Fabric, Legacy Fabric, Quilt, or LiteLoader) first to manage your mods!
mods.restore=Restore
mods.url=Official Page
mods.update_modpack_mod.warning=Updating mods in a modpack can lead to irreparable results, possibly corrupting the modpack so that it cannot launch. Are you sure you want to update?
@@ -1268,7 +1232,6 @@ world.chunkbase.nether_fortress=Nether Fortress
world.duplicate=Duplicate the World
world.duplicate.prompt=Please enter the name of the duplicated world
world.duplicate.failed.already_exists=Directory already exists
world.duplicate.failed.empty_name=Name cannot be empty
world.duplicate.failed.invalid_name=Name contains invalid characters
world.duplicate.failed=Failed to duplicate the world
world.duplicate.success.toast=Successfully duplicated the world
@@ -1374,7 +1337,6 @@ schematics.create_directory=New Directory
schematics.create_directory.prompt=Please enter the new directory name
schematics.create_directory.failed=Failed to create directory
schematics.create_directory.failed.already_exists=Directory already exists
schematics.create_directory.failed.empty_name=Name cannot be empty
schematics.create_directory.failed.invalid_name=Name contains invalid characters
schematics.empty=No Schematics
schematics.info.description=Description
@@ -1413,20 +1375,6 @@ settings.advanced=Advanced Settings
settings.advanced.modify=Edit Advanced Settings
settings.advanced.title=Advanced Settings - %s
settings.advanced.custom_commands=Custom Commands
settings.advanced.custom_commands.hint=The following environment variables are provided:\n\
\ · $INST_NAME: instance name.\n\
\ · $INST_ID: instance name.\n\
\ · $INST_DIR: absolute path of the instance working directory.\n\
\ · $INST_MC_DIR: absolute path of the game directory.\n\
\ · $INST_JAVA: java binary used for launch.\n\
\ · $INST_FORGE: set if Forge is installed.\n\
\ · $INST_NEOFORGE: set if NeoForge is installed.\n\
\ · $INST_CLEANROOM: set if Cleanroom is installed.\n\
\ · $INST_LITELOADER: set if LiteLoader is installed.\n\
\ · $INST_OPTIFINE: set if OptiFine is installed.\n\
\ · $INST_FABRIC: set if Fabric is installed.\n\
\ · $INST_LEGACYFABRIC: set if Legacy Fabric is installed.\n\
\ · $INST_QUILT: set if Quilt is installed.
settings.advanced.dont_check_game_completeness=Do not check game integrity
settings.advanced.dont_check_jvm_validity=Do not check JVM compatibility
settings.advanced.dont_patch_natives=Do not attempt to automatically replace native libraries
@@ -1462,14 +1410,9 @@ settings.advanced.launcher_visibility.keep=Keep the launcher visible
settings.advanced.launcher_visible=Launcher Visibility
settings.advanced.minecraft_arguments=Launch Arguments
settings.advanced.minecraft_arguments.prompt=Default
settings.advanced.natives=Native Libraries
settings.advanced.natives_settings=Native Library Settings
settings.advanced.natives_directory=Native Library Path
settings.advanced.natives_directory.choose=Choose the location of the desired native library
settings.advanced.natives_directory.custom=Custom
settings.advanced.natives_directory.custom.enabled=Use Custom Native Libraries
settings.advanced.natives_directory.default=Default
settings.advanced.natives_directory.default.version_id=<Instance Name>
settings.advanced.no_jvm_args=Do not add default JVM arguments
settings.advanced.no_optimizing_jvm_args=Do not add default JVM optimization arguments
settings.advanced.precall_command=Pre-launch Command
@@ -1536,12 +1479,11 @@ settings.advanced.renderer.zink.desc=OpenGL renderer based on Vulkan
settings.advanced.server_ip=Server Address
settings.advanced.server_ip.prompt=Automatically join after launching the game
settings.advanced.unsupported_system_options=Settings not applicable to the current system
settings.advanced.use_native_glfw=Use System GLFW
settings.advanced.use_native_glfw_or_sdl=Use System GLFW / SDL
settings.advanced.use_native_openal=Use System OpenAL
settings.advanced.use_native_sdl=Use System SDL
settings.advanced.linux_freebsd_only=Linux/FreeBSD Only
settings.advanced.windows_only=Windows Only
settings.advanced.workaround=Workaround
settings.advanced.workaround.warning=Workaround options are intended only for advanced users. Tweaking with these options may crash the game. Unless you know what you are doing, please do not edit these options.
settings.advanced.wrapper_launcher=Wrapper Command
settings.advanced.wrapper_launcher.prompt=Allows launching using an extra wrapper program like "optirun"
@@ -1588,11 +1530,7 @@ settings.game.window_type=Game Window Type
settings.game.window_type.fullscreen=Fullscreen
settings.game.window_type.maximized=Maximized
settings.game.window_type.windowed=Windowed
settings.game.working_directory=Working Directory
settings.game.working_directory.choose=Choose the working directory
settings.game.working_directory.hint=Enable the "Isolated" option in "Working Directory" to allow the current instance to store its settings, worlds, and mods in a separate directory.\n\
\n\
It is recommended to enable this option to avoid mod conflicts, but you will need to move your worlds manually.
settings.icon=Icon
settings.game_directories.read_only=The game directory file was saved by a newer version of HMCL. The current version of HMCL cannot modify game directories.
@@ -1606,7 +1544,6 @@ settings.launcher.brightness=Theme Mode
settings.launcher.brightness.auto=Follow System Settings
settings.launcher.brightness.dark=Dark Mode
settings.launcher.brightness.light=Light Mode
settings.launcher.common_path.tooltip=HMCL will put all game assets and dependencies here. If there are existing libraries in the game directory, then HMCL will prefer to use them first.
settings.launcher.debug=Debug
settings.launcher.disable_april_fools=Do not enable April Fools features
settings.launcher.disable_auto_game_options=Do not switch game language
@@ -1614,11 +1551,9 @@ settings.launcher.download=Download
settings.launcher.download.threads=Download Threads
settings.launcher.download.threads.auto=Automatically Determine
settings.launcher.download.threads.custom=Custom
settings.launcher.download.threads.hint=Too many threads may cause your system to freeze, and your download speed may be affected by your ISP and download servers. It is not always the case that more threads increase your download speed.
settings.launcher.download_source=Download Source
settings.launcher.download_source.auto=Automatically Choose Download Sources
settings.launcher.default_addon_source=Default Addon Source
settings.launcher.enable_game_list=Show instance list in homepage
settings.launcher.misc=Misc
settings.launcher.font=Launcher Font
settings.launcher.font.anti_aliasing=Anti-aliasing
@@ -1709,9 +1644,6 @@ settings.type.global.preset.auto_name=Preset %d
shaderpack.download.title=Download Shader - %1s
sponsor=Donors
sponsor.bmclapi=Downloads for the Chinese Mainland are provided by BMCLAPI. Click here for more information.
sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows users to manage multiple Minecraft instances easily. Click here for more information.
system.architecture=Architecture
system.operating_system=Operating System
@@ -1863,7 +1795,6 @@ update.bubble.subtitle=Click here to update
update.note.stable=Suitable for users who prioritize software stability.\nNew features are added to the stable version only after thorough testing.
update.note.dev=Suitable for users who want to experience new features first.\nThe development version includes the latest features and bug fixes,\nbut may also have more issues due to insufficient testing.
update.latest=This is the latest version
update.no_browser=Cannot open in system browser. But we copied the link to your clipboard, and you can open it manually.
update.tooltip=Update
update.preview=Preview HMCL releases early
update.preview.subtitle=Enable this option to receive new versions of HMCL early for testing before their official release.
@@ -22,7 +22,6 @@
about=حول
about.copyright=حقوق النشر
about.copyright.statement=حقوق النشر © 2013-2026 huangyuhui والمساهمون.
about.author=المطوّر
about.author.statement=bilibili @huanghongxun
about.claim=اتفاقية الترخيص
about.claim.statement=انقر على هذا الرابط للاطلاع على النص الكامل.
@@ -77,7 +76,6 @@ account.failed.server_response_malformed=استجابة الخادم غير صا
account.failed.ssl=حدث خطأ SSL أثناء الاتصال بالخادم. يُرجى محاولة تحديث Java.
account.failed.dns=حدث خطأ SSL أثناء الاتصال بالخادم. قد يكون تحليل DNS غير صحيح. يُرجى تغيير خادم DNS الخاص بك أو استخدام خدمة وكيل.
account.failed.wrong_account=لقد سجّلت الدخول إلى حساب خاطئ.
account.hmcl.hint=تحتاج إلى النقر على "تسجيل الدخول" وإتمام العملية في نافذة المتصفح المفتوحة.
account.injector.add=خادم مصادقة جديد
account.injector.empty=لا يوجد (يمكنك النقر على "+" لإضافة واحد)
account.injector.http=تحذير: يستخدم هذا الخادم بروتوكول HTTP غير الآمن. أي شخص يعترض اتصالك سيتمكن من رؤية بياناتك بنص واضح.
@@ -93,19 +91,15 @@ account.login.retry=إعادة المحاولة
account.login.refresh=تسجيل الدخول مجدداً
account.login.refresh.microsoft.hint=تحتاج إلى تسجيل الدخول إلى حساب Microsoft مجدداً لأن تفويض الحساب انتهت صلاحيته.
account.login.restricted=سجّل الدخول إلى حساب Microsoft لتفعيل هذه الميزة
account.logout=تسجيل الخروج
account.register=إنشاء حساب
account.manage=قائمة الحسابات
account.copy_uuid=نسخ UUID الحساب
account.methods=نوع تسجيل الدخول
account.methods.authlib_injector=authlib-injector
account.methods.microsoft=Microsoft
account.methods.microsoft.birth=كيفية تغيير تاريخ ميلاد حسابك
account.methods.microsoft.code=الرمز (تم النسخ إلى الحافظة)
account.methods.microsoft.close_page=اكتمل تفويض حساب Microsoft.\n\
\n\
لا يزال هناك بعض الخطوات من جانبنا، لكن يمكنك إغلاق هذا التبويب بأمان الآن.
account.methods.microsoft.deauthorize=إلغاء التفويض
account.methods.microsoft.error.add_family=يُرجى النقر <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">هنا</a> لتغيير تاريخ ميلاد حسابك ليكون فوق 18 عاماً، أو إضافة حسابك إلى عائلة.
account.methods.microsoft.error.country_unavailable=XBOX Live غير متاح في بلدك/منطقتك الحالية.
account.methods.microsoft.error.missing_xbox_account=لا يوجد حساب XBOX مرتبط بحساب Microsoft الخاص بك بعد. يُرجى النقر <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">هنا</a> لربط واحد.
@@ -115,8 +109,6 @@ account.methods.microsoft.error.banned=قد يكون حسابك محظوراً
يمكنك النقر <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">هنا</a> للتحقق من حالة الحظر لحسابك.
account.methods.microsoft.error.unknown=فشل تسجيل الدخول، رمز الخطأ: %d.
account.methods.microsoft.error.wrong_verify_method=فشل تسجيل الدخول. يُرجى محاولة تسجيل الدخول إلى حسابك باستخدام كلمة المرور بدلاً من طرق الدخول الأخرى.
account.methods.microsoft.logging_in=جارٍ تسجيل الدخول...
account.methods.microsoft.makegameidsettings=إنشاء ملف اللعبة / تعديل اسم الملف
account.methods.microsoft.hint=انقر على زر "تسجيل الدخول" لبدء إضافة حساب Microsoft الخاص بك.
account.methods.microsoft.methods.device=تسجيل الدخول برمز QR
account.methods.microsoft.methods.device.hint=امسح رمز QR أو زر <a href="%s">%s</a> لإتمام تسجيل الدخول، وأدخل <b>%s</b> في الصفحة المفتوحة.
@@ -127,7 +119,6 @@ account.methods.microsoft.profile=ملف الحساب
account.methods.microsoft.purchase=شراء Minecraft
account.methods.microsoft.snapshot=أنت تستخدم إصداراً غير رسمي من HMCL. يُرجى تنزيل <a href="https://hmcl.huangyuhui.net/download">الإصدار الرسمي</a> لتسجيل الدخول.
account.methods.microsoft.snapshot.tooltip=أنت تستخدم إصداراً غير رسمي من HMCL. يُرجى تنزيل الإصدار الرسمي لتحديث الحساب.
account.methods.microsoft.snapshot.website=الموقع الرسمي
account.methods.offline=غير متصل
account.methods.offline.name.special_characters=استخدم الحروف والأرقام والشرطات السفلية فقط (16 حرفاً كحد أقصى)
account.methods.offline.name.invalid=يُنصح باستخدام الحروف الإنجليزية والأرقام والشرطات السفلية فقط في اسم المستخدم، على ألا يتجاوز الطول 16 حرفاً.\n\
@@ -141,12 +132,10 @@ account.methods.offline.uuid.hint=UUID هو معرّف فريد للاعبي Min
\n\
هذا الخيار مخصص للمستخدمين المتقدمين فقط. لا نوصي بتغيير هذا الخيار إلا إذا كنت تعرف ما تفعله.
account.methods.offline.uuid.malformed=تنسيق غير صالح.
account.methods.ban_query=الاستعلام عن الحظر
account.missing=لا توجد حسابات
account.missing.add=انقر هنا لإضافة واحد.
account.move_to_global=تحويل إلى حساب عام\nسيتم حفظ معلومات الحساب في ملف إعدادات في مجلد المستخدم الحالي للنظام.
account.move_to_portable=تحويل إلى حساب محمول\nسيتم حفظ معلومات الحساب في ملف إعدادات في نفس المجلد الذي يوجد فيه HMCL.
account.not_logged_in=غير مسجّل الدخول
account.password=كلمة المرور
account.portable=محمول
account.skin=المظهر
@@ -167,8 +156,6 @@ account.skin.upload.failed=فشل رفع المظهر.
account.skin.invalid_skin=ملف المظهر غير صالح.
account.username=اسم المستخدم
addon.broken_dependency.title=تبعية معطوبة
addon.broken_dependency.desc=كانت هذه التبعية موجودة مسبقاً، لكنها لم تعد كذلك. جرّب استخدام مصدر تنزيل آخر.
addon.category=الفئة
addon.channel.alpha=ألفا
addon.channel.beta=بيتا
@@ -213,7 +200,6 @@ button.reveal_dir=فتح المجلد
button.refresh=تحديث
button.remove=إزالة
button.remove.confirm=هل أنت متأكد من الإزالة النهائية؟ لا يمكن التراجع عن هذا الإجراء!
button.retry=إعادة المحاولة
button.save=حفظ
button.save_as=حفظ باسم
button.select_all=تحديد الكل
@@ -367,9 +353,6 @@ download=تنزيل
download.code.404=الملف "%s" غير موجود على الخادم البعيد.
download.content=الإضافات
download.shader=الظلال
download.curseforge.unavailable=هذا الإصدار من HMCL لا يدعم الوصول إلى CurseForge. يُرجى استخدام الإصدار الرسمي للوصول إلى CurseForge.
download.existing=لا يمكن حفظ الملف لأنه موجود مسبقاً. يمكنك النقر على "حفظ باسم" لحفظ الملف في مكان آخر.
download.external_link=زيارة موقع التنزيل
download.failed=فشل تنزيل "%1$s"، رمز الاستجابة: %2$d.
download.failed.empty=لا توجد إصدارات متاحة. انقر هنا للرجوع.
download.failed.no_code=فشل التنزيل
@@ -445,10 +428,6 @@ fatal.deprecated_java_version=سيتطلب HMCL Java 17 أو أحدث للتشغ
يُنصح بتثبيت أحدث إصدار من Java لضمان عمل HMCL بشكل صحيح.\n\
\n\
يمكنك الاحتفاظ بإصدار Java القديم. يستطيع HMCL التعرف على إصدارات Java المتعددة وإدارتها وسيختار تلقائياً Java المناسب بناءً على إصدار اللعبة.
fatal.deprecated_java_version.update=سيتطلب HMCL Java 17 أو أحدث للتشغيل في المستقبل. يُرجى تثبيت أحدث إصدار من Java لضمان قدرة HMCL على إتمام الترقية.\n\
\n\
يمكنك الاحتفاظ بإصدار Java القديم.\
يستطيع HMCL التعرف على إصدارات Java المتعددة وإدارتها وسيختار تلقائياً Java المناسب بناءً على إصدار اللعبة.
fatal.deprecated_java_version.download_link=تنزيل Java %d
fatal.samba=إذا فتحت Hello Minecraft! Launcher من محرك أقراص شبكة Samba، قد لا تعمل بعض الميزات. يُرجى محاولة تحديث Java أو نقل المشغّل إلى مجلد آخر.
fatal.illegal_char=مسار المستخدم الخاص بك يحتوي على حرف غير مسموح به "=". لن تتمكن من استخدام authlib-injector أو تغيير مظهر حسابك غير المتصل.
@@ -709,11 +688,8 @@ game_directory.default=الحالي
game_directory.home=مشغّل Minecraft
game_directory.instance_directory=مجلد اللعبة
game_directory.instance_directory.choose=اختر مجلد اللعبة
game_directory.manage=قائمة مجلدات النسخ
game_directory.name=الاسم
game_directory.new=مجلد جديد
game_directory.title=مجلدات اللعبة
game_directory.selected=محدد
game_directory.use_relative_path=استخدام مسار نسبي لمجلد اللعبة إن أمكن
help=مساعدة
@@ -741,7 +717,6 @@ install.failed.version_mismatch=يتطلب هذا المحمّل إصدار ال
install.installer.change_version=%s غير متوافق
install.installer.choose=اختر إصدار %s
install.installer.cleanroom=Cleanroom
install.installer.depend=يتطلب %s
install.installer.do_not_install=لا تثبّت
install.installer.fabric=Fabric
install.installer.fabric-api=Fabric API
@@ -754,9 +729,6 @@ install.installer.game=Minecraft
install.installer.incompatible=غير متوافق مع %s
install.installer.install=جارٍ تثبيت %s
install.installer.install_offline=تثبيت/تحديث من ملف محلي
install.installer.install_offline.tooltip=ندعم استخدام مثبّت (Neo)Forge وCleanroom وOptiFine المحلي.
install.installer.install_online=تثبيت عبر الإنترنت
install.installer.install_online.tooltip=ندعم حالياً Forge وNeoForge وCleanroom وOptiFine وFabric وLegacy Fabric وQuilt وLiteLoader.
install.installer.liteloader=LiteLoader
install.installer.not_installed=غير مثبّت
install.installer.optifine=OptiFine
@@ -770,10 +742,8 @@ install.modpack.installation=تثبيت حزمة المودات
install.name.invalid=الاسم يحتوي على أحرف خاصة (مثل الرموز التعبيرية أو أحرف CJK).\nيُنصح بتغيير الاسم ليحتوي على الحروف الإنجليزية والأرقام والشرطات السفلية فقط لتجنب مشاكل محتملة عند تشغيل اللعبة.\nهل تريد المتابعة؟
install.new_game=تثبيت نسخة
install.new_game.already_exists=اسم هذه النسخة موجود مسبقاً. يُرجى اختيار اسم آخر.
install.new_game.current_game_version=إصدار النسخة الحالية
install.new_game.installation=تثبيت النسخة
install.new_game.malformed=اسم غير صالح.
install.select=اختر العملية
install.success=تم التثبيت بنجاح.
instance=الألعاب
@@ -867,13 +837,11 @@ launch.advice.multi=تم اكتشاف المشاكل التالية:\n\n%s\n\nق
launch.advice.java.auto=إصدار Java الحالي غير متوافق مع هذه النسخة.\n\nانقر "نعم" لاختيار إصدار Java الأكثر توافقاً تلقائياً. أو، انتقل إلى "الإعدادات العامة/الخاصة بالنسخة ← Java" لاختياره بنفسك.
launch.advice.java.modded_java_7=يتطلب Minecraft 1.7.2 والإصدارات الأقدم Java 7 أو أقدم.
launch.advice.cleanroom=لا يعمل Cleanroom %2$s إلا على Java %1$s أو أحدث. يُرجى استخدام Java %1$s أو إصدار أحدث.
launch.advice.corrected=لقد حللنا مشكلة Java. إذا كنت تريد استخدام إصدار Java الذي اخترته، يمكنك تعطيل "عدم التحقق من توافق JVM" في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة".
launch.advice.uncorrected=إذا كنت تريد استخدام إصدار Java الذي اخترته، يمكنك تعطيل "عدم التحقق من توافق JVM" في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة".
launch.advice.different_platform=يُنصح باستخدام إصدار Java 64-bit لجهازك، لكنك ثبّتت إصداراً 32-bit.
launch.advice.forge2760_liteloader=إصدار Forge 2760 غير متوافق مع LiteLoader. يُرجى الترقية إلى Forge 2773 أو أحدث.
launch.advice.forge28_2_2_optifine=إصدار Forge 28.2.2 أو أحدث غير متوافق مع OptiFine. يُرجى تخفيض Forge إلى 28.2.1 أو أقدم.
launch.advice.forge37_0_60=إصدارات Forge قبل 37.0.60 غير متوافقة مع Java 17. يُرجى تحديث Forge إلى 37.0.60 أو أحدث، أو تشغيل اللعبة بـ Java 16.
launch.advice.java8_1_13=يعمل Minecraft 1.13 وأحدث فقط على Java 8 أو أحدث. يُرجى استخدام Java 8 أو إصدار أحدث.
launch.advice.java8_51_1_13=قد يتعطل Minecraft 1.13 على إصدارات Java 8 قبل 1.8.0_51. يُرجى تثبيت أحدث إصدار من Java 8.
launch.advice.java9=لا يمكن تشغيل Minecraft 1.12 أو أقدم على Java 9 أو أحدث. يُرجى استخدام Java 8.
launch.advice.modded_java=قد لا تكون بعض المودات متوافقة مع إصدارات Java الأحدث. يُنصح باستخدام Java %1$s لتشغيل Minecraft %2$s.
@@ -933,7 +901,6 @@ launcher.update_java=يُرجى تحديث إصدار Java الخاص بك.
libraries.download=جارٍ تنزيل المكتبات
login.empty_username=لم تحدد اسم المستخدم بعد!
login.enter_password=يُرجى إدخال كلمة المرور.
logwindow.show_lines=إظهار أرقام الأسطر
@@ -945,7 +912,6 @@ logwindow.export_game_crash_logs=تصدير سجلات التعطل
logwindow.export_dump=تصدير تفريغ مكدس اللعبة
logwindow.export_dump.no_dependency=لا يحتوي Java الخاص بك على المكتبات اللازمة لإنشاء تفريغ المكدس. يُرجى الانضمام إلى Discord أو مجموعة QQ للحصول على مساعدة.
main_page=الرئيسية
message.cancelled=تم إلغاء العملية
message.confirm=تأكيد
@@ -975,7 +941,6 @@ modpack.desc=صف حزمة مودتك، بما في ذلك مقدمة وربما
modpack.description=وصف حزمة المودات
modpack.download=تنزيل حزم المودات
modpack.download.title=تنزيل حزمة المودات - %1s
modpack.enter_name=أدخل اسماً لهذه الحزمة.
modpack.export=تصدير كحزمة مودات
modpack.export.as=تصدير حزمة المودات باسم...
modpack.file_api=بادئة رابط حزمة المودات
@@ -994,11 +959,9 @@ modpack.files.scripts=ملف إعداد MineTweaker
modpack.files.servers_dat=ملف قائمة الخوادم
modpack.installing=جارٍ تثبيت حزمة المودات
modpack.installing.given=جارٍ تثبيت حزمة المودات %s
modpack.introduction=يدعم حالياً حزم Curse وModrinth وMultiMC وMCBBS.
modpack.invalid=حزمة مودات غير صالحة، يمكنك محاولة تنزيلها مجدداً.
modpack.mismatched_type=نوع حزمة المودات غير متطابق، النسخة الحالية من نوع %1$s، لكن الحزمة المقدمة من نوع %2$s.
modpack.name=اسم حزمة المودات
modpack.not_a_valid_name=اسم حزمة مودات غير صالح.
modpack.origin=المصدر
modpack.origin.url=الموقع الرسمي
modpack.origin.mcbbs=MCBBS
@@ -1127,7 +1090,6 @@ modrinth.category.velocity=Velocity
modrinth.category.waterfall=Waterfall
modrinth.category.worldgen=توليد العالم
modrinth.category.8x-=8x-
modrinth.category.16x=16x
modrinth.category.32x=32x
modrinth.category.48x=48x
modrinth.category.64x=64x
@@ -1151,7 +1113,6 @@ mods.mcmod=MCMod
mods.mcmod.page=صفحة MCMod
mods.mcmod.search=البحث في MCMod
mods.name=الاسم
mods.not_modded=يجب تثبيت محمّل مودات (Forge أو NeoForge أو Fabric أو Legacy Fabric أو Quilt أو LiteLoader) أولاً لإدارة موداتك!
mods.restore=استعادة
mods.url=الصفحة الرسمية
mods.update_modpack_mod.warning=تحديث المودات في حزمة مودات قد يؤدي إلى نتائج لا يمكن إصلاحها، وقد يتسبب في تلف الحزمة بحيث لا تعمل. هل أنت متأكد من التحديث؟
@@ -1203,7 +1164,6 @@ world.chunkbase.nether_fortress=قلعة الجحيم
world.duplicate=نسخ العالم
world.duplicate.prompt=يُرجى إدخال اسم العالم المنسوخ
world.duplicate.failed.already_exists=المجلد موجود مسبقاً
world.duplicate.failed.empty_name=لا يمكن أن يكون الاسم فارغاً
world.duplicate.failed.invalid_name=الاسم يحتوي على أحرف غير صالحة
world.duplicate.failed=فشل نسخ العالم
world.duplicate.success.toast=تم نسخ العالم بنجاح
@@ -1299,7 +1259,6 @@ schematics.create_directory=مجلد جديد
schematics.create_directory.prompt=يُرجى إدخال اسم المجلد الجديد
schematics.create_directory.failed=فشل إنشاء المجلد
schematics.create_directory.failed.already_exists=المجلد موجود مسبقاً
schematics.create_directory.failed.empty_name=لا يمكن أن يكون الاسم فارغاً
schematics.create_directory.failed.invalid_name=الاسم يحتوي على أحرف غير صالحة
schematics.info.description=الوصف
schematics.info.enclosing_size=الحجم المحيط
@@ -1335,20 +1294,6 @@ settings.advanced=الإعدادات المتقدمة
settings.advanced.modify=تعديل الإعدادات المتقدمة
settings.advanced.title=الإعدادات المتقدمة - %s
settings.advanced.custom_commands=أوامر مخصصة
settings.advanced.custom_commands.hint=المتغيرات البيئية التالية متاحة:\n\
\ · $INST_NAME: اسم النسخة.\n\
\ · $INST_ID: اسم النسخة.\n\
\ · $INST_DIR: المسار المطلق لمجلد عمل النسخة.\n\
\ · $INST_MC_DIR: المسار المطلق لمجلد اللعبة.\n\
\ · $INST_JAVA: ملف Java المستخدم للتشغيل.\n\
\ · $INST_FORGE: يُضبط إذا كان Forge مثبّتاً.\n\
\ · $INST_NEOFORGE: يُضبط إذا كان NeoForge مثبّتاً.\n\
\ · $INST_CLEANROOM: يُضبط إذا كان Cleanroom مثبّتاً.\n\
\ · $INST_LITELOADER: يُضبط إذا كان LiteLoader مثبّتاً.\n\
\ · $INST_OPTIFINE: يُضبط إذا كان OptiFine مثبّتاً.\n\
\ · $INST_FABRIC: يُضبط إذا كان Fabric مثبّتاً.\n\
\ · $INST_LEGACYFABRIC: يُضبط إذا كان Legacy Fabric مثبّتاً.\n\
\ · $INST_QUILT: يُضبط إذا كان Quilt مثبّتاً.
settings.advanced.dont_check_game_completeness=عدم التحقق من سلامة اللعبة
settings.advanced.dont_check_jvm_validity=عدم التحقق من توافق JVM
settings.advanced.dont_patch_natives=عدم محاولة استبدال المكتبات الأصلية تلقائياً
@@ -1369,10 +1314,6 @@ settings.advanced.launcher_visible=ظهور المشغّل
settings.advanced.minecraft_arguments=وسيطات التشغيل
settings.advanced.minecraft_arguments.prompt=افتراضي
settings.advanced.natives_directory=مسار المكتبات الأصلية
settings.advanced.natives_directory.choose=اختر موقع المكتبة الأصلية المطلوبة
settings.advanced.natives_directory.custom=مخصص
settings.advanced.natives_directory.default=افتراضي
settings.advanced.natives_directory.default.version_id=<اسم النسخة>
settings.advanced.no_jvm_args=عدم إضافة وسيطات JVM الافتراضية
settings.advanced.no_optimizing_jvm_args=عدم إضافة وسيطات تحسين JVM الافتراضية
settings.advanced.precall_command=أمر ما قبل التشغيل
@@ -1398,11 +1339,9 @@ settings.advanced.renderer.zink=Mesa Zink
settings.advanced.server_ip=عنوان الخادم
settings.advanced.server_ip.prompt=الانضمام تلقائياً بعد تشغيل اللعبة
settings.advanced.unsupported_system_options=إعدادات غير مطبّقة على النظام الحالي
settings.advanced.use_native_glfw=استخدام GLFW النظام
settings.advanced.use_native_glfw_or_sdl=استخدام GLFW / SDL النظام
settings.advanced.use_native_openal=استخدام OpenAL النظام
settings.advanced.linux_freebsd_only=Linux/FreeBSD فقط
settings.advanced.workaround=حلول بديلة
settings.advanced.workaround.warning=خيارات الحلول البديلة مخصصة للمستخدمين المتقدمين فقط. قد يتسبب تعديل هذه الخيارات في تعطل اللعبة. يُرجى عدم تعديل هذه الخيارات إلا إذا كنت تعرف ما تفعله.
settings.advanced.wrapper_launcher=أمر التغليف
settings.advanced.wrapper_launcher.prompt=يتيح التشغيل باستخدام برنامج تغليف إضافي مثل "optirun" على Linux
@@ -1422,11 +1361,7 @@ settings.game.java_directory.invalid=مسار Java غير صحيح
settings.game.java_directory.version=تحديد إصدار Java
settings.game.java_directory.template=%1$s (%2$s)
settings.game.management=إدارة
settings.game.working_directory=مجلد العمل
settings.game.working_directory.choose=اختر مجلد العمل
settings.game.working_directory.hint=فعّل خيار "معزول" في "مجلد العمل" للسماح للنسخة الحالية بتخزين إعداداتها وعوالمها ومودتها في مجلد منفصل.\n\
\n\
يُنصح بتفعيل هذا الخيار لتجنب تعارض المودات، لكنك ستحتاج إلى نقل عوالمك يدوياً.
settings.icon=الأيقونة
@@ -1436,16 +1371,13 @@ settings.launcher.brightness=وضع السمة
settings.launcher.brightness.auto=متابعة إعدادات النظام
settings.launcher.brightness.dark=الوضع الداكن
settings.launcher.brightness.light=الوضع الفاتح
settings.launcher.common_path.tooltip=سيضع HMCL جميع أصول اللعبة وتبعياتها هنا. إذا كانت هناك مكتبات موجودة في مجلد اللعبة، فإن HMCL سيفضّل استخدامها أولاً.
settings.launcher.debug=تصحيح
settings.launcher.disable_auto_game_options=عدم تغيير لغة اللعبة تلقائياً
settings.launcher.download=التنزيل
settings.launcher.download.threads=الخيوط
settings.launcher.download.threads.auto=تحديد تلقائي
settings.launcher.download.threads.hint=قد يتسبب عدد كبير من الخيوط في تجميد النظام، وقد تتأثر سرعة التنزيل بمزوّد الخدمة وخوادم التنزيل. ليس دائماً أن المزيد من الخيوط يزيد سرعة التنزيل.
settings.launcher.download_source=مصدر التنزيل
settings.launcher.download_source.auto=اختيار مصادر التنزيل تلقائياً
settings.launcher.enable_game_list=عرض قائمة النسخ في الصفحة الرئيسية
settings.launcher.font=الخط
settings.launcher.font.anti_aliasing=مضاد التعرج
settings.launcher.font.anti_aliasing.auto=تلقائي
@@ -1492,9 +1424,6 @@ settings.type.global.manage=الإعدادات العامة
shaderpack.download.title=تنزيل ظلال - %1s
sponsor=المتبرعون
sponsor.bmclapi=تنزيلات البر الرئيسي الصيني مقدَّمة من BMCLAPI. انقر هنا للمزيد من المعلومات.
sponsor.hmcl=Hello Minecraft! Launcher هو مشغّل Minecraft مفتوح المصدر يتيح للمستخدمين إدارة نسخ متعددة من Minecraft بسهولة. انقر هنا للمزيد من المعلومات.
system.architecture=المعمارية
system.operating_system=نظام التشغيل
@@ -1607,7 +1536,6 @@ update.newest_version=أحدث إصدار: %s
update.bubble.title=تحديث متاح: %s
update.bubble.subtitle=انقر هنا للتحديث
update.latest=هذا هو أحدث إصدار
update.no_browser=لا يمكن الفتح في متصفح النظام. لكننا نسخنا الرابط إلى حافظتك، ويمكنك فتحه يدوياً.
update.tooltip=تحديث
update.preview=الاطلاع المبكر على إصدارات HMCL
update.preview.subtitle=فعّل هذا الخيار لاستقبال إصدارات HMCL الجديدة مبكراً للاختبار قبل إصدارها الرسمي.
File diff suppressed because it is too large. Load diff
@@ -21,7 +21,6 @@
about=Acerca de
about.copyright=Copyright
about.author=Autor
about.author.statement=bilibili @huanghongxun
about.claim=EULA
about.claim.statement=Haga clic en este enlace para ver el texto completo.
@@ -75,7 +74,6 @@ account.failed.server_response_malformed=Respuesta del servidor no válida, el s
account.failed.ssl=Se ha producido un error SSL al conectar con el servidor. Por favor, intente actualizar su Java.
account.failed.dns=Se produjo un error SSL al conectar con el servidor. Es posible que la resolución DNS sea incorrecta. Intente cambiar el servidor DNS o usar un servicio proxy.
account.failed.wrong_account=Ha iniciado sesión en la cuenta equivocada.
account.hmcl.hint=Debe hacer clic en «Iniciar sesión» y completar el proceso en la ventana abierta del navegador.
account.injector.add=Nuevo servidor Auth
account.injector.empty=Ninguno (Puedes hacer clic en el botón más de la derecha para añadir uno)
account.injector.http=Atención: Este servidor utiliza el protocolo HTTP inseguro. Cualquiera entre su conexión podrá ver sus credenciales en texto claro.
@@ -91,23 +89,16 @@ account.login.retry=Reintentar
account.login.refresh=Iniciar sesión de nuevo
account.login.refresh.microsoft.hint=Debe volver a iniciar sesión en su cuenta Microsoft porque la autorización de la cuenta no es válida.
account.login.restricted=Iniciar sesión en su cuenta Microsoft para activar esta función
account.logout=Salir
account.register=Registrarse
account.manage=Lista de cuentas
account.copy_uuid=Copiar UUID de la cuenta
account.methods=Tipo de inicio de sesión
account.methods.authlib_injector=authlib-injector
account.methods.microsoft=Microsoft
account.methods.microsoft.birth=Cómo cambiar la fecha de nacimiento de su cuenta
account.methods.microsoft.close_page=La autorización de la cuenta de Microsoft ha finalizado.\n\
\n\
Hay algunos trabajos extra para nosotros, pero puedes cerrar esta pestaña con seguridad por ahora.
account.methods.microsoft.makegameidsettings=Crear perfil / Editar nombre del perfil
account.methods.microsoft.deauthorize=Desautorizar
account.methods.microsoft.logging_in=Iniciando sesión...
account.methods.microsoft.profile=Perfil de la cuenta
account.methods.microsoft.purchase=Comprar Minecraft
account.methods.microsoft.snapshot.website=Sitio web oficial
account.methods.offline=Sin conexión
account.methods.offline.name.special_characters=Utilice solo letras, números y guiones bajos (máximo 16 caracteres)
account.methods.offline.name.invalid=Se recomienda utilizar sólo letras en inglés, números y guiones bajos para el nombre de usuario, y la longitud no debe superar los 16 caracteres.\n\
@@ -121,12 +112,10 @@ account.methods.offline.uuid.hint=UUID es un identificador único para los jugad
\n\
Esta opción es sólo para usuarios avanzados. No recomendamos editar esta opción a menos que sepas lo que estás haciendo.
account.methods.offline.uuid.malformed=Formato no válido.
account.methods.ban_query=Consulta de bloqueo de cuentas
account.missing=No hay cuentas
account.missing.add=Haga clic aquí para añadir una.
account.move_to_global=Convertir en cuenta global\nLa información de la cuenta se guardará en un archivo de configuración del directorio de usuario actual del sistema.
account.move_to_portable=Convertir en cuenta portátil\nLa información de la cuenta se guardará en un archivo de configuración en el mismo directorio que HMCL.
account.not_logged_in=No ha iniciado sesión
account.password=Contraseña
account.portable=Cuenta portátil
account.skin=Aspecto
@@ -147,8 +136,6 @@ account.skin.upload.failed=No se pudo subir el aspecto.
account.skin.invalid_skin=Archivo de aspecto inválido.
account.username=Nombre de usuario
addon.broken_dependency.title=Dependencia rota
addon.broken_dependency.desc=Esta dependencia existía antes, pero ahora no existe. Intente utilizar otra fuente de descarga.
addon.category=Categoría
addon.channel.alpha=Alpha
addon.channel.beta=Beta
@@ -193,7 +180,6 @@ button.reveal_dir=Carpeta
button.refresh=Refrescar
button.remove=Eliminar
button.remove.confirm=¿Estás seguro de que deseas eliminarlo de forma permanente? ¡Esta acción no se puede deshacer!
button.retry=Reintentar
button.save=Guardar
button.save_as=Guardar como
button.select_all=Seleccionar todo
@@ -347,9 +333,6 @@ download=Descargar
download.code.404=Archivo no encontrado en el servidor remoto: %s
download.content=Complementos
download.shader=Sombreadores
download.curseforge.unavailable=Esta versión de HMCL no permite acceder a CurseForge. Utilice la versión oficial para acceder a CurseForge.
download.existing=El archivo no se puede guardar porque ya existe. Puedes hacer clic en «Guardar como» para guardar el archivo en otro lugar.
download.external_link=Abrir sitio web
download.failed=Falló la descarga de %1$s, código de respuesta: %2$d.
download.failed.empty=No hay versiones disponibles, por favor haga clic aquí para volver.
download.failed.no_code=No se ha podido descargar
@@ -419,11 +402,6 @@ fatal.deprecated_java_version=HMCL requerirá Java 17 o posterior para funcionar
Se recomienda instalar la última versión de Java para garantizar el correcto funcionamiento de HMCL.\n\
\n\
Puedes conservar la versión antigua de Java. HMCL puede reconocer y gestionar varias versiones de Java y elegirá automáticamente la adecuada para usted en función de la versión del juego.
fatal.deprecated_java_version.update=HMCL requerirá Java 17 o posterior para funcionar en el futuro. Por favor, instalar la última versión de Java para asegurarse de que el lanzador puede completar la actualización.\n\
\n\
Puedes conservar la versión antigua de Java.\
HMCL puede reconocer y gestionar varias versiones de Java \
y elegirá automáticamente la adecuada para usted en función de la versión del juego.
fatal.deprecated_java_version.download_link=Descargar Java %d
fatal.samba=Si ha abierto Hello Minecraft! Launcher desde una unidad de red Samba, es posible que algunas funciones no funcionen. Intenta actualizar tu Java o mover el launcher a otro directorio.
fatal.illegal_char=Su ruta de usuario contiene un carácter ilegal «=», por lo que algunas características podrían no funcionar correctamente.\n\
@@ -675,11 +653,8 @@ game_directory.default=Directorio actual
game_directory.home=Minecraft Launcher
game_directory.instance_directory=Directorio ded juego
game_directory.instance_directory.choose=Elegir directorio de juegos
game_directory.manage=Lista de directorios de instancia
game_directory.name=Nombre
game_directory.new=Nuevo directorio
game_directory.title=Directorios del juego
game_directory.selected=Seleccionado
game_directory.use_relative_path=Utilizar ruta relativa para la ruta del juego si es posible
help=Ayuda
@@ -707,7 +682,6 @@ install.failed.version_mismatch=Este cargador requiere la versión del juego %s,
install.installer.change_version=%s Incompatible
install.installer.choose=Elija su versión %s
install.installer.cleanroom=Cleanroom
install.installer.depend=Requiere %s
install.installer.do_not_install=No instalar
install.installer.fabric=Fabric
install.installer.fabric-api=Fabric API
@@ -718,9 +692,6 @@ install.installer.game=Minecraft
install.installer.incompatible=Incompatible con %s
install.installer.install=Instalando %s
install.installer.install_offline=Instalar/actualizar desde archivo local
install.installer.install_offline.tooltip=Apoyamos el uso del instalador local de (Neo)Forge/Cleanroom/OptiFine.
install.installer.install_online=Instalación en línea
install.installer.install_online.tooltip=Actualmente soportamos Forge, NeoForge, Cleanroom, Fabric, Legacy Fabric, Quilt, LiteLoader y OptiFine.
install.installer.liteloader=LiteLoader
install.installer.not_installed=No está instalado
install.installer.optifine=OptiFine
@@ -734,10 +705,8 @@ install.modpack.installation=Instalación de modpack
install.name.invalid=El nombre contiene caracteres especiales (como emoji o caracteres CJK).\n\nSe recomienda cambiar el nombre para que sólo incluya letras, números y guiones bajos en inglés para evitar posibles problemas al iniciar el juego.\n\n¿Desea continuar con la instalación?
install.new_game=Añadir instancia
install.new_game.already_exists=Esta instancia ya existe. Por favor, utilice otro nombre.
install.new_game.current_game_version=Versión actual de la instancia
install.new_game.installation=Instalación de instancia
install.new_game.malformed=Nombre no válido.
install.select=Elegir una operación
install.success=Instalado con éxito.
instance=Juegos
@@ -821,13 +790,11 @@ launch.advice.multi=Se han detectado los siguientes problemas:\n\n%s\n\nEstos pr
launch.advice.java.auto=La versión actual de Java no es compatible con la instancia.\n\nHaga clic en «Sí» para elegir automáticamente la versión de Java más compatible. O bien, puede navegar hasta «Config. Global/Específica de instancia → Java» para elegir uno usted mismo.
launch.advice.java.modded_java_7=Minecraft 1.7.2 y versiones anteriores requieren Java 7 o anterior.
launch.advice.cleanroom=Cleanroom %2$s solo se puede ejecutar en Java %1$s o versiones posteriores. Por favor, use Java %1$s o una versión posterior.
launch.advice.corrected=Hemos resuelto el problema de Java. Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
launch.advice.uncorrected=Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
launch.advice.different_platform=Se recomienda la versión de 64 bits de Java para tu dispositivo, pero has instalado una de 32 bits.
launch.advice.forge2760_liteloader=La versión 2760 o posterior de Forge no es compatible con LiteLoader, por favor considere actualizar Forge a la versión 2773 o posterior.
launch.advice.forge28_2_2_optifine=La versión 28.2.2 o posterior de Forge no es compatible con OptiFine. Considere la posibilidad de actualizar Forge a la versión 28.2.1 o anterior.
launch.advice.forge37_0_60=Las versiones de Forge anteriores a la 37.0.60 no son compatibles con Java 17. Por favor, actualiza Forge a la versión 37.0.60 o posterior, o inicia el juego con Java 16.
launch.advice.java8_1_13=Minecraft 1.13 y posteriores sólo se pueden ejecutar en Java 8 o posterior. Por favor, utilice Java 8 o versiones posteriores.
launch.advice.java8_51_1_13=Minecraft 1.13 puede fallar en versiones de Java 8 anteriores a la 1.8.0_51. Por favor, instala la última versión de Java 8.
launch.advice.java9=No puedes ejecutar Minecraft 1.12 o anterior con Java 9 o más reciente, por favor, utiliza Java 8 en su lugar.
launch.advice.modded_java=Algunos mods pueden no ser compatibles con las nuevas versiones de Java. Se recomienda utilizar Java %s para iniciar Minecraft %s.
@@ -887,7 +854,6 @@ launcher.update_java=Por favor, actualice su versión de Java.
libraries.download=Descargando bibliotecas
login.empty_username=¡Todavía no has puesto tu nombre de usuario!
login.enter_password=Por favor, introduzca su contraseña.
logwindow.show_lines=Mostrar número de líneas
@@ -899,7 +865,6 @@ logwindow.export_game_crash_logs=Exportar registros de colgado
logwindow.export_dump=Exportar volcado de pila de juegos
logwindow.export_dump.no_dependency=Su Java no contiene las dependencias para crear el volcado de pila. Dirígete a nuestro Discord o grupo QQ para obtener ayuda.
main_page=Inicio
message.cancelled=La operación se ha cancelado
message.confirm=Confirmar
@@ -928,7 +893,6 @@ modpack.desc=Describa su modpack, incluyendo una introducción y probablemente a
modpack.description=Descripción
modpack.download=Descargar Modpacks
modpack.download.title=Descargar Modpack - %1s
modpack.enter_name=Introduzca un nombre para este modpack.
modpack.export=Exportar como modpack
modpack.export.as=Exportar Modpack como...
modpack.file_api=Prefijo de la URL del modpack
@@ -947,11 +911,9 @@ modpack.files.scripts=Archivo de configuración de MineTweaker
modpack.files.servers_dat=Archivo de lista de servidores
modpack.installing=Instalando modpack
modpack.installing.given=Instalando %s modpack
modpack.introduction=Actualmente se soportan los modpacks CurseForge, Modrinth, MultiMC y MCBBS.
modpack.invalid=Modpack inválido, puede intentar volver a descargarlo.
modpack.mismatched_type=Tipo de modpack erróneo, la instancia actual es del tipo %s, pero la proporcionada es del tipo %s.
modpack.name=Nombre del modpack
modpack.not_a_valid_name=Nombre de paquete no válido
modpack.origin=Fuente
modpack.origin.url=Sitio web oficial
modpack.origin.mcbbs=MCBBS
@@ -1080,6 +1042,11 @@ modrinth.category.velocity=Velocity
modrinth.category.waterfall=Waterfall
modrinth.category.worldgen=Generación de mundos
modrinth.category.8x-=8x o más bajo
modrinth.category.32x=32x
modrinth.category.48x=48x
modrinth.category.64x=64x
modrinth.category.128x=128x
modrinth.category.256x=256x
modrinth.category.512x+=512x o mayor
mods=Mods
@@ -1096,7 +1063,6 @@ mods.mcmod=MCMod
mods.mcmod.page=Página de MCMod
mods.mcmod.search=Búsqueda en MCMod
mods.name=Nombre
mods.not_modded=¡Debes instalar primero un cargador de mods (Forge, NeoForge, Fabric, Legacy Fabric, Quilt o LiteLoader) para gestionar tus mods!
mods.restore=Restaurar
mods.url=Página oficial
mods.update_modpack_mod.warning=Actualizar mods en un modpack puede generar resultados irreparables, posiblemente corrompiendo el modpack para que no pueda iniciarse. ¿Seguro que quieres actualizar?
@@ -1131,7 +1097,6 @@ world.chunkbase.seed_map=Vista previa de la generación mundial
world.chunkbase.stronghold=Fortaleza
world.chunkbase.nether_fortress=Fortaleza del Nether
world.duplicate.failed.already_exists=El directorio ya existe
world.duplicate.failed.empty_name=El nombre no puede estar vacío
world.duplicate.failed.invalid_name=El nombre contiene caracteres no válidos
world.datapack=Paquetes de datos
world.datetime=Jugado por última vez en %s
@@ -1200,7 +1165,6 @@ schematics.back_to=Atrás a «%s»
schematics.create_directory.prompt=Introduzca el nuevo nombre del directorio
schematics.create_directory.failed=No se ha podido crear el directorio
schematics.create_directory.failed.already_exists=El directorio ya existe
schematics.create_directory.failed.empty_name=El nombre no puede estar vacío
schematics.create_directory.failed.invalid_name=El nombre contiene caracteres no válidos
schematics.info.description=Descripción
schematics.info.enclosing_size=Tamaño de Encierro
@@ -1236,19 +1200,6 @@ settings.advanced=Configuración avanzada
settings.advanced.modify=Editar configuración avanzada
settings.advanced.title=Configuración avanzada - %s
settings.advanced.custom_commands=Comandos personalizados
settings.advanced.custom_commands.hint=Se proporcionan las siguientes variables de entorno:\n\
\ · $INST_NAME: nombre de la instancia.\n\
\ · $INST_ID: nombre de la instancia.\n\
\ · $INST_DIR: ruta absoluta del directorio de trabajo de la instancia.\n\
\ · $INST_MC_DIR: ruta absoluta del directorio del juego.\n\
\ · $INST_JAVA: binario de java utilizado para la ejecución\n\
\ · $INST_FORGE: disponible si Forge está instalado.\n\
\ · $INST_NEOFORGE: disponible si NeoForge está instalado.\n\
\ · $INST_CLEANROOM: disponible si Cleanroom está instalado.\n\
\ · $INST_LITELOADER: disponible si LiteLoader está instalado.\n\
\ · $INST_OPTIFINE: disponible si OptiFine está instalado.\n\
\ · $INST_FABRIC: disponible si Fabric está instalado.\n\
\ · $INST_QUILT: disponible si Quilt está instalado.
settings.advanced.dont_check_game_completeness=No chequear integridad del juego
settings.advanced.dont_check_jvm_validity=No comprobar la compatibilidad con JVM
settings.advanced.dont_patch_natives=No intente sustituir automáticamente las bibliotecas nativas
@@ -1269,10 +1220,6 @@ settings.advanced.launcher_visible=Visibilidad del launcher
settings.advanced.minecraft_arguments=Argumentos de lanzamiento
settings.advanced.minecraft_arguments.prompt=Por defecto
settings.advanced.natives_directory=Ruta de la biblioteca nativa
settings.advanced.natives_directory.choose=Elija la ubicación de la biblioteca nativa deseada
settings.advanced.natives_directory.custom=Personalizado
settings.advanced.natives_directory.default=Por defecto
settings.advanced.natives_directory.default.version_id=<Nombre de la instancia>
settings.advanced.no_jvm_args=No añadir argumentos JVM por defecto
settings.advanced.no_optimizing_jvm_args=No añadir argumentos de optimización de JVM
settings.advanced.precall_command=Comando pre-lanzamiento:
@@ -1293,11 +1240,9 @@ settings.advanced.renderer.zink=Mesa Zink
settings.advanced.server_ip=Dirección del servidor
settings.advanced.server_ip.prompt=Entrar automáticamente después de ejecutar el juego
settings.advanced.unsupported_system_options=Configuración no aplicable al sistema actual
settings.advanced.use_native_glfw=Utilizar GLFW nativo
settings.advanced.use_native_glfw_or_sdl=Utilizar GLFW / SDL nativo
settings.advanced.use_native_openal=Utilizar OpenAL nativo
settings.advanced.linux_freebsd_only=Sólo Linux/FreeBSD
settings.advanced.workaround=Métodos alternativos
settings.advanced.workaround.warning=Éstas opciones están pensadas sólo para usuarios expertos. Jugar con estas opciones puede romper el juego. A menos que sepas lo que estás haciendo, por favor no modifiques estas opciones.
settings.advanced.wrapper_launcher=Comando Wrapper
settings.advanced.wrapper_launcher.prompt=Permite ejecutar el juego usando un programa extra como 'optirun' en Linux.
@@ -1317,23 +1262,19 @@ settings.game.java_directory.invalid=Ruta de Java incorrecta.
settings.game.java_directory.template=%s (%s)
settings.game.java_directory.version=Especifique la versión de Java
settings.game.management=Gestionar
settings.game.working_directory=Directorio de trabajo
settings.game.working_directory.choose=Elija el directorio de trabajo
settings.icon=Icono
settings.launcher=Configuración del launcher
settings.launcher.appearance=Apariencia
settings.launcher.common_path.tooltip=Esta aplicación pondrá todos los activos y dependencias del juego aquí. Si hay bibliotecas existentes en el directorio del juego, el launcher preferirá usarlas primero.
settings.launcher.debug=Depuración
settings.launcher.disable_auto_game_options=No cambiar el idioma del juego
settings.launcher.download=Descargas
settings.launcher.download.threads=Hilos
settings.launcher.download.threads.auto=Determinar automáticamente
settings.launcher.download.threads.hint=Demasiados hilos pueden hacer que tu sistema se congele, y tu velocidad de descarga puede verse afectada por tu ISP y servidores de descarga. No siempre se da el caso de que más hilos aumenten la velocidad de descarga.
settings.launcher.download_source=Fuente de descarga
settings.launcher.download_source.auto=Elegir automáticamente el espejo de descarga
settings.launcher.enable_game_list=Mostrar lista de versiones en la página de inicio
settings.launcher.font=Fuente
settings.launcher.font.anti_aliasing=Suavizado de bordes
settings.launcher.font.anti_aliasing.auto=Automático
@@ -1377,9 +1318,6 @@ settings.take_effect_after_restart=Se aplica después de reiniciar
settings.type=Tipo de configuración de la instancia
settings.type.global.manage=Config. Global
sponsor=Donantes
sponsor.bmclapi=Las descargas de China continental son proporcionadas por BMCLAPI. Haga clic aquí para obtener más información.
sponsor.hmcl=Hello Minecraft! Launcher es un launcher FOSS de Minecraft que permite a los usuarios gestionar múltiples instancias de Minecraft fácilmente. Haga clic aquí para obtener más información.
system.architecture=Arquitectura
system.operating_system=Sistema operativo
@@ -1407,7 +1345,6 @@ update.newest_version=Última versión: %s
update.bubble.title=Actualización disponible: %s
update.bubble.subtitle=Haga clic aquí para actualizar
update.latest=Esta es la última versión.
update.no_browser=No se puede abrir en el navegador del sistema. Pero, hemos copiado el enlace a su portapapeles y puede abrirlo manualmente.
update.tooltip=Actualización
update.preview=Vista previa de actualizaciones anticipadas
update.preview.subtitle=Activa esta opción para recibir nuevas versiones del lanzador antes de su lanzamiento oficial para probarlas
@@ -20,7 +20,6 @@
about=について
about.copyright=著作権
about.author=著者
about.author.statement=bilibili @huanghongxun
about.claim=EULA
about.claim.statement=全文はこのリンクをクリック
@@ -68,7 +67,6 @@ account.failed.migration=アカウントをMicrosoftアカウントに移行す
account.failed.no_character=このアカウントに文字がありません。
account.failed.server_response_malformed=無効なサーバー応答。認証サーバーでエラーが発生している可能性があります。
account.failed.wrong_account=不一致のアカウントでログインしました。
account.hmcl.hint=「ログイン」をクリックし、開いているページでログインを完了する必要があります。
account.injector.add=認証サーバーを追加します
account.injector.empty=Empty(追加するには右側のプラスボタンをクリックしてください)
account.injector.http=警告:このサーバーはHTTPを使用するため、パスワードはクリアテキストで送信されます。
@@ -80,17 +78,11 @@ account.login=ログイン
account.login.hint=パスワードは保存されません。
account.login.refresh=再ログイン
account.login.refresh.microsoft.hint=アカウント認証が無効なため、Microsoft アカウントを再度追加する必要があります
account.logout=ログアウト
account.register=登録
account.manage=アカウントリスト
account.methods=ログインタイプ
account.methods.authlib_injector=authlib-インジェクター
account.methods.microsoft=Microsoft Account
account.methods.microsoft.birth=誕生日の設定を編集する方法...
account.methods.microsoft.deauthorize=アカウントのバインドを解除
account.methods.microsoft.close_page=Microsoftアカウントの認証が終了しました。後で終了するログイン手順がいくつか残っています。このページを今すぐ閉じることができます。
account.methods.microsoft.logging_in=ログイン...
account.methods.microsoft.makegameidsettings=プロファイルを作成/プロフィール名を編集する
account.methods.microsoft.profile=アカウントプロファイル..
account.methods.microsoft.purchase=Minecraftを購入する
account.methods.offline=オフライン
@@ -99,7 +91,6 @@ account.methods.offline.uuid.hint=UUIDは、Minecraftのゲームキャラクタ
account.methods.offline.uuid.malformed=Malformed
account.missing=アカウントなし
account.missing.add=追加するにはここをクリック
account.not_logged_in=ログインしていません
account.password=パスワード
account.skin=スキン
account.skin.file=スキンイメージファイル
@@ -153,7 +144,6 @@ button.ok=OK
button.refresh=更新
button.remove=削除
button.remove.confirm=本当に完全に削除しますか?この操作は元に戻せません!
button.retry=リトライ
button.save=保存
button.save_as=名前を付けて保存
button.select_all=すべて選択
@@ -300,8 +290,6 @@ download=ダウンロード
download.code.404=リモートサーバーにファイルが見つかりません:%s
download.content=ゲームコンテンツ
download.shader=シェーダー
download.existing=ファイルは既に存在するため、保存できません。「名前を付けて保存」を選択して、ファイルを別の場所に保存できます。
download.external_link=ダウンロードサイトを開く
download.failed=%1$s のダウンロードに失敗しました、応答コード:%2$d
download.failed.empty=候補者なし。戻るにはここをクリックしてください。
download.failed.refresh=バージョンリストをダウンロードできません。ここをクリックして再試行してください。
@@ -419,11 +407,8 @@ game_directory.default=現在のディレクトリ
game_directory.home=標準ディレクトリ
game_directory.instance_directory=ゲームディレクトリ
game_directory.instance_directory.choose=ゲームディレクトリを選択してください
game_directory.manage=ゲームディレクトリリスト
game_directory.name=名前
game_directory.new=新規作成
game_directory.title=ゲームディレクトリ
game_directory.selected=選択済み
game_directory.use_relative_path=可能であれば、ゲームディレクトリの相対パスを使用します
help=ヘルプ
@@ -449,7 +434,6 @@ install.failed.optifine_forge_1.17=Minecraft 1.17.1の場合、OptiFine H1 Pre2
install.failed.version_mismatch=ライブラリにはゲームバージョン %s が必要ですが、実際のバージョンは %s です。
install.installer.change_version=%s 、このバージョンは現在のゲームバージョンと互換性がありません。別のものを選択するには、ここをクリックしてください。
install.installer.choose=%s バージョンを選択してください
install.installer.depend=%s に依存
install.installer.fabric=Fabric
install.installer.fabric-api=Fabric API
install.installer.fabric-quilt-api.warning=%1$sはmodであり、新しいゲームのmodディレクトリにインストールされます。新しいゲームの実行ディレクトリを変更しないでください。変更すると、%1$sのインストールが失われます。その設定を変更したい場合は、%1$sを再インストールする必要があります。
@@ -458,9 +442,6 @@ install.installer.game=Minecraft
install.installer.incompatible=%s と互換性がありません
install.installer.install=インストール %s
install.installer.install_offline=ファイルからのインストール/更新
install.installer.install_offline.tooltip=Forge / OptiFineインストールファイルのインポートをサポートします
install.installer.install_online=オンラインでインストール
install.installer.install_online.tooltip=Fabric、Forge、OptiFine、LiteLoaderのインストールをサポートします。
install.installer.liteloader=LiteLoader
install.installer.not_installed=インストールされていません
install.installer.optifine=OptiFine
@@ -470,9 +451,7 @@ install.installer.version=%s
install.modpack=modpackを導入
install.new_game=新規作成
install.new_game.already_exists=このバージョンはすでに存在します。
install.new_game.current_game_version=現在のゲームバージョン
install.new_game.malformed=無効な名前
install.select=操作を選択します
install.success=正常にインストールされました
instance=ゲーム
@@ -542,12 +521,10 @@ lang.default=システム言語を使用する
launch.advice.java.auto=現在選択されているJavaVMは、ゲームの要件を満たしていません。適切なJavaVMを選択することを許可しますか?または、ゲーム設定で適切なJavaVMを選択することもできます。
launch.advice.java.modded_java_7=Minecraft 1.7.2以前では、Java7以前のバージョンが必要です。
launch.advice.corrected=JVMの選択はすでに修正されています。選択したJavaバージョンを維持したい場合は、ゲーム設定でJavaVMチェックを無効にすることができます。
launch.advice.uncorrected=ゲームを正常に開始できることが確実な場合は、ゲーム設定でJavaVMチェックを無効にすることができます。
launch.advice.different_platform=OSは64ビットですが、Javaは32ビットです。64ビットJavaをお勧めします。
launch.advice.forge2760_liteloader=Forge 2760以降はLiteLoaderと互換性がないため、Forgeを2773以降にアップグレードすることを検討してください。
launch.advice.forge28_2_2_optifine=Forge28.2.2以降のバージョンはOptiFineと互換性がありません。Forgeを28.2.1以前のバージョンにダウングレードすることを検討してください。
launch.advice.java8_1_13=Minecraft 1.13以降は、Java8以降でのみ実行できます。
launch.advice.java8_51_1_13=Minecraft 1.13は、1.8.0_51より前のJava8でクラッシュする可能性があります。最新バージョンのJava8をインストールしてください。
launch.advice.java9=Java9以降のバージョンのJavaでMinecraft1.12以前を起動することはできません。ゲームを高速化するには、Java8をお勧めします。
launch.advice.newer_java=多くのMinecraft1.12以降、およびほとんどのModには、Java8が必要です。
@@ -596,7 +573,6 @@ launcher.crash=Hello Minecraft!ランチャーがクラッシュしました
launcher.crash.hmcl_out_dated=Hello Minecraft!ランチャーがクラッシュしました!ランチャーが古くなっています。ランチャーを更新してください!
launcher.update_java=Javaを更新してください。
login.empty_username=ユーザー名を設定していません!
login.enter_password=パスワードを入力してください。
logwindow.show_lines=行を表示
@@ -606,7 +582,6 @@ logwindow.help=HMCL コミュニティにアクセスして、他のユーザー
logwindow.autoscroll=Autoscroll
logwindow.export_game_crash_logs=ゲームのクラッシュ情報をエクスポートする
main_page=ホーム
message.cancelled=操作がキャンセルされました
message.confirm=確認
@@ -631,7 +606,6 @@ modpack.choose.remote.tooltip=リモートmodpackファイルへの直接ダウ
modpack.desc=注意事項と変更ログを含めてmodpackを説明してください。マークダウンとオンライン写真がサポートされています。
modpack.description=説明
modpack.download=Modpackをダウンロード
modpack.enter_name=modpackの名前を入力。
modpack.export=Modpackをエクスポート
modpack.export.as=Export Modpack As...
modpack.file_api=Modpackダウンロードリンクプレフィックス
@@ -649,11 +623,9 @@ modpack.files.saves=保存されたマップ
modpack.files.scripts=MineTweaker構成
modpack.files.servers_dat=サーバーリスト
modpack.installing=modpackのインストール
modpack.introduction= Curse, Modrinth, MultiMC, MCBBS Modpackがサポートされています。
modpack.invalid=無効なmodpackファイル。
modpack.mismatched_type=不適切なmodpackタイプ、現在のゲームは %s modpackですが、更新ファイルは %s modpackです。
modpack.name=Modpack名
modpack.not_a_valid_name=無効なmodpack名
modpack.origin=Origin
modpack.origin.url=公式ウェブサイト
modpack.origin.mcbbs=MCBBS
@@ -709,6 +681,11 @@ modrinth.category.worldgen=Worldgen
modrinth.category.datapack=Datapack
modrinth.category.folia=Folia
modrinth.category.8x-=8x以下
modrinth.category.32x=32x
modrinth.category.48x=48x
modrinth.category.64x=64x
modrinth.category.128x=128x
modrinth.category.256x=256x
modrinth.category.512x+=512x以上
mods=Mods
@@ -724,7 +701,6 @@ mods.mcmod=MCMOD
mods.mcmod.page=MCMOD
mods.mcmod.search=MCMODで検索
mods.name=Name
mods.not_modded=最初にmodloaderをインストールする必要があります(Fabric、Forge、またはLiteLoader)
mods.restore=Restore
mods.url=公式ページ
mods.update_modpack_mod.warning=modpack 内の mod を更新すると、修復不可能な結果につながる可能性があり、modpack が破損して起動できなくなる可能性があります。更新してもよろしいですか?
@@ -777,18 +753,6 @@ settings=設定
settings.advanced=詳細設定
settings.advanced.title=詳細設定 - %s
settings.advanced.custom_commands=カスタムコマンド
settings.advanced.custom_commands.hint=カスタムコマンドは、次の環境変数を使用して実行されます。\n \
\-$ INST_NAME:バージョンの名前\n \
\-$ INST_ID:バージョンの名前\n \
\-$ INST_DIR:バージョンの絶対パス\n \
\-$ INST_MC_DIR:Minecraftの絶対パス\n \
\-$ INST_JAVA:起動に使用されるJavaバイナリ\n \
\-$ INST_FORGE:Forgeがインストールされている場合に設定\n \
\-$ INST_NEOFORGE:NeoForgeがインストールされている場合に設定\n \
\-$ INST_LITELOADER:LiteLoaderがインストールされている場合に設定\n \
\-$ INST_OPTIFINE:OptiFineがインストールされている場合に設定\n \
\-$ INST_FABRIC:ファブリックがインストールされている場合に設定\n \
\-$ INST_QUILT:ファブリックがインストールされている場合に設定
settings.advanced.dont_check_game_completeness=ゲームファイルをスキャンしない
settings.advanced.dont_check_jvm_validity=JVMがゲームを起動できるかどうかを確認しないでください
settings.advanced.game_dir.default=標準(.minecraft /)
@@ -807,9 +771,6 @@ settings.advanced.launcher_visible=ランチャーの設定
settings.advanced.minecraft_arguments=Minecraftの引数
settings.advanced.minecraft_arguments.prompt=デフォルト
settings.advanced.natives_directory=ネイティブライブラリパス(例: LWJGL)
settings.advanced.natives_directory.choose=ネイティブライブラリパスを選択してください
settings.advanced.natives_directory.custom=Custom(ゲームが実行時に必要とするネイティブライブラリを提供する必要があります)
settings.advanced.natives_directory.default=Standard(ネイティブライブラリは当社が提供します)
settings.advanced.no_jvm_args=デフォルトのJVM引数なし
settings.advanced.precall_command=起動前コマンド
settings.advanced.precall_command.prompt=ゲーム開始前に実行されます
@@ -829,11 +790,9 @@ settings.advanced.renderer.zink=Mesa Zink
settings.advanced.server_ip=サーバーアドレス
settings.advanced.server_ip.prompt=ゲームの起動時にサーバーに参加する
settings.advanced.unsupported_system_options=サポートされていないシステムオプション
settings.advanced.use_native_glfw=システムGLFWを使用する
settings.advanced.use_native_glfw_or_sdl=システムGLFW / SDLを使用する
settings.advanced.use_native_openal=システムOpenALを使用する
settings.advanced.linux_freebsd_only=Linux/FreeBSDのみ
settings.advanced.workaround=デバッグ用オプション
settings.advanced.workaround.warning=デバッグオプションはプロフェッショナルのみ使用可能です。デバッグオプションにより、ゲームが起動しない場合があります。これらのオプションは、ご自分が何をしているのかが分からない限り、変更しないでください。
settings.advanced.wrapper_launcher=パッキングオーダー
settings.advanced.wrapper_launcher.prompt=optirunが記入されている場合、起動コマンドが "java ... "から変更されます。を「optirun java ...」に変更しました。
@@ -852,22 +811,18 @@ settings.game.java_directory.choose=Javaディレクトリを選択します。
settings.game.java_directory.invalid=Java のパスが正しくありません。
settings.game.java_directory.template=%s(%s)
settings.game.management=管理
settings.game.working_directory=作業ディレクトリ
settings.game.working_directory.choose=作業ディレクトリを選択してください
settings.icon=ゲームアイコン
settings.launcher=設定
settings.launcher.appearance=外観
settings.launcher.common_path.tooltip=このアプリは、すべてのダウンロードをここにキャッシュします。
settings.launcher.debug=デバッグ
settings.launcher.download=ダウンロード
settings.launcher.download.threads=スレッド
settings.launcher.download.threads.auto=自動
settings.launcher.download.threads.hint=同時実行性が大きすぎると、システムがフリーズする可能性があります。ダウンロード速度は、ICPと宛先サーバーの影響を受ける可能性があります。
settings.launcher.download_source=ソースのダウンロード
settings.launcher.download_source.auto=ダウンロードソースの自動選択
settings.launcher.enable_game_list=メインページにバージョンリストを表示する
settings.launcher.font=フォント
settings.launcher.general=全般的
settings.launcher.language=言語
@@ -905,9 +860,6 @@ settings.take_effect_after_restart=再起動後に有効になります
settings.type=バージョン設定タイプ
settings.type.global.manage=グローバルゲーム設定
sponsor=寄付
sponsor.bmclapi=中国本土向けのダウンロードサービスはBMCLAPIによって提供されています。詳細については、ここをクリックしてください。
sponsor.hmcl=Hello Minecraft! Launcherは、無料のオープンソースのMinecraftランチャーであり、ユーザーは複数の個別のMinecraftインストールを簡単に管理できます。詳細については、ここをクリックしてください。
system.architecture=Arch
system.operating_system=OS
@@ -933,7 +885,6 @@ update.newest_version=最新バージョン:%s
update.bubble.title=利用可能な更新:%s
update.bubble.subtitle=更新するにはここをクリックしてください
update.latest=これは最新バージョンです。
update.no_browser=ブラウザを開くことができません。リンクがクリップボードにコピーされました。ブラウザのアドレスバーに貼り付けて更新します。
update.tooltip=更新
wiki.version.game=https://ja.minecraft.wiki/w/Java_Edition_%s
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more