Compare commits

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

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-26 21:31:35 +08:00
辞庐 b87d40ffcc 优化 实例列表无版本时点击下载页展示 (#6494) 2026-07-26 20:56:41 +08:00
Glavo 1277bf8b5d 使用 kala-encoding-detector 作为编码推测库 (#6477) 2026-07-26 00:47:43 +08:00
辞庐 c2e807055a fix: 下载整合包时未设置任务名 (#6487) 2026-07-25 21:07:43 +08:00
辞庐 9021ec1960 清理模组管理页面冗余 “实例无模组加载器” 提示 (#6500) 2026-07-25 21:01:22 +08:00
辞庐 82a47e9e2d 优化实例列表界面 (#6493) 2026-07-25 21:00:42 +08:00
neveler 4841204fa0 update i18n (#6491) 2026-07-25 21:00:26 +08:00
辞庐 897f5fe11b fix: 形参化类 'Tab<?>' 的原始使用 (#6498) 2026-07-25 20:57:58 +08:00
Glavo 4657e3273e 复制实例时同步保存游戏设置 (#6492) 2026-07-24 20:39:59 +08:00
Glavo 4edea5f5b9 Add support for Minecraft 26.3-snapshot-5+ to unofficial supported platforms (#6481) 2026-07-23 19:51:00 +08:00
辞庐 36bd7d4491 修复 GameAdvancedListItem#loadVersion UI 操作可能不会 JavaFX 线程运行的问题 (#6445)
resolves #6443
2026-07-22 22:11:55 +08:00
neveler 3aa8603bd1 提取 pr-size-label 工作流中的 js 代码到独立文件 (#6458) 2026-07-22 22:06:57 +08:00
庐庐绫 ca7a61e20a fix: 修复模组列表加载时检查错误实例的模组更新 (#6478)
close #6448
2026-07-22 21:57:59 +08:00
辞庐 21afc81cf8 fix: NetwordUtils#doPost(URI u, Map<String, String> params) 未进行 URL 编码 (#6457) 2026-07-22 21:49:46 +08:00
neveler 94a0e26be5 update i18n: remove game.crash.reason.mod_repeat_installation (#6472) 2026-07-22 21:47:13 +08:00
辞庐 14e8d2d0b5 清理 VanillaInstallWizardProvider (#6474) 2026-07-22 21:46:49 +08:00
快乐凤爪王 49c408cb23 Refactor: InstallersPageSkin versionNamePane (#6349) 2026-07-22 21:45:49 +08:00
辞庐 b81c3398ac fix: 切换版本隔离设置后自动刷新其他子页面 功能失效 (#6468) 2026-07-22 13:21:58 +08:00
快乐凤爪王 2855df3693 Feat: Implement dynamic folder icon and use to game directory list (#6435) 2026-07-21 22:03:32 +08:00
快乐凤爪王 8184dc6cee Fix: Alignment for background opacity percentage label (#6450) 2026-07-21 21:55:12 +08:00
快乐凤爪王 df21559c0e Chore: Clean up duplicate key-value pairs for No results found (#6449) 2026-07-21 21:53:21 +08:00
快乐凤爪王 73ff3081f8 Feat: No results found tip for ThemePackManagementPage (#6451) 2026-07-21 21:49:52 +08:00
辞庐 fe05bb58a4 更新时 日志输出新进程命令行时 打码敏感信息 (#6456) 2026-07-21 21:30:32 +08:00
Glavo 4c9cd950e4 移除对 JavaFX 14 的兼容性 (#6467) 2026-07-21 20:40:56 +08:00
neveler 646d9a09a5 update i18n (#6466) 2026-07-21 20:38:56 +08:00
Glavo 255fe75640 Bump JavaFX to 21.0.11 on FreeBSD x86-64 (#6463) 2026-07-21 19:40:18 +08:00
neveler de15dd8e98 Remove extra whitespace (#6462) 2026-07-21 17:57:57 +08:00
Glavo 886319a6c7 避免在代码中直接引用 Controllers.getStage() (#6431) 2026-07-20 21:41:55 +08:00
辞庐 3320f6c341 fix: 整合包安装完成回调执行器不统一 (#6446) 2026-07-20 21:21:33 +08:00
快乐凤爪王 b85be78166 Feat: Add empty state placeholders for versions (#6426) 2026-07-20 21:21:06 +08:00
Glavo a34a7439d7 Clarify nullability and documentation in AGENTS.md (#6440) 2026-07-20 00:02:59 +08:00
ZZZank 591fd51600 实现游戏日志窗口开关自动换行 (#6424) 2026-07-19 21:53:57 +08:00
快乐凤爪王 c1407a9620 Fix: Resolve ModLoaderType.LEGACY_FABRIC for Modrinth (#6434) 2026-07-19 02:46:28 +08:00
Glavo e0a91579f2 更新内置主题相关译文 (#6429) 2026-07-17 21:29:51 +08:00
快乐凤爪王 b68f404c3c Refactor: Style of ModInfoDialog (#6423) 2026-07-17 21:28:02 +08:00
Glavo c1f50f9ba3 删除 Gemini 审查配置文件 (#6430) 2026-07-17 21:17:31 +08:00
辞庐 d8bb2131f2 允许拖入 nbt 文件 (#6427) 2026-07-17 21:08:45 +08:00
Glavo aca761385b Add support for Minecraft 26.3-snapshot-4+ to unofficial supported platforms (#6425) 2026-07-17 21:00:14 +08:00
3gf8jv4dv 15faccf376 chore(workflow): add pr-size-label workflow file (#6381) 2026-07-17 20:23:48 +08:00
Glavo 241c9c419c 修复 FXUtils 意外的被提前初始化的问题 (#6421) 2026-07-16 21:29:12 +08:00
辞庐 4b36f8ce03 fix: 任务窗口速度与取消未对齐 (#6140) 2026-07-16 21:28:31 +08:00
Glavo 5c0baad782 提早加载配置文件 (#6420) 2026-07-16 20:50:20 +08:00
Apostrophe 850a56405c 修复游戏 Java 路径显示滞后问题 (#6419) 2026-07-16 19:59:17 +08:00
快乐凤爪王 e8487c467b Refactor: Replace deprecated URL constructor (#6409) 2026-07-16 03:37:54 +08:00
Glavo 9866d88a16 完善游戏设置页面帮助文本 (#6415) 2026-07-15 21:15:00 +08:00
辞庐 af2d46ae89 Revert "Refactor: Remove redundant downloadSourceComboBox from ModDownloadListPageSkin" (#6410)
Reverts HMCL-dev/HMCL#6370
2026-07-15 19:56:20 +08:00
ToobLac 6f7c561ecb [Bugfix] 修复 1.21.1 安装信雅互联仍显示不支持 Fabric 模组的问题 (#6404)
Fixes #6391
2026-07-14 21:41:39 +08:00
ToobLac 93abe31acf [Chores] 处理升级到 9.6.1 后 Gradle 弃用提示 (#6405) 2026-07-14 21:29:58 +08:00
Glavo a6f8659349 Bump Gradle Wrapper Neo to 0.2.0 (#6397) 2026-07-14 00:06:56 +08:00
Glavo 8728c89093 Bump version root to 3.17 (#6392) 2026-07-13 20:34:38 +08:00
260 changed files with 14023 additions and 6954 deletions

No files matched your search

-3
View File
@@ -1,3 +0,0 @@
code_review:
pull_request_opened:
code_review: false
+105
View File
@@ -0,0 +1,105 @@
module.exports = async ({ github, context, core }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
const ignoredFilePatterns = [
/(?:^|\/)\.cnb\/.+\.ya?ml$/i,
/(?:^|\/)\.gitee\/.+\.ya?ml$/i,
/(?:^|\/)\.github\/.+\.ya?ml$/i,
/(?:^|\/)\.idea\/.+\.xml$/i,
/(?:^|\/)\.editorconfig$/i,
/(?:^|\/)\.gitattributes$/i,
/(?:^|\/)\.gitignore$/i,
/(?:^|\/).+\.kts$/i,
/(?:^|\/).+\.md$/i,
/(?:^|\/)LICENSE(?:_.*)?$/i,
/(?:^|\/)gradlew(?:\.bat|\.ps1)?$/i,
/(?:^|\/)gradle\/wrapper\/gradle-wrapper\.properties$/i,
/(?:^|\/)lang\/.+\.properties$/i,
/(?:^|\/)l10n\/.+\.properties$/i,
/(?:^|\/)checkstyle\.xml$/i,
/(?:^|\/).+\.(?:png|jpe?g|gif|ico|svg|json|ttf|woff2?|webp|avif|jxl|heic|mp4|webm|mp3|ogg|wav|txt|toml)$/i
];
async function processPR(issueNumber) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: issueNumber, per_page: 100,
});
const countedFiles = [];
const ignoredFiles = [];
let totalChanges = 0;
for (const file of files) {
const ignored = ignoredFilePatterns.some(pattern => pattern.test(file.filename));
if (ignored) {
ignoredFiles.push(file.filename);
continue;
}
const fileChanges = file.changes ?? ((file.additions || 0) + (file.deletions || 0));
totalChanges += fileChanges;
countedFiles.push(`${file.filename} (${fileChanges})`);
}
let targetLabel;
if (totalChanges <= 9) targetLabel = "1+";
else if (totalChanges <= 39) targetLabel = "10+";
else if (totalChanges <= 99) targetLabel = "40+";
else if (totalChanges <= 499) targetLabel = "100+";
else if (totalChanges <= 999) targetLabel = "500+";
else if (totalChanges <= 1999) targetLabel = "1000+";
else if (totalChanges <= 4999) targetLabel = "2000+";
else targetLabel = "5000+";
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: issueNumber, per_page: 100,
});
const sizeLabels = currentLabels.map(label => label.name).filter(name => /^\d+\+$/.test(name));
for (const name of sizeLabels) {
if (name !== targetLabel) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: issueNumber, name,
}).catch(() => { });
}
}
if (!currentLabels.some(label => label.name === targetLabel)) {
await github.rest.issues.addLabels({
owner, repo, issue_number: issueNumber, labels: [targetLabel],
});
}
core.info(`Pull request #${issueNumber} has ${totalChanges} counted changed lines, applied ${targetLabel}.`);
core.info(`Ignored files: ${ignoredFiles.length ? ignoredFiles.join(", ") : "none"}`);
core.info(`Counted files: ${countedFiles.length ? countedFiles.join(", ") : "none"}`);
}
if (context.eventName === "workflow_dispatch") {
core.info("Manual trigger detected. Fetching all open pull requests...");
const openPRs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100,
});
const unlabelledPRs = openPRs.filter(pr => {
return !pr.labels.some(label => /^\d+\+$/.test(label.name));
});
core.info(`Found ${openPRs.length} open pull request(s), ${unlabelledPRs.length} of them lack a size label.`);
for (const pr of unlabelledPRs) {
core.info(`----------------------------------------`);
core.info(`Processing unlabelled pull request #${pr.number}: ${pr.title}`);
await processPR(pr.number);
}
} else {
const pr = context.payload.pull_request;
if (!pr) {
core.info("No pull request found in payload, skipping.");
return;
}
await processPR(pr.number);
}
}
+2 -1
View File
@@ -24,8 +24,9 @@ jobs:
with:
cache-cleanup: never
- name: Build with Gradle
run: ./gradlew build --no-daemon --parallel
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
env:
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
+31
View File
@@ -0,0 +1,31 @@
name: PR Size Label
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts
sparse-checkout-cone-mode: true
- name: Label PR by filtered changed lines
uses: actions/github-script@v9
with:
script: |
const script = require("./.github/scripts/pr-size-label.js");
await script({ github, context, core });
+7 -3
View File
@@ -4,9 +4,11 @@ These rules apply to all Java code written or modified in this repository.
## Nullability
- Annotate every class with JetBrains Annotations `@NotNullByDefault`.
- Any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
- Nullability must never be implicit.
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class declared in a newly added Java source file must be annotated with JetBrains Annotations `@NotNullByDefault`.
- When writing or modifying Java code, any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
- Nullability in code being written or modified must never be implicit.
## Immutability
@@ -15,6 +17,8 @@ These rules apply to all Java code written or modified in this repository.
## Documentation
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class, field, and method must have documentation.
- Documentation must use `///` Markdown-style Javadoc comments.
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
+8 -7
View File
@@ -52,7 +52,7 @@ if (buildNumber != null) {
}
}
val embedResources by configurations.registering
val embedResources = configurations.register("embedResources")
dependencies {
implementation(project(":HMCLCore"))
@@ -166,7 +166,7 @@ val hmclProperties = buildList {
}
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
val createPropertiesFile by tasks.registering {
val createPropertiesFile = tasks.register("createPropertiesFile") {
outputs.file(hmclPropertiesFile)
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
@@ -258,7 +258,7 @@ tasks.processResources {
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
val makeExecutables by tasks.registering {
val makeExecutables = tasks.register("makeExecutables") {
val extensions = listOf("exe", "sh")
dependsOn(tasks.jar)
@@ -286,7 +286,7 @@ val makeExecutables by tasks.registering {
}
}
val makeDeb by tasks.registering(CreateDeb::class) {
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
dependsOn(makeExecutables)
val debFile = layout.file(provider { artifactFile("deb") })
@@ -299,6 +299,7 @@ val makeDeb by tasks.registering(CreateDeb::class) {
version.set(project.version.toString())
releaseType.set(debChannel)
launcherClassName.set("org.jackhuang.hmcl.Launcher")
appShFile.set(layout.file(provider { artifactFile("sh") }))
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
outputFile.set(debFile)
@@ -438,19 +439,19 @@ tasks.register<CheckTranslations>("checkTranslations") {
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList") {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
val createLocaleNamesResourceBundle = tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
@@ -52,8 +52,6 @@ public final class EntryPoint {
checkWine();
setupJavaFXVMOptions();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
if (!isInsideMacAppBundle())
@@ -74,71 +72,6 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
try {
@@ -225,15 +158,8 @@ public final class EntryPoint {
private static void checkWine() {
if (OperatingSystem.isRunningUnderWine()) {
SwingUtils.initLookAndFeel();
LOG.warning("HMCL is running under Wine or its distributions!");
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
showWarning(i18n("fatal.wine_warning"));
}
}
@@ -273,10 +199,21 @@ public final class EntryPoint {
}
}
static void showWarning(String message) {
SwingUtils.initLookAndFeel();
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
}
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
private static void showErrorAndExit(String message) {
static void showErrorAndExit(String message) {
SwingUtils.showErrorDialog(message);
exit(1);
}
@@ -17,6 +17,8 @@
*/
package org.jackhuang.hmcl;
import com.sun.jna.Pointer;
import com.sun.jna.WString;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
@@ -29,8 +31,6 @@ import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Duration;
@@ -41,6 +41,7 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
@@ -48,7 +49,14 @@ import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
import org.jackhuang.hmcl.util.platform.windows.Shell32;
import org.jackhuang.hmcl.util.platform.windows.User32;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.io.File;
import java.io.IOException;
import java.lang.management.ManagementFactory;
@@ -60,7 +68,6 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@@ -98,18 +105,7 @@ public final class Launcher extends Application {
}
try {
try {
SettingsManager.init();
initializeSettingsRuntime();
} catch (SambaException e) {
showAlert(AlertType.WARNING, i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigInTempDir();
checkConfigOwner();
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
EntryPoint.exit(1);
}
initializeSettingsRuntime();
// https://lapcatsoftware.com/articles/app-translocation.html
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
@@ -146,6 +142,7 @@ public final class Launcher extends Application {
UpdateChecker.init();
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage);
primaryStage.show();
});
} catch (Throwable e) {
@@ -274,52 +271,10 @@ public final class Launcher extends Application {
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
if (showAlert(AlertType.ERROR,
i18n("fatal.config_loading_failure.unix", owner, command),
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
EntryPoint.exit(1);
}
@Override
public void stop() throws Exception {
Controllers.onApplicationStop();
FileSaver.shutdown();
SettingsManager.shutdown();
LOG.shutdown();
}
@@ -376,6 +331,20 @@ public final class Launcher extends Application {
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
try {
SettingsManager.init();
} catch (SambaException e) {
EntryPoint.showWarning(i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigOwner();
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
EntryPoint.exit(1);
}
setupJavaFXVMOptions();
setupWindowsAppUserModelID();
launch(Launcher.class, args);
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
@@ -408,5 +377,177 @@ public final class Launcher extends Application {
});
}
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
private static void setupWindowsAppUserModelID() {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
return;
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
return;
try {
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
if (hr < 0) {
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
} else {
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
}
} catch (Throwable e) {
LOG.warning("Failed to set AppUserModelID", e);
}
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
setUpAnimationFrameRate:
{
if (System.getProperty("javafx.animation.pulse") != null) {
break setUpAnimationFrameRate;
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
int value = Integer.parseInt(animationFrameRate);
if (value <= 0)
throw new NumberFormatException(animationFrameRate);
if (value != 60)
System.setProperty("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
break setUpAnimationFrameRate;
}
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
break setUpAnimationFrameRate;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
if (pointer != null) {
try {
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
if (refreshRate > 0) {
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
if (refreshRate >= 90) {
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
}
}
} finally {
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
}
}
}
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
SwingUtils.initLookAndFeel();
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
int result = JOptionPane.showOptionDialog(null,
i18n("fatal.config_loading_failure.unix", owner, command),
i18n("message.error"),
JOptionPane.DEFAULT_OPTION,
JOptionPane.ERROR_MESSAGE,
null,
options,
options[0]);
if (result == 0) {
try {
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
} catch (Throwable e) {
LOG.warning("Failed to copy command to clipboard", e);
}
}
EntryPoint.exit(1);
}
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
}
@@ -37,6 +37,9 @@ public final class Metadata {
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
/// Explicit Application User Model ID used for Windows taskbar grouping and pinning.
public static final String WINDOWS_APP_USER_MODEL_ID = "org.jackhuang.hmcl";
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
@@ -42,16 +42,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class HMCLGameLauncher extends DefaultLauncher {
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
this(repository, version, authInfo, options, null);
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) {
this(repository, manifest, authInfo, options, null);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(repository, version, authInfo, options, listener, true);
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(repository, manifest, authInfo, options, listener, true);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(repository, version, authInfo, options, listener, daemon);
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(repository, manifest, authInfo, options, listener, daemon);
}
@Override
@@ -66,7 +66,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isDisableAutoGameOptions())
return;
Path runDir = repository.getRunDirectory(version.getId());
Path runDir = repository.getRunDirectory(manifest.id());
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
@@ -91,7 +91,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
*/
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(manifest));
if (gameVersion.compareTo("1.1") < 0)
return;
@@ -161,7 +161,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isAllowAutoAgent()
&& !options.isNoGeneratedJVMArgs()
&& !options.isNoGeneratedOptimizingJVMArgs()
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
try {
result.add("-javaagent:" + extractLwjglUnsafeAgent());
@@ -178,9 +178,9 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.getArtifact().getFileName();
String fileName = library.artifact().getFileName();
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
Path agentPath = repository.getLibraryFile(manifest, library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
@@ -20,8 +20,9 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import javafx.beans.binding.Binding;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
@@ -45,7 +46,7 @@ import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.ProxyType;
import org.jackhuang.hmcl.setting.SettingFileUtils;
import org.jackhuang.hmcl.setting.GameSettingsPresetID;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
@@ -81,10 +82,10 @@ public final class HMCLGameRepository extends DefaultGameRepository {
/// @param repository the owning game repository
/// @param instanceId the game instance ID, or `null` when only repository context is available
@NotNullByDefault
public record InstanceReference(HMCLGameRepository repository, @Nullable String instanceId) {
public record InstanceReference(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
}
/// Directory under the version root that stores HMCL-managed instance metadata.
/// Directory under the instance root that stores HMCL-managed instance metadata.
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
/// Directory under the instance metadata directory that stores instance configuration files.
@@ -100,22 +101,22 @@ public final class HMCLGameRepository extends DefaultGameRepository {
private final GameDirectory gameDirectory;
/// The selected instance ID persisted for this repository's game directory.
private final StringBinding selectedInstance;
private final ObjectBinding<@Nullable GameInstanceID> selectedInstance;
// instance game settings
private final Map<String, GameSettings.Instance> instanceGameSettings = new HashMap<>();
private final Map<GameInstanceID, GameSettings.Instance> instanceGameSettings = new HashMap<>();
/// Instance IDs whose local game settings file has already been checked.
private final Set<String> loadedInstanceGameSettings = new HashSet<>();
private final Set<String> readOnlyInstanceGameSettings = new HashSet<>();
private final Set<String> beingModpackVersions = new HashSet<>();
private final Set<GameInstanceID> loadedInstanceGameSettings = new HashSet<>();
private final Set<GameInstanceID> readOnlyInstanceGameSettings = new HashSet<>();
private final Set<GameInstanceID> beingModpackInstances = new HashSet<>();
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
public final EventManager<Event> onInstanceIconChanged = new EventManager<>();
/// Creates a repository backed by the given game directory.
public HMCLGameRepository(GameDirectory gameDirectory) {
super(gameDirectory.getPath().toPath());
this.gameDirectory = gameDirectory;
this.selectedInstance = Bindings.stringValueAt(settings().getSelectedInstance(), gameDirectory.getId());
this.selectedInstance = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId());
gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath()));
}
@@ -125,26 +126,26 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
/// Returns the selected instance ID property for this repository's game directory.
public StringBinding selectedInstanceProperty() {
public Binding<@Nullable GameInstanceID> selectedInstanceProperty() {
return selectedInstance;
}
/// Returns the selected instance ID for this repository's game directory.
public @Nullable String getSelectedInstance() {
public @Nullable GameInstanceID getSelectedInstance() {
return selectedInstance.get();
}
/// Sets the selected instance ID for this repository's game directory.
public void setSelectedInstance(@Nullable String instance) {
settings().setSelectedInstance(gameDirectory.getId(), instance);
public void setSelectedInstance(@Nullable GameInstanceID instanceId) {
settings().setSelectedInstance(gameDirectory.getId(), instanceId);
}
/// Refreshes the selected instance ID after versions are loaded.
/// Refreshes the selected instance ID after instances are loaded.
public void refreshSelectedInstance() {
@Nullable String selectedInstance = settings().getSelectedInstance(gameDirectory.getId());
@Nullable String refreshedInstance = selectedInstance;
if (!hasVersion(refreshedInstance)) {
refreshedInstance = getVersions().isEmpty() ? null : getVersions().iterator().next().getId();
@Nullable GameInstanceID selectedInstance = settings().getSelectedInstance(gameDirectory.getId());
@Nullable GameInstanceID refreshedInstance = selectedInstance;
if (refreshedInstance == null || !hasInstance(refreshedInstance)) {
refreshedInstance = getInstanceManifests().isEmpty() ? null : getInstanceManifests().iterator().next().id();
}
if (!Objects.equals(selectedInstance, refreshedInstance)) {
setSelectedInstance(refreshedInstance);
@@ -162,24 +163,24 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public Path getRunDirectory(String id) {
if (beingModpackVersions.contains(id) || isModpack(id)) {
return getVersionRoot(id);
public Path getRunDirectory(GameInstanceID instanceId) {
if (beingModpackInstances.contains(instanceId) || isModpack(instanceId)) {
return getInstanceRoot(instanceId);
}
GameSettings.Instance localSetting = getInstanceGameSettings(id);
GameSettings.Instance localSetting = getInstanceGameSettings(instanceId);
boolean useInstanceRunningDirectory =
localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY);
String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory);
if (StringUtils.isBlank(runningDirectory)) {
return useInstanceRunningDirectory ? getVersionRoot(id) : super.getRunDirectory(id);
return useInstanceRunningDirectory ? getInstanceRoot(instanceId) : super.getRunDirectory(instanceId);
}
try {
return Path.of(runningDirectory);
} catch (InvalidPathException ignored) {
return getVersionRoot(id);
return getInstanceRoot(instanceId);
}
}
@@ -201,24 +202,24 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), "");
}
public Stream<Version> getDisplayVersions() {
return getVersions().stream()
public Stream<GameInstanceManifest> getDisplayInstanceManifests() {
return getInstanceManifests().stream()
.filter(v -> !v.isHidden())
.sorted(Comparator.comparing((Version v) -> Lang.requireNonNullElse(v.getReleaseTime(), Instant.EPOCH))
.thenComparing(v -> VersionNumber.asVersion(v.getId())));
.sorted(Comparator.comparing((GameInstanceManifest v) -> Lang.requireNonNullElse(v.releaseTime(), Instant.EPOCH))
.thenComparing(v -> VersionNumber.asVersion(v.id().id())));
}
@Override
protected void refreshVersionsImpl() {
protected void refreshImpl() {
instanceGameSettings.clear();
loadedInstanceGameSettings.clear();
readOnlyInstanceGameSettings.clear();
super.refreshVersionsImpl();
versions.keySet().forEach(this::loadInstanceGameSettings);
super.refreshImpl();
getInstanceManifests().stream().map(GameInstanceManifest::id).forEach(this::loadInstanceGameSettings);
try {
Path file = getBaseDirectory().resolve("launcher_profiles.json");
if (!Files.exists(file) && !versions.isEmpty()) {
if (!Files.exists(file) && !getInstanceManifests().isEmpty()) {
Files.createDirectories(file.getParent());
Files.writeString(file, PROFILE);
}
@@ -229,7 +230,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
public void changeDirectory(Path newDirectory) {
setBaseDirectory(newDirectory);
refreshVersionsAsync().start();
refreshAsync().start();
}
private void clean(Path directory) throws IOException {
@@ -237,56 +238,56 @@ public final class HMCLGameRepository extends DefaultGameRepository {
FileUtils.deleteDirectory(directory.resolve("logs"));
}
public void clean(String id) throws IOException {
public void clean(GameInstanceID instanceId) throws IOException {
clean(getBaseDirectory());
clean(getRunDirectory(id));
clean(getRunDirectory(instanceId));
}
/// Removes a version from disk and drops any cached instance settings for that version.
/// Removes an instance from disk and clears its cached HMCL settings state.
@Override
public boolean removeVersionFromDisk(String id) {
boolean removed = super.removeVersionFromDisk(id);
public boolean removeInstanceFromDisk(GameInstanceID instanceId) {
boolean removed = super.removeInstanceFromDisk(instanceId);
if (removed) {
instanceGameSettings.remove(id);
loadedInstanceGameSettings.remove(id);
readOnlyInstanceGameSettings.remove(id);
beingModpackVersions.remove(id);
instanceGameSettings.remove(instanceId);
loadedInstanceGameSettings.remove(instanceId);
readOnlyInstanceGameSettings.remove(instanceId);
beingModpackInstances.remove(instanceId);
}
return removed;
}
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
Path srcDir = getVersionRoot(srcId);
Path dstDir = getVersionRoot(dstId);
public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException {
Path srcDir = getInstanceRoot(srcId);
Path dstDir = getInstanceRoot(dstId);
Version fromVersion = getVersion(srcId);
GameInstanceManifest fromManifest = getInstanceManifest(srcId);
List<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
blackList.add(srcId + ".jar");
blackList.add(srcId + ".json");
blackList.add(srcId.id() + ".jar");
blackList.add(srcId.id() + ".json");
if (!copySaves)
blackList.add("saves");
if (Files.exists(dstDir)) throw new IOException("Version exists");
if (Files.exists(dstDir)) throw new IOException("Instance exists");
Files.createDirectories(dstDir);
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
Path fromJson = srcDir.resolve(srcId + ".json");
Path fromJar = srcDir.resolve(srcId + ".jar");
Path toJson = dstDir.resolve(dstId + ".json");
Path toJar = dstDir.resolve(dstId + ".jar");
Path fromJson = srcDir.resolve(srcId.id() + ".json");
Path fromJar = srcDir.resolve(srcId.id() + ".jar");
Path toJson = dstDir.resolve(dstId.id() + ".json");
Path toJar = dstDir.resolve(dstId.id() + ".jar");
if (Files.exists(fromJar)) {
Files.copy(fromJar, toJar);
}
Files.copy(fromJson, toJson);
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
JsonUtils.writeToJsonFile(toJson, fromManifest.withId(dstId).withJar(dstId));
boolean copyOriginalGameDir;
try {
copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getVersionRoot(srcId));
copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getInstanceRoot(srcId));
} catch (IOException e) {
copyOriginalGameDir = true;
}
@@ -305,48 +306,48 @@ public final class HMCLGameRepository extends DefaultGameRepository {
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
}
private GameSettings.Instance copyInstanceGameSettings(String id) {
GameSettings.Instance setting = getInstanceGameSettings(id);
private GameSettings.Instance copyInstanceGameSettings(GameInstanceID instanceId) {
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
if (setting != null) {
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
}
GameSettings.Instance copied = new GameSettings.Instance();
copied.parentProperty().setValue(getEffectiveGameSettings(id).getPreset().idProperty().getValue());
copied.parentProperty().setValue(getEffectiveGameSettings(instanceId).getPreset().idProperty().getValue());
return copied;
}
/// Returns the HMCL-managed metadata directory under the version root.
/// Returns the HMCL-managed metadata directory under the instance root.
///
/// This directory stores instance-scoped files owned by HMCL.
public Path getInstanceMetadataDirectory(String id) {
return getVersionRoot(id).resolve(INSTANCE_METADATA_DIRECTORY);
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
}
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
public Path getInstanceConfigDirectory(String id) {
return getInstanceMetadataDirectory(id).resolve(INSTANCE_CONFIG_DIRECTORY);
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
}
/// Returns the HMCL-managed state directory under the instance metadata directory.
public Path getInstanceStateDirectory(String id) {
return getInstanceMetadataDirectory(id).resolve(INSTANCE_STATE_DIRECTORY);
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
}
/// Returns the current local game settings path under the instance configuration directory.
private Path getInstanceGameSettingsFile(String id) {
return getInstanceConfigDirectory(id).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
private Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
}
private void loadInstanceGameSettings(String id) {
loadedInstanceGameSettings.add(id);
InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(id));
private void loadInstanceGameSettings(GameInstanceID instanceId) {
loadedInstanceGameSettings.add(instanceId);
InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(instanceId));
if (result.setting() != null) {
initInstanceGameSettings(id, result.setting(), result.allowSave());
initInstanceGameSettings(instanceId, result.setting(), result.allowSave());
return;
}
if (!result.allowSave()) {
readOnlyInstanceGameSettings.add(id);
readOnlyInstanceGameSettings.add(instanceId);
return;
}
@@ -357,15 +358,15 @@ public final class HMCLGameRepository extends DefaultGameRepository {
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
this, id,
this, instanceId,
legacyParent);
if (migrationResult != null) {
initInstanceGameSettings(id, migrationResult.setting());
initInstanceGameSettings(instanceId, migrationResult.setting());
try {
saveGameSettingsSync(id);
saveGameSettingsSync(instanceId);
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + id, e);
LOG.warning("Failed to save migrated instance game settings for " + instanceId, e);
}
return;
}
@@ -430,35 +431,35 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public @Nullable GameSettings.Instance createInstanceGameSettings(String id) {
if (!hasVersion(id)) {
public @Nullable GameSettings.Instance createInstanceGameSettings(GameInstanceID instanceId) {
if (!hasInstance(instanceId)) {
return null;
}
if (readOnlyInstanceGameSettings.contains(id)) {
if (readOnlyInstanceGameSettings.contains(instanceId)) {
return null;
}
if (instanceGameSettings.containsKey(id)) {
return getInstanceGameSettings(id);
if (instanceGameSettings.containsKey(instanceId)) {
return getInstanceGameSettings(instanceId);
}
GameSettings.Instance setting = new GameSettings.Instance();
return initInstanceGameSettings(id, setting);
return initInstanceGameSettings(instanceId, setting);
}
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting) {
return initInstanceGameSettings(id, setting, true);
private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting) {
return initInstanceGameSettings(instanceId, setting, true);
}
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting, boolean allowSave) {
private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting, boolean allowSave) {
normalizeRunningDirectoryOverride(setting);
setting.setSavable(allowSave);
loadedInstanceGameSettings.add(id);
instanceGameSettings.put(id, setting);
loadedInstanceGameSettings.add(instanceId);
instanceGameSettings.put(instanceId, setting);
if (allowSave) {
readOnlyInstanceGameSettings.remove(id);
setting.addListener(a -> saveGameSettings(id));
readOnlyInstanceGameSettings.remove(instanceId);
setting.addListener(a -> saveGameSettings(instanceId));
} else {
readOnlyInstanceGameSettings.add(id);
readOnlyInstanceGameSettings.add(instanceId);
}
return setting;
}
@@ -471,59 +472,59 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Nullable
public GameSettings.Instance getInstanceGameSettings(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
public GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) {
if (!loadedInstanceGameSettings.contains(instanceId)) {
loadInstanceGameSettings(instanceId);
}
return instanceGameSettings.get(id);
return instanceGameSettings.get(instanceId);
}
@Nullable
public GameSettings.Instance getInstanceGameSettingsOrCreate(String id) {
GameSettings.Instance setting = getInstanceGameSettings(id);
public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) {
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
if (setting == null) {
setting = createInstanceGameSettings(id);
setting = createInstanceGameSettings(instanceId);
}
return setting;
}
/// Returns whether the instance-specific game settings file cannot be overwritten safely.
///
/// @param id the instance ID
/// @param instanceId the instance ID
/// @return whether the instance settings are loaded in read-only mode
public boolean isInstanceGameSettingsReadOnly(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
public boolean isInstanceGameSettingsReadOnly(GameInstanceID instanceId) {
if (!loadedInstanceGameSettings.contains(instanceId)) {
loadInstanceGameSettings(instanceId);
}
return readOnlyInstanceGameSettings.contains(id);
return readOnlyInstanceGameSettings.contains(instanceId);
}
/// Backs up and overwrites the instance-specific game settings file with the currently loaded settings.
///
/// @param id the instance ID
public void forceOverwriteInstanceGameSettings(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
/// @param instanceId the instance ID
public void forceOverwriteInstanceGameSettings(GameInstanceID instanceId) {
if (!loadedInstanceGameSettings.contains(instanceId)) {
loadInstanceGameSettings(instanceId);
}
GameSettings.Instance setting = instanceGameSettings.get(id);
GameSettings.Instance setting = instanceGameSettings.get(instanceId);
if (setting == null) {
setting = new GameSettings.Instance();
instanceGameSettings.put(id, setting);
loadedInstanceGameSettings.add(id);
instanceGameSettings.put(instanceId, setting);
loadedInstanceGameSettings.add(instanceId);
}
boolean installAutoSave = !setting.isSavable();
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
SettingFileUtils.backupInvalidConfig(file);
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
setting.setSavable(true);
setting.setBackupOnNextSave(false);
readOnlyInstanceGameSettings.remove(id);
saveGameSettings(id);
readOnlyInstanceGameSettings.remove(instanceId);
saveGameSettings(instanceId);
if (installAutoSave) {
setting.addListener(a -> saveGameSettings(id));
setting.addListener(a -> saveGameSettings(instanceId));
}
}
@@ -534,6 +535,33 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
}
public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) {
GameSettings.Instance instance = getInstanceGameSettings(instanceId);
return GameSettings.resolve(getParentGameSettings(instance), instance);
}
public void applyDefaultIsolationSetting(GameInstanceID instanceId) {
if (!hasInstance(instanceId)) {
return;
}
GameSettings.Instance instanceSetting = getInstanceGameSettings(instanceId);
GameSettings.Preset preset = getParentGameSettings(instanceSetting);
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
boolean isolated = switch (type) {
case NEVER -> false;
case ALWAYS -> true;
case MODDED -> LibraryAnalyzer.isModded(getResolvedInstanceManifest(instanceId));
};
if (isolated) {
GameSettings.Instance setting = instanceSetting != null ? instanceSetting : getInstanceGameSettingsOrCreate(instanceId);
if (setting != null && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveGameSettings(instanceId);
}
}
}
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
public boolean shouldIsolateNewInstance(boolean modded) {
GameSettings.Preset preset = getParentGameSettings(null);
@@ -545,28 +573,23 @@ public final class HMCLGameRepository extends DefaultGameRepository {
};
}
/// Applies default isolation to a new instance before the version metadata is saved.
public void applyDefaultIsolationSettingForNewInstance(String id, boolean modded) {
if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(id)) {
/// Applies default isolation to a new instance before its manifest is saved.
public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId, boolean modded) {
if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(instanceId)) {
return;
}
GameSettings.Instance setting = getInstanceGameSettings(id);
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
if (setting == null) {
setting = initInstanceGameSettings(id, new GameSettings.Instance());
setting = initInstanceGameSettings(instanceId, new GameSettings.Instance());
}
if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveGameSettings(id);
saveGameSettings(instanceId);
}
}
public GameSettings.Effective getEffectiveGameSettings(String id) {
GameSettings.Instance instance = getInstanceGameSettings(id);
return GameSettings.resolve(getParentGameSettings(instance), instance);
}
public Optional<Path> getVersionIconFile(String id) {
Path root = getVersionRoot(id);
public Optional<Path> getInstanceIconFile(GameInstanceID instanceId) {
Path root = getInstanceRoot(instanceId);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
@@ -578,19 +601,19 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return Optional.empty();
}
public void setVersionIconFile(String id, Path iconFile) throws IOException {
public void setInstanceIconFile(GameInstanceID instanceId, Path iconFile) throws IOException {
String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) {
throw new IllegalArgumentException("Unsupported icon file: " + ext);
}
deleteIconFile(id);
deleteIconFile(instanceId);
FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext));
FileUtils.copyFile(iconFile, getInstanceRoot(instanceId).resolve("icon." + ext));
}
public void deleteIconFile(String id) {
Path root = getVersionRoot(id);
public void deleteIconFile(GameInstanceID instanceId) {
Path root = getInstanceRoot(instanceId);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
try {
@@ -601,69 +624,69 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public Image getVersionIconImage(@Nullable String id) {
if (id == null || !isLoaded())
return VersionIconType.DEFAULT.getIcon();
public Image getInstanceIconImage(@Nullable GameInstanceID instanceId) {
if (instanceId == null || !isLoaded())
return GameInstanceIconType.DEFAULT.getIcon();
GameSettings.Instance setting = getInstanceGameSettings(id);
VersionIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
GameSettings.Instance setting = getInstanceGameSettings(instanceId);
GameInstanceIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) : GameInstanceIconType.DEFAULT;
if (iconType == VersionIconType.DEFAULT) {
Version version = getVersion(id).resolve(this);
Optional<Path> iconFile = getVersionIconFile(id);
if (iconType == GameInstanceIconType.DEFAULT) {
GameInstanceManifest.Resolved resolvedInstanceManifest = getResolvedInstanceManifest(instanceId);
Optional<Path> iconFile = getInstanceIconFile(instanceId);
if (iconFile.isPresent()) {
try {
return FXUtils.loadImage(iconFile.get(), 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load version icon of " + id, e);
LOG.warning("Failed to load instance icon of " + instanceId, e);
}
}
if (LibraryAnalyzer.isModded(this, version)) {
LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(version, null);
if (LibraryAnalyzer.isModded(resolvedInstanceManifest)) {
LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, null);
if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC))
return VersionIconType.FABRIC.getIcon();
return GameInstanceIconType.FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
return VersionIconType.QUILT.getIcon();
return GameInstanceIconType.QUILT.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC))
return VersionIconType.LEGACY_FABRIC.getIcon();
return GameInstanceIconType.LEGACY_FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
return VersionIconType.NEO_FORGE.getIcon();
return GameInstanceIconType.NEO_FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
return VersionIconType.FORGE.getIcon();
return GameInstanceIconType.FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
return VersionIconType.CLEANROOM.getIcon();
return GameInstanceIconType.CLEANROOM.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER))
return VersionIconType.CHICKEN.getIcon();
return GameInstanceIconType.CHICKEN.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE))
return VersionIconType.OPTIFINE.getIcon();
return GameInstanceIconType.OPTIFINE.getIcon();
}
String gameVersion = getGameVersion(version).orElse(null);
String gameVersion = getGameVersion(resolvedInstanceManifest.launchManifest()).orElse(null);
if (gameVersion != null) {
GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion);
if (versionNumber.isAprilFools()) {
return VersionIconType.APRIL_FOOLS.getIcon();
return GameInstanceIconType.APRIL_FOOLS.getIcon();
} else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) {
return VersionIconType.COMMAND.getIcon();
return GameInstanceIconType.COMMAND.getIcon();
} else if (versionNumber instanceof GameVersionNumber.Old) {
return VersionIconType.CRAFT_TABLE.getIcon();
return GameInstanceIconType.CRAFT_TABLE.getIcon();
}
}
return VersionIconType.GRASS.getIcon();
return GameInstanceIconType.GRASS.getIcon();
} else {
return iconType.getIcon();
}
}
public void saveGameSettings(String id) {
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id))
public void saveGameSettings(GameInstanceID instanceId) {
if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId))
return;
GameSettings.Instance setting = instanceGameSettings.get(id);
GameSettings.Instance setting = instanceGameSettings.get(instanceId);
if (setting == null) {
return;
}
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
@@ -679,19 +702,19 @@ public final class HMCLGameRepository extends DefaultGameRepository {
/// Saves instance-specific game settings synchronously.
///
/// @param id the instance ID
/// @param instanceId the instance ID
/// @throws IOException if saving the file fails
private void saveGameSettingsSync(String id) throws IOException {
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id)) {
private void saveGameSettingsSync(GameInstanceID instanceId) throws IOException {
if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId)) {
return;
}
GameSettings.Instance setting = instanceGameSettings.get(id);
GameSettings.Instance setting = instanceGameSettings.get(instanceId);
if (setting == null) {
return;
}
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
@@ -709,11 +732,11 @@ public final class HMCLGameRepository extends DefaultGameRepository {
boolean allowSave) {
}
public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveGameSettings(version);
public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveGameSettings(instanceId);
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(version));
GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(instanceId));
@Nullable Integer maxMemory;
if (autoMemory) {
@@ -725,10 +748,11 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(instanceId)
.setGameDir(gameDir)
.setJava(javaVersion)
.setVersionType(Metadata.TITLE)
.setVersionName(version)
.setVersionName(instanceId.id())
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
@@ -773,7 +797,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
builder.setQuickPlayOption(quickPlayOption);
}
Path json = getModpackConfiguration(version);
Path json = getModpackConfiguration(instanceId);
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
@@ -792,27 +816,27 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public Path getModpackConfiguration(String version) {
return getVersionRoot(version).resolve("modpack.cfg");
public Path getModpackConfiguration(GameInstanceID instanceId) {
return getInstanceRoot(instanceId).resolve("modpack.cfg");
}
public void markVersionAsModpack(String id) {
beingModpackVersions.add(id);
public void markInstanceAsModpack(GameInstanceID instanceId) {
beingModpackInstances.add(instanceId);
}
public void undoMark(String id) {
beingModpackVersions.remove(id);
public void undoMark(GameInstanceID instanceId) {
beingModpackInstances.remove(instanceId);
}
public void markVersionLaunchedAbnormally(String id) {
public void markInstanceLaunchedAbnormally(GameInstanceID instanceId) {
try {
Files.createFile(getVersionRoot(id).resolve(".abnormal"));
Files.createFile(getInstanceRoot(instanceId).resolve(".abnormal"));
} catch (IOException ignored) {
}
}
public boolean unmarkVersionLaunchedAbnormally(String id) {
Path file = getVersionRoot(id).resolve(".abnormal");
public boolean unmarkInstanceLaunchedAbnormally(GameInstanceID instanceId) {
Path file = getInstanceRoot(instanceId).resolve(".abnormal");
if (Files.isRegularFile(file)) {
try {
Files.delete(file);
@@ -829,35 +853,42 @@ public final class HMCLGameRepository extends DefaultGameRepository {
private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}";
// These version ids are forbidden because they may conflict with modpack configuration filenames
private static final Set<String> FORBIDDEN_VERSION_IDS = new HashSet<>(Arrays.asList(
"modpack", "minecraftinstance", "manifest"));
// These instance ids are forbidden because they may conflict with modpack configuration filenames
private static final Set<String> FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest");
public static boolean isValidVersionId(String id) {
if (FORBIDDEN_VERSION_IDS.contains(id))
public static boolean isValidInstanceId(String id) {
if (FORBIDDEN_INSTANCE_IDS.contains(id))
return false;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS &&
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
FORBIDDEN_INSTANCE_IDS.contains(id.toLowerCase(Locale.ROOT)))
return false;
return FileUtils.isNameValidForJar(id);
}
/**
* Returns true if the given version id conflicts with an existing version.
* Returns true if the given instance id conflicts with an existing instance.
*/
public boolean versionIdConflicts(String id) {
public boolean instanceIdConflicts(String instanceId) {
try {
return instanceIdConflicts(new GameInstanceID(instanceId));
} catch (IllegalArgumentException e) {
return false;
}
}
public boolean instanceIdConflicts(GameInstanceID id) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// on Windows, filenames are case-insensitive
for (String existingId : versions.keySet()) {
if (existingId.equalsIgnoreCase(id)) {
for (GameInstanceManifest manifest : getInstanceManifests()) {
if (manifest.id().toString().equalsIgnoreCase(id.toString())) {
return true;
}
}
return false;
} else {
return versions.containsKey(id);
return hasInstance(id);
}
}
@@ -37,29 +37,29 @@ import java.util.List;
public final class HMCLModpackInstallTask extends Task<Void> {
private final Path zipFile;
private final String name;
private final GameInstanceID instanceId;
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependency;
private final Modpack modpack;
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final List<Task<?>> dependents = new ArrayList<>(4);
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, String name) {
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, GameInstanceID instanceId) {
this.repository = repository;
this.dependency = repository.getDependency();
this.zipFile = zipFile;
this.name = name;
this.instanceId = instanceId;
this.modpack = modpack;
Path run = repository.getRunDirectory(name);
Path json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && Files.notExists(json))
throw new IllegalArgumentException("Version " + name + " already exists");
Path run = repository.getRunDirectory(this.instanceId);
Path json = repository.getModpackConfiguration(this.instanceId);
if (repository.hasInstance(this.instanceId) && Files.notExists(json))
throw new IllegalArgumentException("Instance " + instanceId + " already exists");
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
dependents.add(dependency.newGameBuilder().name(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync());
onDone().register(event -> {
if (event.isFailed()) repository.removeVersionFromDisk(name);
if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId);
});
ModpackConfiguration<Modpack> config = null;
@@ -68,12 +68,12 @@ public final class HMCLModpackInstallTask extends Task<Void> {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
}
} catch (JsonParseException | IOException ignore) {
}
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(this.instanceId)).withStage("hmcl.modpack"));
}
@Override
@@ -89,9 +89,9 @@ public final class HMCLModpackInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
GameInstanceManifest originalManifest = JsonUtils.GSON.fromJson(json, GameInstanceManifest.class).withId(instanceId).withJar(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalManifest, null);
Task<GameInstanceManifest> libraryTask = Task.supplyAsync(() -> originalManifest);
// reinstall libraries
// libraries of Forge and OptiFine should be obtained by installation.
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
@@ -42,12 +42,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) {
return null;
}
@Override
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -55,7 +55,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(repository, zipFile, modpack, name));
return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new HMCLModpackInstallTask(repository, zipFile, modpack, instanceId));
}
@Override
@@ -63,21 +63,21 @@ public final class HMCLModpackProvider implements ModpackProvider {
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
if (game.getJar() == null)
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
if (game.jar() == null)
if (StringUtils.isBlank(manifest.getVersion()))
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
else
manifest.setManifest(HMCLModpackManifest.INSTANCE);
else
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
return manifest;
}
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, name);
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
}
}
@@ -50,8 +50,10 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -84,7 +86,7 @@ public final class LauncherHelper {
private final HMCLGameRepository repository;
private Account account;
private final String selectedVersion;
private final GameInstanceID selectedInstanceId;
private Path scriptFile;
private final GameSettings.Effective setting;
private LauncherVisibility launcherVisibility;
@@ -92,14 +94,14 @@ public final class LauncherHelper {
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(HMCLGameRepository repository, Account account, String selectedVersion) {
public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) {
this.repository = Objects.requireNonNull(repository);
this.account = Objects.requireNonNull(account);
this.selectedVersion = Objects.requireNonNull(selectedVersion);
this.setting = repository.getEffectiveGameSettings(selectedVersion);
this.selectedInstanceId = selectedInstanceId;
this.setting = repository.getEffectiveGameSettings(selectedInstanceId);
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("version.launch"));
this.launchingStepsPane.setTitle(i18n("instance.launch"));
}
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
@@ -132,7 +134,7 @@ public final class LauncherHelper {
public void launch() {
FXUtils.checkFxUserThread();
LOG.info("Launching game version: " + selectedVersion);
LOG.info("Launching game version: " + selectedInstanceId);
Controllers.dialog(launchingStepsPane);
launch0();
@@ -148,9 +150,9 @@ public final class LauncherHelper {
PROCESSES.removeIf(it -> it.get() == null);
DefaultDependencyManager dependencyManager = repository.getDependency();
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
AtomicReference<GameInstanceManifest> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedInstanceManifest(selectedInstanceId).launchManifest()));
Optional<String> gameVersion = repository.getGameVersion(version.get());
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
boolean integrityCheck = repository.unmarkInstanceLaunchedAbnormally(selectedInstanceId);
CountDownLatch launchingLatch = new CountDownLatch(1);
List<String> javaAgents = new ArrayList<>(0);
List<String> javaArguments = new ArrayList<>(0);
@@ -167,10 +169,10 @@ public final class LauncherHelper {
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
Task.composeAsync(() -> {
try {
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedInstanceId));
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
if (provider == null) return null;
else return provider.createCompletionTask(dependencyManager, selectedVersion);
else return provider.createCompletionTask(dependencyManager, selectedInstanceId);
} catch (IOException e) {
return null;
}
@@ -202,7 +204,12 @@ public final class LauncherHelper {
})
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> {
if (gameVersion.isEmpty()) {
return null;
}
return new GameVerificationFixTask(dependencyManager, gameVersion.get(), version.get());
})
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
@@ -228,7 +235,7 @@ public final class LauncherHelper {
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
selectedInstanceId, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
@@ -236,9 +243,39 @@ public final class LauncherHelper {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(javaVersionRef.get().getBinary());
try {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object current = reg.queryValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg
);
if (current == null) {
boolean result = reg.setValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg,
"GpuPreference=2;"
);
if (result)
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
else
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
} else {
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
}
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
}
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedInstanceId), 10));
return new HMCLGameLauncher(
repository,
@@ -271,7 +308,7 @@ public final class LauncherHelper {
} else {
runLater(() -> {
launchingStepsPane.fireEvent(new DialogCloseEvent());
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
});
}
}).withFakeProgress(
@@ -310,7 +347,7 @@ public final class LauncherHelper {
} else if (ex instanceof NotDecompressingNativesException) {
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
} else if (ex instanceof LibraryDownloadException) {
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
int responseCode = rce.getResponseCode();
@@ -373,7 +410,7 @@ public final class LauncherHelper {
message = StringUtils.getStackTrace(ex);
}
Controllers.dialog(message,
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
MessageType.ERROR);
}
}
@@ -386,13 +423,13 @@ public final class LauncherHelper {
executor.start();
}
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, Version version) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, GameInstanceManifest manifest) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null));
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
try {
return setting.getJava(gameVersion, version);
return setting.getJava(gameVersion, manifest);
} catch (InterruptedException e) {
throw new CancellationException();
}
@@ -450,7 +487,7 @@ public final class LauncherHelper {
}
if (targetJavaVersion == null)
targetJavaVersion = version.getJavaVersion();
targetJavaVersion = manifest.javaVersion();
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
@@ -480,8 +517,8 @@ public final class LauncherHelper {
if (java != null) {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.isMandatory()) {
violatedMandatoryConstraints.add(constraint);
} else {
@@ -497,7 +534,7 @@ public final class LauncherHelper {
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
if (suggestedJava != null) {
FXUtils.runInFX(() -> {
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
@@ -524,7 +561,7 @@ public final class LauncherHelper {
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
gameJavaVersion = manifest.javaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
else
@@ -650,14 +687,14 @@ public final class LauncherHelper {
// Forge 2760~2773 will crash game with LiteLoader.
boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0);
boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
boolean hasLiteLoader = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
}
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0);
boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
boolean hasOptiFine = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
}
@@ -760,7 +797,10 @@ public final class LauncherHelper {
case HIDE_AND_REOPEN:
runLater(() -> {
Optional.ofNullable(Controllers.getStage())
.ifPresent(Stage::show);
.ifPresent(stage -> {
stage.setIconified(false);
stage.show();
});
});
break;
case KEEP:
@@ -798,7 +838,7 @@ public final class LauncherHelper {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final GameInstanceManifest manifest;
private final LaunchOptions launchOptions;
private ManagedProcess process;
private volatile boolean lwjgl;
@@ -810,9 +850,9 @@ public final class LauncherHelper {
private Thread submitLogThread;
private LinkedBlockingQueue<Log> logBuffer;
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
public HMCLProcessListener(HMCLGameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.repository = repository;
this.version = version;
this.manifest = manifest;
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
@@ -895,8 +935,10 @@ public final class LauncherHelper {
runLater(() -> {
// If application was stopped and execution services did not finish termination,
// these codes will be executed.
if (Controllers.getStage() != null) {
Controllers.getStage().hide();
@Nullable Stage stage = Controllers.getStage();
if (stage != null) {
stage.setIconified(false);
stage.hide();
launchingLatch.countDown();
}
});
@@ -995,8 +1037,8 @@ public final class LauncherHelper {
}
if (exitType != ExitType.NORMAL) {
repository.markVersionLaunchedAbnormally(version.getId());
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
repository.markInstanceLaunchedAbnormally(manifest.id());
runLater(() -> new GameCrashWindow(process, exitType, repository, manifest, launchOptions, logs).show());
}
checkExit();
@@ -20,7 +20,7 @@ package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.ui.versions.ModTranslations;
import org.jackhuang.hmcl.ui.instances.ModTranslations;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
@@ -123,13 +123,13 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public RemoteAddon getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
}
@Override
public RemoteAddon.File getModFile(String modId, String fileId) throws IOException {
return getBackedRemoteModRepository().getModFile(modId, fileId);
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
}
@Override
@@ -17,8 +17,7 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import kala.encdet.EncodingDetector;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -42,22 +41,22 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript,
PathMatcher logMatcher) {
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
Path runDirectory = repository.getRunDirectory(instanceId);
Path baseDirectory = repository.getBaseDirectory();
List<GameInstanceID> instances = new ArrayList<>();
String currentVersionId = versionId;
HashSet<String> resolvedSoFar = new HashSet<>();
GameInstanceID currentInstanceId = instanceId;
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
while (true) {
if (resolvedSoFar.contains(currentVersionId)) break;
resolvedSoFar.add(currentVersionId);
Version currentVersion = gameRepository.getVersion(currentVersionId);
versions.add(currentVersionId);
if (resolvedSoFar.contains(currentInstanceId)) break;
resolvedSoFar.add(currentInstanceId);
GameInstanceManifest currentVersion = repository.getInstanceManifest(currentInstanceId);
instances.add(currentInstanceId);
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
currentVersionId = currentVersion.getInheritsFrom();
if (currentVersion.inheritsFrom() != null) {
currentInstanceId = currentVersion.inheritsFrom();
} else {
break;
}
@@ -74,10 +73,10 @@ public final class LogExporter {
zipper.putTextFile(logs, "minecraft.log");
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
for (String id : versions) {
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
if (Files.exists(versionJson)) {
zipper.putFile(versionJson, id + ".json");
for (GameInstanceID id : instances) {
Path instanceJson = repository.getInstanceJson(id);
if (Files.exists(instanceJson)) {
zipper.putFile(instanceJson, id + ".json");
}
}
} catch (IOException e) {
@@ -91,7 +90,7 @@ public final class LogExporter {
for (Path file : stream) {
if (Files.isRegularFile(file)) {
if (logMatcher == null || logMatcher.matches(file)) {
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
try (BufferedReader reader = EncodingDetector.MODERN_WEB.newBufferedReader(file)) {
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
} catch (IOException e) {
LOG.warning("Failed to read log file: " + file, e);
@@ -156,13 +156,13 @@ public final class ModpackHelper {
}
}
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, String name, Modpack modpack) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
repository.markInstanceAsModpack(instanceId);
ExceptionalRunnable<?> success = () -> {
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
repository.refresh();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.undoMark(instanceId);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
@@ -175,7 +175,7 @@ public final class ModpackHelper {
}
};
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name)
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
@@ -200,13 +200,13 @@ public final class ModpackHelper {
});
}
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, String name, Modpack modpack, String iconUrl) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, String iconUrl) {
repository.markInstanceAsModpack(instanceId);
ExceptionalRunnable<?> success = () -> {
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
repository.refresh();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.undoMark(instanceId);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
@@ -220,45 +220,45 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), name))
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
switch (configuration.getType()) {
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(repository, name, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name))
.thenComposeAsync(repository.refreshVersionsAsync())
return new ModpackUpdateTask(repository, instanceId, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId))
.thenComposeAsync(repository.refreshAsync())
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
default:
throw new UnsupportedModpackException();
}
}
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
ModpackProvider provider = getProviderByType(configuration.getType());
if (provider == null) {
throw new UnsupportedModpackException();
}
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(repository.refreshVersionsAsync());
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.thenComposeAsync(repository.refreshAsync());
else
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(repository.refreshVersionsAsync());
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
.thenComposeAsync(repository.refreshAsync());
}
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
@@ -333,25 +333,25 @@ public final class ModpackHelper {
}
}
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
});
}
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.toGameSettings(manifest, setting);
});
}
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, String version) {
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Effective effective = repository.getEffectiveGameSettings(version);
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY,
@@ -29,7 +29,7 @@ import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.game.JavaVersionConstraint;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -315,13 +315,13 @@ public final class JavaManager {
}
@Nullable
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, version);
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, manifest);
}
@Nullable
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
LibraryAnalyzer analyzer = manifest != null ? LibraryAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null;
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -342,8 +342,8 @@ public final class JavaManager {
boolean violationSuggested = false;
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.isMandatory()) {
violationMandatory = true;
} else {
@@ -34,8 +34,6 @@ import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
/// Persistent configuration for a game directory.
@JsonAdapter(GameDirectory.Serializer.class)
@NotNullByDefault
@@ -118,7 +116,7 @@ public final class GameDirectory implements Observable {
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
addPropertyChangedListener(onInvalidating(this::invalidate));
addPropertyChangedListener(arg -> invalidate());
}
/// Returns a debug string containing the game directory path and display metadata.
@@ -23,7 +23,8 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -141,10 +142,10 @@ public final class GameDirectoryManager {
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
/// The selected instance ID projected from the selected repository.
private static final ReadOnlyStringWrapper selectedInstance = new ReadOnlyStringWrapper(GameDirectoryManager.class, "selectedInstance");
private static final ReadOnlyObjectWrapper<GameInstanceID> selectedInstance = new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
private static final ChangeListener<String> selectedRepositoryInstanceListener =
private static final ChangeListener<GameInstanceID> selectedRepositoryInstanceListener =
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
/// Initializes game directory state from the stores loaded by [SettingsManager].
@@ -206,11 +207,11 @@ public final class GameDirectoryManager {
selectedRepository.set(repository);
selectedInstance.set(repository.getSelectedInstance());
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
repository.refreshVersionsAsync().start();
repository.refreshAsync().start();
});
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> {
runInFX(() -> {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository != null && repository == event.getSource()) {
@@ -479,17 +480,17 @@ public final class GameDirectoryManager {
}
/// Returns the selected instance property projected from the selected repository.
public static ReadOnlyStringProperty selectedInstanceProperty() {
public static ReadOnlyObjectProperty<@Nullable GameInstanceID> selectedInstanceProperty() {
return selectedInstance.getReadOnlyProperty();
}
/// Returns the selected instance ID for the selected repository.
public static @Nullable String getSelectedInstance() {
public static @Nullable GameInstanceID getSelectedInstance() {
return getSelectedRepository().getSelectedInstance();
}
/// Sets the selected instance ID for the selected repository.
public static void setSelectedInstance(@Nullable String instance) {
public static void setSelectedInstance(@Nullable GameInstanceID instance) {
getSelectedRepository().setSelectedInstance(instance);
}
@@ -21,7 +21,7 @@ import javafx.scene.image.Image;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.ui.FXUtils;
public enum VersionIconType {
public enum GameInstanceIconType {
DEFAULT("/assets/img/grass.png"),
GRASS("/assets/img/grass.png"),
@@ -42,21 +42,21 @@ public enum VersionIconType {
// Please append new items at last
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) {
return switch (modLoaderType) {
case FORGE -> VersionIconType.FORGE;
case NEO_FORGE -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.FABRIC;
case QUILT -> VersionIconType.QUILT;
case LITE_LOADER -> VersionIconType.CHICKEN;
case CLEANROOM -> VersionIconType.CLEANROOM;
default -> VersionIconType.COMMAND;
case FORGE -> GameInstanceIconType.FORGE;
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
case FABRIC -> GameInstanceIconType.FABRIC;
case QUILT -> GameInstanceIconType.QUILT;
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
default -> GameInstanceIconType.COMMAND;
};
}
private final String resourceUrl;
VersionIconType(String resourceUrl) {
GameInstanceIconType(String resourceUrl) {
this.resourceUrl = resourceUrl;
}
@@ -152,10 +152,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The icon of the instance.
@SerializedName("icon")
private final SettingProperty<VersionIconType> icon = newSettingProperty("icon", VersionIconType.DEFAULT);
private final SettingProperty<GameInstanceIconType> icon = newSettingProperty("icon", GameInstanceIconType.DEFAULT);
/// Returns the instance icon property.
public SettingProperty<VersionIconType> iconProperty() {
public SettingProperty<GameInstanceIconType> iconProperty() {
return icon;
}
@@ -643,6 +643,19 @@ public sealed abstract class GameSettings extends ObservableSetting {
return vulkanRenderer;
}
/// Property name for high performance GPU preference.
public static final String PROPERTY_HIGH_PERFORMANCE = "highPerformance";
/// Whether to launch games with high-performance GPU.
@SerializedName(PROPERTY_HIGH_PERFORMANCE)
private final InheritableProperty<Boolean> highPerformance =
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, false);
/// Returns the high performance GPU preference property.
public InheritableProperty<Boolean> highPerformanceProperty() {
return highPerformance;
}
/// Property name for customized environment variables.
public static final String PROPERTY_ENVIRONMENT_VARIABLES = "environmentVariables";
@@ -916,11 +929,11 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Finds the effective Java runtime.
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable Version version) throws InterruptedException {
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable GameInstanceManifest manifest) throws InterruptedException {
JavaVersionType javaVersionType = getInheritable(GameSettings::javaTypeProperty);
switch (javaVersionType) {
case AUTO:
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
case CUSTOM:
try {
return JavaManager.getJava(Path.of(getInheritable(GameSettings::customJavaPathProperty)));
@@ -930,7 +943,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
case VERSION: {
String javaVersion = getInheritable(GameSettings::customJavaVersionProperty);
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
}
int majorVersion = -1;
@@ -948,13 +961,13 @@ public sealed abstract class GameSettings extends ObservableSetting {
Collection<JavaRuntime> allJava = JavaManager.getAllJava().stream()
.filter(it -> it.getParsedVersion() == finalMajorVersion)
.collect(Collectors.toList());
return JavaManager.findSuitableJava(allJava, gameVersion, version);
return JavaManager.findSuitableJava(allJava, gameVersion, manifest);
}
case DETECTED: {
DetectedJava detectedJava = getInheritable(GameSettings::detectedJavaProperty);
String javaVersion = detectedJava.version();
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
}
if (StringUtils.isNotBlank(detectedJava.pathHash())) {
@@ -156,7 +156,14 @@ final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
///
/// @param value the settings object to observe
void installAutoSave(T value) {
value.addListener(source -> save(value));
value.addListener(observable -> {
if (value.shouldSaveImmediately(observable)) {
save(value);
value.setSavePending(false);
} else {
value.setSavePending(true);
}
});
}
/// Saves a settings object.
@@ -34,6 +34,7 @@ import org.hildan.fxgson.creators.ObservableSetCreator;
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AccountID;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
@@ -480,17 +481,19 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// Whether UI animations are disabled.
@SerializedName("animationDisabled")
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
FXUtils.REDUCED_MOTION == Boolean.TRUE
|| !JavaRuntime.CURRENT_JIT_ENABLED
|| !FXUtils.GPU_ACCELERATION_ENABLED
);
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
/// Returns the UI animation disable property.
public BooleanProperty animationDisabledProperty() {
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
return animationDisabled;
}
public boolean isAnimationDisabled() {
// Avoid accessing FXUtils too early during startup
return Objects.requireNonNullElseGet(animationDisabled.get(), () ->
FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED);
}
// Networks
/// Whether HMCL automatically selects the number of download threads.
@@ -621,34 +624,34 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
///
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
@SerializedName(PROPERTY_SELECTED_INSTANCE)
private final ObservableMap<GameDirectoryID, String> selectedInstance = FXCollections.observableHashMap();
private final ObservableMap<GameDirectoryID, GameInstanceID> selectedInstance = FXCollections.observableHashMap();
/// Returns selected instance IDs keyed by game directory ID.
///
/// The map stores persisted selected instance values by game directory ID.
public ObservableMap<GameDirectoryID, String> getSelectedInstance() {
public ObservableMap<GameDirectoryID, GameInstanceID> getSelectedInstance() {
return selectedInstance;
}
/// Returns the selected instance ID for the given game directory ID.
///
/// The value is loaded by the game repository for the matching game directory.
public @Nullable String getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
public @Nullable GameInstanceID getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null;
}
/// Sets the selected instance ID for the given game directory ID.
///
/// Blank values remove the persisted selected instance entry.
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable String selectedInstance) {
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable GameInstanceID selectedInstance) {
if (gameDirectoryId == null) {
return;
}
if (StringUtils.isBlank(selectedInstance)) {
this.selectedInstance.remove(gameDirectoryId);
} else {
if (selectedInstance != null) {
this.selectedInstance.put(gameDirectoryId, selectedInstance);
} else {
this.selectedInstance.remove(gameDirectoryId);
}
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
@@ -54,6 +55,15 @@ public final class LauncherState extends ObservableSetting implements JsonSchema
register();
}
/// Returns whether the changed field should trigger automatic persistence immediately.
@Override
public boolean shouldSaveImmediately(Observable observable) {
return observable != x
&& observable != y
&& observable != width
&& observable != height;
}
/// The schema used by this launcher state file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
@@ -73,22 +73,22 @@ public final class LegacyGameSettingsMigrator {
}
/// Legacy `VersionIconType` ordinal order used by old local settings.
private static final VersionIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
VersionIconType.DEFAULT,
VersionIconType.GRASS,
VersionIconType.CHEST,
VersionIconType.CHICKEN,
VersionIconType.COMMAND,
VersionIconType.OPTIFINE,
VersionIconType.CRAFT_TABLE,
VersionIconType.FABRIC,
VersionIconType.FORGE,
VersionIconType.NEO_FORGE,
VersionIconType.FURNACE,
VersionIconType.QUILT,
VersionIconType.APRIL_FOOLS,
VersionIconType.CLEANROOM,
VersionIconType.LEGACY_FABRIC
private static final GameInstanceIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
GameInstanceIconType.DEFAULT,
GameInstanceIconType.GRASS,
GameInstanceIconType.CHEST,
GameInstanceIconType.CHICKEN,
GameInstanceIconType.COMMAND,
GameInstanceIconType.OPTIFINE,
GameInstanceIconType.CRAFT_TABLE,
GameInstanceIconType.FABRIC,
GameInstanceIconType.FORGE,
GameInstanceIconType.NEO_FORGE,
GameInstanceIconType.FURNACE,
GameInstanceIconType.QUILT,
GameInstanceIconType.APRIL_FOOLS,
GameInstanceIconType.CLEANROOM,
GameInstanceIconType.LEGACY_FABRIC
};
/// Legacy `JavaVersionType` ordinal order used by old game settings.
@@ -130,9 +130,9 @@ public final class LegacyGameSettingsMigrator {
/// @return the migrated instance setting, or `null` when no legacy file can be migrated
public static @Nullable InstanceMigrationResult migrateInstanceGameSettings(
HMCLGameRepository repository,
String instanceId,
GameInstanceID instanceId,
@Nullable GameSettingsPresetID parent) {
Path instanceRoot = repository.getVersionRoot(instanceId);
Path instanceRoot = repository.getInstanceRoot(instanceId);
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
if (!Files.exists(file)) {
return null;
@@ -488,10 +488,10 @@ public final class LegacyGameSettingsMigrator {
}
/// Parses the legacy icon selection with frozen ordinal order.
private static VersionIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
if (primitive == null) {
return VersionIconType.DEFAULT;
return GameInstanceIconType.DEFAULT;
}
try {
@@ -499,11 +499,11 @@ public final class LegacyGameSettingsMigrator {
int index = primitive.getAsInt();
return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length
? LEGACY_VERSION_ICON_TYPES[index]
: VersionIconType.DEFAULT;
: GameInstanceIconType.DEFAULT;
}
String value = primitive.getAsString();
for (VersionIconType iconType : LEGACY_VERSION_ICON_TYPES) {
for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) {
if (iconType.name().equalsIgnoreCase(value)) {
return iconType;
}
@@ -511,7 +511,7 @@ public final class LegacyGameSettingsMigrator {
} catch (RuntimeException ignored) {
}
return VersionIconType.DEFAULT;
return GameInstanceIconType.DEFAULT;
}
/// Reads an enum property from either ordinal or name.
@@ -55,6 +55,20 @@ public final class SettingsManager {
private SettingsManager() {
}
/// Saves deferred launcher state changes and shutdown file saver.
public static void shutdown() {
savePendingChanges();
FileSaver.shutdown();
}
/// Saves deferred launcher state changes.
public static void savePendingChanges() {
if (launcherState != null && launcherState.isSavable() && launcherState.isSavePending()) {
launcherState.setSavePending(false);
STATE_FILE.save(launcherState);
}
}
/// The local directory storing per-workspace configuration files.
private static final Path LOCAL_CONFIG_FILES_DIRECTORY = Metadata.HMCL_LOCAL_HOME.resolve("config");
@@ -28,19 +28,12 @@ import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.ResolvedTheme;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
@@ -67,23 +60,8 @@ public final class StyleSheets {
});
}
private static String toStyleSheetUri(String styleSheet, String fallback) {
if (FXUtils.JAVAFX_MAJOR_VERSION >= 17)
// JavaFX 17+ support loading stylesheets from data URIs
// https://bugs.openjdk.org/browse/JDK-8267554
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
else
try {
Path temp = Files.createTempFile("hmcl", ".css");
// For JavaFX 17 or earlier, CssParser uses the default charset
// https://bugs.openjdk.org/browse/JDK-8279328
Files.writeString(temp, styleSheet, Charset.defaultCharset());
temp.toFile().deleteOnExit();
return temp.toUri().toString();
} catch (IOException | NullPointerException e) {
LOG.error("Unable to create stylesheet, fallback to " + fallback, e);
return fallback;
}
private static String toStyleSheetUri(String styleSheet) {
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
}
private static String getFontStyleSheet() {
@@ -133,7 +111,7 @@ public final class StyleSheets {
builder.append('}');
return toStyleSheetUri(builder.toString(), defaultCss);
return toStyleSheetUri(builder.toString());
}
private static String getBrightnessStyleSheet() {
@@ -184,7 +162,7 @@ public final class StyleSheets {
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
builder.append("}\n");
return toStyleSheetUri(builder.toString(), blueCss);
return toStyleSheetUri(builder.toString());
}
public static void init(Scene scene) {
@@ -24,21 +24,12 @@ import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.Label;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
@@ -49,19 +40,18 @@ import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.account.AccountListPage;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.DecoratorController;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
import org.jackhuang.hmcl.ui.versions.GameListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.ui.instances.GameListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -73,8 +63,10 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
@@ -90,26 +82,11 @@ public final class Controllers {
public static final String SOFTWARE_RENDERING = "softwareRendering";
public static final String APRIL_FOOLS = "aprilFools";
private static final int CUSTOM_DECORATION_SHADOW_SIZE = 8;
private static final int CUSTOM_DECORATION_SHADOW_EXTENT = CUSTOM_DECORATION_SHADOW_SIZE * 2;
public static final int MIN_CONTENT_WIDTH = 800 + 2; // bg width + border width*2
public static final int MIN_CONTENT_HEIGHT = 450 + 2 + 40; // bg height + border width*2 + toolbar height
public static final int MIN_WIDTH = MIN_CONTENT_WIDTH + CUSTOM_DECORATION_SHADOW_EXTENT;
public static final int MIN_HEIGHT = MIN_CONTENT_HEIGHT + CUSTOM_DECORATION_SHADOW_EXTENT;
public static final Screen SCREEN = Screen.getPrimary();
private static InvalidationListener stageSizeChangeListener;
private static DoubleProperty stageX = new SimpleDoubleProperty();
private static DoubleProperty stageY = new SimpleDoubleProperty();
private static DoubleProperty stageWidth = new SimpleDoubleProperty();
private static DoubleProperty stageHeight = new SimpleDoubleProperty();
private static Scene scene;
private static Stage stage;
private static VersionPage versionPage;
private static GameInstancePage gameInstancePage;
private static Lazy<GameListPage> gameListPage = new Lazy<>(GameListPage::new);
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
private static DecoratorController decorator;
/// The coordinator for the main window's scene graph and navigation stack.
private static @Nullable Decorator decorator;
private static DownloadPage downloadPage;
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
AccountListPage accountListPage = new AccountListPage();
@@ -133,27 +110,27 @@ public final class Controllers {
void run() throws Exception;
}
public static Scene getScene() {
return scene;
}
public static Stage getStage() {
return stage;
/// Returns the stage currently attached to the main-window decorator.
///
/// @return the current stage, or `null` before initialization or after detachment
public static @Nullable Stage getStage() {
@Nullable Decorator currentDecorator = decorator;
return currentDecorator == null ? null : currentDecorator.getStage();
}
@FXThread
public static VersionPage getVersionPage() {
if (versionPage == null) {
versionPage = new VersionPage();
public static GameInstancePage getGameInstancePage() {
if (gameInstancePage == null) {
gameInstancePage = new GameInstancePage();
}
return versionPage;
return gameInstancePage;
}
@FXThread
public static void prepareVersionPage() {
if (versionPage == null) {
LOG.info("Prepare the version page");
versionPage = FXUtils.prepareNode(new VersionPage());
public static void prepareGameInstancePage() {
if (gameInstancePage == null) {
LOG.info("Prepare the game instance page");
gameInstancePage = FXUtils.prepareNode(new GameInstancePage());
}
}
@@ -209,71 +186,24 @@ public final class Controllers {
return terracottaPage.get();
}
/// Returns the initialized main-window decorator.
///
/// @return the application-wide main-window decorator
@FXThread
public static DecoratorController getDecorator() {
return decorator;
}
public static void saveWindowStates() {
saveWindowBounds();
}
private static void saveWindowBounds() {
if (stageX != null) {
state().setX(toContentX(stageX.get()) / SCREEN.getBounds().getWidth());
}
if (stageY != null) {
state().setY(toContentY(stageY.get()) / SCREEN.getBounds().getHeight());
}
if (stageHeight != null) {
state().setHeight(toContentHeight(stageHeight.get()));
}
if (stageWidth != null) {
state().setWidth(toContentWidth(stageWidth.get()));
}
public static Decorator getDecorator() {
return Objects.requireNonNull(decorator, "Main window is not initialized");
}
/// Releases stage-specific listeners and retained window ownership before application shutdown.
public static void onApplicationStop() {
stageSizeChangeListener = null;
saveWindowBounds();
stageX = null;
stageY = null;
stageHeight = null;
stageWidth = null;
}
private static double toContentX(double stageX) {
return stageX + CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toContentY(double stageY) {
return stageY + CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toStageX(double contentX) {
return contentX - CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toStageY(double contentY) {
return contentY - CUSTOM_DECORATION_SHADOW_SIZE;
}
private static double toContentWidth(double stageWidth) {
return Math.max(0.0, stageWidth - CUSTOM_DECORATION_SHADOW_EXTENT);
}
private static double toContentHeight(double stageHeight) {
return Math.max(0.0, stageHeight - CUSTOM_DECORATION_SHADOW_EXTENT);
}
private static double toStageWidth(double contentWidth) {
return contentWidth + CUSTOM_DECORATION_SHADOW_EXTENT;
}
private static double toStageHeight(double contentHeight) {
return contentHeight + CUSTOM_DECORATION_SHADOW_EXTENT;
if (decorator != null) {
decorator.detachStage();
}
}
/// Initializes the main application stage, scene graph, and background services.
///
/// @param stage the primary application stage, which must not have been shown
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
@@ -285,102 +215,16 @@ public final class Controllers {
LOG.info("Enable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "true");
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) {
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Screen.getPrimary().getOutputScaleX() > 1) {
LOG.info("Disable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "false");
}
}
Controllers.stage = stage;
stageSizeChangeListener = o -> {
ReadOnlyDoubleProperty sourceProperty = (ReadOnlyDoubleProperty) o;
DoubleProperty targetProperty;
switch (sourceProperty.getName()) {
case "x": {
targetProperty = stageX;
break;
}
case "y": {
targetProperty = stageY;
break;
}
case "width": {
targetProperty = stageWidth;
break;
}
case "height": {
targetProperty = stageHeight;
break;
}
default: {
targetProperty = null;
}
}
if (targetProperty != null
&& Controllers.stage != null
&& !Controllers.stage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ||
!Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized())
) {
targetProperty.set(sourceProperty.get());
}
};
WeakInvalidationListener weakListener = new WeakInvalidationListener(stageSizeChangeListener);
double initContentWidth = Math.max(MIN_CONTENT_WIDTH, state().getWidth());
double initContentHeight = Math.max(MIN_CONTENT_HEIGHT, state().getHeight());
double initWidth = toStageWidth(initContentWidth);
double initHeight = toStageHeight(initContentHeight);
{
double initContentX = state().getX() * SCREEN.getBounds().getWidth();
double initContentY = state().getY() * SCREEN.getBounds().getHeight();
boolean invalid = true;
double border = 20D;
for (Screen screen : Screen.getScreens()) {
Rectangle2D bound = screen.getBounds();
if (bound.getMinX() + border <= initContentX + initContentWidth
&& initContentX <= bound.getMaxX() - border
&& bound.getMinY() + border <= initContentY
&& initContentY <= bound.getMaxY() - border) {
invalid = false;
break;
}
}
if (invalid) {
initContentX = (0.5D - initContentWidth / SCREEN.getBounds().getWidth() / 2)
* SCREEN.getBounds().getWidth();
initContentY = (0.5D - initContentHeight / SCREEN.getBounds().getHeight() / 2)
* SCREEN.getBounds().getHeight();
}
double initX = toStageX(initContentX);
double initY = toStageY(initContentY);
stage.setX(initX);
stage.setY(initY);
stageX.set(initX);
stageY.set(initY);
}
stage.setHeight(initHeight);
stage.setWidth(initWidth);
stageHeight.set(initHeight);
stageWidth.set(initWidth);
stage.xProperty().addListener(weakListener);
stage.yProperty().addListener(weakListener);
stage.heightProperty().addListener(weakListener);
stage.widthProperty().addListener(weakListener);
stage.setOnCloseRequest(e -> Launcher.stopApplication());
decorator = new DecoratorController(stage, getRootPage());
decorator = new Decorator(getRootPage());
Scene mainScene = decorator.attachStage(stage);
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
getRootPage().getMainPage().showUpdateDialogProperty().bind(
decorator.backableProperty().not()
@@ -396,36 +240,10 @@ public final class Controllers {
Lang.thread(JavaManager::initialize, "Search Java", true);
scene = new Scene(decorator.getDecorator());
scene.setFill(Color.TRANSPARENT);
stage.setMinWidth(MIN_WIDTH);
stage.setMinHeight(MIN_HEIGHT);
decorator.getDecorator().prefWidthProperty().bind(scene.widthProperty());
decorator.getDecorator().prefHeightProperty().bind(scene.heightProperty());
StyleSheets.init(scene);
StyleSheets.init(mainScene);
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
stage.initStyle(StageStyle.TRANSPARENT);
stage.setScene(scene);
if (AnimationUtils.playWindowAnimation()) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.millis(0),
new KeyValue(decorator.getDecorator().opacityProperty(), 0, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleXProperty(), 0.8, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleYProperty(), 0.8, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleZProperty(), 0.8, Motion.EASE)
),
new KeyFrame(Duration.millis(600),
new KeyValue(decorator.getDecorator().opacityProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleXProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleYProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleZProperty(), 1, Motion.EASE)
)
);
timeline.play();
}
if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) {
Runnable continueAction = () -> {
@@ -585,7 +403,7 @@ public final class Controllers {
/// Shows a warning that confirms backing up a read-only settings file before overwriting it.
///
/// @param text the file-specific read-only warning
/// @param text the file-specific read-only warning
/// @param overwrite the action that backs up and overwrites the file
public static void confirmBackupAndOverwrite(String text, ThrowingRunnable overwrite) {
dialog(new MessageDialogPane.Builder(
@@ -694,6 +512,38 @@ public final class Controllers {
decorator.showToast(content);
}
/// Shows `directoryChooser` with the current main window as its owner.
///
/// @param directoryChooser the chooser to show
/// @return the selected directory, or `null` if the chooser is cancelled
public static @Nullable Path showDialog(DirectoryChooser directoryChooser) {
return FileUtils.toPath(directoryChooser.showDialog(getStage()));
}
/// Shows `fileChooser` for opening one file with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected file, or `null` if the chooser is cancelled
public static @Nullable Path showOpenDialog(FileChooser fileChooser) {
return FileUtils.toPath(fileChooser.showOpenDialog(getStage()));
}
/// Shows `fileChooser` for saving one file with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected file, or `null` if the chooser is cancelled
public static @Nullable Path showSaveDialog(FileChooser fileChooser) {
return FileUtils.toPath(fileChooser.showSaveDialog(getStage()));
}
/// Shows `fileChooser` for opening multiple files with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected files, or `null` if the chooser is cancelled
public static @Nullable List<Path> showOpenMultipleDialog(FileChooser fileChooser) {
return FileUtils.toPaths(fileChooser.showOpenMultipleDialog(getStage()));
}
public static void onHyperlinkAction(String href) {
if (href.startsWith("hmcl://")) {
switch (href) {
@@ -703,7 +553,7 @@ public final class Controllers {
break;
case "hmcl://game/launch":
var repository = GameDirectoryManager.getSelectedRepository();
Versions.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep);
Instances.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep);
break;
}
} else {
@@ -715,18 +565,17 @@ public final class Controllers {
return decorator == null;
}
/// Releases controller-owned pages, window state, and JavaFX helper resources.
public static void shutdown() {
onApplicationStop();
rootPage = null;
versionPage = null;
gameInstancePage = null;
gameListPage = null;
downloadPage = null;
accountListPage = null;
settingsPage = null;
terracottaPage = null;
decorator = null;
stage = null;
scene = null;
onApplicationStop();
FXUtils.shutdown();
}
@@ -49,17 +49,31 @@ public final class DialogUtils {
public static final String PROPERTY_DIALOG_SHOW_LATER = DialogUtils.class.getName() + ".dialog.showLater";
/// Shows content in the main-window dialog stack owned by `decorator`.
///
/// @param decorator the main-window decorator
/// @param content the dialog content
public static void show(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> show(decorator, content));
StackPane dialogContainer = decorator.getDialogContainer();
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showInDecorator(decorator, dialogContainer, content));
return;
}
show(decorator.getDrawerWrapper(), content, (dialog) -> {
showInDecorator(decorator, dialogContainer, content);
}
/// Shows content in a decorator's resolved dialog container.
///
/// @param decorator the main-window decorator
/// @param dialogContainer the container resolved before any deferred execution
/// @param content the dialog content
private static void showInDecorator(Decorator decorator, StackPane dialogContainer, Node content) {
show(dialogContainer, content, dialog -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingWindow(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(decorator.getDrawerWrapper());
dialog.setDialogContainer(dialogContainer);
});
}
@@ -125,12 +139,18 @@ public final class DialogUtils {
}
}
/// Queues content in the main-window dialog stack owned by `decorator`.
///
/// @param decorator the main-window decorator
/// @param content the dialog content
public static void showLater(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> showLater(decorator, content));
StackPane dialogContainer = decorator.getDialogContainer();
Runnable showDialogAction = () -> showInDecorator(decorator, dialogContainer, content);
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showLater(dialogContainer, showDialogAction));
return;
}
showLater(decorator.getDrawerWrapper(), () -> show(decorator, content));
showLater(dialogContainer, showDialogAction);
}
@SuppressWarnings("unchecked")
@@ -33,6 +33,7 @@ import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import kala.encdet.EncodingDetector;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.*;
@@ -48,7 +49,6 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
@@ -73,7 +73,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameCrashWindow extends Stage {
private final Version version;
private final GameInstanceManifest manifest;
private final String memory;
private final String total_memory;
private final String java;
@@ -91,16 +91,16 @@ public class GameCrashWindow extends Stage {
private final List<Log> logs;
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List<Log> logs) {
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, GameInstanceManifest manifest, LaunchOptions launchOptions, List<Log> logs) {
Themes.applyNativeDarkMode(this);
this.managedProcess = managedProcess;
this.exitType = exitType;
this.repository = repository;
this.version = version;
this.manifest = manifest;
this.launchOptions = launchOptions;
this.logs = logs;
this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null));
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-");
@@ -142,14 +142,14 @@ public class GameCrashWindow extends Stage {
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
}), Task.supplyAsync(() -> {
Path latestLog = repository.getRunDirectory(version.getId()).resolve("logs/latest.log");
Path latestLog = repository.getRunDirectory(manifest.id()).resolve("logs/latest.log");
if (!Files.isReadable(latestLog)) {
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
}
String log;
try {
log = FileUtils.readTextMaybeNativeEncoding(latestLog);
log = EncodingDetector.MODERN_WEB.readString(latestLog);
} catch (IOException e) {
LOG.warning("Failed to read logs/latest.log", e);
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
@@ -291,7 +291,7 @@ public class GameCrashWindow extends Stage {
}
});
return LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs,
return LogExporter.exportLogs(logFile, repository, launchOptions.getInstanceId(), logs,
new CommandBuilder().addAll(managedProcess.getCommands()).toString(),
path -> {
try {
@@ -345,7 +345,7 @@ public class GameCrashWindow extends Stage {
TwoLineListItem version = new TwoLineListItem();
version.getStyleClass().setAll("two-line-item-second-large");
version.setTitle(i18n("game.version"));
version.setSubtitle(GameCrashWindow.this.version.getId());
version.setSubtitle(GameCrashWindow.this.manifest.id().toString());
TwoLineListItem total_memory = new TwoLineListItem();
total_memory.getStyleClass().setAll("two-line-item-second-large");
@@ -35,7 +35,7 @@ import javafx.scene.control.SkinBase;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -54,7 +54,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
*/
public class InstallerItem extends Control {
private final String id;
private final VersionIconType iconType;
private final GameInstanceIconType iconType;
private final Style style;
private final ObjectProperty<InstalledState> versionProperty = new SimpleObjectProperty<>(this, "version", null);
private final ObjectProperty<State> resolvedStateProperty = new SimpleObjectProperty<>(this, "resolvedState", InstallableState.INSTANCE);
@@ -92,15 +92,15 @@ public class InstallerItem extends Control {
this.style = style;
iconType = switch (id) {
case "game" -> VersionIconType.GRASS;
case "fabric", "fabric-api" -> VersionIconType.FABRIC;
case "legacyfabric", "legacyfabric-api" -> VersionIconType.LEGACY_FABRIC;
case "forge" -> VersionIconType.FORGE;
case "cleanroom" -> VersionIconType.CLEANROOM;
case "liteloader" -> VersionIconType.CHICKEN;
case "optifine" -> VersionIconType.OPTIFINE;
case "quilt", "quilt-api" -> VersionIconType.QUILT;
case "neoforge" -> VersionIconType.NEO_FORGE;
case "game" -> GameInstanceIconType.GRASS;
case "fabric", "fabric-api" -> GameInstanceIconType.FABRIC;
case "legacyfabric", "legacyfabric-api" -> GameInstanceIconType.LEGACY_FABRIC;
case "forge" -> GameInstanceIconType.FORGE;
case "cleanroom" -> GameInstanceIconType.CLEANROOM;
case "liteloader" -> GameInstanceIconType.CHICKEN;
case "optifine" -> GameInstanceIconType.OPTIFINE;
case "quilt", "quilt-api" -> GameInstanceIconType.QUILT;
case "neoforge" -> GameInstanceIconType.NEO_FORGE;
default -> null;
};
}
@@ -164,6 +164,7 @@ public final class LogWindow extends Stage {
private final ListView<Log> listView = new JFXListView<>();
private final BooleanProperty autoScroll = new SimpleBooleanProperty();
private final BooleanProperty wrapText = new SimpleBooleanProperty(true);
private final StringProperty[] buttonText = new StringProperty[LEVELS.length];
private final BooleanProperty[] showLevel = new BooleanProperty[LEVELS.length];
private final JFXButton btnAlwaysOnTop = FXUtils.newToggleButton4(SVG.KEEP, 20);
@@ -174,7 +175,6 @@ public final class LogWindow extends Stage {
LogWindowImpl() {
getStyleClass().add("log-window");
listView.getStyleClass().add("no-horizontal-scrollbar");
listView.getProperties().put("no-smooth-scrolling", true);
listView.setItems(FXCollections.observableList(new CircularArrayList<>(logs.size())));
@@ -331,11 +331,26 @@ public final class LogWindow extends Stage {
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
maxWidthProperty().bind(clippedContainer.widthProperty());
prefWidthProperty().bind(clippedContainer.widthProperty());
wrapTextProperty().addListener((obs, oldWrap, nowWrap) -> {
if (nowWrap) {
maxWidthProperty().bind(clippedContainer.widthProperty());
prefWidthProperty().bind(clippedContainer.widthProperty());
listView.getStyleClass().add("no-horizontal-scrollbar");
} else {
maxWidthProperty().unbind();
prefWidthProperty().unbind();
setMaxWidth(Region.USE_PREF_SIZE);
setPrefWidth(Region.USE_COMPUTED_SIZE);
listView.getStyleClass().remove("no-horizontal-scrollbar");
}
// only for horizontal scrollbar
listView.requestLayout();
});
}
setPadding(new Insets(2));
setWrapText(true);
wrapTextProperty().bind(control.wrapText);
setGraphic(null);
}
@@ -395,6 +410,10 @@ public final class LogWindow extends Stage {
autoScrollCheckBox.setSelected(true);
control.autoScroll.bind(autoScrollCheckBox.selectedProperty());
JFXCheckBox wrapTextCheckBox = new JFXCheckBox(i18n("logwindow.wrap_text"));
wrapTextCheckBox.setSelected(true);
control.wrapText.bind(wrapTextCheckBox.selectedProperty());
JFXButton exportLogsButton = new JFXButton(i18n("button.export"));
exportLogsButton.setOnAction(e -> getSkinnable().onExportLogs());
@@ -414,7 +433,7 @@ public final class LogWindow extends Stage {
JFXButton clearButton = new JFXButton(i18n("button.clear"));
clearButton.setOnAction(e -> getSkinnable().onClear());
hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
hBox.getChildren().setAll(autoScrollCheckBox, wrapTextCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
control.getGameProcess().getProcess()
.onExit()
@@ -66,6 +66,7 @@ public enum SVG {
FILE_OPEN("M6 22q-0.825 0-1.4125-0.5875T4 20v-16q0-0.825 0.5875-1.4125T6 2h8l6 6v6h-2v-5H13v-5H6v16h9v2H6Zm15.95 0.375L19 19.425v2.225h-2v-5.65h5.65v2h-2.25l2.95 2.95-1.4 1.425Zm-15.95-2.375v-16 16Z"),
FOLDER("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8V18Q22 18.825 21.4125 19.4125T20 20H4ZM4 18H20V8H11.175L9.175 6H4V18ZM4 18V6 18Z"),
FOLDER_COPY("M3 21Q2.175 21 1.5875 20.4125T1 19V6H3V19H20V21H3ZM7 17Q6.175 17 5.5875 16.4125T5 15V4Q5 3.175 5.5875 2.5875T7 2H12L14 4H21Q21.825 4 22.4125 4.5875T23 6V15Q23 15.825 22.4125 16.4125T21 17H7ZM7 15H21V6H13.175L11.175 4H7V15ZM7 15V4 15Z"),
FOLDER_FILL("M4 20q-.825 0-1.4125-.5875T2 18v-12q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8v10q0 .825-.5875 1.4125T20 20H4Z"),
FOLDER_OPEN("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8H11.175L9.175 6H4V18L6.4 10H23.5L20.925 18.575Q20.725 19.225 20.1875 19.6125T19 20H4ZM6.1 18H19L20.8 12H7.9L6.1 18ZM6.1 18 7.9 12 6.1 18ZM4 8V6 8Z"),
FORMAT_LIST_BULLETED("M9 19V17H21V19H9ZM9 13V11H21V13H9ZM9 7V5H21V7H9ZM5 20Q4.175 20 3.5875 19.4125T3 18Q3 17.175 3.5875 16.5875T5 16Q5.825 16 6.4125 16.5875T7 18Q7 18.825 6.4125 19.4125T5 20ZM5 14Q4.175 14 3.5875 13.4125T3 12Q3 11.175 3.5875 10.5875T5 10Q5.825 10 6.4125 10.5875T7 12Q7 12.825 6.4125 13.4125T5 14ZM5 8Q4.175 8 3.5875 7.4125T3 6Q3 5.175 3.5875 4.5875T5 4Q5.825 4 6.4125 4.5875T7 6Q7 6.825 6.4125 7.4125T5 8Z"),
FORT("M1 21V17l2-2V9L1 7V3H3V5H5V3H7V5H9V3h2V7L9 9v1h6V9L13 7V3h2V5h2V3h2V5h2V3h2V7L21 9v6l2 2v4H14V18q0-.825-.5875-1.4125T12 16q-.825 0-1.4125.5875T10 18v3H1Zm2-2H8V18q0-1.65 1.175-2.825T12 14q1.65 0 2.825 1.175T16 18v1h5V17.825l-2-2V8.175L20.175 7h-4.35L17 8.175V12H7V8.175L8.175 7H3.825L5 8.175v7.65l-2 2V19Zm9-6Z"),
@@ -22,6 +22,8 @@ import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -34,8 +36,6 @@ import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Node;
import java.net.URL;
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -44,8 +44,8 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class UpgradeDialog extends JFXDialogLayout {
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
maxWidthProperty().bind(Controllers.getScene().widthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
setHeading(new Label(i18n("update.changelog")));
setBody(new JFXSpinner());
@@ -59,7 +59,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
// Downgrade update, no need to display changelog
return null;
Document document = Jsoup.parse(new URL(url), 30 * 1000);
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
if (node == null || !"h1".equals(node.nodeName())) {
@@ -19,10 +19,22 @@ package org.jackhuang.hmcl.ui;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.NativeUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.windows.IPropertyStore;
import org.jackhuang.hmcl.util.platform.windows.Shell32;
import org.jackhuang.hmcl.util.platform.windows.WinTypes;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.OptionalLong;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -55,6 +67,145 @@ public final class WindowsNativeUtils {
}
}
/// Installs permanent window listeners that keep AppUserModel relaunch properties in sync
/// with the stage's native HWND lifecycle.
///
/// JavaFX may destroy and recreate the HWND across `hide()` / `show()` (for example with
/// `HIDE_AND_REOPEN`). Properties are therefore applied on every [`WindowEvent#WINDOW_SHOWN`]
/// and removed with `VT_EMPTY` on every [`WindowEvent#WINDOW_HIDING`], as required by
/// [`SHGetPropertyStoreForWindow`](https://learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-shgetpropertystoreforwindow).
///
/// Handlers must run on the JavaFX application thread: `IPropertyStore` is a COM interface,
/// and the FX thread is already initialized for COM on Windows.
///
/// @param stage the primary launcher stage
public static void installWindowsAppUserModelRelaunchProperties(Stage stage) {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
return;
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
return;
@Nullable Path thisJar = JarUtils.thisJarPath();
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
return;
String exePath = FileUtils.getAbsolutePath(thisJar);
String iconResource = exePath + ",0";
String relaunchCommand = '"' + exePath + '"';
stage.addEventFilter(WindowEvent.WINDOW_SHOWN, event -> {
try {
applyWindowsAppUserModelRelaunchProperties(stage, relaunchCommand, iconResource);
} catch (Throwable e) {
LOG.warning("Failed to set AppUserModel relaunch properties", e);
}
});
stage.addEventFilter(WindowEvent.WINDOW_HIDING, event -> {
try {
clearWindowsAppUserModelRelaunchProperties(stage);
} catch (Throwable e) {
LOG.warning("Failed to clear AppUserModel relaunch properties", e);
}
});
}
/// Writes AppUserModel relaunch properties for the current native window of `stage`.
///
/// Successful [`IPropertyStore#SetValue`] calls on a window property store take effect
/// immediately; no `Commit` is required or useful.
///
/// @param stage the stage whose native window receives the properties
/// @param relaunchCommand the `PKEY_AppUserModel_RelaunchCommand` value
/// @param iconResource the `PKEY_AppUserModel_RelaunchIconResource` value
private static void applyWindowsAppUserModelRelaunchProperties(Stage stage, String relaunchCommand, String iconResource) {
Shell32 shell32 = Shell32.INSTANCE;
if (shell32 == null)
return;
OptionalLong handle = WindowsNativeUtils.getWindowHandle(stage);
if (handle.isEmpty() || handle.getAsLong() == WinTypes.HANDLE.INVALID_VALUE) {
LOG.warning("Failed to get window handle for AppUserModel relaunch properties");
return;
}
try (IPropertyStore store = IPropertyStore.forWindow(shell32, handle.getAsLong())) {
if (store == null) {
LOG.warning("Failed to call SHGetPropertyStoreForWindow");
return;
}
// Set Relaunch* first, then AppUserModel.ID last. Window property store values take
// effect immediately on SetValue; assigning the ID first can let the shell register the
// window before relaunch metadata is complete.
if (!setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchCommand, relaunchCommand)
|| !setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchIconResource, iconResource)
|| !setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchDisplayNameResource, Metadata.FULL_NAME)
|| !setWindowsPropertyStoreString(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_ID, Metadata.WINDOWS_APP_USER_MODEL_ID)) {
return;
}
LOG.info("Set AppUserModel relaunch properties for HWND " + Long.toHexString(handle.getAsLong()));
}
}
/// Removes AppUserModel relaunch properties from the current native window before it is hidden.
///
/// @param stage the stage whose native window is about to be destroyed or hidden
private static void clearWindowsAppUserModelRelaunchProperties(Stage stage) {
Shell32 shell32 = Shell32.INSTANCE;
if (shell32 == null)
return;
OptionalLong handle = WindowsNativeUtils.getWindowHandle(stage);
if (handle.isEmpty() || handle.getAsLong() == WinTypes.HANDLE.INVALID_VALUE) {
return;
}
try (IPropertyStore store = IPropertyStore.forWindow(shell32, handle.getAsLong())) {
if (store == null) {
LOG.warning("Failed to call SHGetPropertyStoreForWindow while clearing properties");
return;
}
WinTypes.PROPVARIANT empty = new WinTypes.PROPVARIANT();
empty.setEmpty();
// Clear in reverse of apply order so ID is removed before relaunch metadata when possible.
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_ID, empty);
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchDisplayNameResource, empty);
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchIconResource, empty);
clearWindowsPropertyStoreValue(store, WinTypes.PROPERTYKEY.PKEY_AppUserModel_RelaunchCommand, empty);
}
}
/// Sets a string property on an [`IPropertyStore`].
///
/// @param store the property store
/// @param key the property key
/// @param value the string value
/// @return `true` if [`IPropertyStore#SetValue`] succeeds
private static boolean setWindowsPropertyStoreString(IPropertyStore store, WinTypes.PROPERTYKEY key, String value) {
WinTypes.PROPVARIANT propvar = new WinTypes.PROPVARIANT();
propvar.setStringValue(value);
int hr = store.SetValue(key, propvar);
if (hr < 0) {
LOG.warning("Failed to set property pid=" + key.pid + " on IPropertyStore, HRESULT=0x" + Integer.toHexString(hr));
return false;
}
return true;
}
/// Removes a property by writing `VT_EMPTY`.
///
/// @param store the property store
/// @param key the property key to clear
/// @param empty a `VT_EMPTY` value
private static void clearWindowsPropertyStoreValue(IPropertyStore store, WinTypes.PROPERTYKEY key, WinTypes.PROPVARIANT empty) {
int hr = store.SetValue(key, empty);
if (hr < 0) {
LOG.warning("Failed to clear property pid=" + key.pid + " on IPropertyStore, HRESULT=0x" + Integer.toHexString(hr));
}
}
private WindowsNativeUtils() {
}
}
@@ -43,7 +43,6 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogController;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import org.jackhuang.hmcl.util.skin.NormalizedSkin;
import org.jetbrains.annotations.Nullable;
@@ -151,7 +150,7 @@ public class AccountListItem extends RadioButton {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("account.skin.upload"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png"));
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile == null) {
return null;
}
@@ -37,7 +37,7 @@ public final class AnimationUtils {
public static void init() {
}
private static final boolean ENABLED = !settings().animationDisabledProperty().get();
private static final boolean ENABLED = !settings().isAnimationDisabled();
private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD();
public static boolean isAnimationEnabled() {
@@ -120,7 +120,7 @@ public class FileSelector extends HBox {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().addAll(getExtensionFilters());
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_file") : chooserTitle);
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path file = Controllers.showOpenDialog(chooser);
if (file != null) {
String path = FileUtils.getAbsolutePath(file);
customField.setText(path);
@@ -131,7 +131,7 @@ public class FileSelector extends HBox {
private void openDirectoryChooser(JFXTextField customField) {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_directory") : chooserTitle);
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
Path dir = Controllers.showDialog(chooser);
if (dir != null) {
String path = FileUtils.getAbsolutePath(dir);
customField.setText(path);
@@ -20,11 +20,11 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.Window;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -57,7 +57,9 @@ public class LineFileChooserButton extends LineButton {
public void fire() {
super.fire();
Stage owner = Controllers.getStage(); // TODO: Allow user to set owner stage
Scene scene = this.getScene();
Window owner = scene != null ? scene.getWindow() : null; // TODO: Allow user to set owner stage
String windowTitle = getFileChooserTitle();
Path initialDirectory = null;
@@ -88,7 +88,7 @@ public interface TabControl {
setSelectedIndex(index);
Tab tab = getModelItem(index);
Tab<?> tab = getModelItem(index);
if (tab != null) {
setSelectedItem(tab);
}
@@ -106,7 +106,7 @@ public interface TabControl {
final int itemCount = getItemCount();
for (int i = 0; i < itemCount; i++) {
final Tab value = getModelItem(i);
final Tab<?> value = getModelItem(i);
if (value != null && value.equals(tab)) {
select(i);
return;
@@ -21,11 +21,12 @@ import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.task.*;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -67,16 +68,18 @@ public class TaskExecutorDialogPane extends BorderPane {
center.getChildren().setAll(lblTitle, taskListPane);
}
BorderPane bottom = new BorderPane();
this.setBottom(bottom);
HBox bottom = new HBox();
bottom.setAlignment(Pos.CENTER_LEFT);
bottom.setPadding(new Insets(0, 8, 8, 8));
bottom.setSpacing(8);
this.setBottom(bottom);
{
lblProgress = new Label();
bottom.setLeft(lblProgress);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
btnCancel = new JFXButton(i18n("button.cancel"));
btnCancel.getStyleClass().add("dialog-cancel");
bottom.setRight(btnCancel);
bottom.getChildren().setAll(lblProgress, spacer, btnCancel);
}
setCancel(cancel);
File diff suppressed because it is too large. Load diff
@@ -1,253 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXSnackbar;
import com.jfoenix.controls.JFXSnackbarLayout;
import javafx.animation.Interpolator;
import javafx.beans.property.BooleanProperty;
import javafx.scene.Node;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogUtils;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane.AnimationProducer;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
public class DecoratorController {
private final Decorator decorator;
private final Navigator navigator;
public DecoratorController(Stage stage, Node mainPage) {
decorator = new Decorator(stage);
decorator.titleTransparentProperty().bind(Themes.titleBarTransparentProperty());
navigator = new Navigator();
navigator.setOnNavigated(this::onNavigated);
navigator.init(mainPage);
decorator.getContent().setAll(navigator);
decorator.onCloseNavButtonActionProperty().set(e -> close());
decorator.onBackNavButtonActionProperty().set(e -> back());
decorator.onRefreshNavButtonActionProperty().set(e -> refresh());
setupAuthlibInjectorDnD();
decorator.contentBackgroundProperty().bind(Themes.backgroundProperty());
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
if (!(e.getTarget() instanceof Node t)) {
return;
}
Node newTarget;
JFXDialogPane currentDialogPane = null;
if (decorator.getDrawerWrapper() != null) {
currentDialogPane = (JFXDialogPane) decorator.getDrawerWrapper().getProperties().get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
}
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
newTarget = currentDialogPane.peek().get();
} else {
newTarget = navigator.getCurrentPage();
}
boolean needsRedirect = true;
while (t != null) {
if (t == newTarget) {
// current event target is in newTarget
needsRedirect = false;
break;
}
t = t.getParent();
}
if (!needsRedirect) {
return;
}
e.consume();
newTarget.fireEvent(e.copyFor(e.getSource(), newTarget));
});
// press ESC to go back
onEscPressed(navigator, this::back);
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
// press F11 to toggle full screen
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.F11) {
stage.setFullScreen(!stage.isFullScreen());
e.consume();
}
});
}
navigator.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.BACK) {
back();
e.consume();
}
});
}
public Decorator getDecorator() {
return decorator;
}
// ==== Navigation ====
public void navigate(Node node, AnimationProducer animationProducer, Duration duration, Interpolator interpolator) {
navigator.navigate(node, animationProducer, duration, interpolator);
}
public BooleanProperty backableProperty() {
return navigator.backableProperty();
}
private void close() {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
if (page.isPageCloseable()) {
page.closePage();
return;
}
}
navigator.clear();
}
private void back() {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
if (page.back()) {
if (navigator.canGoBack()) {
navigator.close();
}
}
} else {
if (navigator.canGoBack()) {
navigator.close();
}
}
}
private void refresh() {
if (navigator.getCurrentPage() instanceof Refreshable) {
Refreshable refreshable = (Refreshable) navigator.getCurrentPage();
if (refreshable.refreshableProperty().get())
refreshable.refresh();
}
}
private void onNavigated(Navigator.NavigationEvent event) {
if (event.getSource() != this.navigator) return;
Node to = event.getNode();
if (to instanceof Refreshable) {
decorator.canRefreshProperty().bind(((Refreshable) to).refreshableProperty());
} else {
decorator.canRefreshProperty().unbind();
decorator.canRefreshProperty().set(false);
}
decorator.canCloseProperty().set(navigator.size() > 2);
if (to instanceof DecoratorPage) {
decorator.showCloseAsHomeProperty().set(!((DecoratorPage) to).isPageCloseable());
} else {
decorator.showCloseAsHomeProperty().set(true);
}
decorator.setNavigationDirection(event.getDirection());
// state property should be updated at last.
if (to instanceof DecoratorPage) {
decorator.stateProperty().bind(((DecoratorPage) to).stateProperty());
} else {
decorator.stateProperty().unbind();
decorator.stateProperty().set(new DecoratorPage.State("", null, navigator.canGoBack(), false, true));
}
if (to instanceof Region) {
Region region = (Region) to;
// Let root pane fix window size.
StackPane parent = (StackPane) region.getParent();
region.prefWidthProperty().bind(parent.widthProperty());
region.prefHeightProperty().bind(parent.heightProperty());
}
}
// ==== Dialog ====
public void showDialog(Node node) {
DialogUtils.show(decorator, node);
}
private void closeDialog(Node node) {
DialogUtils.close(node);
}
public void showDialogLater(Node node) {
DialogUtils.showLater(decorator, node);
}
// ==== Toast ====
public void showToast(String content) {
decorator.getSnackbar().fireEvent(new JFXSnackbar.SnackbarEvent(new JFXSnackbarLayout(content)));
}
// ==== Wizard ====
public void startWizard(WizardProvider wizardProvider) {
startWizard(wizardProvider, null);
}
public void startWizard(WizardProvider wizardProvider, String category) {
FXUtils.checkFxUserThread();
navigator.navigate(new DecoratorWizardDisplayer(wizardProvider, category),
ContainerAnimations.FORWARD, Motion.SHORT4, Motion.EASE);
}
// ==== Authlib Injector DnD ====
private void setupAuthlibInjectorDnD() {
decorator.addEventFilter(DragEvent.DRAG_OVER, AuthlibInjectorDnD.dragOverHandler());
decorator.addEventFilter(DragEvent.DRAG_DROPPED, AuthlibInjectorDnD.dragDroppedHandler(
url -> Controllers.dialog(new AddAuthlibInjectorServerPane(url))));
}
}
@@ -1,602 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
public class DecoratorSkin extends SkinBase<Decorator> {
private final StackPane root, parent;
private final StackPane titleContainer;
private final Stage primaryStage;
private final TransitionPane navBarPane;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener onWindowsStatusChange;
private final EventHandler<MouseEvent> onTitleBarDoubleClick;
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
public DecoratorSkin(Decorator control) {
super(control);
primaryStage = control.getPrimaryStage();
Decorator skinnable = getSkinnable();
root = new StackPane();
root.getStyleClass().add("window");
StackPane shadowContainer = new StackPane();
shadowContainer.getStyleClass().add("body");
shadowContainer.setEffect(new DropShadow(BlurType.ONE_PASS_BOX, Color.rgb(0, 0, 0, 0.4), 10, 0.3, 0.0, 0.0));
parent = new StackPane();
Rectangle clip = new Rectangle();
clip.widthProperty().bind(parent.widthProperty());
clip.heightProperty().bind(parent.heightProperty());
clip.setArcWidth(8);
clip.setArcHeight(8);
parent.setClip(clip);
skinnable.getSnackbar().registerSnackbarContainer(parent);
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
onWindowsStatusChange = observable -> {
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
} else {
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
};
onTitleBarDoubleClick = event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
primaryStage.setMaximized(!primaryStage.isMaximized());
event.consume();
}
};
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
onWindowsStatusChange.invalidated(null);
} else {
onWindowsStatusChange = null;
onTitleBarDoubleClick = null;
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
shadowContainer.getChildren().setAll(parent);
root.getChildren().setAll(shadowContainer);
StackPane wrapper = new StackPane();
wrapper.backgroundProperty().bind(Bindings.createObjectBinding(
() -> Themes.windowTransparentProperty().get()
? null
: new Background(new BackgroundFill(
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
CornerRadii.EMPTY,
Insets.EMPTY)),
Themes.windowTransparentProperty(),
Themes.colorSchemeProperty()));
Region backgroundNode = new Region();
backgroundNode.setMouseTransparent(true);
backgroundNode.backgroundProperty().bind(Bindings.createObjectBinding(
() -> skinnable.getContentBackground() == null
? null
: skinnable.getContentBackground().background(),
skinnable.contentBackgroundProperty()));
backgroundNode.opacityProperty().bind(Bindings.createDoubleBinding(
() -> skinnable.getContentBackground() == null
? 1.0
: skinnable.getContentBackground().opacity(),
skinnable.contentBackgroundProperty()));
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
BorderPane frame = new BorderPane();
frame.getStyleClass().addAll("jfx-decorator");
wrapper.getChildren().setAll(backgroundNode, frame);
skinnable.setDrawerWrapper(wrapper);
parent.getChildren().add(wrapper);
// center node with an animation layer at bottom, a container layer at middle and a "welcome" layer at top.
StackPane container = new StackPane();
FXUtils.setOverflowHidden(container);
// content layer at middle
{
StackPane contentPlaceHolder = new StackPane();
contentPlaceHolder.getStyleClass().add("jfx-decorator-content-container");
Bindings.bindContent(contentPlaceHolder.getChildren(), skinnable.contentProperty());
container.getChildren().add(contentPlaceHolder);
}
// welcome and hint layer at top
{
StackPane floatLayer = new StackPane();
Bindings.bindContent(floatLayer.getChildren(), skinnable.containerProperty());
ListChangeListener<Node> listener = c -> {
if (skinnable.getContainer().isEmpty()) {
floatLayer.setMouseTransparent(true);
floatLayer.setVisible(false);
} else {
floatLayer.setMouseTransparent(false);
floatLayer.setVisible(true);
}
};
skinnable.containerProperty().addListener(listener);
listener.onChanged(null);
container.getChildren().add(floatLayer);
}
frame.setCenter(container);
titleContainer = new StackPane();
titleContainer.setPickOnBounds(false);
titleContainer.getStyleClass().addAll("jfx-tool-bar");
backgroundNode.maxHeightProperty().bind(Bindings.createDoubleBinding(
() -> Math.max(0.0, wrapper.getHeight()
- (skinnable.isTitleTransparent() ? 0.0 : titleContainer.getHeight())),
wrapper.heightProperty(),
skinnable.titleTransparentProperty(),
titleContainer.heightProperty()));
// Maybe, we can automatically identify whether the top part of the picture is light-coloured or dark when the title is transparent,
// and decide whether the whole top bar should be rendered in white or black. TODO
FXUtils.onChangeAndOperate(skinnable.titleTransparentProperty(), titleTransparent -> {
if (titleTransparent) {
titleContainer.getStyleClass().remove("background");
titleContainer.getStyleClass().add("gray-background");
} else {
titleContainer.getStyleClass().add("background");
titleContainer.getStyleClass().remove("gray-background");
}
});
control.capableDraggingWindow(titleContainer);
BorderPane titleBar = new BorderPane();
titleContainer.getChildren().add(titleBar);
Rectangle buttonsContainerPlaceHolder = new Rectangle();
{
navBarPane = new TransitionPane();
navBarPane.setId("decoratorTitleTransitionPane");
FXUtils.onChangeAndOperate(skinnable.stateProperty(), s -> {
if (s == null) return;
Node node = createNavBar(skinnable, s.leftPaneWidth(), s.backable(), skinnable.canCloseProperty().get(), skinnable.showCloseAsHomeProperty().get(), s.refreshable(), s.title(), s.titleNode());
if (s.animate()) {
TransitionPane.AnimationProducer animation = switch (skinnable.getNavigationDirection()) {
case NEXT -> NavBarAnimations.NEXT;
case PREVIOUS -> NavBarAnimations.PREVIOUS;
default -> ContainerAnimations.FADE;
};
skinnable.setNavigationDirection(Navigation.NavigationDirection.START);
navBarPane.setContent(node, animation, Motion.SHORT4);
} else {
navBarPane.getChildren().setAll(node);
}
});
titleBar.setCenter(navBarPane);
titleBar.setRight(buttonsContainerPlaceHolder);
}
frame.setTop(titleContainer);
{
HBox buttonsContainer = new HBox();
buttonsContainer.setAlignment(Pos.TOP_RIGHT);
buttonsContainer.setMaxHeight(40);
{
JFXButton btnHelp = new JFXButton();
btnHelp.setFocusTraversable(false);
btnHelp.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty()));
btnHelp.getStyleClass().add("jfx-decorator-button");
btnHelp.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL));
JFXButton btnMin = new JFXButton();
btnMin.setFocusTraversable(false);
btnMin.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
btnMin.getStyleClass().add("jfx-decorator-button");
btnMin.setOnAction(e -> skinnable.minimize());
JFXButton btnClose = new JFXButton();
btnClose.setFocusTraversable(false);
btnClose.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
btnClose.getStyleClass().add("jfx-decorator-button");
btnClose.setOnAction(e -> skinnable.close());
buttonsContainer.getChildren().setAll(btnHelp, btnMin, btnClose);
}
AnchorPane layer = new AnchorPane();
layer.setPickOnBounds(false);
layer.getChildren().add(buttonsContainer);
AnchorPane.setTopAnchor(buttonsContainer, 0.0);
AnchorPane.setRightAnchor(buttonsContainer, 0.0);
buttonsContainerPlaceHolder.widthProperty().bind(buttonsContainer.widthProperty());
parent.getChildren().add(layer);
}
getChildren().add(root);
}
private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean canBack, boolean canClose, boolean showCloseAsHome, boolean canRefresh, String title, Node titleNode) {
BorderPane navBar = new BorderPane();
navBar.getStyleClass().add("navigation-bar");
{
HBox navLeft = new HBox();
navLeft.setAlignment(Pos.CENTER_LEFT);
navLeft.setPadding(new Insets(0, 5, 0, 5));
if (canBack) {
JFXButton backNavButton = new JFXButton();
skinnable.forbidDraggingWindow(backNavButton);
backNavButton.setFocusTraversable(false);
backNavButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty()));
backNavButton.getStyleClass().add("jfx-decorator-button");
backNavButton.onActionProperty().bind(skinnable.onBackNavButtonActionProperty());
backNavButton.visibleProperty().set(canBack);
navLeft.getChildren().add(backNavButton);
}
if (canClose) {
JFXButton closeNavButton = new JFXButton();
skinnable.forbidDraggingWindow(closeNavButton);
closeNavButton.setFocusTraversable(false);
closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeNavButton.getStyleClass().add("jfx-decorator-button");
closeNavButton.onActionProperty().bind(skinnable.onCloseNavButtonActionProperty());
if (showCloseAsHome)
closeNavButton.setGraphic(SVG.HOME.createIcon(Themes.titleFillProperty()));
else
closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
navLeft.getChildren().add(closeNavButton);
}
if (canBack || canClose) {
navBar.setLeft(navLeft);
}
BorderPane center = new BorderPane();
if (title != null) {
Label titleLabel = new Label();
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT);
titleLabel.getStyleClass().add("jfx-decorator-title");
if (titleNode == null) {
titleLabel.maxWidthProperty().bind(Bindings.createDoubleBinding(
() -> skinnable.getWidth() - 150 - navLeft.getWidth(),
skinnable.widthProperty(), navLeft.widthProperty()));
} else {
titleLabel.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
// 8 (margin-left)
return leftPaneWidth - 8 - navLeft.getWidth();
}, navLeft.widthProperty()));
}
titleLabel.setText(title);
center.setLeft(titleLabel);
BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT);
}
if (titleNode != null) {
center.setCenter(titleNode);
BorderPane.setAlignment(titleNode, Pos.CENTER_LEFT);
BorderPane.setMargin(titleNode, new Insets(0, 0, 0, 8));
}
if (onTitleBarDoubleClick != null)
center.setOnMouseClicked(onTitleBarDoubleClick);
center.setOnMouseDragged(mouseEvent -> {
if (!getSkinnable().isDragging() && primaryStage.isMaximized()) {
getSkinnable().setDragging(true);
mouseInitX = mouseEvent.getScreenX();
mouseInitY = mouseEvent.getScreenY();
primaryStage.setMaximized(false);
stageInitWidth = primaryStage.getWidth();
stageInitHeight = primaryStage.getHeight();
primaryStage.setY(stageInitY = 0);
primaryStage.setX(stageInitX = mouseInitX - stageInitWidth / 2);
}
});
navBar.setCenter(center);
if (canRefresh) {
HBox navRight = new HBox();
navRight.setAlignment(Pos.CENTER_RIGHT);
JFXButton refreshNavButton = new JFXButton();
refreshNavButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty()));
refreshNavButton.getStyleClass().add("jfx-decorator-button");
refreshNavButton.onActionProperty().bind(skinnable.onRefreshNavButtonActionProperty());
skinnable.forbidDraggingWindow(refreshNavButton);
navRight.getChildren().setAll(refreshNavButton);
navBar.setRight(navRight);
}
}
return navBar;
}
private boolean isRightEdge(double x, double y, Bounds boundsInParent) {
return x < root.getWidth() && x >= root.getWidth() - root.snappedLeftInset();
}
private boolean isTopEdge(double x, double y, Bounds boundsInParent) {
return y >= 0 && y <= root.snappedTopInset();
}
private boolean isBottomEdge(double x, double y, Bounds boundsInParent) {
return y < root.getHeight() && y >= root.getHeight() - root.snappedLeftInset();
}
private boolean isLeftEdge(double x, double y, Bounds boundsInParent) {
return x >= 0 && x <= root.snappedLeftInset();
}
private void resizeStage(double newWidth, double newHeight) {
if (newWidth < 0)
newWidth = primaryStage.getWidth();
if (newWidth < primaryStage.getMinWidth())
newWidth = primaryStage.getMinWidth();
if (newWidth < titleContainer.getMinWidth())
newWidth = titleContainer.getMinWidth();
if (newHeight < 0)
newHeight = primaryStage.getHeight();
if (newHeight < primaryStage.getMinHeight())
newHeight = primaryStage.getMinHeight();
if (newHeight < titleContainer.getMinHeight())
newHeight = titleContainer.getMinHeight();
// Width and height must be set simultaneously to avoid JDK-8344372 (https://github.com/openjdk/jfx/pull/1654)
primaryStage.setWidth(newWidth);
primaryStage.setHeight(newHeight);
}
private void onMouseMoved(MouseEvent mouseEvent) {
if (!primaryStage.isFullScreen() && primaryStage.isResizable()) {
double x = mouseEvent.getX(), y = mouseEvent.getY();
Bounds boundsInParent = root.getBoundsInParent();
double diagonalSize = root.snappedLeftInset() + 10;
if (this.isRightEdge(x, y, boundsInParent)) {
if (y < diagonalSize) {
root.setCursor(Cursor.NE_RESIZE);
} else if (y > root.getHeight() - diagonalSize) {
root.setCursor(Cursor.SE_RESIZE);
} else {
root.setCursor(Cursor.E_RESIZE);
}
} else if (this.isLeftEdge(x, y, boundsInParent)) {
if (y < diagonalSize) {
root.setCursor(Cursor.NW_RESIZE);
} else if (y > root.getHeight() - diagonalSize) {
root.setCursor(Cursor.SW_RESIZE);
} else {
root.setCursor(Cursor.W_RESIZE);
}
} else if (this.isTopEdge(x, y, boundsInParent)) {
if (x < diagonalSize) {
root.setCursor(Cursor.NW_RESIZE);
} else if (x > root.getWidth() - diagonalSize) {
root.setCursor(Cursor.NE_RESIZE);
} else {
root.setCursor(Cursor.N_RESIZE);
}
} else if (this.isBottomEdge(x, y, boundsInParent)) {
if (x < diagonalSize) {
root.setCursor(Cursor.SW_RESIZE);
} else if (x > root.getWidth() - diagonalSize) {
root.setCursor(Cursor.SE_RESIZE);
} else {
root.setCursor(Cursor.S_RESIZE);
}
} else {
root.setCursor(Cursor.DEFAULT);
}
} else {
root.setCursor(Cursor.DEFAULT);
}
}
private void onMouseReleased(MouseEvent mouseEvent) {
getSkinnable().setDragging(false);
}
private void onMouseDragged(MouseEvent mouseEvent) {
if (!getSkinnable().isDragging()) {
getSkinnable().setDragging(true);
mouseInitX = mouseEvent.getScreenX();
mouseInitY = mouseEvent.getScreenY();
stageInitX = primaryStage.getX();
stageInitY = primaryStage.getY();
stageInitWidth = primaryStage.getWidth();
stageInitHeight = primaryStage.getHeight();
}
if (primaryStage.isFullScreen() || !mouseEvent.isPrimaryButtonDown() || mouseEvent.isStillSincePress())
return;
double dx = mouseEvent.getScreenX() - mouseInitX;
double dy = mouseEvent.getScreenY() - mouseInitY;
Cursor cursor = root.getCursor();
if (getSkinnable().isAllowMove()) {
if (cursor == Cursor.DEFAULT) {
primaryStage.setX(stageInitX + dx);
primaryStage.setY(stageInitY + dy);
mouseEvent.consume();
}
}
if (getSkinnable().isResizable()) {
if (cursor == Cursor.E_RESIZE) {
resizeStage(stageInitWidth + dx, -1);
mouseEvent.consume();
} else if (cursor == Cursor.S_RESIZE) {
resizeStage(-1, stageInitHeight + dy);
mouseEvent.consume();
} else if (cursor == Cursor.W_RESIZE) {
resizeStage(stageInitWidth - dx, -1);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
mouseEvent.consume();
} else if (cursor == Cursor.N_RESIZE) {
resizeStage(-1, stageInitHeight - dy);
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
} else if (cursor == Cursor.SE_RESIZE) {
resizeStage(stageInitWidth + dx, stageInitHeight + dy);
mouseEvent.consume();
} else if (cursor == Cursor.SW_RESIZE) {
resizeStage(stageInitWidth - dx, stageInitHeight + dy);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
mouseEvent.consume();
} else if (cursor == Cursor.NW_RESIZE) {
resizeStage(stageInitWidth - dx, stageInitHeight - dy);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
} else if (cursor == Cursor.NE_RESIZE) {
resizeStage(stageInitWidth + dx, stageInitHeight - dy);
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
}
}
}
enum NavBarAnimations implements TransitionPane.AnimationProducer {
NEXT {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), 50, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), -50, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator))
);
}
@Override
public TransitionPane.AnimationProducer opposite() {
return NEXT;
}
},
PREVIOUS {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), -50, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), 50, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator))
);
}
@Override
public TransitionPane.AnimationProducer opposite() {
return PREVIOUS;
}
};
}
}
@@ -0,0 +1,400 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Renders the clipped background, title bar, and navigation content owned by a [Decorator].
@NotNullByDefault
final class MainWindowPane extends StackPane {
/// The diameter, in pixels, of the rounded window corners.
private static final double ARC = 8.0;
/// The decorator whose state and actions are represented by this pane.
private final Decorator decorator;
/// The clip that rounds normal window corners and becomes square while the window fills the screen.
private final Rectangle clip = new Rectangle();
/// The frame containing the title bar and current navigation page.
private final BorderPane frame;
/// The title bar containing page navigation state.
private final BorderPane titleBar;
/// The transition container used when the title-bar state changes.
private final TransitionPane navBarPane;
/// Retains listener delegates that are registered through weak listener wrappers.
@SuppressWarnings("FieldCanBeLocal")
private final WeakListenerHolder holder = new WeakListenerHolder();
/// Creates and wires the main-window content for `decorator`.
///
/// @param decorator the owning window decorator
MainWindowPane(Decorator decorator) {
this.decorator = decorator;
clip.widthProperty().bind(widthProperty());
clip.heightProperty().bind(heightProperty());
clip.setArcWidth(ARC);
clip.setArcHeight(ARC);
setClip(clip);
backgroundProperty().bind(Bindings.createObjectBinding(
() -> Themes.windowTransparentProperty().get()
? null
: new Background(new BackgroundFill(
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
CornerRadii.EMPTY,
Insets.EMPTY)),
Themes.windowTransparentProperty(),
Themes.colorSchemeProperty()));
Region backgroundNode = createBackgroundNode();
frame = new BorderPane();
frame.getStyleClass().add("jfx-decorator");
StackPane center = new StackPane();
center.getStyleClass().add("jfx-decorator-content-container");
FXUtils.setOverflowHidden(center);
center.getChildren().setAll(decorator.getNavigator());
frame.setCenter(center);
HBox rightButtonsContainer = createWindowButtons();
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(rightButtonsContainer);
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
frame.setTop(titleBar);
updateTitleBarBackground();
InvalidationListener titleBarBackgroundListener = observable -> updateTitleBarBackground();
Themes.colorSchemeProperty().addListener(holder.weak(titleBarBackgroundListener));
Themes.titleBarTransparentProperty().addListener(holder.weak(titleBarBackgroundListener));
InvalidationListener navBarListener = observable -> updateNavBar();
decorator.stateProperty().addListener(holder.weak(navBarListener));
updateNavBar();
decorator.capableDraggingWindow(titleBar);
getChildren().setAll(backgroundNode, frame);
}
/// Updates the content-corner shape for an edge-to-edge window state.
///
/// @param edgeToEdge whether the attached window is maximized or full-screen
void setWindowEdgeToEdge(boolean edgeToEdge) {
double arc = edgeToEdge ? 0.0 : ARC;
clip.setArcWidth(arc);
clip.setArcHeight(arc);
}
/// Creates the launcher-background layer and keeps it synchronized with the active theme.
///
/// @return the mouse-transparent background node
private Region createBackgroundNode() {
Region backgroundNode = new Region();
backgroundNode.setMouseTransparent(true);
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
holder.onWeakChangeAndOperate(Themes.backgroundProperty(), background -> {
double opacity = background.opacity();
if (opacity >= 0.001) {
backgroundNode.setBackground(background.background());
backgroundNode.setOpacity(opacity);
} else {
backgroundNode.setBackground(null);
backgroundNode.setOpacity(0);
}
});
return backgroundNode;
}
/// Creates the help, minimize, and application-close buttons.
///
/// @return a size-limited top-right button row
private HBox createWindowButtons() {
HBox buttons = new HBox();
buttons.setAlignment(Pos.TOP_RIGHT);
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton helpButton = new JFXButton();
helpButton.setFocusTraversable(false);
helpButton.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty()));
helpButton.getStyleClass().add("jfx-decorator-button");
helpButton.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL));
decorator.forbidDraggingWindow(helpButton);
JFXButton minimizeButton = new JFXButton();
minimizeButton.setFocusTraversable(false);
minimizeButton.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
minimizeButton.getStyleClass().add("jfx-decorator-button");
minimizeButton.setOnAction(event -> decorator.minimizeWindow());
decorator.forbidDraggingWindow(minimizeButton);
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.setOnAction(event -> decorator.closeWindow());
decorator.forbidDraggingWindow(closeButton);
buttons.getChildren().setAll(helpButton, minimizeButton, closeButton);
return buttons;
}
/// Recomputes the title-bar background for the current theme and transparency preference.
private void updateTitleBarBackground() {
@Nullable ColorScheme colorScheme = Themes.getColorScheme();
if (colorScheme == null) {
titleBar.setBackground(null);
} else if (Themes.titleBarTransparentProperty().get()) {
Color surface = colorScheme.getSurface();
titleBar.setBackground(new Background(new BackgroundFill(
new Color(surface.getRed(), surface.getGreen(), surface.getBlue(), 0.5),
CornerRadii.EMPTY,
Insets.EMPTY)));
} else {
titleBar.setBackground(new Background(new BackgroundFill(
colorScheme.getPrimaryContainer(),
CornerRadii.EMPTY,
Insets.EMPTY)));
}
}
/// Rebuilds the navigation bar from the current page state.
private void updateNavBar() {
DecoratorPage.@Nullable State state = decorator.getState();
if (state == null) {
navBarPane.getChildren().clear();
return;
}
Node navBar = createNavBar(state);
if (state.animate()) {
TransitionPane.AnimationProducer animation = switch (decorator.getNavigationDirection()) {
case NEXT -> NavBarAnimations.NEXT;
case PREVIOUS -> NavBarAnimations.PREVIOUS;
default -> ContainerAnimations.FADE;
};
decorator.setNavigationDirection(Navigation.NavigationDirection.START);
navBarPane.setContent(navBar, animation, Motion.SHORT4);
} else {
navBarPane.getChildren().setAll(navBar);
}
}
/// Creates the page-specific navigation controls and title.
///
/// @param state the current page state
/// @return a newly constructed navigation bar
private Node createNavBar(DecoratorPage.State state) {
HBox navBar = new HBox();
navBar.setAlignment(Pos.CENTER_LEFT);
// Left navigation buttons
if (state.backable()) {
JFXButton backButton = new JFXButton();
backButton.setFocusTraversable(false);
backButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty()));
backButton.getStyleClass().add("jfx-decorator-button");
backButton.setOnAction(event -> decorator.back());
decorator.forbidDraggingWindow(backButton);
navBar.getChildren().add(backButton);
}
if (decorator.canCloseProperty().get()) {
boolean showCloseAsHome = decorator.showCloseAsHomeProperty().get();
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic((showCloseAsHome ? SVG.HOME : SVG.CLOSE).createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.setOnAction(event -> decorator.closeCurrentPage());
decorator.forbidDraggingWindow(closeButton);
navBar.getChildren().add(closeButton);
}
// Center title area
StackPane titleArea = new StackPane();
titleArea.setAlignment(Pos.CENTER_LEFT);
titleArea.setMinWidth(0);
HBox.setHgrow(titleArea, Priority.ALWAYS);
if (state.titleNode() != null) {
titleArea.getChildren().setAll(state.titleNode());
StackPane.setMargin(state.titleNode(), new Insets(0, 0, 0, 8));
} else if (state.title() != null) {
Label titleLabel = new Label(state.title());
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
titleLabel.getStyleClass().add("jfx-decorator-title");
titleLabel.setMinWidth(0);
titleArea.getChildren().setAll(titleLabel);
}
decorator.registerTitleBar(titleArea);
if (!navBar.getChildren().isEmpty()) {
Insets padding = new Insets(0, 0, 0, 5);
navBar.setPadding(padding);
titleArea.setPadding(padding);
}
navBar.getChildren().add(titleArea);
// Right refresh button
if (state.refreshable()) {
JFXButton refreshButton = new JFXButton();
refreshButton.setFocusTraversable(false);
refreshButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty()));
refreshButton.getStyleClass().add("jfx-decorator-button");
refreshButton.setOnAction(event -> decorator.refresh());
decorator.forbidDraggingWindow(refreshButton);
navBar.getChildren().add(refreshButton);
}
return navBar;
}
/// Produces directional transitions for page-title changes.
private enum NavBarAnimations implements TransitionPane.AnimationProducer {
/// Moves the next title in from the right.
NEXT {
/// {@inheritDoc}
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
/// {@inheritDoc}
@Override
public Timeline animate(
Pane container,
Node previousNode,
Node nextNode,
Duration duration,
Interpolator interpolator) {
return createTimeline(previousNode, nextNode, 50, -50, duration, interpolator);
}
/// {@inheritDoc}
@Override
public TransitionPane.AnimationProducer opposite() {
return PREVIOUS;
}
},
/// Moves the previous title in from the left.
PREVIOUS {
/// {@inheritDoc}
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(-container.getWidth());
}
/// {@inheritDoc}
@Override
public Timeline animate(
Pane container,
Node previousNode,
Node nextNode,
Duration duration,
Interpolator interpolator) {
return createTimeline(previousNode, nextNode, -50, 50, duration, interpolator);
}
/// {@inheritDoc}
@Override
public TransitionPane.AnimationProducer opposite() {
return NEXT;
}
};
/// Creates a title transition with the supplied horizontal offsets.
///
/// @param previousNode the title being removed
/// @param nextNode the title being shown
/// @param nextOffset the initial offset of the next title
/// @param previousOffset the final offset of the previous title
/// @param duration the transition duration
/// @param interpolator the transition interpolator
/// @return the unstarted transition timeline
private static Timeline createTimeline(
Node previousNode,
Node nextNode,
double nextOffset,
double previousOffset,
Duration duration,
Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), nextOffset, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), previousOffset, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator)));
}
}
}
@@ -21,13 +21,14 @@ import com.jfoenix.controls.JFXButton;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jetbrains.annotations.NotNullByDefault;
@@ -48,18 +49,23 @@ public class GameDirectoryListItemSkin extends SkinBase<GameDirectoryListItem> {
root.setPickOnBounds(false);
RipplerContainer container = new RipplerContainer(root);
SVGContainer left = (skinnable.isSelected() ? SVG.FOLDER_FILL : SVG.FOLDER).createIcon(20);
left.setMouseTransparent(true);
BorderPane.setMargin(left, new Insets(0, 6, 0, 6));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
root.setLeft(left);
FXUtils.onChangeAndOperate(skinnable.selectedProperty(), active -> {
skinnable.pseudoClassStateChanged(SELECTED, active);
SVG targetIcon = active ? SVG.FOLDER_FILL : SVG.FOLDER;
if (left.getIcon() != targetIcon) {
left.setIcon(targetIcon, Motion.SHORT4);
}
});
FXUtils.onClicked(getSkinnable(), () -> GameDirectoryManager.setSelectedGameDirectory(skinnable.getGameDirectory()));
Node left = SVG.FOLDER.createIcon(20);
left.setMouseTransparent(true);
BorderPane.setMargin(left, new Insets(0, 6, 0, 6));
root.setLeft(left);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
TwoLineListItem item = new TwoLineListItem();
item.setPickOnBounds(false);
BorderPane.setAlignment(item, Pos.CENTER);
@@ -27,11 +27,15 @@ import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
@@ -42,6 +46,8 @@ import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public abstract class AbstractInstallersPage extends Control implements WizardPage {
public static final SettingsMap.Key<GameInstanceID> INSTANCE_ID = new SettingsMap.Key<>("instanceId");
public static final String FABRIC_QUILT_API_TIP = "fabricQuiltApi";
protected final WizardController controller;
@@ -112,6 +118,14 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
return new InstallersPageSkin(this);
}
/// Returns whether the name field includes clear and reset controls.
///
/// @return `true` to display the controls; `false` otherwise
protected abstract boolean showExtendPane();
/// Restores the default value of the name field.
protected abstract void resetDefaultName();
protected static class InstallersPageSkin extends SkinBase<AbstractInstallersPage> {
/**
* Constructor for all SkinBase instances.
@@ -127,11 +141,27 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
{
HBox versionNamePane = new HBox(8);
versionNamePane.getStyleClass().add("card-non-transparent");
versionNamePane.setStyle("-fx-padding: 20 8 20 16");
versionNamePane.setStyle("-fx-padding: 20 16 20 16");
versionNamePane.setAlignment(Pos.CENTER_LEFT);
control.txtName.setMaxWidth(300);
versionNamePane.getChildren().setAll(new Label(i18n("version.name")), control.txtName);
HBox.setHgrow(control.txtName, Priority.ALWAYS);
versionNamePane.getChildren().addAll(new Label(i18n("instance.name")), control.txtName);
if (control.showExtendPane()) {
JFXButton clearButton = FXUtils.newToggleButton4(SVG.CLOSE);
FXUtils.installFastTooltip(clearButton, i18n("button.clear"));
clearButton.disableProperty().bind(control.txtName.textProperty().isEmpty().or(control.txtName.disableProperty()));
clearButton.setOnAction(e -> control.txtName.clear());
JFXButton resetButton = FXUtils.newToggleButton4(SVG.RESTORE);
FXUtils.installFastTooltip(resetButton, i18n("button.reset"));
resetButton.disableProperty().bind(control.txtName.disableProperty());
resetButton.setOnAction(e -> control.resetDefaultName());
versionNamePane.getChildren().addAll(clearButton, resetButton);
}
root.setTop(versionNamePane);
}
@@ -23,9 +23,8 @@ import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.game.GameRepository;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.Lang;
@@ -38,17 +37,17 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
class AdditionalInstallersPage extends AbstractInstallersPage {
protected final BooleanProperty compatible = new SimpleBooleanProperty();
protected final GameRepository repository;
protected final HMCLGameRepository repository;
protected final String gameVersion;
protected final Version version;
protected final GameInstanceManifest manifest;
public AdditionalInstallersPage(String gameVersion, Version version, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
this.gameVersion = gameVersion;
this.version = version;
this.manifest = manifest;
this.repository = repository;
txtName.setText(version.getId());
txtName.setText(manifest.id().toString());
txtName.setEditable(false);
for (InstallerItem library : group.getLibraries()) {
@@ -81,8 +80,8 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
@Override
protected void reload() {
Version resolved = version.resolvePreservingPatches(repository);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolved, repository.getGameVersion(resolved).orElse(null));
GameInstanceManifest.Resolved resolvedManifest = repository.resolve(manifest);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null));
String game = analyzer.getVersion(MINECRAFT).orElse(null);
String currentGameVersion = Lang.nonNull(getVersion("game"), game);
@@ -111,4 +110,13 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
@Override
public void cleanup(SettingsMap settings) {
}
@Override
protected boolean showExtendPane() {
return false;
}
@Override
protected void resetDefaultName() {
}
}
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -42,10 +43,10 @@ import org.jackhuang.hmcl.ui.construct.TabHeader;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.DownloadListPage;
import org.jackhuang.hmcl.ui.versions.HMCLLocalizedDownloadListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.ui.instances.DownloadListPage;
import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
@@ -68,11 +69,11 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage {
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_MOD =
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_MOD =
(downloadProvider, repository, version, mod, file) -> download(downloadProvider, repository, version, file, "mods");
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
(downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks");
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_SHADER =
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER =
(downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks");
private final ReadOnlyObjectWrapper<DecoratorPage.State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1));
@@ -92,16 +93,16 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
this(null);
}
public DownloadPage(String uploadVersion) {
public DownloadPage(GameInstanceID uploadInstance) {
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
"game", versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
Versions.downloadModpackImpl(downloadProvider, repository, uploadVersion, modpack, file);
Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file);
}, false);
JFXButton installLocalModpackButton = FXUtils.newRaisedButton(i18n("install.modpack"));
installLocalModpackButton.setOnAction(e -> Versions.importModpack());
installLocalModpackButton.setOnAction(e -> Instances.importModpack());
page.getActions().add(installLocalModpackButton);
return page;
@@ -134,17 +135,19 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
private static <T extends Node> Supplier<T> loadVersionFor(Supplier<T> nodeSupplier) {
return () -> {
T node = nodeSupplier.get();
if (node instanceof VersionPage.GameInstanceLoadable loadable) {
if (node instanceof GameInstancePage.GameInstanceLoadable loadable) {
loadable.loadInstance(GameDirectoryManager.getSelectedRepository(), null);
}
return node;
};
}
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable String version, RemoteAddon.Version file, String subdirectoryName) {
if (version == null) version = repository.getSelectedInstance();
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
if (instanceId == null) {
instanceId = repository.getSelectedInstance();
}
Path runDirectory = repository.hasVersion(version) ? repository.getRunDirectory(version) : repository.getBaseDirectory();
Path runDirectory = instanceId != null && repository.hasInstance(instanceId) ? repository.getRunDirectory(instanceId) : repository.getBaseDirectory();
Set<String> existingFiles;
@@ -306,10 +309,10 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
GameBuilder builder = dependencyManager.gameBuilder();
GameBuilder builder = dependencyManager.newGameBuilder();
String name = (String) settings.get("name");
builder.name(name);
GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
builder.name(instanceId);
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
settings.asStringMap().forEach((key, value) -> {
@@ -318,10 +321,11 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
builder.version(remoteVersion);
});
repository.applyDefaultIsolationSettingForNewInstance(name, settings.isInstallingModdedVersion());
repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion());
return builder.buildAsync().whenComplete(any -> {
repository.refreshVersions();
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
repository.refresh();
repository.applyDefaultIsolationSetting(instanceId);
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
}
@Override
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.InstallerItem;
@@ -42,8 +43,8 @@ public class InstallersPage extends AbstractInstallersPage {
txtName.getValidators().addAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.versionIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
installable.bind(createBooleanBinding(txtName::validate, txtName.textProperty()));
txtName.textProperty().addListener((obs, oldText, newText) -> isNameModifiedByUser = true);
@@ -91,22 +92,22 @@ public class InstallersPage extends AbstractInstallersPage {
}
protected void onInstall() {
String name = txtName.getText();
GameInstanceID instanceId = new GameInstanceID(txtName.getText());
if (!checkName(name)) {
if (!checkName(instanceId.id())) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
MessageDialogPane.MessageType.QUESTION)
.yesOrNo(() -> {
controller.getSettings().put("name", name);
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}, () -> {
// The user selects Cancel and does nothing.
})
.build());
} else {
controller.getSettings().put("name", name);
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}
}
@@ -143,4 +144,15 @@ public class InstallersPage extends AbstractInstallersPage {
txtName.setText(nameBuilder.toString());
isNameModifiedByUser = false;
}
@Override
protected boolean showExtendPane() {
return true;
}
@Override
protected void resetDefaultName() {
isNameModifiedByUser = false;
setTxtNameWithLoaders();
}
}
@@ -66,8 +66,8 @@ public final class LocalModpackPage extends ModpackPage {
if (installAsVersion) {
txtModpackName.getValidators().setAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.versionIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
} else {
txtModpackName.getValidators().setAll(
new RequiredValidator(),
@@ -75,7 +75,7 @@ public final class LocalModpackPage extends ModpackPage {
&& GameDirectoryManager.getGameDirectories().stream()
.noneMatch(existingProfile ->
str.equals(GameDirectoryManager.getGameDirectoryCustomName(existingProfile)))),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
}
});
}
@@ -90,7 +90,7 @@ public final class LocalModpackPage extends ModpackPage {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile == null) {
controller.onEnd();
return;
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
import org.jackhuang.hmcl.game.ModpackHelper;
@@ -57,14 +58,14 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
this(repository, modpackFile, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, String updateVersion) {
this(repository, null, updateVersion);
public ModpackInstallWizardProvider(HMCLGameRepository repository, GameInstanceID updateInstanceId) {
this(repository, null, updateInstanceId);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, String updateVersion) {
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, GameInstanceID updateInstanceId) {
this.repository = repository;
this.file = modpackFile;
this.updateVersion = updateVersion;
this.updateVersion = updateInstanceId != null ? updateInstanceId.toString() : null;
}
public void setIconUrl(String iconUrl) {
@@ -99,6 +100,8 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
if ((selected == null && serverModpackManifest == null) || modpack == null || name == null) return null;
GameInstanceID instanceId = new GameInstanceID(name);
if (updateVersion != null) {
if (selected == null) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
@@ -106,9 +109,9 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
}
try {
if (serverModpackManifest != null) {
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), name, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(name)));
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
} else {
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), name, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(name)));
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
}
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
@@ -120,11 +123,11 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
return null;
} else {
if (serverModpackManifest != null) {
return ModpackHelper.getInstallTask(repository, serverModpackManifest, name, modpack)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
} else {
return ModpackHelper.getInstallTask(repository, selected, name, modpack, iconUrl)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl)
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
}
}
}
@@ -62,8 +62,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
var archiveNamePane = new LinePane();
{
FXUtils.setLimitHeight(archiveNamePane, 75);
archiveNamePane.setTitle(i18n("version.name"));
archiveNamePane.setTitle(i18n("instance.name"));
txtModpackName = new JFXTextField();
txtModpackName.setPrefWidth(300);
@@ -27,6 +27,7 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.SVGPath;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.FileDownloadTask;
@@ -42,9 +43,7 @@ import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -112,9 +111,8 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip", "*.mrpack"));
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile == null) {
Platform.runLater(controller::onEnd);
return;
}
@@ -158,15 +156,15 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
TaskCancellationAction.NORMAL
);
}
} catch (IOException e) {
handler.reject(e.getMessage());
} catch (Exception e) {
handler.reject(i18n("message.failed"));
}
}, "", new URLValidator());
}
public void onChooseRepository() {
String modPackName = controller.getSettings().get(MODPACK_NAME);
DownloadPage downloadPage = new DownloadPage(modPackName);
DownloadPage downloadPage = new DownloadPage(modPackName != null ? new GameInstanceID(modPackName) : null);
downloadPage.showModpackDownloads();
Controllers.navigate(downloadPage);
}
@@ -66,8 +66,8 @@ public final class RemoteModpackPage extends ModpackPage {
txtModpackName.setText(manifest.getName().trim());
txtModpackName.getValidators().addAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.versionIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId));
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
}
btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription()));
@@ -21,8 +21,8 @@ import javafx.scene.Node;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
import org.jackhuang.hmcl.download.game.LibraryDownloadException;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.Task;
@@ -49,15 +49,15 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependencyManager;
private final String gameVersion;
private final Version version;
private final GameInstanceManifest manifest;
private final String libraryId;
private final String oldLibraryVersion;
private final DownloadProvider downloadProvider;
public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull Version version, @NotNull String libraryId, @Nullable String oldLibraryVersion) {
public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull GameInstanceManifest manifest, @NotNull String libraryId, @Nullable String oldLibraryVersion) {
this.repository = repository;
this.gameVersion = gameVersion;
this.version = version;
this.manifest = manifest;
this.libraryId = libraryId;
this.oldLibraryVersion = oldLibraryVersion;
this.downloadProvider = DownloadProviders.getDownloadProvider();
@@ -76,7 +76,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
// We remove library but not save it,
// so if installation failed will not break down current version.
Task<Version> ret = Task.supplyAsync(() -> version);
Task<GameInstanceManifest> ret = Task.supplyAsync(() -> manifest);
var hints = new ArrayList<Task.StagesHint>();
for (Object value : settings.asStringMap().values()) {
if (value instanceof RemoteVersion remoteVersion) {
@@ -91,7 +91,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
}
return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshVersionsAsync()).withStagesHints(hints);
return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshAsync()).withStagesHints(hints);
}
@Override
@@ -103,7 +103,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
controller.onFinish();
} else if ("game".equals(libraryId)) {
String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion();
controller.onNext(new AdditionalInstallersPage(newGameVersion, version, controller, repository, downloadProvider));
controller.onNext(new AdditionalInstallersPage(newGameVersion, manifest, controller, repository, downloadProvider));
} else {
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()),
i18n("install.change_version"), controller::onFinish, controller::onCancel);
@@ -128,7 +128,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
public static void alertFailureMessage(Exception exception, Runnable next) {
if (exception instanceof LibraryDownloadException) {
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().getName()) + "\n";
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().name()) + "\n";
if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
int responseCode = rce.getResponseCode();
@@ -1,95 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.GameBuilder;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class VanillaInstallWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependencyManager;
private final DownloadProvider downloadProvider;
public VanillaInstallWizardProvider(HMCLGameRepository repository) {
this.repository = repository;
this.downloadProvider = DownloadProviders.getDownloadProvider();
this.dependencyManager = repository.getDependency(downloadProvider);
}
@Override
public void start(SettingsMap settings) {
settings.put(ModpackPage.GAME_DIRECTORY, repository.getGameDirectory());
settings.put(ModpackPage.REPOSITORY, repository);
}
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
GameBuilder builder = dependencyManager.gameBuilder();
String name = (String) settings.get("name");
builder.name(name);
builder.gameVersion(((RemoteVersion) settings.get("game")).getGameVersion());
settings.asStringMap().forEach((key, value) -> {
if (!"game".equals(key) && value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
repository.applyDefaultIsolationSettingForNewInstance(name, settings.isInstallingModdedVersion());
return builder.buildAsync().whenComplete(any -> {
repository.refreshVersions();
})
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name));
}
@Override
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.new_game.installation"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
return finishVersionDownloadingAsync(settings);
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer.game")), "", downloadProvider, "game",
() -> controller.onNext(new InstallersPage(controller, repository, ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider)));
default:
throw new IllegalStateException("error step " + step + ", settings: " + settings + ", pages: " + controller.getPages());
}
}
@Override
public boolean cancel() {
return true;
}
}
@@ -45,7 +45,7 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -213,7 +213,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
@Override
public void updateItem(RemoteVersion remoteVersion, boolean empty) {
RemoteVersion oldRemoteVersion = getItem();
super.updateItem(remoteVersion, empty);
if (empty) {
@@ -239,49 +239,49 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
switch (versionType) {
case RELEASE -> {
twoLineListItem.addTag(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
twoLineListItem.addTag(i18n("instance.game.release"));
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
}
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("version.game.april_fools"));
imageView.setImage(VersionIconType.APRIL_FOOLS.getIcon());
twoLineListItem.addTag(i18n("instance.game.april_fools"));
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
} else {
twoLineListItem.addTag(i18n("version.game.snapshot"));
imageView.setImage(VersionIconType.COMMAND.getIcon());
twoLineListItem.addTag(i18n("instance.game.snapshot"));
imageView.setImage(GameInstanceIconType.COMMAND.getIcon());
}
}
default -> {
twoLineListItem.addTag(i18n("version.game.old"));
imageView.setImage(VersionIconType.CRAFT_TABLE.getIcon());
twoLineListItem.addTag(i18n("instance.game.old"));
imageView.setImage(GameInstanceIconType.CRAFT_TABLE.getIcon());
}
}
switch (NativePatcher.checkSupportedStatus(gameVersion, Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION)) {
case UNTESTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.untested"));
case UNSUPPORTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.unsupported"));
case UNTESTED -> twoLineListItem.addTagWarning(i18n("instance.game.support_status.untested"));
case UNSUPPORTED -> twoLineListItem.addTagWarning(i18n("instance.game.support_status.unsupported"));
}
} else {
VersionIconType iconType;
GameInstanceIconType iconType;
if (remoteVersion instanceof LiteLoaderRemoteVersion)
iconType = VersionIconType.CHICKEN;
iconType = GameInstanceIconType.CHICKEN;
else if (remoteVersion instanceof OptiFineRemoteVersion)
iconType = VersionIconType.OPTIFINE;
iconType = GameInstanceIconType.OPTIFINE;
else if (remoteVersion instanceof ForgeRemoteVersion)
iconType = VersionIconType.FORGE;
iconType = GameInstanceIconType.FORGE;
else if (remoteVersion instanceof CleanroomRemoteVersion)
iconType = VersionIconType.CLEANROOM;
iconType = GameInstanceIconType.CLEANROOM;
else if (remoteVersion instanceof NeoForgeRemoteVersion)
iconType = VersionIconType.NEO_FORGE;
iconType = GameInstanceIconType.NEO_FORGE;
else if (remoteVersion instanceof LegacyFabricRemoteVersion || remoteVersion instanceof LegacyFabricAPIRemoteVersion)
iconType = VersionIconType.LEGACY_FABRIC;
iconType = GameInstanceIconType.LEGACY_FABRIC;
else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion)
iconType = VersionIconType.FABRIC;
iconType = GameInstanceIconType.FABRIC;
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = VersionIconType.QUILT;
iconType = GameInstanceIconType.QUILT;
else
iconType = VersionIconType.COMMAND;
iconType = GameInstanceIconType.COMMAND;
imageView.setImage(iconType.getIcon());
String displayGameVersion = I18n.getDisplayVersion(GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()));
@@ -334,7 +334,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
{
nameField = new JFXTextField();
nameField.setPromptText(i18n("version.search.prompt"));
nameField.setPromptText(i18n("instance.search.prompt"));
nameField.textProperty().addListener(o -> updateList());
if ("game".equals(control.libraryId)) {
@@ -354,7 +354,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
);
categoryField.getSelectionModel().select(VersionTypeFilter.ALL);
}
categoryField.setConverter(stringConverter(type -> i18n("version.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.setConverter(stringConverter(type -> i18n("instance.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.getSelectionModel().selectedItemProperty().addListener(o -> updateList());
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
@@ -362,13 +362,13 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
if (control.versionList.hasType()) {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("version.game.type")), categoryField,
new Label(i18n("instance.search")), nameField,
new Label(i18n("instance.game.type")), categoryField,
refreshButton
);
} else {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("instance.search")), nameField,
refreshButton
);
}
@@ -394,7 +394,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
RemoteVersionListCell dummyCell = new RemoteVersionListCell(control);
dummyCell.twoLineListItem.setTitle("Dummy");
dummyCell.twoLineListItem.setSubtitle("Dummy");
dummyCell.imageView.setImage(VersionIconType.GRASS.getIcon());
dummyCell.imageView.setImage(GameInstanceIconType.GRASS.getIcon());
prepareNode(dummyCell.pane);
list.setFixedCellSize(dummyCell.pane.prefHeight(-1));
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.export;
import javafx.scene.Node;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
@@ -47,11 +48,11 @@ import static org.jackhuang.hmcl.setting.SettingsManager.settings;
public final class ExportWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final String version;
private final GameInstanceID instanceId;
public ExportWizardProvider(HMCLGameRepository repository, String version) {
public ExportWizardProvider(HMCLGameRepository repository, GameInstanceID instanceId) {
this.repository = repository;
this.version = version;
this.instanceId = instanceId;
}
@Override
@@ -164,7 +165,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new McbbsModpackExportTask(repository, version, exportInfo, modpackFile);
dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile);
}
@Override
@@ -184,8 +185,8 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
GameSettings.Effective setting = repository.getEffectiveGameSettings(version);
dependency = new MultiMCModpackExportTask(repository, version, exportInfo.getWhitelist(),
GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId);
dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(),
new MultiMCInstanceConfiguration(
"OneSix",
exportInfo.getName() + "-" + exportInfo.getVersion(),
@@ -232,7 +233,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new ServerModpackExportTask(repository, version, exportInfo, modpackFile);
dependency = new ServerModpackExportTask(repository, instanceId, exportInfo, modpackFile);
}
@Override
@@ -254,7 +255,7 @@ public final class ExportWizardProvider implements WizardProvider {
public void execute() {
dependency = new ModrinthModpackExportTask(
repository,
version,
instanceId,
exportInfo,
modpackFile
);
@@ -271,8 +272,8 @@ public final class ExportWizardProvider implements WizardProvider {
public Node createPage(WizardController controller, int step, SettingsMap settings) {
return switch (step) {
case 0 -> new ModpackTypeSelectionPage(controller);
case 1 -> new ModpackInfoPage(controller, repository, version);
case 2 -> new ModpackFileSelectionPage(controller, repository, version, ModAdviser::suggestMod);
case 1 -> new ModpackInfoPage(controller, repository, instanceId);
case 2 -> new ModpackFileSelectionPage(controller, repository, instanceId, ModAdviser::suggestMod);
default -> throw new IllegalArgumentException("step");
};
}
@@ -27,16 +27,21 @@ import javafx.scene.control.Label;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
@@ -44,6 +49,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -56,37 +62,69 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class ModpackFileSelectionPage extends BorderPane implements WizardPage {
private final WizardController controller;
private final String version;
private final GameInstanceID instanceId;
private final ModAdviser adviser;
private final ModpackFileTreeItem rootNode;
private @Nullable ModpackFileTreeItem rootNode;
public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, String version, ModAdviser adviser) {
public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId, ModAdviser adviser) {
this.controller = controller;
this.version = version;
this.instanceId = instanceId;
this.adviser = adviser;
JFXTreeView<String> treeView = new JFXTreeView<>();
rootNode = getTreeItem(repository.getRunDirectory(version), "minecraft", 0);
treeView.setRoot(rootNode);
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
onEscPressed(treeView, () -> controller.onPrev(true));
setMargin(treeView, new Insets(10, 10, 5, 10));
this.setCenter(treeView);
Label placeholder = new Label(i18n("modpack.files.empty"));
StackPane placeholderPane = new StackPane(placeholder);
placeholderPane.getStyleClass().add("notice-pane");
placeholderPane.setVisible(false);
SpinnerPane spinnerPane = new SpinnerPane();
StackPane center = new StackPane(treeView, placeholderPane);
spinnerPane.setContent(center);
setMargin(spinnerPane, new Insets(10, 10, 5, 10));
this.setCenter(spinnerPane);
HBox nextPane = new HBox();
nextPane.setPadding(new Insets(16, 16, 16, 0));
nextPane.setAlignment(Pos.CENTER_RIGHT);
{
JFXButton btnNext = FXUtils.newRaisedButton(i18n("wizard.next"));
btnNext.setPrefSize(100, 40);
btnNext.setOnAction(e -> onNext());
nextPane.getChildren().setAll(btnNext);
}
JFXButton btnNext = FXUtils.newRaisedButton(i18n("wizard.next"));
btnNext.setPrefSize(100, 40);
btnNext.setOnAction(e -> onNext());
nextPane.getChildren().setAll(btnNext);
loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext);
spinnerPane.setOnFailedAction((__) -> loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext));
this.setBottom(nextPane);
}
private void loadRoot(HMCLGameRepository repository, JFXTreeView<String> treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) {
spinnerPane.setLoading(true);
btnNext.setDisable(true);
CompletableFuture
.supplyAsync(() -> getTreeItem(repository.getRunDirectory(instanceId), "minecraft", 0), Schedulers.io())
.whenCompleteAsync((root, throwable) -> {
if (throwable == null) {
if (root != null) {
treeView.setRoot(rootNode = root);
} else {
placeholderPane.setVisible(true);
}
spinnerPane.setFailedReason(null);
btnNext.setDisable(false);
} else {
LOG.warning("Failed to load modpack file tree", throwable);
spinnerPane.setFailedReason(i18n("modpack.files.load_failed"));
}
spinnerPane.setLoading(false);
}, Schedulers.javafx());
}
private ModpackFileTreeItem getTreeItem(Path file, String basePath, int level) {
if (Files.notExists(file))
return null;
@@ -94,7 +132,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
boolean isDirectory = Files.isDirectory(file);
ModAdviser.ModSuggestion state = ModAdviser.ModSuggestion.SUGGESTED;
if (basePath.length() > "minecraft/".length()) {
if (level > 0) {
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (isDirectory ? "/" : ""), isDirectory);
String fileName = FileUtils.getName(file);
@@ -107,12 +145,12 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
if (fileName.startsWith("._")) // macOS system file
state = ModAdviser.ModSuggestion.HIDDEN;
if (FileUtils.getNameWithoutExtension(file).equals(version))
if (FileUtils.getNameWithoutExtension(file).equals(instanceId.toString()))
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (isDirectory) {
if (fileName.equals(version + "-natives")) { // Ignore <version>-natives
if (fileName.equals(instanceId + "-natives")) { // Ignore <version>-natives
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (level == 1 && fileName.startsWith("natives-")) { // Ignore natives-os-arch
@@ -123,7 +161,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
return null;
}
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? version : StringUtils.substringAfterLast(basePath, '/'), basePath);
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? instanceId.toString() : StringUtils.substringAfterLast(basePath, '/'), basePath);
if (state == ModAdviser.ModSuggestion.SUGGESTED)
node.setSelected(true);
@@ -35,6 +35,7 @@ import javafx.stage.FileChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
@@ -47,7 +48,6 @@ import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
@@ -65,9 +65,9 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackInfoPage extends Control implements WizardPage {
private final WizardController controller;
private final HMCLGameRepository gameRepository;
private final HMCLGameRepository repository;
private final ModpackExportInfo.Options options;
private final String versionName;
private final GameInstanceID instanceId;
private final boolean canIncludeLauncher;
private final ModpackExportInfo exportInfo = new ModpackExportInfo();
@@ -88,19 +88,19 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepository, String version) {
public ModpackInfoPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId) {
this.controller = controller;
this.gameRepository = gameRepository;
this.repository = repository;
this.options = controller.getSettings().get(MODPACK_INFO_OPTION);
this.versionName = version;
this.instanceId = instanceId;
if (this.options == null)
throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required");
name.set(version);
name.set(instanceId.toString());
author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse(""));
GameSettings.Effective versionSetting = gameRepository.getEffectiveGameSettings(versionName);
GameSettings.Effective versionSetting = repository.getEffectiveGameSettings(this.instanceId);
minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0));
launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty));
javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty));
@@ -118,7 +118,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
fileChooser.setInitialFileName(name.get() + ".zip");
}
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
Path file = Controllers.showSaveDialog(fileChooser);
if (file == null) {
controller.onEnd();
return;
@@ -213,7 +213,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
var instanceNamePane = new LineTextPane();
{
instanceNamePane.setTitle(i18n("modpack.wizard.step.initialization.exported_version"));
instanceNamePane.setText(skinnable.versionName);
instanceNamePane.setText(skinnable.instanceId.toString());
list.getContent().add(instanceNamePane);
}
@@ -49,8 +49,8 @@ import org.jackhuang.hmcl.setting.property.SettingProperty;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.VersionIconDialog;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.instances.GameInstanceIconDialog;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.ServerAddress;
@@ -76,7 +76,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// @author Glavo
@NotNullByDefault
public final class GameSettingsPage<S extends GameSettings> extends StackPane
implements DecoratorPage, VersionPage.GameInstanceLoadable, PageAware {
implements DecoratorPage, GameInstancePage.GameInstanceLoadable, PageAware {
private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object();
private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden");
@@ -95,7 +95,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
private @Nullable HMCLGameRepository repository;
/// The current instance ID.
private @Nullable String instanceId;
private @Nullable GameInstanceID instanceId;
/// The current setting.
private final ObjectProperty<@Nullable S> currentSetting = new SimpleObjectProperty<>(this, "setting");
@@ -787,6 +787,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
graphicsSettings.getContent().add(vulkanRendererPane);
vulkanRendererPane.setTitle(i18n("settings.advanced.renderer.vulkan"));
var highPerformancePane = createInheritableBooleanButton(GameSettings::highPerformanceProperty);
graphicsSettings.getContent().add(highPerformancePane);
highPerformancePane.setTitle(i18n("settings.advanced.renderer.gpu_preferences"));
highPerformancePane.setSubtitle(i18n("settings.advanced.windows_only"));
this.currentGameVersionNumber.addListener((o, oldValue, newValue) -> {
boolean showBackendChoose = isPresetSetting || newValue.compareTo("26.2-snapshot-2") >= 0;
graphicsBackendPane.setVisible(showBackendChoose);
@@ -1721,7 +1726,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
refresh.invalidated(instance);
fireEvent(new VersionPage.WorkingDirChangedEvent());
fireEvent(new GameInstancePage.WorkingDirChangedEvent());
});
currentSetting.addListener((observable, oldValue, newValue) -> {
@@ -1811,7 +1816,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
refresh.invalidated(instance);
fireEvent(new VersionPage.WorkingDirChangedEvent());
fireEvent(new GameInstancePage.WorkingDirChangedEvent());
event.consume();
});
@@ -1833,7 +1838,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
updating.value = false;
}
fireEvent(new VersionPage.WorkingDirChangedEvent());
fireEvent(new GameInstancePage.WorkingDirChangedEvent());
});
currentSetting.addListener((observable, oldValue, newValue) -> {
@@ -1864,7 +1869,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
return "";
}
return repository.getVersionRoot(instanceId).toString();
return repository.getInstanceRoot(instanceId).toString();
}
/// Keeps a listener attached to the current instance's parent preset property.
@@ -2625,7 +2630,7 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
@SuppressWarnings("unchecked")
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.gameDirectory = repository.getGameDirectory();
this.repository = repository;
this.instanceId = instanceId;
@@ -2652,6 +2657,11 @@ 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
@@ -2691,12 +2701,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
/// Backs up and overwrites the current instance's `instance-game-settings.json`.
private void forceOverwriteInstanceGameSettings() {
if (repository == null || instanceId == null) {
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null) {
return;
}
Controllers.confirmBackupAndOverwrite(i18n("settings.game.instance_settings.unsupported"), () -> {
repository.forceOverwriteInstanceGameSettings(instanceId);
repository.forceOverwriteInstanceGameSettings(loadedInstanceId);
setSettingsReadOnly(false, "");
});
}
@@ -2710,10 +2721,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
}
private void loadIcon() {
if (repository == null || instanceId == null)
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null)
return;
iconPickerItem.setImage(repository.getVersionIconImage(instanceId));
iconPickerItem.setImage(repository.getInstanceIconImage(loadedInstanceId));
}
/// Refreshes Java selection controls and keeps inherited parent Java properties observed.
@@ -2782,7 +2794,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
initializeSelectedJava();
JavaVersionType javaVersionType = setting.javaTypeProperty().getValue();
GameSettings.Effective effectiveSetting = this.instanceId != null && repository != null ? repository.getEffectiveGameSettings(this.instanceId) : null;
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
GameSettings.Effective effectiveSetting = loadedInstanceId != null && repository != null ? repository.getEffectiveGameSettings(loadedInstanceId) : null;
JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getInheritable(GameSettings::javaTypeProperty) : javaVersionType;
boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION;
@@ -2799,17 +2812,17 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
if (JavaManager.isInitialized()) {
GameVersionNumber gameVersionNumber = this.currentGameVersionNumber.get();
Version version;
GameInstanceManifest manifest;
if (this.instanceId == null) {
version = null;
manifest = null;
} else {
version = repository != null ? repository.getResolvedVersion(this.instanceId) : null;
manifest = repository != null && loadedInstanceId != null ? repository.getResolvedInstanceManifest(loadedInstanceId).launchManifest() : null;
}
try {
JavaRuntime java = effectiveSetting != null
? effectiveSetting.getJava(gameVersionNumber, version)
: resolveEffectiveSetting(setting).getJava(gameVersionNumber, version);
? effectiveSetting.getJava(gameVersionNumber, manifest)
: resolveEffectiveSetting(setting).getJava(gameVersionNumber, manifest);
if (java != null) {
javaSublist.setDescription(java.getBinary().toString());
} else {
@@ -2827,17 +2840,18 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
if (repository == null || instanceId == null)
return;
Controllers.dialog(new VersionIconDialog(repository, instanceId, this::loadIcon));
Controllers.dialog(new GameInstanceIconDialog(repository, instanceId, this::loadIcon));
}
private void onDeleteIcon() {
if (repository == null || instanceId == null)
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
if (repository == null || loadedInstanceId == null)
return;
repository.deleteIconFile(instanceId);
GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(instanceId);
repository.deleteIconFile(loadedInstanceId);
GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(loadedInstanceId);
if (localGameSettings != null) {
localGameSettings.iconProperty().setValue(VersionIconType.DEFAULT);
localGameSettings.iconProperty().setValue(GameInstanceIconType.DEFAULT);
}
loadIcon();
}
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.LocalAddonFile;
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
@@ -206,7 +206,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
fileName.set(data.localAddonFile().getFileName());
currentVersion.set(data.currentVersion().version());
targetVersion.set(data.targetVersion().version());
switch (data.currentVersion().self().getType()) {
switch (data.currentVersion().self().getSource()) {
case CURSEFORGE:
source.set(i18n("addon.curseforge"));
break;
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.BooleanProperty;
import javafx.collections.ObservableList;
@@ -91,8 +91,7 @@ public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.Da
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("datapack.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.datapack"), "*.zip"));
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res != null) {
installMultiDataPack(res);
}
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
@@ -25,6 +25,7 @@ import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -33,6 +34,7 @@ import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
@@ -51,7 +53,9 @@ 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.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.SoftReference;
@@ -68,6 +72,7 @@ import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final TransitionPane toolbarPane;
@@ -79,10 +84,15 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final JFXListView<DataPackInfoObject> listView;
private final FilteredList<DataPackInfoObject> filteredList;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final BooleanProperty isSelecting = 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));
private static final AtomicInteger lastShiftClickIndex = new AtomicInteger(-1);
final Consumer<Integer> toggleSelect;
@@ -148,16 +158,21 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> filteredList.setPredicate(skinnable.updateSearchPredicate(searchField.getText())));
searchPause.setOnFinished(e -> filteredList.setPredicate(skinnable.updateSearchPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
isSearching.set(false);
searchField.clear();
searchPause.stop();
filteredList.setPredicate(null);
isSearching.set(false);
});
FXUtils.onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().addAll(searchField, closeSearchBar);
@@ -193,6 +208,23 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnly));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("datapack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("datapack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
this.listView.setItems(filteredList);
listView.getItems().addListener(listener);
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
@@ -38,8 +38,9 @@ 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.Version;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
@@ -67,17 +68,17 @@ 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, VersionPage.GameInstanceLoadable {
public class DownloadListPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final boolean versionSelection;
private final boolean instanceSelection;
private final ObjectProperty<HMCLGameRepository.InstanceReference> 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());
private final ObservableList<String> versions = FXCollections.observableArrayList();
private final StringProperty selectedVersion = new SimpleStringProperty();
private final ObservableList<GameInstanceID> instances = FXCollections.observableArrayList();
private final ObjectProperty<GameInstanceID> selectedInstance = new SimpleObjectProperty<>();
private final DownloadPage.DownloadCallback callback;
private boolean searchInitialized = false;
protected final BooleanProperty supportChinese = new SimpleBooleanProperty();
@@ -95,10 +96,10 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
this(repository, null, false);
}
public DownloadListPage(RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean versionSelection) {
public DownloadListPage(RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
this.repository = repository;
this.callback = callback;
this.versionSelection = versionSelection;
this.instanceSelection = instanceSelection;
this.downloadProvider = DownloadProviders.getDownloadProvider();
}
@@ -111,7 +112,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
}
@Override
public void loadInstance(HMCLGameRepository repository, @Nullable String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId));
setLoading(false);
@@ -122,11 +123,11 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
search("", null, 0, "", RemoteAddonRepository.SortType.POPULARITY);
}
if (versionSelection) {
versions.setAll(repository.getDisplayVersions()
.map(Version::getId)
.collect(Collectors.toList()));
selectedVersion.set(repository.getSelectedInstance());
if (instanceSelection) {
instances.setAll(repository.getDisplayInstanceManifests()
.map(GameInstanceManifest::id)
.toList());
selectedInstance.set(repository.getSelectedInstance());
}
}
@@ -154,8 +155,8 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
this.loading.set(loading);
}
public void selectVersion(String versionID) {
FXUtils.runInFX(() -> selectedVersion.set(versionID));
public void selectInstance(GameInstanceID instanceId) {
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
}
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
@@ -166,12 +167,10 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
int currentSearchID = searchID = searchID + 1;
Task.supplyAsync(() -> {
HMCLGameRepository.InstanceReference instanceReference = this.instanceReference.get();
if (StringUtils.isBlank(instanceReference.instanceId())) {
if (instanceReference.instanceId() == null) {
return userGameVersion;
} else {
return StringUtils.isNotBlank(instanceReference.instanceId())
? instanceReference.repository().getGameVersion(instanceReference.instanceId()).orElse("")
: "";
return instanceReference.repository().getGameVersion(instanceReference.instanceId()).orElse("");
}
}).thenApplyAsync(
gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
@@ -219,8 +218,9 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
}
protected HMCLGameRepository.InstanceReference getInstanceReference() {
if (versionSelection) {
return new HMCLGameRepository.InstanceReference(instanceReference.get().repository(), selectedVersion.get());
if (instanceSelection) {
@Nullable GameInstanceID instanceId = selectedInstance.get();
return new HMCLGameRepository.InstanceReference(instanceReference.get().repository(), instanceId);
} else {
return instanceReference.get();
}
@@ -273,18 +273,18 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
{
int rowIndex = 0;
if (control.versionSelection || !control.downloadSources.isEmpty()) {
if (control.instanceSelection || !control.downloadSources.isEmpty()) {
searchPane.addRow(rowIndex);
int columns = 0;
Node lastNode = null;
if (control.versionSelection) {
JFXComboBox<String> versionsComboBox = new JFXComboBox<>();
versionsComboBox.setMaxWidth(Double.MAX_VALUE);
Bindings.bindContent(versionsComboBox.getItems(), control.versions);
selectedItemPropertyFor(versionsComboBox).bindBidirectional(control.selectedVersion);
if (control.instanceSelection) {
JFXComboBox<GameInstanceID> instancesComboBox = new JFXComboBox<>();
instancesComboBox.setMaxWidth(Double.MAX_VALUE);
Bindings.bindContent(instancesComboBox.getItems(), control.instances);
selectedItemPropertyFor(instancesComboBox).bindBidirectional(control.selectedInstance);
searchPane.add(new Label(i18n("version")), columns++, rowIndex);
searchPane.add(lastNode = versionsComboBox, columns++, rowIndex);
searchPane.add(new Label(i18n("instance")), columns++, rowIndex);
searchPane.add(lastNode = instancesComboBox, columns++, rowIndex);
}
if (control.downloadSources.getSize() > 1) {
@@ -329,7 +329,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
FXUtils.installFastTooltip(gameVersionField, i18n("search.enter"));
FXUtils.onChangeAndOperate(getSkinnable().instanceReference, instanceReference -> {
if (StringUtils.isNotBlank(instanceReference.instanceId())) {
if (instanceReference.instanceId() != null) {
GridPane.setColumnSpan(nameField, 3);
} else {
GridPane.setColumnSpan(nameField, 1);
@@ -519,13 +519,13 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
if (getSkinnable().isFailed()) {
return i18n("download.failed.refresh");
} else if (!getSkinnable().isLoading() && getSkinnable().pageCount.get() >= 0 && getSkinnable().items.isEmpty()) {
return i18n("download.failed.no_results_found");
return i18n("search.no_results_found");
} else {
return null;
}
},
getSkinnable().failedProperty(),
getSkinnable().loadingProperty(),
getSkinnable().failedProperty(),
getSkinnable().loadingProperty(),
getSkinnable().pageCount,
getSkinnable().items)
);
@@ -542,6 +542,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.setCellFactory(x -> new ListCell<>() {
private static final Insets PADDING = new Insets(9, 9, 0, 9);
private static final Insets LAST_PADDING = new Insets(9, 9, 9, 9);
private final RipplerContainer graphic;
private final StackPane wrapper = new StackPane();
@@ -584,6 +585,12 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
if (empty || item == null) {
setGraphic(null);
} else {
setPadding(
getIndex() == getListView().getItems().size() - 1
? LAST_PADDING
: PADDING
);
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.title());
String description = item.description();
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
@@ -31,8 +31,9 @@ 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.Version;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
@@ -42,12 +43,11 @@ 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.SVGContainer;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
@@ -171,7 +171,7 @@ public class DownloadPage extends Control implements DecoratorPage {
fileChooser.setTitle(i18n("button.save_as"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("file"), "*." + extension));
fileChooser.setInitialFileName(file.file().filename());
Path dest = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
Path dest = Controllers.showSaveDialog(fileChooser);
if (dest == null) {
return;
}
@@ -271,13 +271,13 @@ public class DownloadPage extends Control implements DecoratorPage {
if (control.instanceReference.repository() != null && control.instanceReference.instanceId() != null) {
HMCLGameRepository repository = control.instanceReference.repository();
Version game = repository.getResolvedPreservingPatchesVersion(control.instanceReference.instanceId());
String gameVersion = repository.getGameVersion(game).orElse(null);
GameInstanceManifest.Resolved resolvedManifest = repository.getResolvedInstanceManifest(control.instanceReference.instanceId());
String gameVersion = repository.getGameVersion(resolvedManifest.unresolved()).orElse(null);
if (gameVersion != null && control.versions.containsKey(gameVersion)) {
List<RemoteAddon.Version> modVersions = control.versions.get(gameVersion);
if (modVersions != null && !modVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = LibraryAnalyzer.analyze(game, gameVersion).getModLoaders();
Set<ModLoaderType> targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders();
resolve:
for (RemoteAddon.Version modVersion : modVersions) {
@@ -437,19 +437,34 @@ public class DownloadPage extends Control implements DecoratorPage {
content.setTitle(dataItem.name());
content.setSubtitle(I18n.formatDateTime(dataItem.datePublished()));
switch (dataItem.versionType()) {
case Alpha:
content.addTag(i18n("addon.channel.alpha"));
graphicPane.getChildren().setAll(SVG.ALPHA_CIRCLE.createIcon(24));
break;
case Beta:
content.addTag(i18n("addon.channel.beta"));
graphicPane.getChildren().setAll(SVG.BETA_CIRCLE.createIcon(24));
break;
case Release:
content.addTag(i18n("addon.channel.release"));
graphicPane.getChildren().setAll(SVG.RELEASE_CIRCLE.createIcon(24));
break;
@Nullable SVG svg = null;
@Nullable String styleClass = null;
if (dataItem.versionType() != null) {
switch (dataItem.versionType()) {
case Alpha:
content.addTag(i18n("addon.channel.alpha"));
svg = SVG.ALPHA_CIRCLE;
styleClass = "alpha";
break;
case Beta:
content.addTag(i18n("addon.channel.beta"));
svg = SVG.BETA_CIRCLE;
styleClass = "beta";
break;
case Release:
content.addTag(i18n("addon.channel.release"));
svg = SVG.RELEASE_CIRCLE;
styleClass = "release";
break;
default:
styleClass = "";
break;
}
}
if (svg != null) {
SVGContainer icon = svg.createIcon(24);
icon.getStyleClass().addAll("addon-channel-icon", styleClass);
graphicPane.getChildren().setAll(icon);
}
for (ModLoaderType modLoaderType : dataItem.loaders()) {
@@ -472,6 +487,9 @@ public class DownloadPage extends Control implements DecoratorPage {
case QUILT:
content.addTag(i18n("install.installer.quilt"));
break;
case LEGACY_FABRIC:
content.addTag(i18n("install.installer.legacyfabric"));
break;
}
}
@@ -557,8 +575,8 @@ public class DownloadPage extends Control implements DecoratorPage {
this.setActions(downloadButton, saveAsButton, cancelButton);
}
this.prefWidthProperty().bind(BindingMapping.of(Controllers.getStage().widthProperty()).map(w -> w.doubleValue() * 0.7));
this.prefHeightProperty().bind(BindingMapping.of(Controllers.getStage().heightProperty()).map(w -> w.doubleValue() * 0.7));
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
onEscPressed(this, cancelButton::fire);
}
@@ -615,6 +633,6 @@ public class DownloadPage extends Control implements DecoratorPage {
@FunctionalInterface
public interface DownloadCallback {
void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable String version, RemoteAddon addon, RemoteAddon.Version file);
void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon addon, RemoteAddon.Version file);
}
}
@@ -15,15 +15,16 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import javafx.geometry.Pos;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.setting.VersionIconType;
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;
@@ -37,11 +38,11 @@ public class GameAdvancedListItem extends AdvancedListItem {
private final ImageContainer imageContainer;
private final WeakListenerHolder holder = new WeakListenerHolder();
private HMCLGameRepository repository;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private Consumer<Event> onVersionIconChangedListener;
@SuppressWarnings("unused")
private Consumer<Event> onInstanceIconChangedListener;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private Consumer<RefreshedVersionsEvent> onRefreshedVersionsListener;
private Consumer<RefreshedGameInstancesEvent> onRefreshedInstancesListener;
public GameAdvancedListItem() {
this.imageContainer = new ImageContainer(LEFT_GRAPHIC_SIZE);
@@ -49,33 +50,34 @@ public class GameAdvancedListItem extends AdvancedListItem {
AdvancedListItem.setAlignment(imageContainer, Pos.CENTER);
setLeftGraphic(imageContainer);
holder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), it -> this.loadVersion()));
holder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), this::loadInstance));
}
private void loadVersion() {
String version = GameDirectoryManager.getSelectedInstance();
boolean repositoryChanged = GameDirectoryManager.getSelectedRepository() != repository;
if (repositoryChanged) {
private void loadInstance(GameInstanceID instanceId) {
if (GameDirectoryManager.getSelectedRepository() != repository) {
repository = GameDirectoryManager.getSelectedRepository();
onVersionIconChangedListener = repository.onVersionIconChanged.registerWeak(event -> {
FXUtils.runInFX(this::loadVersion);
});
if (!repository.isLoaded()) {
onRefreshedVersionsListener = EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class)
.registerWeak(event -> FXUtils.runInFX(this::loadVersion));
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;
}
}
if (version != null && repository != null && repository.hasVersion(version)) {
setTitle(i18n("version.manage.manage"));
setSubtitle(version);
imageContainer.setImage(repository.getVersionIconImage(version));
} else {
setTitle(i18n("version.empty"));
setSubtitle(i18n("version.empty.add"));
imageContainer.setImage(VersionIconType.DEFAULT.getIcon());
}
setTitle(i18n("instance.empty"));
setSubtitle(i18n("instance.empty.add"));
imageContainer.setImage(GameInstanceIconType.DEFAULT.getIcon());
}
}
@@ -15,22 +15,22 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
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.setting.GameSettings;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.DialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Path;
@@ -38,17 +38,17 @@ import java.nio.file.Path;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class VersionIconDialog extends DialogPane {
public class GameInstanceIconDialog extends DialogPane {
private final HMCLGameRepository repository;
private final String versionId;
private final GameInstanceID instanceId;
private final Runnable onFinish;
private final GameSettings.Instance setting;
public VersionIconDialog(HMCLGameRepository repository, String versionId, Runnable onFinish) {
public GameInstanceIconDialog(HMCLGameRepository repository, GameInstanceID instanceId, Runnable onFinish) {
this.repository = repository;
this.versionId = versionId;
this.instanceId = instanceId;
this.onFinish = onFinish;
this.setting = repository.getInstanceGameSettingsOrCreate(versionId);
this.setting = repository.getInstanceGameSettingsOrCreate(this.instanceId);
setTitle(i18n("settings.icon"));
FlowPane pane = new FlowPane();
@@ -56,33 +56,33 @@ public class VersionIconDialog extends DialogPane {
pane.getChildren().setAll(
createCustomIcon(),
createIcon(VersionIconType.GRASS),
createIcon(VersionIconType.CHEST),
createIcon(VersionIconType.CHICKEN),
createIcon(VersionIconType.COMMAND),
createIcon(VersionIconType.APRIL_FOOLS),
createIcon(VersionIconType.OPTIFINE),
createIcon(VersionIconType.CRAFT_TABLE),
createIcon(VersionIconType.FABRIC),
createIcon(VersionIconType.LEGACY_FABRIC),
createIcon(VersionIconType.FORGE),
createIcon(VersionIconType.CLEANROOM),
createIcon(VersionIconType.NEO_FORGE),
createIcon(VersionIconType.FURNACE),
createIcon(VersionIconType.QUILT)
createIcon(GameInstanceIconType.GRASS),
createIcon(GameInstanceIconType.CHEST),
createIcon(GameInstanceIconType.CHICKEN),
createIcon(GameInstanceIconType.COMMAND),
createIcon(GameInstanceIconType.APRIL_FOOLS),
createIcon(GameInstanceIconType.OPTIFINE),
createIcon(GameInstanceIconType.CRAFT_TABLE),
createIcon(GameInstanceIconType.FABRIC),
createIcon(GameInstanceIconType.LEGACY_FABRIC),
createIcon(GameInstanceIconType.FORGE),
createIcon(GameInstanceIconType.CLEANROOM),
createIcon(GameInstanceIconType.NEO_FORGE),
createIcon(GameInstanceIconType.FURNACE),
createIcon(GameInstanceIconType.QUILT)
);
}
private void exploreIcon() {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().add(FXUtils.getImageExtensionFilter());
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path selectedFile = Controllers.showOpenDialog(chooser);
if (selectedFile != null) {
try {
repository.setVersionIconFile(versionId, selectedFile);
repository.setInstanceIconFile(instanceId, selectedFile);
if (setting != null) {
setting.iconProperty().setValue(VersionIconType.DEFAULT);
setting.iconProperty().setValue(GameInstanceIconType.DEFAULT);
}
onAccept();
@@ -102,7 +102,7 @@ public class VersionIconDialog extends DialogPane {
return container;
}
private Node createIcon(VersionIconType type) {
private Node createIcon(GameInstanceIconType type) {
ImageView imageView = new ImageView(type.getIcon());
imageView.setMouseTransparent(true);
RipplerContainer container = new RipplerContainer(imageView);
@@ -119,7 +119,7 @@ public class VersionIconDialog extends DialogPane {
@Override
protected void onAccept() {
repository.onVersionIconChanged.fireEvent(new Event(this));
repository.onInstanceIconChanged.fireEvent(new Event(this));
onFinish.run();
super.onAccept();
}
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXPopup;
import javafx.application.Platform;
@@ -28,7 +28,8 @@ 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.RefreshedVersionsEvent;
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.task.Schedulers;
@@ -53,21 +54,21 @@ import java.util.function.Supplier;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage {
public class GameInstancePage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final TabHeader tab;
private final TabHeader.Tab<GameSettingsPage<GameSettings.Instance>> versionSettingsTab = new TabHeader.Tab<>("versionSettingsTab");
private final TabHeader.Tab<GameSettingsPage<GameSettings.Instance>> gameSettingsTab = new TabHeader.Tab<>("versionSettingsTab");
private final TabHeader.Tab<InstallerListPage> installerListTab = new TabHeader.Tab<>("installerListTab");
private final TabHeader.Tab<ModListPage> modListTab = new TabHeader.Tab<>("modListTab");
private final TabHeader.Tab<WorldListPage> worldListTab = new TabHeader.Tab<>("worldList");
private final TabHeader.Tab<SchematicsPage> schematicsTab = new TabHeader.Tab<>("schematicsTab");
private final TabHeader.Tab<ResourcePackListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
private final TransitionPane transitionPane = new TransitionPane();
private final BooleanProperty currentVersionUpgradable = new SimpleBooleanProperty();
private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty();
private final ObjectProperty<HMCLGameRepository.InstanceReference> instanceReference = new SimpleObjectProperty<>();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private String preferredVersionName = null;
private GameInstanceID preferredInstanceId = null;
public static class WorkingDirChangedEvent extends Event {
public static final EventType<WorkingDirChangedEvent> EVENT_TYPE = new EventType<>(Event.ANY, "WORKING_DIR_CHANGED");
@@ -77,44 +78,45 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
}
}
public VersionPage() {
versionSettingsTab.setNodeSupplier(loadVersionFor(() -> new GameSettingsPage<>(GameSettings.Instance.class)));
installerListTab.setNodeSupplier(loadVersionFor(InstallerListPage::new));
modListTab.setNodeSupplier(loadVersionFor(ModListPage::new));
resourcePackTab.setNodeSupplier(loadVersionFor(ResourcePackListPage::new));
worldListTab.setNodeSupplier(loadVersionFor(WorldListPage::new));
schematicsTab.setNodeSupplier(loadVersionFor(SchematicsPage::new));
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));
tab = new TabHeader(transitionPane, versionSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab);
tab.select(versionSettingsTab);
tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab);
tab.select(gameSettingsTab);
addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
addEventHandler(WorkingDirChangedEvent.EVENT_TYPE, event -> {
if (this.instanceReference.get() != null) {
if (installerListTab.isInitialized())
installerListTab.getNode().loadInstance(getRepository(), getVersion());
installerListTab.getNode().loadInstance(getRepository(), getInstanceId());
if (modListTab.isInitialized())
modListTab.getNode().loadInstance(getRepository(), getVersion());
modListTab.getNode().loadInstance(getRepository(), getInstanceId());
if (resourcePackTab.isInitialized())
resourcePackTab.getNode().loadInstance(getRepository(), getVersion());
resourcePackTab.getNode().loadInstance(getRepository(), getInstanceId());
if (worldListTab.isInitialized())
worldListTab.getNode().loadInstance(getRepository(), getVersion());
worldListTab.getNode().loadInstance(getRepository(), getInstanceId());
if (schematicsTab.isInitialized())
schematicsTab.getNode().loadInstance(getRepository(), getVersion());
schematicsTab.getNode().loadInstance(getRepository(), getInstanceId());
}
});
listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> checkSelectedVersion(), EventPriority.HIGHEST));
listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> checkSelectedInstance(), EventPriority.HIGHEST));
}
private void checkSelectedVersion() {
private void checkSelectedInstance() {
runInFX(() -> {
if (this.instanceReference.get() == null) return;
HMCLGameRepository repository = this.instanceReference.get().repository();
if (!repository.hasVersion(this.instanceReference.get().instanceId())) {
if (preferredVersionName != null) {
loadVersion(preferredVersionName, repository);
@Nullable GameInstanceID instanceId = this.instanceReference.get().instanceId();
if (instanceId == null || !repository.hasInstance(instanceId)) {
if (preferredInstanceId != null) {
loadInstance(preferredInstanceId, repository);
} else {
fireEvent(new PageCloseEvent());
}
@@ -122,12 +124,13 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
});
}
private <T extends Node> Supplier<T> loadVersionFor(Supplier<T> nodeSupplier) {
private <T extends Node> Supplier<T> loadInstanceFor(Supplier<T> nodeSupplier) {
return () -> {
T node = nodeSupplier.get();
if (instanceReference.get() != null) {
if (node instanceof VersionPage.GameInstanceLoadable) {
((GameInstanceLoadable) node).loadInstance(instanceReference.get().repository(), instanceReference.get().instanceId());
if (node instanceof GameInstancePage.GameInstanceLoadable loadable) {
@Nullable GameInstanceID instanceId = instanceReference.get().instanceId();
loadable.loadInstance(instanceReference.get().repository(), instanceId);
}
}
return node;
@@ -135,38 +138,38 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
}
public void showInstanceSettings() {
tab.select(versionSettingsTab, false);
tab.select(gameSettingsTab, false);
}
public void setVersion(String version, HMCLGameRepository repository) {
this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, version));
public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) {
this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId));
}
public void loadVersion(String version, HMCLGameRepository repository) {
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() ||
!getRepository().hasVersion(version))) {
!getRepository().hasInstance(instanceId))) {
Platform.runLater(() -> fireEvent(new PageCloseEvent()));
return;
}
setVersion(version, repository);
preferredVersionName = version;
setInstance(instanceId, repository);
preferredInstanceId = instanceId;
if (versionSettingsTab.isInitialized())
versionSettingsTab.getNode().loadInstance(repository, version);
if (gameSettingsTab.isInitialized())
gameSettingsTab.getNode().loadInstance(repository, instanceId);
if (installerListTab.isInitialized())
installerListTab.getNode().loadInstance(repository, version);
installerListTab.getNode().loadInstance(repository, instanceId);
if (modListTab.isInitialized())
modListTab.getNode().loadInstance(repository, version);
modListTab.getNode().loadInstance(repository, instanceId);
if (resourcePackTab.isInitialized())
resourcePackTab.getNode().loadInstance(repository, version);
resourcePackTab.getNode().loadInstance(repository, instanceId);
if (worldListTab.isInitialized())
worldListTab.getNode().loadInstance(repository, version);
worldListTab.getNode().loadInstance(repository, instanceId);
if (schematicsTab.isInitialized())
schematicsTab.getNode().loadInstance(repository, version);
currentVersionUpgradable.set(repository.isModpack(version));
schematicsTab.getNode().loadInstance(repository, instanceId);
currentInstanceUpgradable.set(repository.isModpack(instanceId));
}
private void onNavigated(Navigator.NavigationEvent event) {
@@ -176,20 +179,20 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
// If we jumped to game list page and deleted this version
// and back to this page, we should return to main page.
if (!getRepository().isLoaded() ||
!getRepository().hasVersion(getVersion())) {
!getRepository().hasInstance(getInstanceId())) {
Platform.runLater(() -> fireEvent(new PageCloseEvent()));
return;
}
loadVersion(getVersion(), getRepository());
loadInstance(getInstanceId(), getRepository());
}
private void onBrowse(String sub) {
FXUtils.openFolder(getRepository().getRunDirectory(getVersion()).resolve(sub));
FXUtils.openFolder(getRepository().getRunDirectory(getInstanceId()).resolve(sub));
}
private void redownloadAssetIndex() {
Versions.updateGameAssets(getRepository(), getVersion());
Instances.updateGameAssets(getRepository(), getInstanceId());
}
private void clearLibraries() {
@@ -224,44 +227,46 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
}
private void clearJunkFiles() {
Versions.cleanVersion(getRepository(), getVersion());
Instances.cleanInstance(getRepository(), getInstanceId());
}
private void testGame() {
Versions.testGame(getRepository(), getVersion());
Instances.testGame(getRepository(), getInstanceId());
}
private void updateGame() {
Versions.updateVersion(getRepository(), getVersion());
Instances.updateInstance(getRepository(), getInstanceId());
}
private void generateLaunchScript() {
Versions.generateLaunchScript(getRepository(), getVersion());
Instances.generateLaunchScript(getRepository(), getInstanceId());
}
private void export() {
Versions.exportVersion(getRepository(), getVersion());
Instances.exportInstance(getRepository(), getInstanceId());
}
private void rename() {
Versions.renameVersion(getRepository(), getVersion())
.thenApply(newVersionName -> this.preferredVersionName = newVersionName);
Instances.renameInstance(getRepository(), getInstanceId())
.thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId));
}
private void remove() {
Versions.deleteVersion(getRepository(), getVersion());
Instances.deleteInstance(getRepository(), getInstanceId());
}
private void duplicate() {
Versions.duplicateVersion(getRepository(), getVersion());
Instances.duplicateInstance(getRepository(), getInstanceId());
}
public HMCLGameRepository getRepository() {
return Optional.ofNullable(instanceReference.get()).map(HMCLGameRepository.InstanceReference::repository).orElse(null);
}
public String getVersion() {
return Optional.ofNullable(instanceReference.get()).map(HMCLGameRepository.InstanceReference::instanceId).orElse(null);
public @Nullable GameInstanceID getInstanceId() {
return Optional.ofNullable(instanceReference.get())
.map(HMCLGameRepository.InstanceReference::instanceId)
.orElse(null);
}
@Override
@@ -274,19 +279,19 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
return state.getReadOnlyProperty();
}
public static class Skin extends DecoratorAnimatedPageSkin<VersionPage> {
public static class Skin extends DecoratorAnimatedPageSkin<GameInstancePage> {
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
protected Skin(VersionPage control) {
protected Skin(GameInstancePage control) {
super(control);
{
AdvancedListBox sideBar = new AdvancedListBox()
.addNavigationDrawerTab(control.tab, control.versionSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL)
.addNavigationDrawerTab(control.tab, control.gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL)
.addNavigationDrawerTab(control.tab, control.installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL)
.addNavigationDrawerTab(control.tab, control.modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(control.tab, control.resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE)
@@ -312,25 +317,25 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
PopupMenu managementList = new PopupMenu();
JFXPopup managementPopup = new JFXPopup(managementList);
managementList.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), control::testGame, managementPopup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), control::generateLaunchScript, managementPopup),
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch.test"), control::testGame, managementPopup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), control::generateLaunchScript, managementPopup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), control::rename, managementPopup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), control::duplicate, managementPopup),
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), control::remove, managementPopup),
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), control::rename, managementPopup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), control::duplicate, managementPopup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), control::remove, managementPopup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), control::export, managementPopup),
new MenuSeparator(),
new IconedMenuItem(null, i18n("version.manage.redownload_assets_index"), control::redownloadAssetIndex, managementPopup),
new IconedMenuItem(null, i18n("version.manage.remove_assets"), control::clearAssets, managementPopup),
new IconedMenuItem(null, i18n("version.manage.remove_libraries"), control::clearLibraries, managementPopup),
new IconedMenuItem(null, i18n("version.manage.clean"), control::clearJunkFiles, managementPopup).addTooltip(i18n("version.manage.clean.tooltip"))
new IconedMenuItem(null, i18n("instance.manage.redownload_assets_index"), control::redownloadAssetIndex, managementPopup),
new IconedMenuItem(null, i18n("instance.manage.remove_assets"), control::clearAssets, managementPopup),
new IconedMenuItem(null, i18n("instance.manage.remove_libraries"), control::clearLibraries, managementPopup),
new IconedMenuItem(null, i18n("instance.manage.clean"), control::clearJunkFiles, managementPopup).addTooltip(i18n("instance.manage.clean.tooltip"))
);
AdvancedListBox toolbar = new AdvancedListBox()
.addNavigationDrawerItem(i18n("version.update"), SVG.UPDATE, control::updateGame, upgradeItem -> {
upgradeItem.visibleProperty().bind(control.currentVersionUpgradable);
.addNavigationDrawerItem(i18n("instance.update"), SVG.UPDATE, control::updateGame, upgradeItem -> {
upgradeItem.visibleProperty().bind(control.currentInstanceUpgradable);
})
.addNavigationDrawerItem(i18n("version.launch.test"), SVG.ROCKET_LAUNCH, control::testGame)
.addNavigationDrawerItem(i18n("instance.launch.test"), SVG.ROCKET_LAUNCH, control::testGame)
.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, null, browseMenuItem -> {
browseMenuItem.setOnAction(e -> browsePopup.show(browseMenuItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, browseMenuItem.getWidth(), 0));
})
@@ -344,7 +349,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
}
control.state.bind(Bindings.createObjectBinding(() ->
State.fromTitle(i18n("version.manage.manage.title", getSkinnable().getVersion()), -1),
State.fromTitle(i18n("instance.manage.manage.title", getSkinnable().getInstanceId()), -1),
getSkinnable().instanceReference));
//control.transitionPane.getStyleClass().add("gray-background");
@@ -359,6 +364,6 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
///
/// @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 String instanceId);
void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId);
}
}
@@ -15,11 +15,12 @@
* 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.versions;
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.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.setting.GameDirectory;
@@ -44,6 +45,7 @@ public class GameItem {
protected final HMCLGameRepository repository;
protected final String id;
protected final GameInstanceID instanceId;
private boolean initialized = false;
private StringProperty title;
@@ -51,9 +53,10 @@ public class GameItem {
private StringProperty subtitle;
private ObjectProperty<Image> image;
public GameItem(HMCLGameRepository repository, String id) {
public GameItem(HMCLGameRepository repository, GameInstanceID instanceId) {
this.repository = repository;
this.id = id;
this.id = instanceId.toString();
this.instanceId = instanceId;
}
public GameDirectory getGameDirectory() {
@@ -83,10 +86,10 @@ public class GameItem {
CompletableFuture.supplyAsync(() -> {
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
Optional<String> gameVersion = repository.getGameVersion(id);
Optional<String> gameVersion = repository.getGameVersion(instanceId);
String modPackVersion = null;
try {
ModpackConfiguration<?> config = repository.readModpackConfiguration(id);
ModpackConfiguration<?> config = repository.readModpackConfiguration(instanceId);
modPackVersion = config != null ? config.getVersion() : null;
} catch (IOException e) {
LOG.warning("Failed to read modpack configuration from " + id, e);
@@ -99,7 +102,7 @@ public class GameItem {
}
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedPreservingPatchesVersion(id), result.gameVersion);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), result.gameVersion);
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
String libraryId = mark.getLibraryId();
String libraryVersion = mark.getLibraryVersion();
@@ -118,7 +121,7 @@ public class GameItem {
}, Schedulers.javafx());
title.set(id);
image.set(repository.getVersionIconImage(id));
image.set(repository.getInstanceIconImage(instanceId));
}
public ReadOnlyStringProperty titleProperty() {
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.beans.property.SimpleStringProperty;
@@ -29,6 +29,7 @@ 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.*;
@@ -69,7 +70,7 @@ public final class GameListCell extends ListCell<GameListItem> {
fireEvent(new ActionEvent());
GameListItem item = GameListCell.this.getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getId());
item.getRepository().setSelectedInstance(new GameInstanceID(item.getId()));
}
}
}
@@ -113,7 +114,7 @@ public final class GameListCell extends ListCell<GameListItem> {
if (item != null)
item.update();
});
FXUtils.installFastTooltip(btnUpgrade, i18n("version.update"));
FXUtils.installFastTooltip(btnUpgrade, i18n("instance.update"));
right.getChildren().add(btnUpgrade);
this.btnLaunch = FXUtils.newToggleButton4(SVG.ROCKET_LAUNCH);
@@ -123,7 +124,7 @@ public final class GameListCell extends ListCell<GameListItem> {
item.testGame();
});
BorderPane.setAlignment(btnLaunch, Pos.CENTER);
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test"));
FXUtils.installFastTooltip(btnLaunch, i18n("instance.launch.test"));
right.getChildren().add(btnLaunch);
this.btnManage = FXUtils.newToggleButton4(SVG.MORE_VERT);
@@ -191,14 +192,14 @@ public final class GameListCell extends ListCell<GameListItem> {
JFXPopup popup = new JFXPopup(menu);
menu.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), item::testGame, popup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup),
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch.test"), item::testGame, popup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), item::generateLaunchScript, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), item::modifyGameSettings, popup),
new IconedMenuItem(SVG.SETTINGS, i18n("instance.manage.manage"), item::modifyGameSettings, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), item::remove, popup),
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), item::remove, popup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), item::export, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
@@ -15,12 +15,13 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.binding.Bindings;
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.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -30,11 +31,14 @@ public class GameListItem extends GameItem {
private final boolean isModpack;
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public GameListItem(HMCLGameRepository repository, String id) {
super(repository, id);
this.isModpack = repository.isModpack(id);
public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) {
super(repository, instanceId);
this.isModpack = repository.isModpack(instanceId);
selected.bind(Bindings.createBooleanBinding(
() -> repository.getGameDirectory() == GameDirectoryManager.getSelectedGameDirectory() && Objects.equals(repository.getSelectedInstance(), id),
() -> {
if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false;
return Objects.equals(repository.getSelectedInstance(), instanceId);
},
GameDirectoryManager.selectedGameDirectoryProperty(),
GameDirectoryManager.selectedInstanceProperty()));
}
@@ -44,39 +48,39 @@ public class GameListItem extends GameItem {
}
public void rename() {
Versions.renameVersion(repository, id);
Instances.renameInstance(repository, instanceId);
}
public void duplicate() {
Versions.duplicateVersion(repository, id);
Instances.duplicateInstance(repository, instanceId);
}
public void remove() {
Versions.deleteVersion(repository, id);
Instances.deleteInstance(repository, instanceId);
}
public void export() {
Versions.exportVersion(repository, id);
Instances.exportInstance(repository, instanceId);
}
public void browse() {
Versions.openFolder(repository, id);
Instances.openFolder(repository, instanceId);
}
public void testGame() {
Versions.testGame(repository, id);
Instances.testGame(repository, instanceId);
}
public void launch() {
Versions.launch(repository, id);
Instances.launch(repository, instanceId);
}
public void modifyGameSettings() {
Versions.modifyGameSettings(repository, id);
Instances.modifyGameSettings(repository, instanceId);
}
public void generateLaunchScript() {
Versions.generateLaunchScript(repository, id);
Instances.generateLaunchScript(repository, instanceId);
}
public boolean canUpdate() {
@@ -84,6 +88,6 @@ public class GameListItem extends GameItem {
}
public void update() {
Versions.updateVersion(repository, id);
Instances.updateInstance(repository, instanceId);
}
}
@@ -15,20 +15,24 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
@@ -51,10 +55,11 @@ import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
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.directory.GameDirectoryListItem;
import org.jackhuang.hmcl.ui.directory.GameDirectoryPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
@@ -70,7 +75,7 @@ import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("version.manage")));
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("instance.manage")));
/// Navigation drawer items for configured game directories.
@SuppressWarnings("FieldCanBeLocal")
private final ObservableList<GameDirectoryListItem> gameDirectoryListItems;
@@ -101,12 +106,11 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
wrapper.getChildren().setAll(box, addGameDirectoryItem);
pane.setContent(wrapper);
}
FXUtils.smoothScrolling(pane);
AdvancedListBox bottomLeftCornerList = new AdvancedListBox()
.addNavigationDrawerItem(i18n("install.new_game"), SVG.ADD_CIRCLE, Versions::addNewGame)
.addNavigationDrawerItem(i18n("install.modpack"), SVG.PACKAGE2, Versions::importModpack)
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings);
FXUtils.setLimitHeight(bottomLeftCornerList, 40 * 3 + 12 * 2);
FXUtils.setLimitHeight(bottomLeftCornerList, 40 + 12 * 2);
setLeft(pane, bottomLeftCornerList);
}
@@ -118,13 +122,13 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file), i18n("install.modpack"));
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Versions.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
});
}
public void modifyGlobalGameSettings() {
Versions.modifyGlobalSettings(GameDirectoryManager.getSelectedRepository());
Instances.modifyGlobalSettings(GameDirectoryManager.getSelectedRepository());
}
@Override
@@ -143,7 +147,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
GameDirectoryManager.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
setOnFailedAction(e -> Instances.addNewGame());
}
@FXThread
@@ -152,12 +156,12 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
setLoading(true);
setFailedReason(null);
List<GameListItem> versionItems = repository.getDisplayVersions().map(instance -> new GameListItem(repository, instance.getId())).toList();
List<GameListItem> versionItems = repository.getDisplayInstanceManifests().map(instance -> new GameListItem(repository, instance.id())).toList();
sourceList.setAll(versionItems);
if (versionItems.isEmpty()) {
setFailedReason(i18n("version.empty.hint"));
setFailedReason(i18n("instance.empty.hint"));
}
setLoading(false);
@@ -182,7 +186,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
}
public void refreshList() {
GameDirectoryManager.getSelectedRepository().refreshVersionsAsync().start();
GameDirectoryManager.getSelectedRepository().refreshAsync().start();
}
@Override
@@ -195,6 +199,9 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
private final HBox searchBar;
private final HBox toolbarNormal;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
public GameListSkin(GameList skinnable) {
@@ -222,20 +229,26 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> skinnable.filteredList.setPredicate(skinnable.createPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
pause.setRate(1);
pause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> {
changeToolbar(toolbarNormal);
searchField.clear();
pause.stop();
skinnable.filteredList.setPredicate(null);
isSearching.set(false);
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.new_game"), SVG.DOWNLOAD, Instances::addNewGame), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::importModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
@@ -255,6 +268,15 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label();
placeholderLabel.textProperty().bind(
Bindings.when(isSearching).then(i18n("search.no_results_found")).otherwise("")
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
center.setContent(listView);
root.getContent().add(center);
}
@@ -269,6 +291,8 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
runInFX(searchField::requestFocus);
isSearching.set(true);
}
}
}
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
@@ -33,8 +33,9 @@ import javafx.scene.control.ListView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
@@ -48,19 +49,21 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
/// Shows an instance selection popup relative to its owner.
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
HMCLGameRepository repository, List<Version> versions) {
HMCLGameRepository repository, List<GameInstanceManifest> versions) {
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, repository, versions);
}
/// Shows and returns an instance selection popup relative to its owner.
public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
HMCLGameRepository repository, List<Version> versions) {
double initOffsetX, double initOffsetY,
HMCLGameRepository repository, List<GameInstanceManifest> versions) {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream()
.filter(it -> repository.hasVersion(it.getId()))
.map(it -> new GameItem(repository, it.getId()))
.filter(it -> repository.hasInstance(it.id()))
.map(it -> new GameItem(repository, it.id()))
.toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY);
@@ -82,7 +85,7 @@ public final class GameListPopupMenu extends StackPane {
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(50).add(2));
Label placeholder = new Label(i18n("version.empty"));
Label placeholder = new Label(i18n("instance.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> {
@@ -135,7 +138,7 @@ public final class GameListPopupMenu extends StackPane {
FXUtils.onClicked(rootPane, () -> {
GameItem item = getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getId());
item.getRepository().setSelectedInstance(new GameInstanceID(item.getId()));
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
@@ -30,42 +30,42 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
public static DownloadListPage ofMod(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
public static DownloadListPage ofMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofCurseForgeMod(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, null);
public static DownloadListPage ofCurseForgeMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, null);
}
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
}
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
}
public static DownloadListPage ofCurseForgeResourcePack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, null);
public static DownloadListPage ofCurseForgeResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, null);
}
public static DownloadListPage ofModrinthResourcePack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.RESOURCE_PACK, null, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
public static DownloadListPage ofModrinthResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, null, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteAddonRepository.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
page.supportChinese.set(false);
return page;
}
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean versionSelection, RemoteAddonRepository.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
super(null, callback, versionSelection);
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddonRepository.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
super(null, callback, instanceSelection);
repository = new Repository(type, curseForge, modrinth);
@@ -15,15 +15,16 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import javafx.application.Platform;
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.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
@@ -32,6 +33,7 @@ import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.download.UpdateInstallerWizardProvider;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Arrays;
@@ -42,10 +44,10 @@ import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InstallerListPage extends ListPageBase<InstallerItem> implements VersionPage.GameInstanceLoadable {
public class InstallerListPage extends ListPageBase<InstallerItem> implements GameInstancePage.GameInstanceLoadable {
private HMCLGameRepository repository;
private String versionId;
private Version version;
private GameInstanceID instanceId;
private GameInstanceManifest manifest;
private String gameVersion;
{
@@ -61,16 +63,16 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ve
}
@Override
public void loadInstance(HMCLGameRepository repository, String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.versionId = instanceId;
this.version = repository.getVersion(instanceId);
this.instanceId = instanceId;
this.manifest = repository.getInstanceManifest(instanceId);
this.gameVersion = null;
CompletableFuture.supplyAsync(() -> {
gameVersion = repository.getGameVersion(version).orElse(null);
gameVersion = repository.getGameVersion(manifest).orElse(null);
return LibraryAnalyzer.analyze(repository.getResolvedPreservingPatchesVersion(instanceId), gameVersion);
return LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion);
}).thenAcceptAsync(analyzer -> {
itemsProperty().clear();
@@ -98,13 +100,13 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ve
}
item.setOnInstall(() -> {
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(repository, gameVersion, version, libraryId, libraryVersion));
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(repository, gameVersion, manifest, libraryId, libraryVersion));
});
item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(version, libraryId)
item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId)
.thenComposeAsync(repository::saveAsync)
.withComposeAsync(repository.refreshVersionsAsync())
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.versionId))
.withComposeAsync(repository.refreshAsync())
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId))
.start());
itemsProperty().add(item);
@@ -123,10 +125,10 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ve
InstallerItem installerItem = new InstallerItem(libraryId, InstallerItem.Style.LIST_ITEM);
installerItem.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false));
installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(version, libraryId)
installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId)
.thenComposeAsync(repository::saveAsync)
.withComposeAsync(repository.refreshVersionsAsync())
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.versionId))
.withComposeAsync(repository.refreshAsync())
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId))
.start());
itemsProperty().add(installerItem);
@@ -137,21 +139,21 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ve
public void installOffline() {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.modloader.installer"), "*.jar", "*.exe"));
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path file = Controllers.showOpenDialog(chooser);
if (file != null) doInstallOffline(file);
}
private void doInstallOffline(Path file) {
Task<?> task = repository.getDependency().installLibraryAsync(version, file)
Task<?> task = repository.getDependency().installLibraryAsync(manifest, file)
.thenComposeAsync(repository::saveAsync)
.thenComposeAsync(repository.refreshVersionsAsync());
.thenComposeAsync(repository.refreshAsync());
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, versionId);
loadInstance(repository, instanceId);
Controllers.dialog(i18n("install.success"));
} else {
if (executor.getException() == null)
@@ -15,13 +15,12 @@
* 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.versions;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
@@ -29,8 +28,11 @@ 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.*;
import org.jackhuang.hmcl.addon.RemoteAddon;
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.setting.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
@@ -47,6 +49,7 @@ import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
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;
@@ -62,8 +65,8 @@ import java.util.function.Consumer;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class Versions {
private Versions() {
public final class Instances {
private Instances() {
}
public static void addNewGame() {
@@ -78,7 +81,7 @@ public final class Versions {
}
}
public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGameRepository repository, String version, RemoteAddon mod, RemoteAddon.Version file) {
public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGameRepository repository, GameInstanceID instanceId, RemoteAddon mod, RemoteAddon.Version file) {
Path modpack;
List<URI> downloadURLs;
try {
@@ -92,11 +95,12 @@ public final class Versions {
}
Controllers.taskDialog(
new FileDownloadTask(downloadURLs, modpack)
.setName(file.name())
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
ModpackInstallWizardProvider installWizardProvider;
if (version != null)
installWizardProvider = new ModpackInstallWizardProvider(repository, modpack, version);
if (instanceId != null)
installWizardProvider = new ModpackInstallWizardProvider(repository, modpack, instanceId);
else
installWizardProvider = new ModpackInstallWizardProvider(repository, modpack);
if (StringUtils.isNotBlank(mod.iconUrl()))
@@ -115,19 +119,19 @@ public final class Versions {
);
}
public static void deleteVersion(HMCLGameRepository repository, String version) {
boolean isIndependent = repository.getRunDirectory(version).toAbsolutePath().normalize()
.equals(repository.getVersionRoot(version).toAbsolutePath().normalize());
String message = isIndependent ? i18n("version.manage.remove.confirm.independent", version) :
i18n("version.manage.remove.confirm.trash", version, version + "_removed");
public static void deleteInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
boolean isIndependent = repository.getRunDirectory(instanceId).toAbsolutePath().normalize()
.equals(repository.getInstanceRoot(instanceId).toAbsolutePath().normalize());
String message = isIndependent ? i18n("instance.manage.remove.confirm.independent", instanceId) :
i18n("instance.manage.remove.confirm.trash", instanceId, instanceId + "_removed");
JFXButton deleteButton = new JFXButton(i18n("button.delete"));
deleteButton.getStyleClass().add("dialog-error");
deleteButton.setOnAction(e -> {
Task.supplyAsync(Schedulers.io(), () -> repository.removeVersionFromDisk(version))
Task.supplyAsync(Schedulers.io(), () -> repository.removeInstanceFromDisk(instanceId))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || !Boolean.TRUE.equals(result)) {
Controllers.dialog(i18n("version.manage.remove.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
Controllers.dialog(i18n("instance.manage.remove.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}).start();
});
@@ -135,50 +139,57 @@ public final class Versions {
Controllers.confirmAction(message, i18n("message.warning"), MessageDialogPane.MessageType.WARNING, deleteButton);
}
public static CompletableFuture<String> renameVersion(HMCLGameRepository repository, String version) {
return Controllers.prompt(i18n("version.manage.rename.message"), (newName, handler) -> {
if (newName.equals(version)) {
public static CompletableFuture<String> renameInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
return Controllers.prompt(i18n("instance.manage.rename.message"), (newName, handler) -> {
if (newName.equals(instanceId.toString())) {
handler.resolve();
return;
}
if (repository.renameVersion(version, newName)) {
GameInstanceID oldInstanceId = instanceId;
GameInstanceID newInstanceId = new GameInstanceID(newName);
if (repository.renameInstance(oldInstanceId, newInstanceId)) {
handler.resolve();
repository.refreshVersionsAsync()
repository.refreshAsync()
.thenRunAsync(Schedulers.javafx(), () -> {
if (repository.hasVersion(newName)) {
repository.setSelectedInstance(newName);
if (repository.hasInstance(newInstanceId)) {
repository.setSelectedInstance(newInstanceId);
}
}).start();
} else {
handler.reject(i18n("version.manage.rename.fail"));
handler.reject(i18n("instance.manage.rename.fail"));
}
}, version,
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId),
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.versionIdConflicts(newVersionName) || newVersionName.equals(version)));
}, instanceId.toString(),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId),
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString())));
}
public static void exportVersion(HMCLGameRepository repository, String version) {
Controllers.getDecorator().startWizard(new ExportWizardProvider(repository, version), i18n("modpack.wizard"));
public static void exportInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
Controllers.getDecorator().startWizard(new ExportWizardProvider(repository, instanceId), i18n("modpack.wizard"));
}
public static void openFolder(HMCLGameRepository repository, String version) {
FXUtils.openFolder(repository.getRunDirectory(version));
public static void openFolder(HMCLGameRepository repository, GameInstanceID instanceId) {
FXUtils.openFolder(repository.getRunDirectory(instanceId));
}
public static void installFromJson(HMCLGameRepository repository, Path file) {
Version version;
GameInstanceManifest manifest;
try {
version = repository.readVersionJson(file);
manifest = JsonUtils.fromJsonFile(file, GameInstanceManifest.class);
if (manifest == null) {
throw new IllegalArgumentException("Missing game manifest");
}
} catch (Exception e) {
Controllers.dialog(i18n("install.new_game.malformed_json"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
return;
}
Controllers.prompt(i18n("version.manage.duplicate.prompt"), (result, handler) -> {
Controllers.prompt(i18n("instance.manage.duplicate.prompt"), (result, handler) -> {
handler.resolve();
GameInstanceID instanceId = new GameInstanceID(result);
DefaultDependencyManager dependencyManager = repository.getDependency();
Version newVersion = version.setId(result).setJar(result);
GameInstanceManifest newVersion = manifest.withId(instanceId).withJar(instanceId);
Controllers.taskDialog(
Task.allOf(new GameDownloadTask(dependencyManager, null, newVersion),
@@ -189,71 +200,72 @@ public final class Versions {
// ignore failure
}))
.thenComposeAsync(repository.saveAsync(newVersion))
.thenRunAsync(repository::refreshVersions)
.thenRunAsync(repository::refresh)
.whenComplete(Schedulers.javafx(), (exception) -> {
if (exception == null) {
repository.setSelectedInstance(result);
repository.setSelectedInstance(new GameInstanceID(result));
} else {
Controllers.dialog(
DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
}
}), i18n("install.new_game"), TaskCancellationAction.NORMAL);
}, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.versionIdConflicts(newVersionName)));
}, 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 duplicateVersion(HMCLGameRepository repository, String version) {
public static void duplicateInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
Controllers.prompt(
new PromptDialogPane.Builder(i18n("version.manage.duplicate.prompt"), (res, handler) -> {
String newVersionName = ((PromptDialogPane.Builder.StringQuestion) res.get(1)).getValue();
new PromptDialogPane.Builder(i18n("instance.manage.duplicate.prompt"), (res, handler) -> {
String newInstanceName = ((PromptDialogPane.Builder.StringQuestion) res.get(1)).getValue();
GameInstanceID newInstanceId = new GameInstanceID(newInstanceName);
boolean copySaves = ((PromptDialogPane.Builder.BooleanQuestion) res.get(2)).getValue();
Task.runAsync(() -> repository.duplicateVersion(version, newVersionName, copySaves))
.thenComposeAsync(repository.refreshVersionsAsync())
Task.runAsync(() -> repository.duplicateInstance(instanceId, newInstanceId, copySaves))
.thenComposeAsync(repository.refreshAsync())
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
handler.resolve();
} else {
handler.reject(StringUtils.getStackTrace(exception));
if (!repository.versionIdConflicts(newVersionName)) {
repository.removeVersionFromDisk(newVersionName);
if (!repository.instanceIdConflicts(newInstanceId)) {
repository.removeInstanceFromDisk(newInstanceId);
}
}
}).start();
})
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("version.manage.duplicate.confirm")))
.addQuestion(new PromptDialogPane.Builder.StringQuestion(null, version,
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId),
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.versionIdConflicts(newVersionName))))
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("version.manage.duplicate.duplicate_save"), false)));
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("instance.manage.duplicate.confirm")))
.addQuestion(new PromptDialogPane.Builder.StringQuestion(null, instanceId.toString(),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId),
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName))))
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
}
public static void updateVersion(HMCLGameRepository repository, String version) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository, version));
public static void updateInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository, instanceId));
}
public static void updateGameAssets(HMCLGameRepository repository, String version) {
TaskExecutor executor = new GameAssetDownloadTask(repository.getDependency(), repository.getVersion(version), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true)
public static void updateGameAssets(HMCLGameRepository repository, GameInstanceID instanceId) {
TaskExecutor executor = new GameAssetDownloadTask(repository.getDependency(), repository.getInstanceManifest(instanceId), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true)
.executor();
Controllers.taskDialog(executor, i18n("version.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL);
Controllers.taskDialog(executor, i18n("instance.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL);
executor.start();
}
public static void cleanVersion(HMCLGameRepository repository, String id) {
public static void cleanInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
try {
repository.clean(id);
repository.clean(instanceId);
} catch (IOException e) {
LOG.warning("Unable to clean game directory", e);
}
}
@SafeVarargs
public static void generateLaunchScript(HMCLGameRepository repository, String id, Consumer<LauncherHelper>... injecters) {
if (!checkVersionForLaunching(repository, id))
public static void generateLaunchScript(HMCLGameRepository repository, GameInstanceID instanceId, Consumer<LauncherHelper>... injecters) {
if (!checkVersionForLaunching(repository, instanceId))
return;
ensureSelectedAccount(account -> {
FileChooser chooser = new FileChooser();
if (Files.isDirectory(repository.getRunDirectory(id)))
chooser.setInitialDirectory(repository.getRunDirectory(id).toFile());
chooser.setTitle(i18n("version.launch_script.save"));
if (Files.isDirectory(repository.getRunDirectory(instanceId)))
chooser.setInitialDirectory(repository.getRunDirectory(instanceId).toFile());
chooser.setTitle(i18n("instance.launch_script.save"));
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
chooser.getExtensionFilters().add(
new FileChooser.ExtensionFilter(i18n("extension.command"), "*.command")
@@ -263,14 +275,14 @@ public final class Versions {
? new FileChooser.ExtensionFilter(i18n("extension.bat"), "*.bat")
: new FileChooser.ExtensionFilter(i18n("extension.sh"), "*.sh"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.ps1"), "*.ps1"));
Path file = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage()));
Path file = Controllers.showSaveDialog(chooser);
if (file != null) {
if (!isValidScriptExtension(FileUtils.getExtension(file))) {
String defaultExt = getDefaultScriptExtension();
file = file.resolveSibling(file.getFileName().toString() + "." + defaultExt);
}
LauncherHelper launcherHelper = new LauncherHelper(repository, account, id);
LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId);
for (Consumer<LauncherHelper> injecter : injecters) {
injecter.accept(launcherHelper);
}
@@ -295,11 +307,11 @@ public final class Versions {
}
@SafeVarargs
public static void launch(HMCLGameRepository repository, String id, Consumer<LauncherHelper>... injecters) {
if (!checkVersionForLaunching(repository, id))
public static void launch(HMCLGameRepository repository, GameInstanceID instanceId, Consumer<LauncherHelper>... injecters) {
if (!checkVersionForLaunching(repository, instanceId))
return;
ensureSelectedAccount(account -> {
LauncherHelper launcherHelper = new LauncherHelper(repository, account, id);
LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId);
for (Consumer<LauncherHelper> injecter : injecters) {
injecter.accept(launcherHelper);
}
@@ -307,27 +319,34 @@ public final class Versions {
});
}
public static void testGame(HMCLGameRepository repository, String id) {
launch(repository, id, LauncherHelper::setTestMode);
public static void testGame(HMCLGameRepository repository, GameInstanceID instanceId) {
launch(repository, instanceId, LauncherHelper::setTestMode);
}
public static void launchAndEnterWorld(HMCLGameRepository repository, String id, String worldFolderName) {
launch(repository, id, launcherHelper ->
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, String id, String worldFolderName) {
generateLaunchScript(repository, id, launcherHelper ->
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, String id) {
if (id == null || !repository.isLoaded() || !repository.hasVersion(id)) {
JFXButton gotoDownload = new JFXButton(i18n("version.empty.launch.goto_download"));
private static boolean checkVersionForLaunching(HMCLGameRepository repository, GameInstanceID instanceId) {
boolean unavailable;
if (instanceId == null || !repository.isLoaded()) {
unavailable = true;
} else {
unavailable = !repository.hasInstance(instanceId);
}
if (unavailable) {
JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download"));
gotoDownload.getStyleClass().add("dialog-accept");
gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
Controllers.confirmAction(i18n("version.empty.launch"), i18n("launch.failed"),
Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"),
MessageDialogPane.MessageType.ERROR,
gotoDownload,
null);
@@ -372,10 +391,10 @@ public final class Versions {
Controllers.navigate(Controllers.getSettingsPage());
}
public static void modifyGameSettings(HMCLGameRepository repository, String version) {
Controllers.getVersionPage().setVersion(version, repository);
Controllers.getVersionPage().showInstanceSettings();
public static void modifyGameSettings(HMCLGameRepository repository, GameInstanceID instanceId) {
Controllers.getGameInstancePage().setInstance(instanceId, repository);
Controllers.getGameInstancePage().showInstanceSettings();
// VersionPage.loadVersion will be invoked after navigation
Controllers.navigate(Controllers.getVersionPage());
Controllers.navigate(Controllers.getGameInstancePage());
}
}
@@ -15,16 +15,15 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.ObservableList;
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.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.mod.ModManager;
@@ -39,6 +38,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageAware;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -51,14 +51,12 @@ import java.util.concurrent.locks.ReentrantLock;
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 VersionPage.GameInstanceLoadable, PageAware {
private final BooleanProperty modded = new SimpleBooleanProperty(this, "modded", false);
public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObject> implements GameInstancePage.GameInstanceLoadable, PageAware {
private final ReentrantLock lock = new ReentrantLock();
private ModManager modManager;
private HMCLGameRepository repository;
private String instanceId;
private GameInstanceID instanceId;
private String gameVersion;
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
@@ -86,20 +84,22 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}
@Override
public void loadInstance(HMCLGameRepository repository, String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
Version resolved = repository.getResolvedPreservingPatchesVersion(instanceId);
GameInstanceManifest resolved = repository.getResolvedInstanceManifest(instanceId).standaloneManifest();
this.gameVersion = repository.getGameVersion(resolved).orElse(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolved, gameVersion);
modded.set(analyzer.hasModLoader());
loadMods(repository.getModManager(instanceId));
}
private void loadMods(ModManager modManager) {
setLoading(true);
if (this.modManager != modManager) {
getItems().clear();
}
this.modManager = modManager;
CompletableFuture.supplyAsync(() -> {
lock.lock();
@@ -112,6 +112,10 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
lock.unlock();
}
}, Schedulers.io()).whenCompleteAsync((list, exception) -> {
if (this.modManager != modManager) {
return;
}
updateSupportedLoaders(modManager);
if (exception == null) {
@@ -173,7 +177,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.litemod"));
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null) return;
@@ -236,6 +240,10 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
public void checkUpdates(Collection<LocalModFile> mods) {
Objects.requireNonNull(mods);
if (isLoading()) {
return;
}
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
Optional<String> gameVersion = repository.getGameVersion(instanceId);
@@ -265,7 +273,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}
public void download() {
Controllers.getDownloadPage().showModDownloads().selectVersion(instanceId);
Controllers.getDownloadPage().showModDownloads().selectInstance(instanceId);
Controllers.navigate(Controllers.getDownloadPage());
}
@@ -278,18 +286,6 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
}
}
public boolean isModded() {
return modded.get();
}
public BooleanProperty moddedProperty() {
return modded;
}
public void setModded(boolean modded) {
this.modded.set(modded);
}
public GameDirectory getGameDirectory() {
return this.repository.getGameDirectory();
}
@@ -298,7 +294,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
return this.repository;
}
public String getInstanceId() {
public GameInstanceID getInstanceId() {
return this.instanceId;
}
}
@@ -15,14 +15,16 @@
* 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.versions;
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;
@@ -35,16 +37,16 @@ import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.*;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
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.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -61,6 +63,7 @@ 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;
@@ -80,6 +83,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;
@NotNullByDefault
final class ModListPageSkin extends SkinBase<ModListPage> {
private final TransitionPane toolbarPane;
@@ -88,10 +92,14 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
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;
@FXThread
private boolean isSearching = false;
/// 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);
@@ -101,6 +109,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
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");
@@ -118,19 +127,22 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> search());
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
isSearching = false;
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
@@ -150,7 +162,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
.toList()
)
),
createToolbarButton2(i18n("download"), SVG.DOWNLOAD, skinnable::download),
createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
@@ -192,7 +204,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching ? searchBar : toolbarNormal);
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
@@ -219,9 +231,26 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
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) {
if (isSearching.get()) {
search();
}
});
@@ -242,14 +271,6 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
root.getContent().add(center);
}
Label label = new Label(i18n("mods.not_modded"));
label.prefWidthProperty().bind(pane.widthProperty().add(-100));
FXUtils.onChangeAndOperate(skinnable.moddedProperty(), modded -> {
if (modded) pane.getChildren().setAll(root);
else pane.getChildren().setAll(label);
});
getChildren().setAll(pane);
}
@@ -264,7 +285,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
}
private void search() {
isSearching = true;
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
@@ -343,7 +364,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
LOG.warning("Failed to load mod icons", e);
}
return VersionIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<ModInfoObject>> current) {
@@ -359,7 +380,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
this.iconCache = new SoftReference<>(imageFuture);
}
imageContainer.setImage(VersionIconType.getIconType(localModFile.getModLoaderType()).getIcon());
imageContainer.setImage(GameInstanceIconType.getIconType(localModFile.getModLoaderType()).getIcon());
imageFuture.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<ModInfoObject> infoObjectProperty = current.get();
@@ -379,11 +400,14 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
ModInfoDialog(ModInfoObject modInfo) {
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
titleContainer.setPadding(new Insets(0, 0, 12, 0));
Stage stage = Controllers.getStage();
maxWidthProperty().bind(stage.widthProperty().multiply(0.7));
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();
@@ -392,13 +416,13 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
else
title.setTitle(modInfo.getModInfo().getName());
StringJoiner subtitle = new StringJoiner(" | ");
subtitle.add(FileUtils.getName(modInfo.getModInfo().getFile()));
StringJoiner subtitle = new StringJoiner("\n");
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
subtitle.add(modInfo.getModInfo().getGameVersion());
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
subtitle.add(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());
@@ -418,7 +442,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
description.heightProperty().addListener((obs, oldVal, newVal) -> {
double maxHeight = stage.getHeight() * 0.5;
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
@@ -435,7 +459,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getModById(DownloadProviders.getDownloadProvider(), versionOptional.get().modid());
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
for (ModLoaderType modLoaderType : versionOptional.get().loaders()) {
String loaderName = switch (modLoaderType) {
@@ -543,7 +567,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
content.setMouseTransparent(true);
setSelectable();
imageContainer.setImage(VersionIconType.COMMAND.getIcon());
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.util.Pair;
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
@@ -35,16 +35,18 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.*;
import org.jackhuang.hmcl.addon.LocalAddonFile;
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.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.setting.SettingsManager;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -57,7 +59,7 @@ 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.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -74,7 +76,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 VersionPage.GameInstanceLoadable {
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> implements GameInstancePage.GameInstanceLoadable {
private static final String TIP_KEY = "resourcePackWarning";
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
@@ -89,7 +91,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
private HMCLGameRepository repository;
private String instanceId;
private GameInstanceID instanceId;
private Path resourcePackDirectory;
private ResourcePackManager resourcePackManager;
@@ -106,7 +108,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
@Override
public void loadInstance(HMCLGameRepository repository, String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
this.resourcePackManager = new ResourcePackManager(repository, instanceId);
@@ -176,14 +178,14 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("resourcepack.add"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip"));
List<Path> files = FileUtils.toPaths(fileChooser.showOpenMultipleDialog(Controllers.getStage()));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
private void onDownload() {
Controllers.getDownloadPage().showResourcePackDownloads().selectVersion(instanceId);
Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(instanceId);
Controllers.navigate(Controllers.getDownloadPage());
}
@@ -257,6 +259,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
}
@NotNullByDefault
private static final class ResourcePackListPageSkin extends SkinBase<ResourcePackListPage> {
private final JFXListView<ResourcePackInfoObject> listView;
private final JFXTextField searchField = new JFXTextField();
@@ -266,7 +269,11 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
private final HBox toolbarNormal = new HBox();
private final HBox toolbarSelecting = new HBox();
private boolean isSearching;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
private ResourcePackListPageSkin(ResourcePackListPage control) {
super(control);
@@ -309,19 +316,22 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> search());
searchPause.setOnFinished(e -> search());
FXUtils.onChange(searchField.textProperty(), newValue -> {
pause.setRate(1);
pause.playFromStart();
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
isSearching = false;
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
@@ -339,14 +349,14 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
),
createToolbarButton2(i18n("download"), SVG.DOWNLOAD, control::onDownload),
createToolbarButton2(i18n("resourcepack.download"), SVG.DOWNLOAD, control::onDownload),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching ? searchBar : toolbarNormal);
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
@@ -371,6 +381,23 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
listView.setCellFactory(x -> new ResourcePackListCell(listView, control));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("resourcepack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("resourcepack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), control.getItems());
listView.setOnContextMenuRequested(event -> {
@@ -403,7 +430,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
private void search() {
isSearching = true;
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
@@ -567,8 +594,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
Stage stage = Controllers.getStage();
maxWidthProperty().bind(stage.widthProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
imageContainer.setImage(packInfoObject.getIcon());
@@ -594,7 +620,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
FXUtils.onChange(description.heightProperty(), newVal -> {
double maxHeight = stage.getHeight() * 0.5;
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
@@ -610,7 +636,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(packInfoObject.getFile().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getModById(DownloadProviders.getDownloadProvider(), versionOptional.get().modid());
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
button.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
@@ -34,6 +34,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.schematic.LitematicFile;
import org.jackhuang.hmcl.task.Schedulers;
@@ -62,7 +63,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> implements VersionPage.GameInstanceLoadable {
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> implements GameInstancePage.GameInstanceLoadable {
private static String translateAuthorName(String author) {
if (I18n.isUseChinese() && "hsds".equals(author)) {
@@ -87,7 +88,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
}
@Override
public void loadInstance(HMCLGameRepository repository, String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.schematicsDirectory = repository.getSchematicsDirectory(instanceId);
refresh();
@@ -149,7 +150,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
fileChooser.setTitle(i18n("schematics.add.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
i18n("extension.schematic"), "*.litematic"));
List<Path> files = FileUtils.toPaths(fileChooser.showOpenMultipleDialog(Controllers.getStage()));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
}
@@ -636,6 +637,12 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
private final class SchematicsPageSkin extends ToolbarListPageSkin<Item, SchematicsPage> {
SchematicsPageSkin() {
super(SchematicsPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("schematics.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Task;
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.BooleanProperty;
@@ -24,6 +24,7 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
@@ -162,6 +163,12 @@ public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.Backup
WorldBackupsPageSkin() {
super(WorldBackupsPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.backup.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
@@ -639,7 +639,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png"));
fileChooser.setInitialFileName("icon.png");
Path iconPath = FileUtils.toPath(fileChooser.showOpenDialog(Controllers.getStage()));
Path iconPath = Controllers.showOpenDialog(fileChooser);
if (iconPath == null) return;
Image image;
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
@@ -27,6 +27,7 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
@@ -35,6 +36,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.World;
import org.jackhuang.hmcl.task.Schedulers;
@@ -45,6 +47,7 @@ import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
@@ -61,13 +64,13 @@ 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 VersionPage.GameInstanceLoadable {
public final class WorldListPage extends ListPageBase<World> implements GameInstancePage.GameInstanceLoadable {
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
private Path savesDir;
private List<World> worlds;
private HMCLGameRepository repository;
private String instanceId;
private GameInstanceID instanceId;
private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false);
private int refreshCount = 0;
@@ -86,7 +89,7 @@ public final class WorldListPage extends ListPageBase<World> implements VersionP
}
@Override
public void loadInstance(HMCLGameRepository repository, String instanceId) {
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
this.repository = repository;
this.instanceId = instanceId;
this.savesDir = repository.getSavesDirectory(instanceId);
@@ -140,7 +143,7 @@ public final class WorldListPage extends ListPageBase<World> implements VersionP
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("world.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.world"), "*.zip"));
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null || res.isEmpty()) return;
installWorld(res.get(0));
@@ -197,11 +200,11 @@ public final class WorldListPage extends ListPageBase<World> implements VersionP
}
public void launch(World world) {
Versions.launchAndEnterWorld(repository, instanceId, world.getFileName());
Instances.launchAndEnterWorld(repository, instanceId, world.getFileName());
}
public void generateLaunchScript(World world) {
Versions.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
}
public BooleanProperty showAllProperty() {
@@ -216,6 +219,12 @@ public final class WorldListPage extends ListPageBase<World> implements VersionP
WorldListPageSkin() {
super(WorldListPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
@@ -280,7 +289,7 @@ public final class WorldListPage extends ListPageBase<World> implements VersionP
btnLaunch.visibleProperty().bind(page.supportQuickPlayProperty());
btnLaunch.managedProperty().bind(btnLaunch.visibleProperty());
right.getChildren().add(btnLaunch);
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch"));
FXUtils.installFastTooltip(btnLaunch, i18n("instance.launch"));
btnLaunch.setOnAction(event -> {
World world = getItem();
if (world != null)
@@ -359,12 +368,12 @@ public final class WorldListPage extends ListPageBase<World> implements VersionP
if (supportQuickPlay) {
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), () -> page.launch(world), popup);
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch_and_enter_world"), () -> page.launch(world), popup);
launchItem.setDisable(worldLocked);
popupMenu.getContent().addAll(
launchItem,
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> page.generateLaunchScript(world), popup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> page.generateLaunchScript(world), popup),
new MenuSeparator()
);
}
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXPopup;
import javafx.application.Platform;
@@ -24,6 +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.World;
import org.jackhuang.hmcl.ui.Controllers;
@@ -54,7 +55,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
private final World world;
private final Path backupsDir;
private final HMCLGameRepository repository;
private final String instanceId;
private final GameInstanceID instanceId;
private final boolean supportQuickPlay;
private FileChannel sessionLockChannel;
@@ -69,7 +70,7 @@ 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, String instanceId) {
public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceID instanceId) {
this.world = world;
this.backupsDir = repository.getBackupsDirectory(instanceId);
this.repository = repository;
@@ -150,11 +151,11 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
public void launch() {
fireEvent(new PageCloseEvent());
Versions.launchAndEnterWorld(repository, instanceId, world.getFileName());
Instances.launchAndEnterWorld(repository, instanceId, world.getFileName());
}
public void generateLaunchScript() {
Versions.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
}
@Override
@@ -232,7 +233,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
{
if (getSkinnable().supportQuickPlay) {
toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> getSkinnable().launch(), advancedListItem -> advancedListItem.disableProperty().bind(getSkinnable().readOnlyProperty()));
toolbar.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> getSkinnable().launch(), advancedListItem -> advancedListItem.disableProperty().bind(getSkinnable().readOnlyProperty()));
}
if (ChunkBaseApp.isSupported(getSkinnable().world)) {
@@ -265,8 +266,8 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
if (getSkinnable().supportQuickPlay) {
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), () -> getSkinnable().launch(), managePopup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> getSkinnable().generateLaunchScript(), managePopup),
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch"), () -> getSkinnable().launch(), managePopup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> getSkinnable().generateLaunchScript(), managePopup),
new MenuSeparator()
);
}
@@ -15,7 +15,7 @@
* 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.versions;
package org.jackhuang.hmcl.ui.instances;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
@@ -74,7 +74,7 @@ public final class WorldManageUIUtils {
fileChooser.setTitle(i18n("world.export.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
fileChooser.setInitialFileName(world.getWorldName() + ".zip");
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
Path file = Controllers.showSaveDialog(fileChooser);
if (file == null) {
return;
}
@@ -52,6 +52,7 @@ import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
@@ -115,7 +116,7 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Java", "java.exe"));
chooser.setTitle(i18n("settings.game.java_directory.choose"));
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
Path file = Controllers.showOpenDialog(chooser);
if (file != null) {
JavaManager.getAddJavaTask(file).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
@@ -244,10 +245,10 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
center.setAlignment(Pos.CENTER_LEFT);
label.setAlignment(Pos.CENTER);
label.setMinSize(24, 24);
label.setMaxSize(24, 24);
label.setPrefSize(24, 24);
label.setStyle("-fx-background-color: -monet-secondary-container; -fx-background-radius: 2; -fx-padding: 2; -fx-font-weight: normal; -fx-font-size: 12px;");
FXUtils.setLimitWidth(label, 32);
FXUtils.setLimitHeight(label, 32);
label.setStyle("-fx-background-color: -monet-secondary-container; -fx-background-radius: 2; -fx-padding: 2; -fx-font-weight: normal; -fx-font-size: 16px;");
this.content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
@@ -304,15 +305,14 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
int parsedVersion = item.getParsedVersion();
label.setText(parsedVersion >= 0 ? String.valueOf(parsedVersion) : "?");
content.setTitle((item.isJDK() ? "JDK" : "JRE") + " " + item.getVersion());
@Nullable String vendor = JavaInfo.normalizeVendor(item.getVendor());
content.setTitle((vendor != null ? vendor : i18n("message.unknown")) + " " + (item.isJDK() ? "JDK" : "JRE") + " " + item.getVersion());
content.setSubtitle(item.getBinary().toString());
if (oldItem != item) {
content.getTags().clear();
content.addTag(i18n("java.info.architecture") + ": " + item.getArchitecture().getDisplayName());
String vendor = JavaInfo.normalizeVendor(item.getVendor());
if (vendor != null)
content.addTag(i18n("java.info.vendor") + ": " + vendor);
content.addTag(item.getArchitecture().getDisplayName());
}
SVG newRemoveIcon = item.isManaged() ? SVG.DELETE_FOREVER : SVG.DELETE;
@@ -151,6 +151,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
BorderPane root = new BorderPane();
Label label = new Label(skinnable.path);
label.setMouseTransparent(true);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
root.setCenter(label);
@@ -46,8 +46,9 @@ 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.game.Version;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -63,8 +64,8 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
@@ -74,6 +75,7 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.List;
@@ -92,11 +94,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final StringProperty currentGame = new SimpleStringProperty(this, "currentGame");
private final ObjectProperty<@Nullable GameInstanceID> 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<Version> versions = FXCollections.observableArrayList();
private final ObservableList<GameInstanceManifest> versions = FXCollections.observableArrayList();
private HMCLGameRepository repository;
private TransitionPane announcementPane;
@@ -210,9 +212,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
HBox launchPane = new HBox();
launchPane.getStyleClass().add("launch-pane");
FXUtils.onScroll(launchPane, versions, list -> {
String currentId = getCurrentGame();
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> repository.setSelectedInstance(it.getId()));
GameInstanceID currentId = getCurrentGame();
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
}, it -> repository.setSelectedInstance(it.id()));
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
@@ -231,18 +233,18 @@ public final class MainPage extends StackPane implements DecoratorPage {
private Tooltip tooltip;
@Override
public void accept(String currentGame) {
public void accept(@Nullable GameInstanceID currentGame) {
if (currentGame == null) {
launchLabel.setText(i18n("version.launch.empty"));
launchLabel.setText(i18n("instance.launch.empty"));
currentLabel.setText(null);
graphic.getChildren().setAll(launchLabel);
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launchNoGame);
if (tooltip == null)
tooltip = new Tooltip(i18n("version.launch.empty.tooltip"));
tooltip = new Tooltip(i18n("instance.launch.empty.tooltip"));
FXUtils.installFastTooltip(launchButton, tooltip);
} else {
launchLabel.setText(i18n("version.launch"));
currentLabel.setText(currentGame);
launchLabel.setText(i18n("instance.launch"));
currentLabel.setText(currentGame.toString());
graphic.getChildren().setAll(launchLabel, currentLabel);
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch);
if (tooltip != null)
@@ -258,36 +260,34 @@ public final class MainPage extends StackPane implements DecoratorPage {
menuButton.getStyleClass().add("menu-button");
menuButton.setOnAction(e -> {
JFXPopup popup = GameListPopupMenu.showAndGetPopup(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.RIGHT,
0,
-menuButton.getHeight(),
repository, versions
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.RIGHT,
0,
-menuButton.getHeight(),
repository, versions
);
if (popup != null) {
Node graphic = menuButton.getGraphic();
if (graphic != null) {
if (AnimationUtils.isAnimationEnabled()) {
Duration duration = Duration.millis(200);
RotateTransition rotateOpen = new RotateTransition(duration, graphic);
rotateOpen.setToAngle(-180);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateOpen);
Node graphic = menuButton.getGraphic();
if (graphic != null) {
if (AnimationUtils.isAnimationEnabled()) {
Duration duration = Duration.millis(200);
RotateTransition rotateOpen = new RotateTransition(duration, graphic);
rotateOpen.setToAngle(-180);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateOpen);
popup.setOnHidden(windowEvent -> {
RotateTransition rotateClose = new RotateTransition(duration, graphic);
rotateClose.setToAngle(0);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateClose);
});
} else {
graphic.setRotate(-180);
popup.setOnHidden(windowEvent -> graphic.setRotate(0));
}
popup.setOnHidden(windowEvent -> {
RotateTransition rotateClose = new RotateTransition(duration, graphic);
rotateClose.setToAngle(0);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateClose);
});
} else {
graphic.setRotate(-180);
popup.setOnHidden(windowEvent -> graphic.setRotate(0));
}
}
});
FXUtils.installFastTooltip(menuButton, i18n("version.switch"));
FXUtils.installFastTooltip(menuButton, i18n("instance.switch"));
menuButton.setGraphic(SVG.ARROW_DROP_UP.createIcon(30));
EventHandler<MouseEvent> secondaryClickHandle = event -> {
@@ -342,14 +342,14 @@ public final class MainPage extends StackPane implements DecoratorPage {
private void launch() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
Versions.launch(repository, repository.getSelectedInstance());
Instances.launch(repository, repository.getSelectedInstance());
}
private void launchNoGame() {
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
VersionList<?> versionList = downloadProvider.getVersionListById("game");
Holder<String> gameVersionHolder = new Holder<>();
Holder<GameInstanceID> instanceHolder = new Holder<>();
Task<?> task = versionList.refreshAsync("")
.thenSupplyAsync(() -> versionList.getVersions("").stream()
.filter(it -> it.getVersionType() == RELEASE)
@@ -360,17 +360,21 @@ public final class MainPage extends StackPane implements DecoratorPage {
.thenComposeAsync(version -> {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
DefaultDependencyManager dependency = repository.getDependency();
String gameVersion = gameVersionHolder.value = version.getGameVersion();
return dependency.gameBuilder()
.name(gameVersion)
String gameVersion = version.getGameVersion();
GameInstanceID instanceId = new GameInstanceID(gameVersion);
instanceHolder.value = instanceId;
return dependency.newGameBuilder()
.name(instanceId)
.gameVersion(gameVersion)
.buildAsync();
})
.whenComplete(any -> GameDirectoryManager.getSelectedRepository().refreshVersions())
.whenComplete(any -> GameDirectoryManager.getSelectedRepository().refresh())
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
GameDirectoryManager.getSelectedRepository().setSelectedInstance(gameVersionHolder.value);
GameDirectoryManager.getSelectedRepository().setSelectedInstance(instanceHolder.value);
launch();
} else if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
@@ -381,7 +385,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
MessageDialogPane.MessageType.WARNING);
}
});
Controllers.taskDialog(task, i18n("version.launch.empty.installing"), TaskCancellationAction.NORMAL);
Controllers.taskDialog(task, i18n("instance.launch.empty.installing"), TaskCancellationAction.NORMAL);
}
private void onUpgrade() {
@@ -410,19 +414,19 @@ public final class MainPage extends StackPane implements DecoratorPage {
return repository;
}
public String getCurrentGame() {
public GameInstanceID getCurrentGame() {
return currentGame.get();
}
public StringProperty currentGameProperty() {
public ObjectProperty<@Nullable GameInstanceID> currentGameProperty() {
return currentGame;
}
public void setCurrentGame(String currentGame) {
public void setCurrentGame(@Nullable GameInstanceID currentGame) {
this.currentGame.set(currentGame);
}
public ObservableList<Version> getVersions() {
public ObservableList<GameInstanceManifest> getVersions() {
return versions;
}
@@ -462,7 +466,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
this.latestVersion.set(latestVersion);
}
public void initVersions(HMCLGameRepository repository, List<Version> versions) {
public void initVersions(HMCLGameRepository repository, List<GameInstanceManifest> versions) {
FXUtils.checkFxUserThread();
this.repository = repository;
this.versions.setAll(versions);
@@ -44,12 +44,7 @@ import javafx.stage.FileChooser;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorStyle;
import org.glavo.uuid.UUIDs;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.setting.FontManager;
import org.jackhuang.hmcl.setting.LauncherSettings;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.setting.ThemeColorType;
import org.jackhuang.hmcl.setting.UserSettings;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
@@ -347,20 +342,25 @@ public class PersonalizationPage extends StackPane {
String defaultPackId = "com.example.hmcl.theme-pack." + UUIDs.toCompactString(UUIDs.generateV7(exportTimestamp));
String defaultPackName = LocalDateTime.ofInstant(exportTimestamp, ZoneId.systemDefault()).format(EXPORTED_THEME_NAME_FORMATTER);
String userName = System.getProperty("user.name").trim();
String defaultAuthorName = StringUtils.isBlank(userName) ? "Unknown" : userName;
String accountName = null;
{
var currentAccount = Accounts.getSelectedAccount();
if (currentAccount != null) accountName = currentAccount.getProfileName();
}
if (StringUtils.isBlank(accountName)) accountName = System.getProperty("user.name").trim();
String defaultAuthorName = StringUtils.isBlank(accountName) ? "Unknown" : accountName;
PromptDialogPane.Builder.StringQuestion packNameQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.name"),
"")
defaultPackName)
.setPromptText(defaultPackName);
PromptDialogPane.Builder.StringQuestion versionQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.version"),
"")
ThemePackManager.CURRENT_THEME_PACK_VERSION)
.setPromptText(ThemePackManager.CURRENT_THEME_PACK_VERSION);
PromptDialogPane.Builder.StringQuestion authorNameQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.author"),
"")
defaultAuthorName)
.setPromptText(defaultAuthorName);
Controllers.prompt(new PromptDialogPane.Builder(i18n("theme_pack.export.title"), (questions, handler) -> handler.resolve())
@@ -387,7 +387,7 @@ public class PersonalizationPage extends StackPane {
chooser.getExtensionFilters().setAll(
new FileChooser.ExtensionFilter(i18n("theme_pack.file"), "*" + ThemePackExporter.FILE_EXTENSION));
@Nullable Path output = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage()));
@Nullable Path output = Controllers.showSaveDialog(chooser);
if (output == null) {
return;
}
@@ -713,6 +713,17 @@ public class PersonalizationPage extends StackPane {
}
updatingBackground.value = true;
try {
builtinBackgroundComboBox.setValue(Objects.requireNonNullElse(
settings().builtinBackgroundIdProperty().get(),
BuiltinBackground.FALLBACK.id()));
customBackgroundOption.setPath(Objects.toString(
settings().customBackgroundImagePathProperty().get(),
""));
networkBackgroundOption.setText(Objects.toString(
settings().networkBackgroundImageUrlProperty().get(),
""));
paintBackgroundOption.setPaint(settings().customBackgroundPaintProperty().get());
boolean overridden = settings().getThemeAppearanceOverrides().contains(
LauncherSettings.THEME_APPEARANCE_BACKGROUND);
ThemePackManager.ResolvedBackground background;
@@ -1007,6 +1018,7 @@ public class PersonalizationPage extends StackPane {
Label textOpacity = new Label();
FXUtils.setLimitWidth(textOpacity, 50);
textOpacity.setAlignment(Pos.CENTER);
StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty());
textOpacity.textProperty().bind(valueBinding);
@@ -1171,7 +1183,8 @@ public class PersonalizationPage extends StackPane {
LineToggleButton animationButton = new LineToggleButton();
appearanceList.getContent().add(animationButton);
animationButton.selectedProperty().bindBidirectional(settings().animationDisabledProperty());
animationButton.setSelected(settings().isAnimationDisabled());
FXUtils.onChange(animationButton.selectedProperty(), value -> settings().animationDisabledProperty().set(value));
animationButton.setTitle(i18n("settings.launcher.turn_off_animations"));
animationButton.setSubtitle(i18n("settings.take_effect_after_restart"));
@@ -22,10 +22,11 @@ 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.RefreshedVersionsEvent;
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.ModpackHelper;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -46,9 +47,9 @@ 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.versions.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
import org.jackhuang.hmcl.ui.versions.Versions;
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.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
@@ -74,7 +75,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
private MainPage mainPage = null;
public RootPage() {
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class)
EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class)
.register(event -> onRefreshedVersions((HMCLGameRepository) event.getSource()));
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
@@ -115,7 +116,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Versions.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
});
@@ -124,11 +125,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
GameDirectoryManager.registerVersionsListener(repository -> {
GameDirectory gameDirectory = repository.getGameDirectory();
List<Version> children = repository.getVersions().parallelStream()
List<GameInstanceManifest> children = repository.getInstanceManifests().parallelStream()
.filter(version -> !version.isHidden())
.sorted(Comparator
.comparing((Version version) -> Lang.requireNonNullElse(version.getReleaseTime(), Instant.EPOCH))
.thenComparing(version -> VersionNumber.asVersion(repository.getGameVersion(version).orElse(version.getId()))))
.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())
@@ -154,26 +155,26 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
// second item in left sidebar
GameAdvancedListItem gameListItem = new GameAdvancedListItem();
gameListItem.setOnAction(e -> {
String version = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (version == null) {
GameInstanceID instanceId = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instanceId == null) {
Controllers.navigate(Controllers.getGameListPage());
} else {
Versions.modifyGameSettings(GameDirectoryManager.getSelectedRepository(), version);
Instances.modifyGameSettings(GameDirectoryManager.getSelectedRepository(), instanceId);
}
});
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> {
String currentId = getSkinnable().getMainPage().getCurrentGame();
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> getSkinnable().getMainPage().getRepository().setSelectedInstance(it.getId()));
GameInstanceID currentId = getSkinnable().getMainPage().getCurrentGame();
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
}, it -> getSkinnable().getMainPage().getRepository().setSelectedInstance(it.id()));
if (AnimationUtils.isAnimationEnabled()) {
FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareVersionPage);
FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareGameInstancePage);
}
FXUtils.onSecondaryButtonClicked(gameListItem, () -> showGameListPopupMenu(gameListItem));
// third item in left sidebar
AdvancedListItem gameItem = new AdvancedListItem();
gameItem.setLeftIcon(SVG.FORMAT_LIST_BULLETED);
gameItem.setTitle(i18n("version.manage"));
gameItem.setTitle(i18n("instance.manage"));
gameItem.setOnAction(e -> Controllers.navigate(Controllers.getGameListPage()));
FXUtils.onSecondaryButtonClicked(gameItem, () -> showGameListPopupMenu(gameItem));
@@ -228,7 +229,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
AdvancedListBox sideBar = new AdvancedListBox()
.startCategory(i18n("account").toUpperCase(Locale.ROOT))
.add(accountListItem)
.startCategory(i18n("version").toUpperCase(Locale.ROOT))
.startCategory(i18n("instance").toUpperCase(Locale.ROOT))
.add(gameListItem)
.add(gameItem)
.add(downloadItem)
@@ -263,7 +264,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
if (!checkedModpack) {
checkedModpack = true;
if (repository.getVersionCount() == 0) {
if (repository.getInstanceCount() == 0) {
Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip");
Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack");
@@ -280,7 +281,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile))
.thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding))
.thenApplyAsync(modpack -> ModpackHelper
.getInstallTask(repository, modpackFile, modpack.getName(), modpack, null)
.getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null)
.executor())
.thenAcceptAsync(Schedulers.javafx(), executor -> {
Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL);
@@ -22,8 +22,11 @@ import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -32,6 +35,7 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
@@ -41,7 +45,6 @@ import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.*;
import org.jackhuang.hmcl.ui.*;
@@ -159,7 +162,7 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
chooser.getExtensionFilters().setAll(
new FileChooser.ExtensionFilter(i18n("theme_pack.file"), "*" + ThemePackExporter.FILE_EXTENSION));
@Nullable Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
@Nullable Path file = Controllers.showOpenDialog(chooser);
if (file == null) {
return;
}
@@ -415,8 +418,7 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
/// @param themePack the installed theme pack to display
private ThemePackInfoDialog(ThemePackManagementPage page, ThemePackManager.InstalledThemePack themePack) {
ThemePackManifest manifest = themePack.manifest();
Stage stage = Controllers.getStage();
maxWidthProperty().bind(stage.widthProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
HBox heading = new HBox(8);
heading.setAlignment(Pos.CENTER_LEFT);
@@ -481,7 +483,7 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setPrefViewportWidth(520);
scrollPane.setPrefViewportHeight(Math.min(360, manifest.themes().size() * 86));
scrollPane.maxHeightProperty().bind(Controllers.getStage().heightProperty().multiply(0.55));
scrollPane.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.55));
StackPane body = new StackPane(scrollPane);
body.setPadding(new Insets(10, 0, 0, 0));
@@ -508,6 +510,9 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
/// Toolbar shown during normal browsing.
private final HBox toolbarNormal = new HBox();
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
/// Search input.
private final JFXTextField searchField = new JFXTextField();
@@ -545,13 +550,20 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
pause.setOnFinished(event ->
skinnable.filteredList.setPredicate(skinnable.createPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
pause.setRate(1);
pause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> {
changeToolbar(toolbarNormal);
searchField.clear();
pause.stop();
skinnable.filteredList.setPredicate(null);
isSearching.set(false);
});
onEscPressed(searchField, closeSearchBar::fire);
@@ -586,6 +598,15 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
listView.getStyleClass().add("no-horizontal-scrollbar");
ignoreEvent(listView, KeyEvent.KEY_PRESSED, event -> event.getCode() == KeyCode.ESCAPE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label();
placeholderLabel.textProperty().bind(
Bindings.when(isSearching).then(i18n("search.no_results_found")).otherwise("")
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
center.setContent(listView);
root.getContent().add(center);
}
@@ -597,6 +618,8 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
runInFX(searchField::requestFocus);
isSearching.set(true);
}
}
}
@@ -29,7 +29,7 @@ import java.nio.file.Path;
/// @author Glavo
public enum NBTFileType {
COMPRESSED("dat", "dat_old") {
COMPRESSED("nbt", "dat", "dat_old") {
@Override
public Tag read(Path file) throws IOException {
return NBTCodec.of().readTag(file);
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more