Compare commits

..
Author SHA1 Message Date
Glavo fbcc17472f [release/3.16] Add version entries for 26.3 snapshots and release candidates (#6879)
https://github.com/HMCL-dev/HMCL/pull/6849
https://github.com/HMCL-dev/HMCL/pull/6877
2026-09-16 21:48:50 +08:00
Glavo 7b85f0dfce [release/3.16] 优化加载模组列表的性能 (#6878)
https://github.com/HMCL-dev/HMCL/pull/6834
https://github.com/HMCL-dev/HMCL/pull/6837
2026-09-16 21:45:50 +08:00
GlavoandApostrophe b8a830dd40 [release/3.16] 解决导出 Modrinth 整合包因大文件导致的 Out Of Memory (#6876)
https://github.com/HMCL-dev/HMCL/pull/6631

Co-authored-by: Apostrophe <Hylfrd@outlook.com>
2026-09-16 21:35:00 +08:00
GlavoandToobLac ce368caa67 [release/3.16] [Bugfix] 修复无法读取 zip 后缀名的模组的问题 (#6875)
https://github.com/HMCL-dev/HMCL/pull/6712

Co-authored-by: ToobLac <tooblac39@gmail.com>
2026-09-16 21:29:04 +08:00
Glavo 850ad82aa1 [release/3.16] fix: LegacyFabric 实例图标异常 (#6874)
https://github.com/HMCL-dev/HMCL/pull/6784
2026-09-16 21:22:58 +08:00
Glavo 552276e673 [release/3.16] 优化 SVG 图像支持 (#6873)
https://github.com/HMCL-dev/HMCL/pull/6613
https://github.com/HMCL-dev/HMCL/pull/6789
https://github.com/HMCL-dev/HMCL/pull/6790
2026-09-16 21:19:57 +08:00
Glavo e79855685e [release/3.16] 更新模组数据 (2026-07-21) (#6872)
https://github.com/HMCL-dev/HMCL/pull/6543
2026-09-16 21:14:22 +08:00
Glavo ad5afe7e06 [release/3.16] Add support for Minecraft 26.3-snapshot-10+ to unofficial supported platforms (#6871)
https://github.com/HMCL-dev/HMCL/pull/6870
2026-09-16 21:05:11 +08:00
GlavoandApostrophe 8210c77de9 [release/3.16] 修复游戏 Java 路径显示滞后问题 (#6508)
https://github.com/HMCL-dev/HMCL/pull/6419

Co-authored-by: Apostrophe <Hylfrd@outlook.com>
2026-07-25 21:39:06 +08:00
Glavo b11b298312 [release/3.16] 更新内置主题相关译文 (#6507)
https://github.com/HMCL-dev/HMCL/pull/6429
2026-07-25 21:34:11 +08:00
Glavoand辞庐 5c605c58d2 [release/3.16] 修复 GameAdvancedListItem#loadVersion UI 操作可能不会 JavaFX 线程运行的问题 (#6506)
https://github.com/HMCL-dev/HMCL/pull/6445

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-07-25 21:32:06 +08:00
Glavo 01d4100a2f [release/3.16] 复制实例时同步保存游戏设置 (#6503)
https://github.com/HMCL-dev/HMCL/pull/6492
2026-07-25 21:26:38 +08:00
Glavoand辞庐 d07eb2f748 [release/3.16] fix: 切换版本隔离设置后自动刷新其他子页面 功能失效 (#6505)
https://github.com/HMCL-dev/HMCL/pull/6468

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-07-25 21:26:09 +08:00
Glavo 345bb176fd [release/3.16] Add support for Minecraft 26.3-snapshot-4+ to unofficial supported platforms (#6504)
https://github.com/HMCL-dev/HMCL/pull/6425
https://github.com/HMCL-dev/HMCL/pull/6481
2026-07-25 21:23:57 +08:00
Glavo 83a38fadf0 [release/3.16] 完善游戏设置页面帮助文本 (#6416)
https://github.com/HMCL-dev/HMCL/pull/6415
2026-07-15 21:22:17 +08:00
Glavoand辞庐 9980dc8aea [release/3.16] Revert "Refactor: Remove redundant downloadSourceComboBox from ModDownloadListPageSkin" (#6413)
https://github.com/HMCL-dev/HMCL/pull/6410

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-07-15 20:54:59 +08:00
GlavoandToobLac 7dfbf76b69 [release/3.16] 修复 1.21.1 安装信雅互联仍显示不支持 Fabric 模组的问题 (#6414)
https://github.com/HMCL-dev/HMCL/pull/6404

Co-authored-by: ToobLac <tooblac39@gmail.com>
2026-07-15 20:54:45 +08:00
Glavo 7f2f77f54f [release/3.16] Bump Gradle Wrapper Neo to 0.2.0 (#6412)
https://github.com/HMCL-dev/HMCL/pull/6397
2026-07-15 20:07:13 +08:00
540 changed files with 51084 additions and 74887 deletions

No files matched your search

-3
View File
@@ -6,6 +6,3 @@ contact_links:
- name: Discord 服务器
url: https://discord.gg/jVvC7HfM6U
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
- name: GitHub Issue
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
+3
View File
@@ -0,0 +1,3 @@
code_review:
pull_request_opened:
code_review: false
-1
View File
@@ -4,7 +4,6 @@
# ─── Explicitly text – source & build ────────────────────────────────────────
*.java text eol=lf
*.js text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.groovy text eol=lf
-3
View File
@@ -3,6 +3,3 @@ contact_links:
- name: QQ 群
url: https://docs.hmcl.net/groups.html
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
- name: GitHub Issue
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
-120
View File
@@ -1,120 +0,0 @@
const fs = require("fs");
module.exports = async ({ github, context, core }) => {
const prNumberPath = process.env.PULL_REQUEST_NUMBER_PATH;
if (!prNumberPath || !fs.existsSync(prNumberPath) || !fs.statSync(prNumberPath).isFile()) {
core.setFailed("Pull request number file not found.");
return;
}
const reportsPath = process.env.REPORTS_PATH;
if (!reportsPath || !fs.existsSync(reportsPath) || !fs.statSync(reportsPath).isFile()) {
core.setFailed("Reports file not found.");
return;
}
const PULL_REQUEST_NUMBER = parseInt(fs.readFileSync(prNumberPath, "utf8").trim(), 10);
if (isNaN(PULL_REQUEST_NUMBER)) {
core.setFailed("Failed to parse the pull request number.");
return;
}
const { owner, repo } = context.repo;
let pr;
try {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
});
pr = response.data;
} catch (error) {
core.setFailed(`Failed to fetch PR #${PULL_REQUEST_NUMBER}: ${error.message}`);
return;
}
core.info(`Current PR state: ${pr.state}`);
if (pr.state !== "open") {
core.setFailed("The pull request is not open. Skipping comment creation.");
return;
}
const run = context.payload.workflow_run;
if (!run) {
core.setFailed("context.payload.workflow_run is undefined. Ensure this script runs on the 'workflow_run' event.");
return;
}
if (pr.head.sha !== run.head_sha) {
core.setFailed("PR head SHA does not match the workflow run head SHA. Skipping.");
return;
}
if (pr.head.ref !== run.head_branch) {
core.setFailed("PR head branch does not match the workflow run head branch. Skipping.");
return;
}
if (pr.head.repo.full_name !== run.head_repository.full_name) {
core.setFailed("PR head repository fullname does not match the workflow run head repository fullname. Skipping.");
return;
}
const comments = [];
const maxCommentCount = 10;
const reports = fs.readFileSync(reportsPath, "utf8").trim().split("\n").filter(Boolean);
for (const report of reports) {
if (comments.length >= maxCommentCount) break;
try {
const data = JSON.parse(report);
if (typeof data !== "object" || Array.isArray(data)) continue;
const { location, message, severity, code } = data;
if (typeof location !== "object" || Array.isArray(location)) continue;
if (typeof message !== "string") continue;
const severities = ["UNKNOWN_SEVERITY", "ERROR", "WARNING", "INFO", 0, 1, 2, 3];
if (!severities.includes(severity)) continue;
const severityText = typeof severity === "string" ? severity : severities[severity];
if (typeof code !== "object" || Array.isArray(code)) continue;
const path = location.path;
const line = location.range?.start?.line;
const codeText = code.value ? `[${code.value}] ` : "";
if (path && line) {
comments.push({
path,
line,
side: "RIGHT",
body: `**[${severityText}]** ${codeText}\n\n${message}`
});
}
} catch (error) {
core.warning(`Failed to parse report: ${error.message}`);
}
}
if (comments.length === 0) {
core.info("No diagnostics found to report as comments.");
return;
}
core.info(`Successfully prepared ${comments.length} comment${comments.length > 1 ? "s" : ""} to post.`);
try {
await github.rest.pulls.createReview({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
commit_id: run.head_sha,
body: "🤖 Static analysis found the following issues (max count 10):",
event: "COMMENT",
comments: comments
});
core.info("Review comments successfully posted to the pull request.");
} catch (error) {
core.setFailed(`Failed to post review comments to GitHub: ${error.message}`);
return;
}
}
-105
View File
@@ -1,105 +0,0 @@
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);
}
}
-40
View File
@@ -1,40 +0,0 @@
name: Check Codes Comment
on:
workflow_run:
workflows: ["Check Codes"]
types:
- completed
permissions:
actions: read
contents: read
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: Checkout Script
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts/check-codes-comment.js
sparse-checkout-cone-mode: false
- name: Download Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: checkstyle-artifact
path: ${{ runner.temp }}/checkstyle-artifact
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Review Comment
uses: actions/github-script@v9
env:
REPORTS_PATH: ${{ runner.temp }}/checkstyle-artifact/ALL_REPORTS.jsonl
PULL_REQUEST_NUMBER_PATH: ${{ runner.temp }}/checkstyle-artifact/PULL_REQUEST_NUMBER
with:
script: |
const script = require("./.github/scripts/check-codes-comment.js");
await script({ github, context, core });
+1 -27
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup JDK 17
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'zulu'
@@ -27,29 +27,3 @@ jobs:
cache-cleanup: never
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
- if: failure() && github.event_name == 'pull_request'
name: Setup reviewdog
uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0
with:
reviewdog_version: v0.21.0
- if: failure() && github.event_name == 'pull_request'
name: Generate Artifact
run: |
echo "${{ github.event.number }}" > PULL_REQUEST_NUMBER
> ALL_REPORTS.jsonl
find . -path "*/build/reports/checkstyle/*.xml" | while read -r xml_file; do
if [ -s "$xml_file" ]; then
echo "Processing $xml_file..."
reviewdog -f=checkstyle -name="checkstyle" -reporter=rdjsonl < "$xml_file" >> ALL_REPORTS.jsonl
else
echo "Skipping empty or missing report: $xml_file"
fi
done
- if: failure() && github.event_name == 'pull_request'
name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: checkstyle-artifact
path: |
ALL_REPORTS.jsonl
PULL_REQUEST_NUMBER
+3 -4
View File
@@ -14,9 +14,9 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: 'liberica'
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
@@ -24,9 +24,8 @@ jobs:
with:
cache-cleanup: never
- name: Build with Gradle
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
run: ./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
@@ -1,31 +0,0 @@
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/pr-size-label.js
sparse-checkout-cone-mode: false
- 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 });
-3
View File
@@ -4,8 +4,6 @@
hs_err_pid*
*.hprof
*.jfr
.gradle
gradle.properties
@@ -19,7 +17,6 @@ NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
terracotta-log-*
hmcl-mod-update-list-*
*.hmcl-theme
/.java/
/.local/
+3 -7
View File
@@ -4,11 +4,9 @@ These rules apply to all Java code written or modified in this repository.
## Nullability
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.
- 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.
## Immutability
@@ -17,8 +15,6 @@ Apply the following requirements when writing or modifying code. Do not use them
## 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.
+18 -42
View File
@@ -1,13 +1,11 @@
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.pack.CreateDeb
import org.jackhuang.hmcl.gradle.pack.ReleaseType
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
@@ -54,7 +52,7 @@ if (buildNumber != null) {
}
}
val embedResources = configurations.register("embedResources")
val embedResources by configurations.registering
dependencies {
implementation(project(":HMCLCore"))
@@ -146,7 +144,6 @@ val addOpens = listOf(
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.graphics/com.sun.javafx.tk.quantum",
"javafx.controls/javafx.scene.control",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
@@ -169,7 +166,7 @@ val hmclProperties = buildList {
}
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
val createPropertiesFile = tasks.register("createPropertiesFile") {
val createPropertiesFile by tasks.registering {
outputs.file(hmclPropertiesFile)
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
@@ -261,7 +258,7 @@ tasks.processResources {
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
val makeExecutables = tasks.register("makeExecutables") {
val makeExecutables by tasks.registering {
val extensions = listOf("exe", "sh")
dependsOn(tasks.jar)
@@ -289,7 +286,7 @@ val makeExecutables = tasks.register("makeExecutables") {
}
}
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
val makeDeb by tasks.registering(CreateDeb::class) {
dependsOn(makeExecutables)
val debFile = layout.file(provider { artifactFile("deb") })
@@ -302,7 +299,6 @@ val makeDeb = tasks.register("makeDeb", 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)
@@ -433,62 +429,42 @@ tasks.register<CheckTranslations>("checkTranslations") {
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
englishFile.set(dir.file("I18N.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
tasks.register<SyncTranslations>("syncTranslations") {
group = "localization"
description = "Synchronizes translation keys and blank lines with I18N.properties."
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
sourceFile.set(dir.file("I18N.properties"))
translationFiles.from(fileTree(dir) { include("I18N_*.properties") })
}
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
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 = tasks.register<CreateLanguageList>("createLanguageList") {
val createLanguageList by tasks.registering(CreateLanguageList::class) {
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 =
tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
dependsOn(createLanguageList)
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<FormatJsonFile>("formatModJson") {
file.set(layout.projectDirectory.file("mod.json"))
}
tasks.register<FormatJsonFile>("formatModpackJson") {
file.set(layout.projectDirectory.file("modpack.json"))
}
tasks.register<UpdateAddonProjectInfos>("updateModProjectInfos") {
tasks.register<ParseModDataTask>("parseModData") {
inputFile.set(layout.projectDirectory.file("mod.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/mod.csv"))
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MOD)
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt"))
}
tasks.register<UpdateAddonProjectInfos>("updateModpackProjectInfos") {
tasks.register<ParseModDataTask>("parseModPackData") {
inputFile.set(layout.projectDirectory.file("modpack.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/modpack.csv"))
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MODPACK)
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt"))
}
@@ -1,101 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXCheckBoxSkin;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.CheckBoxTreeItem;
import javafx.scene.layout.HBox;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
///
/// The checkbox's selected and indeterminate states are bidirectionally bound to
/// the current tree item. Reusing or clearing the cell removes the old bindings.
/// Installing a checkbox skin or switching tree items applies the selection state
/// immediately, without playing a selection animation.
/// Ordinary tree items retain the display provided by [JFXTreeCell].
/// Empty cells and `null` values display neither content nor a checkbox.
///
/// @param <T> the type of value stored in each tree item
@NotNullByDefault
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
/// The checkbox bound to the currently displayed checkable tree item.
private final JFXCheckBox checkBox = new JFXCheckBox();
/// Places the checkbox before the graphic supplied by the superclass.
private final HBox graphic = new HBox();
/// The tree item whose selection properties are currently bound, if any.
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
public JFXCheckTreeCell() {
checkBox.setAllowIndeterminate(false);
checkBox.setFocusTraversable(false);
graphic.setAlignment(Pos.CENTER_LEFT);
graphic.setPickOnBounds(false);
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
}
/// Finishes animations from the previous item and immediately displays the current state.
private void finishSelectionAnimation() {
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
skin.finishSelectionAnimation();
}
}
/// Adds the checkbox to the inherited display and updates its selection bindings.
@Override
protected void updateDisplay(@Nullable T item, boolean empty) {
// Release graphics before the superclass reuses or reparents them.
graphic.getChildren().clear();
super.updateDisplay(item, empty);
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
if (boundTreeItem != treeItem) {
if (boundTreeItem != null) {
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
}
boundTreeItem = treeItem;
if (treeItem != null) {
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
} else {
checkBox.setSelected(false);
checkBox.setIndeterminate(false);
}
finishSelectionAnimation();
}
if (treeItem != null) {
@Nullable Node itemGraphic = getGraphic();
graphic.getChildren().add(checkBox);
if (itemGraphic != null) {
graphic.getChildren().add(itemGraphic);
}
setGraphic(graphic);
}
}
}
@@ -70,7 +70,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
private void initialize() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
this.setCellFactory(listView -> new JFXListCell<>() {
this.setCellFactory(listView -> new JFXListCell<T>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
@@ -80,7 +80,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
// had to refactor the code out of the skin class to allow
// customization of the button cell
this.setButtonCell(new ListCell<>() {
this.setButtonCell(new ListCell<T>() {
{
// fixed clearing the combo box value is causing
// java prompt text to be shown because the button cell is not updated
@@ -199,9 +199,6 @@ public class JFXListCell<T> extends ListCell<T> {
*/
@Override
protected void updateItem(T item, boolean empty) {
T oldItem = getItem();
boolean oldEmpty = isEmpty();
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
super.updateItem(item, empty);
if (empty) {
setText(null);
@@ -278,10 +278,6 @@ public class JFXRippler extends StackPane {
rippler.releaseRipple();
}
public void releaseRippleImmediately() {
rippler.releaseRippleImmediately();
}
/**
* creates Ripple effect in the center of the control
*
@@ -409,21 +405,6 @@ public class JFXRippler extends StackPane {
}
}
private void releaseRippleImmediately() {
Ripple ripple = ripplesQueue.poll();
if (ripple != null) {
getChildren().remove(ripple);
if (generating.getAndSet(false)) {
if (overlayRect != null) {
overlayRect.inAnimation.stop();
if (!forceOverlay) {
overlayRect.setOpacity(0D);
}
}
}
}
}
void cacheRippleClip(boolean cached) {
cacheRipplerClip = cached;
}
@@ -28,7 +28,6 @@ import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
@@ -84,7 +83,8 @@ public class JFXTreeCell<T> extends TreeCell<T> {
treeItemRef = new WeakReference<>(newTreeItem);
}
};
treeItemProperty().addListener(treeItemInvalidationListener);
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
treeItemProperty().addListener(weakTreeItemListener);
if (getTreeItem() != null) {
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
}
@@ -104,20 +104,13 @@ public class JFXTreeCell<T> extends TreeCell<T> {
selectedPane.setVisible(isSelected());
}
/// Updates the text and graphic for the current value and tree item.
///
/// @implSpec Called when the cell value or the current tree item's graphic changes.
/// Subclasses may override this method to decorate the default display.
///
/// @param item the cell value, or `null` to clear the display
/// @param empty whether the cell has no item
protected void updateDisplay(@Nullable T item, boolean empty) {
private void updateDisplay(T item, boolean empty) {
if (item == null || empty) {
hbox = null;
setText(null);
setGraphic(null);
} else {
@Nullable TreeItem<T> treeItem = getTreeItem();
TreeItem<T> treeItem = getTreeItem();
if (treeItem != null && treeItem.getGraphic() != null) {
if (item instanceof Node) {
setText(null);
@@ -12,25 +12,35 @@ import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.transitions.CachedTransition;
import com.jfoenix.transitions.JFXFillTransition;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.*;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.CheckBox;
import javafx.scene.control.skin.CheckBoxSkin;
import javafx.scene.layout.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
public class JFXCheckBoxSkin extends CheckBoxSkin {
private final StackPane box = new StackPane();
private final StackPane mark = new StackPane();
private final SVGPath markShape = new SVGPath();
private final double lineThick = 2.0;
private final double padding = 10.0;
private final JFXRippler rippler;
@@ -38,11 +48,7 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
private final double labelOffset = -8.0;
private final Transition transition;
private boolean invalid = true;
private boolean selected;
private boolean indeterminate;
/// The checkbox fill animation, initialized with the configured checked color.
private JFXFillTransition fillTransition;
private JFXFillTransition select;
public JFXCheckBoxSkin(JFXCheckBox control) {
super(control);
@@ -55,17 +61,22 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
boxContainer.getChildren().add(this.box);
boxContainer.setPadding(new Insets(this.padding));
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
this.updateRippleColor(control.isSelected(), control.isIndeterminate());
this.mark.setShape(this.markShape);
this.updateMarkShape(true, false);
this.updateRippleColor();
SVGPath shape = new SVGPath();
shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
this.mark.setShape(shape);
this.mark.setMaxSize(15.0, 12.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
this.mark.setVisible(false);
this.mark.setScaleX(0.0);
this.mark.setScaleY(0.0);
boxContainer.getChildren().add(this.mark);
this.container.getChildren().add(this.rippler);
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
this.registerChangeListener(control.selectedProperty(), ignored -> requestSelectionUpdate());
this.registerChangeListener(control.indeterminateProperty(), ignored -> requestSelectionUpdate());
control.selectedProperty().addListener((o, oldVal, newVal) -> {
this.updateRippleColor();
this.playSelectAnimation(newVal);
});
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null)
@@ -82,63 +93,37 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
this.updateChildren();
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
if (fillTransition != null) {
fillTransition.stop();
if (select != null) {
select.stop();
}
boolean selected = control.isSelected();
boolean indeterminate = control.isIndeterminate();
JFXFillTransition fillTransition = this.createFillTransition();
this.fillTransition = fillTransition;
fillTransition.jumpTo(selected || indeterminate ? fillTransition.getCycleDuration() : Duration.ZERO);
updateColors(selected, indeterminate);
this.createFillTransition();
updateColors();
});
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors(control.isSelected(), control.isIndeterminate()));
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors());
this.transition = new CheckBoxTransition();
this.fillTransition = this.createFillTransition();
this.createFillTransition();
}
private void requestSelectionUpdate() {
this.invalid = true;
this.getSkinnable().requestLayout();
}
private void updateRippleColor(boolean selected, boolean indeterminate) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.rippler.setRipplerFill(selected || indeterminate
private void updateRippleColor() {
var control = (JFXCheckBox) this.getSkinnable();
this.rippler.setRipplerFill(control.isSelected()
? control.getCheckedColor()
: control.getUnCheckedColor());
}
private void updateColors(boolean selected, boolean indeterminate) {
private void updateColors() {
var control = (JFXCheckBox) getSkinnable();
boolean marked = selected || indeterminate;
JFXNodeUtils.updateBackground(box.getBackground(), box, marked ? control.getCheckedColor() : Color.TRANSPARENT);
rippler.setRipplerFill(marked ? control.getCheckedColor() : control.getUnCheckedColor());
this.updateBorder(selected, indeterminate);
}
private void updateBorder(boolean selected, boolean indeterminate) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
boolean isSelected = control.isSelected();
JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT);
rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor());
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
box.setBorder(new Border(new BorderStroke(
selected || indeterminate ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
borderStroke.getTopStyle(),
borderStroke.getRadii(),
borderStroke.getWidths())));
}
private void updateMarkShape(boolean selected, boolean indeterminate) {
if (indeterminate) {
this.markShape.setContent("M1 0 H9 A1 1 0 0 1 10 1 A1 1 0 0 1 9 2 H1 A1 1 0 0 1 0 1 A1 1 0 0 1 1 0 Z");
this.mark.setMaxSize(10.0, 2.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary;");
} else if (selected) {
this.markShape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
this.mark.setMaxSize(15.0, 12.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
}
}
protected void updateChildren() {
super.updateChildren();
if (this.container != null) {
@@ -166,10 +151,9 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
if (this.invalid) {
boolean selected = checkBox.isSelected();
boolean indeterminate = checkBox.isIndeterminate();
this.updateRippleColor(selected, indeterminate);
this.playSelectAnimation(selected, indeterminate);
if (this.getSkinnable().isSelected()) {
this.playSelectAnimation(true);
}
this.invalid = false;
}
@@ -196,85 +180,27 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
};
}
private void playSelectAnimation(boolean selected, boolean indeterminate) {
boolean previousSelected = this.selected;
boolean previousIndeterminate = this.indeterminate;
this.selected = selected;
this.indeterminate = indeterminate;
boolean previousMarked = previousSelected || previousIndeterminate;
boolean marked = selected || indeterminate;
boolean sameVisualState = previousIndeterminate == indeterminate
&& (indeterminate || previousSelected == selected);
this.updateBorder(selected, indeterminate);
if (sameVisualState) {
return;
}
this.invalid = false;
if (!AnimationUtils.isAnimationEnabled()) {
finishSelectionAnimation(selected, indeterminate);
return;
private void playSelectAnimation(Boolean selection) {
if (selection == null) {
selection = false;
}
if (previousMarked && marked) {
this.transition.stop();
this.transition.setOnFinished(null);
this.transition.jumpTo(this.transition.getCycleDuration());
this.transition.setRate(-1.0);
this.transition.setOnFinished(event -> {
this.transition.setOnFinished(null);
this.updateMarkShape(selected, indeterminate);
this.transition.jumpTo(Duration.ZERO);
this.transition.setRate(1.0);
this.transition.play();
});
this.transition.play();
return;
}
this.transition.setOnFinished(null);
if (marked) {
this.updateMarkShape(selected, indeterminate);
}
this.transition.setRate(marked ? 1.0 : -1.0);
this.fillTransition.setRate(marked ? 1.0 : -1.0);
this.fillTransition.play();
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.transition.setRate(selection ? 1.0 : -1.0);
this.select.setRate(selection ? 1.0 : -1.0);
this.transition.play();
this.select.play();
this.box.setBorder(new Border(new BorderStroke(
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
BorderStrokeStyle.SOLID,
new CornerRadii(2.0),
new BorderWidths(this.lineThick))));
}
/// Stops selection animations and immediately displays the checkbox's current state.
///
/// The state indicator, fill, and border are updated even if no animation is running.
/// This also suppresses pending selection updates and the initial selection animation.
/// The skin must not have been disposed.
public void finishSelectionAnimation() {
CheckBox checkBox = this.getSkinnable();
this.finishSelectionAnimation(checkBox.isSelected(), checkBox.isIndeterminate());
}
private void finishSelectionAnimation(boolean selected, boolean indeterminate) {
this.selected = selected;
this.indeterminate = indeterminate;
boolean marked = selected || indeterminate;
transition.stop();
transition.setOnFinished(null);
transition.jumpTo(marked ? transition.getCycleDuration() : Duration.ZERO);
fillTransition.stop();
fillTransition.jumpTo(marked ? fillTransition.getCycleDuration() : Duration.ZERO);
invalid = false;
updateMarkShape(selected, indeterminate);
mark.setVisible(marked);
mark.setScaleX(marked ? 1.0 : 0.0);
mark.setScaleY(marked ? 1.0 : 0.0);
updateColors(selected, indeterminate);
}
private JFXFillTransition createFillTransition() {
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
private void createFillTransition() {
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
fillTransition.setInterpolator(Interpolator.EASE_OUT);
return fillTransition;
this.select.setInterpolator(Interpolator.EASE_OUT);
}
private final class CheckBoxTransition extends CachedTransition {
@@ -21,7 +21,6 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBoxBase;
import javafx.scene.control.ListView;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
@@ -29,7 +28,6 @@ import javafx.scene.paint.Paint;
import javafx.scene.text.Text;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.List;
@@ -180,11 +178,6 @@ public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
comboBox.hide();
});
Platform.runLater(() -> {
if (listView.lookup(".virtual-flow") instanceof VirtualFlow<?> virtualFlow) {
FXUtils.smoothScrolling(virtualFlow);
}
});
}
}
}
@@ -1,731 +0,0 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDatePicker;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.svg.SVGGlyph;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.*;
import javafx.animation.Animation.Status;
import javafx.beans.InvalidationListener;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.chrono.ChronoLocalDate;
import java.time.chrono.Chronology;
import java.time.format.DateTimeFormatter;
import java.time.format.DecimalStyle;
import java.time.temporal.ChronoUnit;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.IntStream;
public class JFXDatePickerContent extends VBox {
private static final String SPINNER_LABEL = "spinner-label";
private static final String ROBOTO = "Roboto";
protected JFXDatePicker datePicker;
private JFXButton backMonthButton;
private JFXButton forwardMonthButton;
private Label selectedDateLabel;
private Label selectedYearLabel;
private Label monthYearLabel;
protected GridPane contentGrid;
private final StackPane calendarPlaceHolder = new StackPane();
private final CachedTransition showTransition;
private final CachedTransition hideTransition;
private ParallelTransition tempImageTransition;
private final int daysPerWeek = 7;
private final List<DateCell> weekDaysCells = new ArrayList<>();
private final List<DateCell> weekNumberCells = new ArrayList<>();
protected List<DateCell> dayCells = new ArrayList<>();
private LocalDate[] dayCellDates;
private DateCell currentFocusedDayCell = null;
private final ListView<String> yearsListView = new JFXListView<>() {
{
this.getStyleClass().addAll("date-picker-list-view", "no-padding");
this.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
this.setCellFactory((listView) -> new ListCell<>() {
static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
final Label label = new Label();
final StackPane root;
final RipplerContainer ripplerContainer;
final WeakListenerHolder holder = new WeakListenerHolder();
{
label.getStyleClass().add(SPINNER_LABEL);
label.setMaxWidth(Double.MAX_VALUE);
label.textProperty().bind(itemProperty());
StackPane pane = new StackPane(label);
pane.setPadding(new Insets(7));
ripplerContainer = new RipplerContainer(pane);
root = new StackPane(ripplerContainer);
root.getStyleClass().add("data-picker-list-cell");
this.setGraphic(root);
var selectedListenerWeak = holder.weak((InvalidationListener) observable -> {
String newVal = JFXDatePickerContent.this.selectedYearLabel.getText();
if (newVal != null && getItem() != null && getItem().equals(newVal))
updateSelected(true);
});
JFXDatePickerContent.this.selectedYearLabel.textProperty().addListener(selectedListenerWeak);
itemProperty().addListener(selectedListenerWeak);
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
if (getListView() != null) {
if (selected) {
getListView().getSelectionModel().select(getIndex());
} else {
getListView().getSelectionModel().clearSelection(getIndex());
}
}
root.pseudoClassStateChanged(SELECTED, selected);
if (selected) {
int offset = Integer.parseInt(getItem()) - Integer.parseInt(JFXDatePickerContent.this.selectedYearLabel.getText());
JFXDatePickerContent.this.forward(offset, ChronoUnit.YEARS, false, false);
}
});
}
public void updateItem(String item, boolean empty) {
String oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
setText(null);
if (Objects.equals(oldItem, item) && oldEmpty == empty) return;
ripplerContainer.releaseRippleImmediately();
if (!empty && item != null) {
setGraphic(root);
} else {
setGraphic(null);
}
}
});
}
};
final DateTimeFormatter fullFormatter = DateTimeFormatter.ofPattern("EEE, MMM yyyy");
final DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMMM");
final DateTimeFormatter yearFormatter = DateTimeFormatter.ofPattern("y");
final DateTimeFormatter weekNumberFormatter = DateTimeFormatter.ofPattern("w");
final DateTimeFormatter weekDayNameFormatter = DateTimeFormatter.ofPattern("ccc");
final DateTimeFormatter dayCellFormatter = DateTimeFormatter.ofPattern("d");
private final ObjectProperty<YearMonth> selectedYearMonth = new SimpleObjectProperty<>(this, "selectedYearMonth");
final ObjectProperty<LocalDate> valueProperty = new SimpleObjectProperty<>(this, "value");
JFXDatePickerContent(final DatePicker datePicker) {
this.datePicker = (JFXDatePicker) datePicker;
this.getStyleClass().add("date-picker-popup");
LocalDate date = datePicker.getValue();
this.valueProperty.set(date);
this.valueProperty.addListener((o, old, n) -> {
this.displayedYearMonthProperty().set(n != null ? YearMonth.from(n) : YearMonth.now());
this.updateValues();
});
this.selectedYearMonth.set(date != null ? YearMonth.from(date) : YearMonth.now());
this.selectedYearMonth.addListener((observable, oldValue, newValue) -> this.updateValues());
this.getChildren().add(this.createHeaderPane());
this.getChildren().add(new Separator());
this.contentGrid = new GridPane() {
protected double computePrefWidth(double height) {
int nCols = JFXDatePickerContent.this.daysPerWeek + (datePicker.isShowWeekNumbers() ? 1 : 0);
double leftSpace = this.snapSpaceX(this.getInsets().getLeft());
double rightSpace = this.snapSpaceX(this.getInsets().getRight());
double hgaps = this.snapSpaceX(this.getHgap()) * (double) (nCols - 1);
double contentWidth = super.computePrefWidth(height) - leftSpace - rightSpace - hgaps;
return this.snapSizeX(contentWidth / (double) nCols) * (double) nCols + leftSpace + rightSpace + hgaps;
}
protected void layoutChildren() {
if (this.getWidth() > (double) 0.0F && this.getHeight() > (double) 0.0F) {
super.layoutChildren();
}
}
};
this.contentGrid.setFocusTraversable(true);
this.contentGrid.getStyleClass().add("calendar-grid");
this.contentGrid.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
this.contentGrid.setPadding(new Insets(0.0F, 12.0F, 12.0F, 12.0F));
this.contentGrid.setVgap(4);
this.contentGrid.setHgap(4);
this.createWeekDaysCells();
this.createDayCells();
this.yearsListView.getItems().addAll(IntStream.rangeClosed(1900, Year.now().getValue() + 100).mapToObj(Integer::toString).toList());
StackPane main = new StackPane();
this.yearsListView.setVisible(false);
this.yearsListView.setOpacity(0.0F);
this.yearsListView.maxWidthProperty().bind(main.widthProperty());
this.yearsListView.maxHeightProperty().bind(main.heightProperty());
main.getChildren().setAll(this.contentGrid, this.yearsListView);
VBox contentHolder = new VBox();
contentHolder.getChildren().setAll(this.createCalendarMonthLabelPane(), main);
this.calendarPlaceHolder.getStyleClass().add("content");
this.calendarPlaceHolder.getChildren().setAll(contentHolder, this.createCalendarArrowsPane());
FXUtils.setOverflowHidden(this.calendarPlaceHolder);
this.getChildren().add(this.calendarPlaceHolder);
this.getChildren().add(this.createActions());
this.refresh();
this.addEventHandler(KeyEvent.ANY, (event) -> {
Node node = this.getScene().getFocusOwner();
if (node instanceof DateCell) {
this.currentFocusedDayCell = (DateCell) node;
}
switch (event.getCode()) {
case HOME -> {
this.init();
this.goToDate(LocalDate.now(), true);
event.consume();
}
case PAGE_UP -> {
if (!this.backMonthButton.isDisabled()) {
this.forward(-1, ChronoUnit.MONTHS, true, true);
}
event.consume();
}
case PAGE_DOWN -> {
if (!this.forwardMonthButton.isDisabled()) {
this.forward(1, ChronoUnit.MONTHS, true, true);
}
event.consume();
}
case ESCAPE -> {
fireEvent(new DialogCloseEvent());
event.consume();
}
default -> event.consume();
}
});
this.showTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)))) {
{
this.setCycleDuration(Duration.millis(320.0F));
this.setDelay(Duration.seconds(0.0F));
}
protected void starting() {
super.starting();
JFXDatePickerContent.this.yearsListView.setVisible(true);
}
};
this.hideTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)))) {
{
this.setCycleDuration(Duration.millis(320.0F));
this.setDelay(Duration.seconds(0.0F));
}
protected void stopping() {
super.stopping();
JFXDatePickerContent.this.yearsListView.setVisible(false);
}
};
}
ObjectProperty<YearMonth> displayedYearMonthProperty() {
return this.selectedYearMonth;
}
private void createWeekDaysCells() {
for (int i = 0; i < this.daysPerWeek; ++i) {
DateCell cell = new DateCell();
cell.getStyleClass().add("day-name-cell");
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
cell.setAlignment(Pos.BASELINE_CENTER);
this.weekDaysCells.add(cell);
}
for (int i = 0; i < 6; ++i) {
DateCell cell = new DateCell();
cell.getStyleClass().add("week-number-cell");
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
this.weekNumberCells.add(cell);
}
}
protected VBox createHeaderPane() {
Label title = new Label(I18n.i18n("button.select_date"));
title.getStyleClass().add("title");
title.setFont(Font.font(ROBOTO, FontWeight.NORMAL, 12.0F));
HBox titleContainer = new HBox(title);
titleContainer.setAlignment(Pos.CENTER_LEFT);
titleContainer.setPadding(new Insets(0, 0, 5, 0));
this.selectedYearLabel = new Label();
this.selectedYearLabel.getStyleClass().add(SPINNER_LABEL);
this.selectedYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 14.0F));
HBox yearLabelContainer = new HBox();
yearLabelContainer.getStyleClass().add("spinner");
yearLabelContainer.getChildren().addAll(this.selectedYearLabel);
yearLabelContainer.setAlignment(Pos.CENTER_LEFT);
yearLabelContainer.setFillHeight(false);
yearLabelContainer.setOnMouseClicked((click) -> {
if (!this.yearsListView.isVisible()) {
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
this.hideTransition.stop();
this.showTransition.play();
}
});
this.selectedDateLabel = new Label();
this.selectedDateLabel.getStyleClass().add(SPINNER_LABEL);
this.selectedDateLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 32.0F));
HBox selectedDateContainer = new HBox();
selectedDateContainer.getStyleClass().add("spinner");
selectedDateContainer.getChildren().addAll(this.selectedDateLabel);
selectedDateContainer.setAlignment(Pos.CENTER_LEFT);
selectedDateContainer.setOnMouseClicked((click) -> {
if (this.yearsListView.isVisible()) {
this.showTransition.stop();
this.hideTransition.play();
}
});
VBox headerPanel = new VBox();
headerPanel.getStyleClass().add("month-year-pane");
headerPanel.setPadding(new Insets(12.0F, 24.0F, 12.0F, 24.0F));
headerPanel.getChildren().addAll(titleContainer, yearLabelContainer, selectedDateContainer);
return headerPanel;
}
protected BorderPane createCalendarArrowsPane() {
SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.GRAY);
SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.GRAY);
leftChevron.setSize(6.0F, 11.0F);
rightChevron.setSize(6.0F, 11.0F);
this.backMonthButton = new JFXButton();
this.backMonthButton.setMinSize(40.0F, 40.0F);
this.backMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
this.backMonthButton.getStyleClass().add("left-button");
this.backMonthButton.setGraphic(leftChevron);
this.backMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
this.backMonthButton.setOnAction((t) -> this.forward(-1, ChronoUnit.MONTHS, false, true));
this.forwardMonthButton = new JFXButton();
this.forwardMonthButton.setMinSize(40.0F, 40.0F);
this.forwardMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
this.forwardMonthButton.getStyleClass().add("right-button");
this.forwardMonthButton.setGraphic(rightChevron);
this.forwardMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
this.forwardMonthButton.setOnAction((t) -> this.forward(1, ChronoUnit.MONTHS, false, true));
BorderPane arrowsContainer = new BorderPane();
arrowsContainer.setLeft(this.backMonthButton);
arrowsContainer.setRight(this.forwardMonthButton);
arrowsContainer.setPadding(new Insets(4.0F, 12.0F, 2.0F, 12.0F));
arrowsContainer.setPickOnBounds(false);
return arrowsContainer;
}
protected BorderPane createCalendarMonthLabelPane() {
this.monthYearLabel = new Label();
this.monthYearLabel.getStyleClass().add(SPINNER_LABEL);
this.monthYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 13.0F));
SVGContainer svgContainer = new SVGContainer(13);
HBox box = new HBox(monthYearLabel, svgContainer);
box.setSpacing(3);
box.setAlignment(Pos.CENTER);
FXUtils.onChangeAndOperate(this.yearsListView.visibleProperty(), b -> svgContainer.setIcon(b ? SVG.ARROW_DROP_UP : SVG.ARROW_DROP_DOWN));
FXUtils.onClicked(box, () -> {
if (!this.yearsListView.isVisible()) {
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
this.hideTransition.stop();
this.showTransition.play();
} else {
this.showTransition.stop();
this.hideTransition.play();
}
});
BorderPane monthContainer = new BorderPane();
monthContainer.setMinHeight(50.0F);
monthContainer.setCenter(box);
monthContainer.setPadding(new Insets(2.0F, 12.0F, 2.0F, 12.0F));
return monthContainer;
}
protected HBox createActions() {
JFXButton cancelButton = new JFXButton(I18n.i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
FXUtils.onClicked(cancelButton, () -> fireEvent(new DialogCloseEvent()));
JFXButton acceptButton = new JFXButton(I18n.i18n("button.ok"));
acceptButton.getStyleClass().add("dialog-accept");
FXUtils.onClicked(acceptButton, () -> {
datePicker.setValue(valueProperty.get());
fireEvent(new DialogCloseEvent());
});
HBox hBox = new HBox(cancelButton, acceptButton);
hBox.setPadding(new Insets(0, 5, 3, 0));
hBox.setSpacing(5);
hBox.setAlignment(Pos.CENTER_RIGHT);
return hBox;
}
void updateContentGrid() {
this.contentGrid.getColumnConstraints().clear();
this.contentGrid.getChildren().clear();
int colsNumber = this.daysPerWeek + (this.datePicker.isShowWeekNumbers() ? 1 : 0);
ColumnConstraints columnConstraints = new ColumnConstraints();
columnConstraints.setPercentWidth(100.0F);
for (int i = 0; i < colsNumber; ++i) {
this.contentGrid.getColumnConstraints().add(columnConstraints);
}
for (int i = 0; i < this.daysPerWeek; ++i) {
this.contentGrid.add(this.weekDaysCells.get(i), i + colsNumber - this.daysPerWeek, 1);
}
if (this.datePicker.isShowWeekNumbers()) {
for (int i = 0; i < 6; ++i) {
this.contentGrid.add(this.weekNumberCells.get(i), 0, i + 2);
}
}
for (int row = 0; row < 6; ++row) {
for (int col = 0; col < this.daysPerWeek; ++col) {
this.contentGrid.add(this.dayCells.get(row * this.daysPerWeek + col), col + colsNumber - this.daysPerWeek, row + 2);
}
}
}
private void refresh() {
this.updateDayNameCells();
this.updateValues();
}
private void updateDayNameCells() {
int weekFirstDay = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
LocalDate date = LocalDate.of(2009, 7, 12 + weekFirstDay);
for (int i = 0; i < this.daysPerWeek; ++i) {
String name = this.weekDayNameFormatter.withLocale(this.getLocale()).format(date.plusDays(i));
if (!LocaleUtils.isChinese(this.weekNumberFormatter.getLocale())) {
name = name.substring(0, 1).toUpperCase(Locale.ROOT);
}
this.weekDaysCells.get(i).setText(name);
}
}
void updateValues() {
this.updateWeekNumberDateCells();
this.updateDayCells();
this.updateMonthYearPane();
}
void updateWeekNumberDateCells() {
if (this.datePicker.isShowWeekNumbers()) {
Locale locale = this.getLocale();
LocalDate firstDayOfMonth = this.selectedYearMonth.get().atDay(1);
for (int i = 0; i < 6; ++i) {
LocalDate date = firstDayOfMonth.plusWeeks(i);
String weekNumber = this.weekNumberFormatter.withLocale(locale).withDecimalStyle(DecimalStyle.of(locale)).format(date);
this.weekNumberCells.get(i).setText(weekNumber);
}
}
}
private void updateDayCells() {
Locale locale = this.getLocale();
Chronology chrono = this.getPrimaryChronology();
int firstDayOfWeek = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
int firstOfMonthIndex = this.selectedYearMonth.get().atDay(1).getDayOfWeek().getValue() - firstDayOfWeek;
firstOfMonthIndex += firstOfMonthIndex < 0 ? this.daysPerWeek : 0;
YearMonth currentYearMonth = this.selectedYearMonth.get();
int daysInCurMonth = -1;
for (int i = 0; i < 6 * this.daysPerWeek; ++i) {
DateCell dayCell = this.dayCells.get(i);
try {
if (daysInCurMonth == -1) {
daysInCurMonth = currentYearMonth.lengthOfMonth();
}
int dayIndex = i - firstOfMonthIndex + 1;
LocalDate date;
if (dayIndex <= 0) {
YearMonth previous = currentYearMonth.minusMonths(1);
date = previous.atDay(previous.lengthOfMonth() + dayIndex);
} else if (dayIndex > daysInCurMonth) {
YearMonth next = currentYearMonth.plusMonths(1);
date = next.atDay(dayIndex - daysInCurMonth);
} else {
date = currentYearMonth.atDay(dayIndex);
}
this.dayCellDates[i] = date;
if (date.equals(LocalDate.now())) {
dayCell.getStyleClass().add("today");
} else {
dayCell.getStyleClass().removeAll("today");
}
if (date.equals(valueProperty.get())) {
dayCell.getStyleClass().add("selected");
this.currentFocusedDayCell = dayCell;
} else {
dayCell.getStyleClass().removeAll("selected");
}
ChronoLocalDate cDate = chrono.date(date);
String cellText = this.dayCellFormatter.withLocale(locale).withChronology(chrono).withDecimalStyle(DecimalStyle.of(locale)).format(cDate);
dayCell.setText(cellText);
if (i < firstOfMonthIndex) {
dayCell.getStyleClass().add("previous-month");
dayCell.getStyleClass().removeAll("next-month");
dayCell.setDisable(true);
} else if (i >= firstOfMonthIndex + daysInCurMonth) {
dayCell.getStyleClass().add("next-month");
dayCell.getStyleClass().removeAll("previous-month");
dayCell.setDisable(true);
} else {
dayCell.getStyleClass().removeAll("previous-month", "next-month");
dayCell.setDisable(false);
}
dayCell.updateItem(date, false);
} catch (DateTimeException var13) {
dayCell.setText("");
dayCell.setDisable(true);
}
}
}
protected void updateMonthYearPane() {
YearMonth yearMonth = this.selectedYearMonth.get();
this.selectedDateLabel.setText(fullFormatter.withLocale(this.getLocale()).format(Objects.requireNonNullElseGet(valueProperty.get(), LocalDate::now)));
this.selectedYearLabel.setText(this.formatYear(yearMonth));
this.monthYearLabel.setText(this.formatMonth(yearMonth) + " " + this.formatYear(yearMonth));
Chronology chrono = this.datePicker.getChronology();
LocalDate firstDayOfMonth = yearMonth.atDay(1);
this.backMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, -1, ChronoUnit.DAYS));
this.forwardMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, 1, ChronoUnit.MONTHS));
}
private String formatMonth(YearMonth yearMonth) {
try {
Chronology chrono = this.getPrimaryChronology();
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
return this.monthFormatter.withLocale(this.getLocale()).withChronology(chrono).format(cDate);
} catch (DateTimeException var4) {
return "";
}
}
private String formatYear(YearMonth yearMonth) {
try {
Chronology chrono = this.getPrimaryChronology();
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
return this.yearFormatter.withLocale(this.getLocale()).withChronology(chrono).withDecimalStyle(DecimalStyle.of(this.getLocale())).format(cDate);
} catch (DateTimeException var4) {
return "";
}
}
protected LocalDate dayCellDate(DateCell dateCell) {
assert this.dayCellDates != null;
return this.dayCellDates[this.dayCells.indexOf(dateCell)];
}
protected void forward(int offset, ChronoUnit unit, boolean focusDayCell, boolean withAnimation) {
if (withAnimation && (this.tempImageTransition == null || this.tempImageTransition.getStatus() == Status.STOPPED)) {
Pane monthContent = (Pane) this.calendarPlaceHolder.getChildren().get(0);
this.getParent().setManaged(false);
SnapshotParameters snapShotparams = new SnapshotParameters();
snapShotparams.setFill(Color.TRANSPARENT);
WritableImage temp = monthContent.snapshot(snapShotparams, new WritableImage((int) monthContent.getWidth(), (int) monthContent.getHeight()));
ImageView tempImage = new ImageView(temp);
this.calendarPlaceHolder.getChildren().add(this.calendarPlaceHolder.getChildren().size() - 2, tempImage);
TranslateTransition imageTransition = new TranslateTransition(Duration.millis(160.0F), tempImage);
imageTransition.setToX((double) (-offset) * this.calendarPlaceHolder.getWidth());
imageTransition.setOnFinished((finish) -> this.calendarPlaceHolder.getChildren().remove(tempImage));
monthContent.setTranslateX((double) offset * this.calendarPlaceHolder.getWidth());
TranslateTransition contentTransition = new TranslateTransition(Duration.millis(160.0F), monthContent);
contentTransition.setToX(0.0F);
this.tempImageTransition = new ParallelTransition(imageTransition, contentTransition);
this.tempImageTransition.setOnFinished((finish) -> {
this.calendarPlaceHolder.getChildren().remove(tempImage);
this.getParent().setManaged(true);
});
this.tempImageTransition.play();
}
YearMonth yearMonth = this.selectedYearMonth.get();
DateCell dateCell = this.currentFocusedDayCell;
if (dateCell == null || this.dayCellDate(dateCell).getMonth() != yearMonth.getMonth()) {
dateCell = this.findDayCellOfDate(yearMonth.atDay(1));
}
this.goToDayCell(dateCell, offset, unit, focusDayCell);
}
private void goToDayCell(DateCell dateCell, int offset, ChronoUnit unit, boolean focusDayCell) {
this.goToDate(this.dayCellDate(dateCell).plus(offset, unit), focusDayCell);
}
private void goToDate(LocalDate date, boolean focusDayCell) {
if (this.isValidDate(this.datePicker.getChronology(), date)) {
this.valueProperty.set(date);
this.selectedYearMonth.set(YearMonth.from(date));
if (focusDayCell) {
this.findDayCellOfDate(date).requestFocus();
}
}
}
private void selectDayCell(DateCell dateCell) {
this.valueProperty.set(this.dayCellDate(dateCell));
}
private DateCell findDayCellOfDate(LocalDate date) {
for (int i = 0; i < this.dayCellDates.length; ++i) {
if (date.equals(this.dayCellDates[i])) {
return this.dayCells.get(i);
}
}
return this.dayCells.get(this.dayCells.size() / 2 + 1);
}
void init() {
this.contentGrid.setOpacity(1.0F);
this.yearsListView.setOpacity(0.0F);
this.yearsListView.setVisible(false);
}
void clearFocus() {
LocalDate focusDate = this.valueProperty.get();
if (focusDate == null) {
focusDate = LocalDate.now();
}
if (YearMonth.from(focusDate).equals(this.selectedYearMonth.get())) {
this.goToDate(focusDate, true);
}
}
protected void createDayCells() {
for (int row = 0; row < 6; ++row) {
for (int col = 0; col < this.daysPerWeek; ++col) {
DateCell dayCell = this.createDayCell();
dayCell.addEventHandler(MouseEvent.MOUSE_CLICKED, (click) -> {
if (click.getButton() == MouseButton.PRIMARY) {
DateCell selectedDayCell = (DateCell) click.getSource();
this.selectDayCell(selectedDayCell);
}
});
dayCell.setOnMouseEntered((event) -> dayCell.getStyleClass().add("hovered"));
dayCell.setOnMouseExited((event) -> dayCell.getStyleClass().removeAll("hovered"));
dayCell.setAlignment(Pos.BASELINE_CENTER);
dayCell.setBorder(new Border(new BorderStroke(Color.TRANSPARENT, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(5.0F))));
dayCell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
this.dayCells.add(dayCell);
}
}
this.dayCellDates = new LocalDate[6 * this.daysPerWeek];
this.updateContentGrid();
}
private DateCell createDayCell() {
DateCell dayCell = null;
if (this.datePicker.getDayCellFactory() != null) {
dayCell = this.datePicker.getDayCellFactory().call(this.datePicker);
}
if (dayCell == null) {
dayCell = new DateCell();
}
dayCell.getStyleClass().add("day-cell");
dayCell.setPrefSize(36.0F, 36.0F);
dayCell.setDisable(false);
dayCell.setStyle(null);
dayCell.setGraphic(null);
dayCell.setTooltip(null);
return dayCell;
}
protected Chronology getPrimaryChronology() {
return this.datePicker.getChronology();
}
protected Locale getLocale() {
return SettingsManager.settings().languageProperty().get().getLocale();
}
protected boolean isValidDate(Chronology chrono, LocalDate date, int offset, ChronoUnit unit) {
return date != null && this.isValidDate(chrono, date.plus(offset, unit));
}
protected boolean isValidDate(Chronology chrono, LocalDate date) {
try {
if (date != null) {
chrono.date(date);
}
return true;
} catch (DateTimeException var4) {
return false;
}
}
}
@@ -1,181 +0,0 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.ReflectionHelper;
import com.jfoenix.adapters.skins.DatePickerSkin;
import com.jfoenix.controls.JFXDatePicker;
import com.jfoenix.controls.JFXDialog;
import com.jfoenix.controls.JFXDialog.DialogTransition;
import javafx.beans.value.ChangeListener;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.TextField;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Stack;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class JFXDatePickerSkin extends DatePickerSkin {
public static final String PROPERTY_DATE_PICKER_CONTENTS = JFXDatePicker.class.getName() + ".dialog.contents";
private final JFXDatePicker jfxDatePicker;
private TextField displayNode;
private JFXDatePickerContent content;
private JFXDialog dialog;
public JFXDatePickerSkin(JFXDatePicker datePicker) {
super(datePicker);
this.jfxDatePicker = datePicker;
try {
Object expressionHelper = ReflectionHelper.getFieldContent(datePicker.focusedProperty().getClass().getSuperclass(), datePicker.focusedProperty(), "helper");
ChangeListener<Boolean>[] changeListeners = ReflectionHelper.getFieldContent(expressionHelper, "changeListeners");
int i = changeListeners.length - 1;
if (i >= 0) {
while (i >= 0 && changeListeners[i] == null) {
--i;
}
if (i >= 0) datePicker.focusedProperty().removeListener(changeListeners[i]);
}
} catch (NullPointerException e) {
LOG.warning("Cannot remove focusedProperty listener", e);
}
datePicker.focusedProperty().addListener((obj, oldVal, newVal) -> {
if (this.getEditor() != null && !newVal) {
this.setTextFromTextFieldIntoComboBoxValue2();
}
});
var arrowBox = new HBox(new RipplerContainer(SVG.EDIT_CALENDAR.createIcon(30)));
arrowBox.setAlignment(Pos.CENTER);
this.setArrow(arrowBox);
this.getArrowButton().getChildren().setAll(arrowBox);
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
this.getArrowButton().setPadding(new Insets(1.0F, 8.0F, 1.0F, 8.0F));
this.registerChangeListener2(datePicker.converterProperty(), "CONVERTER", this::updateDisplayNode2);
this.registerChangeListener2(datePicker.dayCellFactoryProperty(), "DAY_CELL_FACTORY", () -> {
this.updateDisplayNode2();
this.content = null;
this.setPopup2(null);
});
this.registerChangeListener2(datePicker.editorProperty(), "EDITOR", this::getEditableInputNode2);
this.registerChangeListener2(datePicker.showingProperty(), "SHOWING", () -> {
if (this.jfxDatePicker.isShowing()) {
if (this.content != null) {
LocalDate date = this.jfxDatePicker.getValue();
this.content.displayedYearMonthProperty().set(date != null ? YearMonth.from(date) : YearMonth.now());
this.content.updateValues();
}
this.show();
} else {
this.hide();
}
});
this.registerChangeListener2(datePicker.showWeekNumbersProperty(), "SHOW_WEEK_NUMBERS", () -> {
if (this.content != null) {
this.content.updateContentGrid();
this.content.updateWeekNumberDateCells();
}
});
this.registerChangeListener2(datePicker.valueProperty(), "VALUE", () -> {
this.updateDisplayNode2();
this.jfxDatePicker.fireEvent(new ActionEvent());
});
}
@Override
public JFXDatePickerContent getPopupContent() {
if (this.content == null) {
this.content = new JFXDatePickerContent(this.jfxDatePicker);
}
return this.content;
}
@Override
public void show() {
if (!((JFXDatePicker) this.getSkinnable()).isOverLay()) {
super.show();
}
if (this.content != null) {
this.content.init();
this.content.clearFocus();
}
if (((JFXDatePicker) this.getSkinnable()).isOverLay() && this.dialog == null) {
StackPane dialogParent = this.jfxDatePicker.getDialogParent();
if (dialogParent == null) {
dialogParent = (StackPane) this.getSkinnable().getScene().getRoot();
}
this.dialog = new JFXDialog(dialogParent, this.getPopupContent(), DialogTransition.CENTER, true) {
@Override
public void close() {
super.close();
Object o = getDialogContainer().getProperties().get(PROPERTY_DATE_PICKER_CONTENTS);
if (o instanceof Stack<?> stack) {
stack.pop();
}
jfxDatePicker.hide();
}
};
this.getPopupContent().addEventHandler(DialogCloseEvent.CLOSE, e -> this.dialog.close());
getArrowButton().setOnMouseClicked((click) -> {
if (((JFXDatePicker) this.getSkinnable()).isOverLay()) {
StackPane parent = this.jfxDatePicker.getDialogParent();
if (parent == null) {
parent = (StackPane) this.getSkinnable().getScene().getRoot();
}
Stack<JFXDatePickerContent> contentStack = (Stack<JFXDatePickerContent>) parent.getProperties().computeIfAbsent(PROPERTY_DATE_PICKER_CONTENTS, k -> new Stack<>());
contentStack.push(this.getPopupContent());
this.getPopupContent().valueProperty.set(this.getSkinnable().getValue());
this.dialog.show(parent);
}
});
}
}
JFXDialog getDialog() {
return this.dialog;
}
@Override
public Node getDisplayNode() {
if (this.displayNode == null) {
this.displayNode = this.getEditableInputNode2();
this.displayNode.getStyleClass().add("date-picker-display-node");
this.updateDisplayNode2();
}
this.displayNode.setEditable(this.jfxDatePicker.isEditable());
return this.displayNode;
}
public void syncWithAutoUpdate() {
if (!this.getPopup2().isShowing() && this.jfxDatePicker.isShowing()) {
this.jfxDatePicker.hide();
}
}
}
@@ -69,7 +69,7 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
arc = new Arc();
arc.setManaged(false);
arc.setLength(-180);
arc.setLength(180);
arc.getStyleClass().setAll("arc");
arc.setFill(Color.TRANSPARENT);
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
@@ -192,24 +192,24 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
frames.add(new KeyFrame(Duration.seconds(duration),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 45 + startingAngle),
angle + 45 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 90 + startingAngle),
angle + 90 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 135 + startingAngle),
angle + 135 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 435 + startingAngle),
angle + 435 + startingAngle,
Interpolator.LINEAR)));
}
@@ -222,21 +222,21 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
addKeyFrames(keyFrames, 900, 2.8);
addKeyFrames(keyFrames, 1350, 4.2);
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(1845 + startingAngle),
1845 + startingAngle,
Interpolator.LINEAR)));
timeline = new Timeline();
timeline.getKeyFrames().setAll(keyFrames);
} else {
final double arcLength = -250;
final double arcLength = 250;
timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(arc.startAngleProperty(), -(45 + startingAngle), Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
new KeyFrame(Duration.seconds(1.2),
new KeyValue(arc.startAngleProperty(), -(45 + 360 + startingAngle), Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
);
}
@@ -52,6 +52,8 @@ public final class EntryPoint {
checkWine();
setupJavaFXVMOptions();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
if (!isInsideMacAppBundle())
@@ -72,6 +74,71 @@ 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 {
@@ -158,8 +225,15 @@ public final class EntryPoint {
private static void checkWine() {
if (OperatingSystem.isRunningUnderWine()) {
SwingUtils.initLookAndFeel();
LOG.warning("HMCL is running under Wine or its distributions!");
showWarning(i18n("fatal.wine_warning"));
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);
}
}
}
@@ -199,21 +273,10 @@ 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.
*/
static void showErrorAndExit(String message) {
private static void showErrorAndExit(String message) {
SwingUtils.showErrorDialog(message);
exit(1);
}
@@ -17,8 +17,6 @@
*/
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;
@@ -31,6 +29,8 @@ 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;
@@ -38,6 +38,7 @@ import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
@@ -47,20 +48,9 @@ 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.macos.LibSystem;
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.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.CookieHandler;
@@ -70,11 +60,10 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
@@ -82,7 +71,6 @@ import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// The main JavaFX application entry point.
public final class Launcher extends Application {
public static final CookieManager COOKIE_MANAGER = new CookieManager();
@@ -110,7 +98,18 @@ public final class Launcher extends Application {
}
try {
initializeSettingsRuntime();
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);
}
// https://lapcatsoftware.com/articles/app-translocation.html
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
@@ -142,6 +141,9 @@ public final class Launcher extends Application {
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
Themes.applyNativeDarkMode(primaryStage);
UpdateChecker.init();
primaryStage.show();
@@ -272,10 +274,52 @@ 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();
SettingsManager.shutdown();
FileSaver.shutdown();
LOG.shutdown();
}
@@ -309,8 +353,8 @@ public final class Launcher extends Application {
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
try {
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
@@ -328,27 +372,10 @@ public final class Launcher extends Application {
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
}
@Nullable Boolean isRoot = isRootUser();
LOG.info("Administrator User: " + (isRoot == null ? "Unknown" : isRoot.toString()));
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
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);
@@ -381,283 +408,5 @@ 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;
@Nullable Path thisJar = JarUtils.thisJarPath();
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
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);
}
}
/// Sets JavaFX VM options before the toolkit starts.
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
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);
}
}
}
}
}
float uiScaleLowerBound;
float uiScaleUpperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
uiScaleLowerBound = 0.25f;
uiScaleUpperBound = 4f;
} else {
uiScaleLowerBound = 0.01f;
uiScaleUpperBound = 10f;
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
float scaleValue = Float.NaN;
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
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);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
if ("wayland".equals(xdgSessionType)
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
try {
@Nullable Path xrdb = SystemUtils.which("xrdb");
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
@Nullable String dpiLine;
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
BufferedReader bufferedReader = new BufferedReader(reader);
Stream<String> lines = bufferedReader.lines()) {
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
.findFirst().orElse(null);
}
return dpiLine != null
? dpiLine.substring("Xft.dpi:".length()).trim()
: null;
}, java.time.Duration.ofSeconds(1)) : null;
if (xftDpi != null) {
float dpiValue = Float.parseFloat(xftDpi);
float scale = dpiValue / 96f;
if (scale > 1 && scale <= 10) {
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
scaleValue = scale;
uiScale = Float.toString(scale);
}
}
} catch (Exception e) {
LOG.warning("Failed to read Xft.dpi from xrdb", e);
}
}
}
if (Float.isFinite(scaleValue)) {
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
}
}
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);
}
private static @Nullable Boolean isRootUser() {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
WinReg reg = WinReg.INSTANCE;
if (reg == null)
return null;
try {
return reg.exists(WinReg.HKEY.HKEY_USERS, "S-1-5-19");
} catch (Exception e) {
LOG.warning("Failed to check Windows administrator privileges", e);
return null;
}
}
case LINUX -> {
Path selfStatus = Paths.get("/proc/self/status");
if (Files.exists(selfStatus)) {
try (var reader = Files.newBufferedReader(selfStatus)) {
@Nullable String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("Uid:")) {
String[] parts = line.split("\t");
if (parts.length >= 3) {
String uid = parts[2].trim();
return "0".equals(uid);
}
}
}
} catch (Exception e) {
LOG.warning("Failed to read " + selfStatus, e);
}
}
}
case MACOS -> {
if (NativeUtils.USE_JNA) {
LibSystem libSystem = LibSystem.INSTANCE;
if (libSystem != null) {
try {
return libSystem.geteuid() == 0;
} catch (Throwable e) {
LOG.warning("Failed to check macOS root privileges", e);
return null;
}
}
}
}
}
if (OperatingSystem.CURRENT_OS.isUnixLike()) {
@Nullable Path id = SystemUtils.which("id");
if (id != null) {
try {
return "0".equals(SystemUtils.run(id.toString(), "-u").trim());
} catch (Exception e) {
LOG.warning("Failed to run id -u", e);
return null;
}
}
}
return null;
}
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
}
@@ -23,7 +23,6 @@ import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
@@ -38,9 +37,6 @@ 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;
@@ -134,32 +130,4 @@ public final class Metadata {
return null;
}
}
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
public static Path getBundledModpackDirectory() {
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
}
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
///
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
/// signal to offer automatic install; the file is removed after a successful install.
///
/// @return the modpack path, or `null` when no package is present
public static @Nullable Path findBundledModpackFile() {
Path directory = getBundledModpackDirectory();
Path zipModpack = directory.resolve("modpack.zip");
if (Files.isRegularFile(zipModpack)) {
return zipModpack;
}
Path mrpackModpack = directory.resolve("modpack.mrpack");
if (Files.isRegularFile(mrpackModpack)) {
return mrpackModpack;
}
return null;
}
}
@@ -1,493 +0,0 @@
/*
* 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.addon;
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
import org.jackhuang.hmcl.modpack.ModpackProjectInfo;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.ref.SoftReference;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
@NotNullByDefault
public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
public static final AddonProjectInfoRegistry<ModProjectInfo> MOD = new AddonProjectInfoRegistry<>(
"/assets/addon/mod.csv",
32768
) {
@Override
protected int getFieldsCount() {
return MOD_IDS_INDEX + 1;
}
@Override
protected ModProjectInfo parseAddonProjectInfo(String[] fields) throws Exception {
return new ModProjectInfo(
Integer.parseInt(fields[MCMOD_ID_INDEX]),
fields[CURSEFORGE_ID_INDEX],
fields[MODRINTH_ID_INDEX],
fields[MAIN_NAME_INDEX],
fields[SUB_NAME_INDEX],
fields[ABBR_NAME_INDEX],
fields[MOD_IDS_INDEX]
);
}
};
/// Provides metadata for modpacks.
public static final AddonProjectInfoRegistry<ModpackProjectInfo> MODPACK = new AddonProjectInfoRegistry<>(
"/assets/addon/modpack.csv", 2048
) {
@Override
protected int getFieldsCount() {
return ABBR_NAME_INDEX + 1;
}
@Override
protected ModpackProjectInfo parseAddonProjectInfo(String[] fields) {
return new ModpackProjectInfo(Integer.parseInt(fields[MCMOD_ID_INDEX]),
fields[CURSEFORGE_ID_INDEX], fields[MODRINTH_ID_INDEX],
fields[MAIN_NAME_INDEX], fields[SUB_NAME_INDEX], fields[ABBR_NAME_INDEX]);
}
};
/// Returns the registry for the addon type, or `null` for null or unsupported types.
public static @Nullable AddonProjectInfoRegistry<?> getByAddonType(@Nullable RemoteAddon.Type type) {
if (type == null) return null;
return switch (type) {
case MOD -> MOD;
case MODPACK -> MODPACK;
default -> null;
};
}
protected static final int MCMOD_ID_INDEX = 0;
protected static final int CURSEFORGE_ID_INDEX = 1;
protected static final int MODRINTH_ID_INDEX = 2;
protected static final int MAIN_NAME_INDEX = 3;
protected static final int SUB_NAME_INDEX = 4;
protected static final int ABBR_NAME_INDEX = 5;
protected static final int MOD_IDS_INDEX = 6;
private final ReentrantLock lock = new ReentrantLock();
private final String assetsPath;
private final int initialCapacity;
private volatile @Nullable SoftReference<@Unmodifiable List<I>> infosRef;
/// Softly caches projects indexed by mod ID.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> modIdMapRef;
/// Softly caches projects indexed by cleaned secondary name.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> subnameMapRef;
/// Softly caches projects indexed by CurseForge identifier.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> curseForgeMapRef;
/// Softly caches searchable names in resource and field order.
private volatile @Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> keywordsRef;
/// Creates a registry reading records from the given classpath resource.
AddonProjectInfoRegistry(String assetsPath, int initialCapacity) {
this.assetsPath = assetsPath;
this.initialCapacity = initialCapacity;
}
protected abstract int getFieldsCount();
protected abstract I parseAddonProjectInfo(String[] fields) throws Exception;
private @Unmodifiable List<I> loadInfos() throws IOException {
@Nullable InputStream inputStream = AddonProjectInfoRegistry.class.getResourceAsStream(assetsPath);
if (inputStream == null) {
return List.of();
}
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
ArrayList<I> infos = new ArrayList<>(initialCapacity);
@Unmodifiable List<I> result = Collections.unmodifiableList(infos);
int fieldsCount = getFieldsCount();
String[] fields = new String[fieldsCount];
@Nullable StringBuilder fieldBuilder = null;
@Nullable String line;
loop:
while ((line = reader.readLine()) != null) {
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
int offset = 0;
for (int fieldIndex = 0; fieldIndex < fieldsCount; fieldIndex++) {
if (offset > line.length()) {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
int end = line.indexOf(',', offset);
if (end < 0)
end = line.length();
String field;
if (offset == end) {
field = "";
} else {
int cursor = offset;
while (cursor < end) {
if (line.charAt(cursor) == '\\') {
break;
} else {
cursor++;
}
}
if (cursor == end) {
field = line.substring(offset, end);
} else {
if (fieldBuilder == null) {
fieldBuilder = new StringBuilder(32);
} else {
fieldBuilder.setLength(0);
}
fieldBuilder.append(line, offset, cursor);
while (cursor < end) {
char c = line.charAt(cursor);
if (c == '\\') {
if (cursor < end - 1) {
char next = line.charAt(cursor + 1);
switch (next) {
case '0' -> {
fieldBuilder.append('\0');
cursor += 2;
}
case 'n' -> {
fieldBuilder.append('\n');
cursor += 2;
}
case 'r' -> {
fieldBuilder.append('\r');
cursor += 2;
}
case 't' -> {
fieldBuilder.append('\t');
cursor += 2;
}
case '\\' -> {
fieldBuilder.append('\\');
cursor += 2;
}
case 'c' -> {
fieldBuilder.append(',');
cursor += 2;
}
case 'u' -> {
if (cursor < end - 5) {
try {
int codePoint = Integer.parseInt(line, cursor + 2, cursor + 6, 16);
fieldBuilder.append((char) codePoint);
cursor += 6;
} catch (NumberFormatException e) {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
} else {
fieldBuilder.append(c);
cursor++;
}
}
default -> {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
}
} else {
fieldBuilder.append(c);
cursor++;
}
} else {
fieldBuilder.append(c);
cursor++;
}
}
field = fieldBuilder.toString();
}
}
fields[fieldIndex] = field;
offset = end + 1;
}
try {
I info = parseAddonProjectInfo(fields);
info.setKeepAlive(result);
infos.add(info);
} catch (Exception e) {
LOG.warning("Failed to parse line in " + assetsPath + ": " + line, e);
}
}
infos.trimToSize();
return result;
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getModIdMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.modIdMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.modIdMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
if (info instanceof ModProjectInfo mod) {
for (String id : mod.getModIds()) {
if (!id.isEmpty())
map.putIfAbsent(id, info);
}
}
}
cached = Collections.unmodifiableMap(map);
this.modIdMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getSubnameMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.subnameMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.subnameMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
String name = cleanSubname(info.subName());
if (!name.isEmpty())
map.putIfAbsent(name, info);
}
cached = Collections.unmodifiableMap(map);
this.subnameMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getCurseForgeMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.curseForgeMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.curseForgeMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
String id = info.curseforgeId();
if (StringUtils.isNotBlank(id))
map.putIfAbsent(id, info);
}
cached = Collections.unmodifiableMap(map);
this.curseForgeMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns the first project with the exact CurseForge identifier, or `null` if the
/// identifier is null, blank or unknown.
public @Nullable I getInfoByCurseForgeId(@Nullable String id) {
return StringUtils.isBlank(id) ? null : getCurseForgeMap().get(id);
}
/// Finds a project by its cleaned secondary name, falling back to its exact mod ID.
/// A name match is accepted only when the ID is blank or belongs to that mod project.
/// Null and blank inputs are absent; returns `null` if neither lookup matches.
/// Duplicate keys resolve to the first project in resource order.
public @Nullable I getInfo(@Nullable String id, @Nullable String subname) {
String name = cleanSubname(subname);
if (StringUtils.isNotBlank(name)) {
@Nullable I info = getSubnameMap().get(name);
if (info != null && (StringUtils.isBlank(id)
|| info instanceof ModProjectInfo mod && mod.getModIds().contains(id)))
return info;
}
return StringUtils.isBlank(id) ? null : getModIdMap().get(id);
}
private static boolean isSupportedSubnameChar(int ch) {
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|| ".+\\".indexOf(ch) >= 0;
}
/// Removes supported separators and pictographs from a secondary name.
/// Returns an empty string for null, blank or unsupported names; matching is case-sensitive.
private static String cleanSubname(@Nullable String subname) {
if (StringUtils.isBlank(subname))
return "";
int offset = 0;
while (offset < subname.length()) {
int ch = subname.charAt(offset);
if (!isSupportedSubnameChar(ch))
break;
offset++;
}
if (offset == subname.length())
return subname;
StringBuilder builder = new StringBuilder(subname.length());
builder.append(subname, 0, offset);
while (offset < subname.length()) {
int ch = subname.codePointAt(offset);
if (isSupportedSubnameChar(ch)) {
builder.appendCodePoint(ch);
} else if (!(Character.isWhitespace(ch)
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0
|| ch >= 0x1F300 && ch <= 0x1FAFF)) {
return "";
}
offset += Character.charCount(ch);
}
return builder.toString();
}
/// Returns searchable names, rebuilding the index after its soft reference is cleared.
private @Unmodifiable List<Pair<String, I>> getKeywords() {
@Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> reference = keywordsRef;
@Unmodifiable List<Pair<String, I>> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = keywordsRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
List<Pair<String, I>> keywords = new ArrayList<>();
for (I info : getInfos()) {
if (!info.mainName().isEmpty())
keywords.add(Pair.pair(info.mainName(), info));
if (!info.subName().isEmpty())
keywords.add(Pair.pair(info.subName(), info));
if (!info.abbrName().isEmpty())
keywords.add(Pair.pair(info.abbrName(), info));
}
cached = List.copyOf(keywords);
keywordsRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns projects ranked by descending longest common subsequence length after removing
/// space characters from the query. Comparison is case-sensitive and uses UTF-16 units.
/// Matches require at least one unit and at least query length minus three units.
/// Each matching name contributes a result, so projects may occur more than once.
/// Ties preserve resource order, then primary name, secondary name and abbreviation order.
public @Unmodifiable List<I> search(String query) {
query = query.chars().filter(ch -> !Character.isSpaceChar(ch))
.collect(StringBuilder::new, (sb, ch) -> sb.append((char) ch), StringBuilder::append).toString();
@Unmodifiable List<Pair<String, I>> keywords = getKeywords();
int maxLength = keywords.stream().mapToInt(keyword -> keyword.getKey().length()).max().orElse(0);
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxLength);
List<Pair<Integer, I>> matches = new ArrayList<>();
for (Pair<String, I> keyword : keywords) {
int score = lcs.calc(query, keyword.getKey());
if (score >= Math.max(1, query.length() - 3))
matches.add(Pair.pair(score, keyword.getValue()));
}
return matches.stream().sorted((a, b) -> Integer.compare(b.getKey(), a.getKey()))
.map(Pair::getValue).toList();
}
/// Returns cached project metadata, loading it on demand. Missing resources and I/O failures
/// produce an empty list; malformed records are skipped. The cache is softly referenced.
public @Unmodifiable List<I> getInfos() {
@Nullable SoftReference<@Unmodifiable List<I>> infosRef = this.infosRef;
@Unmodifiable List<I> infos = infosRef != null ? infosRef.get() : null;
if (infos != null)
return infos;
lock.lock();
try {
infosRef = this.infosRef;
infos = infosRef != null ? infosRef.get() : null;
if (infos != null)
return infos;
try {
infos = loadInfos();
} catch (IOException e) {
LOG.warning("Failed to load addon project info from " + assetsPath, e);
infos = List.of();
}
this.infosRef = new SoftReference<>(infos);
return infos;
} finally {
lock.unlock();
}
}
}
@@ -1,123 +0,0 @@
/*
* 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.addon.update;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.LocalAddonManager;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdateTask extends Task<Void> {
private final Collection<Task<?>> dependents = new ArrayList<>();
private final List<LocalAddonFile<?>> failedAddons = new ArrayList<>();
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
setStage("addon.check_update.confirm");
getProperties().put("total", addons.size());
for (AddonUpdate addon : addons) {
LocalAddonFile<?> local = addon.localAddonFile();
RemoteAddon.Version remote = addon.targetVersion();
boolean isDisabled = local.isDisabled();
String fileName = remote.file().filename();
if (isDisabled)
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
String newFileName = fileName;
Path targetFile = addonDirectory.resolve(newFileName);
dependents.add(Task
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
.thenComposeAsync(tempFile ->
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
.setName(remote.name())
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
.whenComplete(Schedulers.javafx(), exception -> {
try {
Files.deleteIfExists(tempFile);
} catch (Exception e) {
LOG.warning("Failed to delete temp file " + tempFile, e);
}
if (exception != null) {
// restore state if failed
local.setOld(false);
if (isDisabled)
local.markDisabled();
} else {
local.onUpdated(newFileName);
if (!local.keepOldFiles()) {
try {
local.delete();
} catch (IOException e) {
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
}
}
}
})
).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
LOG.warning("Failed to update addon", exception);
failedAddons.add(local);
}
}).withCounter("addon.check_update.confirm"));
}
}
public List<LocalAddonFile<?>> getFailedAddons() {
return failedAddons;
}
@Override
public Collection<Task<?>> getDependents() {
return dependents;
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() {
notifyPropertiesChanged();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (!isDependentsSucceeded())
throw getException();
}
}
@@ -1,296 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.game.AssetObject;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.*;
/// @author Glavo
@NotNullByDefault
public final class HMCLDownloadProvider extends DownloadProvider {
private volatile DownloadSource versionListSource = DownloadSource.DEFAULT;
private volatile DownloadSource fileSource = DownloadSource.DEFAULT;
private static DownloadCandidates getCandidates(
DownloadSource source,
WebURL defaultUrl, WebURL bmclapiUrl
) {
if (LocaleUtils.IS_CHINA_MAINLAND) {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
} else {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
}
}
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
if (urls.isEmpty()) {
throw new IllegalArgumentException("urls cannot be empty");
}
// true means prefer mirror, false means prefer official, null means do not use mirror
@Nullable Boolean mirrorFirst;
if (LocaleUtils.IS_CHINA_MAINLAND) {
mirrorFirst = switch (source) {
case DEFAULT, MIRROR -> true;
case OFFICIAL -> false;
};
} else {
mirrorFirst = switch (source) {
case DEFAULT, OFFICIAL -> null;
case MIRROR -> true;
};
}
if (mirrorFirst == null) {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 2);
for (String url : urls) {
DownloadCandidate candidate = DownloadCandidate.of(url);
if (candidate.url() == null) {
// Invalid URL, just add it to the list and let the download task handle it.
candidates.add(candidate);
continue;
}
@Nullable DownloadCandidate mirrorCandidate = null;
boolean fallback = false;
for (MirrorRule rule : rules) {
if (url.startsWith(rule.source)) {
mirrorCandidate = DownloadCandidate.of(rule.target + url.substring(rule.source.length()));
fallback = rule.fallback;
break;
}
}
if (mirrorCandidate == null) {
candidates.add(candidate);
} else if (mirrorFirst && !fallback) {
candidates.add(mirrorCandidate);
candidates.add(candidate);
} else {
candidates.add(candidate);
candidates.add(mirrorCandidate);
}
}
return DownloadCandidates.of(List.copyOf(candidates));
}
private final String bmclRoot;
private final List<MirrorRule> rules;
public HMCLDownloadProvider(String bmclRoot) {
this.bmclRoot = bmclRoot;
this.rules = List.of(
new MirrorRule("https://bmclapi2.bangbang93.com", bmclRoot),
new MirrorRule("https://launchermeta.mojang.com", bmclRoot),
new MirrorRule("https://piston-meta.mojang.com", bmclRoot),
new MirrorRule("https://piston-data.mojang.com", bmclRoot),
new MirrorRule("https://launcher.mojang.com", bmclRoot),
new MirrorRule("https://libraries.minecraft.net", bmclRoot + "/libraries"),
new MirrorRule("http://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new MirrorRule("https://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new MirrorRule("https://maven.minecraftforge.net", bmclRoot + "/maven"),
new MirrorRule("https://maven.neoforged.net/releases/", bmclRoot + "/maven/"),
new MirrorRule("http://dl.liteloader.com/versions", bmclRoot + "/maven"),
new MirrorRule("https://dl.liteloader.com/versions", bmclRoot + "/maven"),
new MirrorRule("https://meta.fabricmc.net", bmclRoot + "/fabric-meta"),
new MirrorRule("https://maven.fabricmc.net", bmclRoot + "/maven"),
new MirrorRule("https://authlib-injector.yushi.moe", bmclRoot + "/mirrors/authlib-injector"),
new MirrorRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new MirrorRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new MirrorRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
new MirrorRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
new MirrorRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
// https://github.com/mcmod-info-mirror/mcim-rust-api
new MirrorRule("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth", true),
new MirrorRule("https://cdn.modrinth.com", "https://mod.mcimirror.top", true),
new MirrorRule("https://api.curseforge.com", "https://mod.mcimirror.top/curseforge", true),
new MirrorRule("https://edge.forgecdn.net", "https://mod.mcimirror.top", true)
);
}
@Override
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
assert (gameVersion == null) == (type == GameComponentType.GAME);
switch (type) {
case FORGE -> {
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case NEO_FORGE -> {
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR || source == DownloadSource.DEFAULT) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case OPTIFINE -> {
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
}
}
return super.fetchVersionsAsync(type, gameVersion);
}
public void setFileSource(DownloadSource fileSource) {
this.fileSource = Objects.requireNonNull(fileSource);
}
public void setVersionListSource(DownloadSource versionListSource) {
this.versionListSource = Objects.requireNonNull(versionListSource);
}
@Override
public HMCLCacheRepository getCacheRepository() {
return HMCLCacheRepository.REPOSITORY;
}
@Override
public DownloadCandidates getGameVersionListCandidates() {
return getCandidates(
versionListSource,
GameRemoteVersion.VERSION_MANIFEST_URL,
WebURL.parse(bmclRoot + "/mc/game/version_manifest.json")
);
}
@Override
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
DownloadSource source = versionListSource;
return new FabricLikeVersionListCandidates(
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/game"))
);
}
@Override
public DownloadCandidates getCleanroomVersionListCandidates() {
return LocaleUtils.IS_CHINA_MAINLAND
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
}
@Override
public DownloadCandidates getDownloadCandidates(List<String> urls) {
return getCandidates(fileSource, rules, urls);
}
@Override
public DownloadCandidates getVersionListCandidates(List<String> urls) {
return getCandidates(versionListSource, rules, urls);
}
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + assetObject.getLocation()),
WebURL.parse(bmclRoot + "/assets/" + assetObject.getLocation())
);
}
@NotNullByDefault
private static final class FallbackTask<T> extends Task<T> {
private final Task<? extends T> task;
private final Task<? extends T> fallback;
private List<Task<?>> dependencies = List.of();
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
this.task = task;
this.fallback = fallback;
}
@Override
public Collection<? extends Task<?>> getDependents() {
return List.of(task);
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
setResult(task.getResult());
} else {
dependencies = List.of(fallback);
fallback.storeTo(this::setResult);
}
}
@Override
public List<Task<?>> getDependencies() {
return dependencies;
}
}
private record MirrorRule(String source, String target, boolean fallback) {
public MirrorRule(String source, String target) {
this(source, target, false);
}
}
}
@@ -1,65 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DefaultCacheRepository;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jetbrains.annotations.NotNullByDefault;
/// Provides HMCL-specific game builders for an HMCL game repository.
@NotNullByDefault
public class HMCLDependencyManager extends DefaultDependencyManager {
/// Creates a dependency manager for a repository and download context.
///
/// @param repository the associated game repository
/// @param downloadProvider the remote download provider
/// @param cacheRepository the artifact cache
public HMCLDependencyManager(
HMCLGameRepository repository,
DownloadProvider downloadProvider,
DefaultCacheRepository cacheRepository) {
super(repository, downloadProvider, cacheRepository);
}
/// {@inheritDoc}
@Override
public HMCLGameRepository getGameRepository() {
return (HMCLGameRepository) super.getGameRepository();
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
validateGameInstance(instance);
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
return new HMCLGameBuilder(
this, updateTarget.getId(), updateTarget, draft, initialManifest);
}
}
@@ -1,53 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DefaultGameBuilder;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Builds game instances and applies HMCL-specific post-installation settings.
@NotNullByDefault
public class HMCLGameBuilder extends DefaultGameBuilder {
/// Creates a builder around an HMCL target already reserved by its dependency manager.
///
/// @param dependencyManager the dependency manager for the target repository
/// @param instanceId the id of the reserved instance
/// @param updateTarget the exact update target retained by the draft, or `null` for install
/// @param draft the open draft containing `initialManifest`
/// @param initialManifest the empty target manifest retained by `draft`
HMCLGameBuilder(
HMCLDependencyManager dependencyManager,
GameInstanceID instanceId,
@Nullable HMCLGameInstance updateTarget,
DefaultGameRepositoryDraft draft,
GameInstanceManifest initialManifest) {
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
}
/// {@inheritDoc}
///
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
/// launches use the instance root. A read-only instance retains its existing setting.
@Override
protected void onInstanceCommitted(DefaultGameInstance instance) {
if (isolationEnabled) {
((HMCLGameInstance) instance).enableIsolation();
}
}
}
@@ -1,863 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectPropertyBase;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
@NotNullByDefault
public class HMCLGameInstance extends DefaultGameInstance {
/// Whether the instance-local game settings file has already been inspected.
private boolean gameSettingsLoaded;
/// Whether the instance-local game settings file cannot be overwritten safely.
private boolean gameSettingsReadOnly;
/// Cached instance-local game settings, or `null` when none exist after loading.
private GameSettings.@Nullable Instance gameSettings;
/// Creates a registered instance bound to the given repository snapshot.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
this(snapshot, id, manifest, (Path) null);
}
/// Creates a registered instance with an optional non-conventional manifest path.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
protected HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
@Nullable Path manifestFile) {
super(snapshot, id, manifest, manifestFile);
}
/// Creates an instance that shares mutable instance-local state with another instance.
///
/// Used when the repository clones a snapshot so that settings and the icon property remain
/// available on the new wrapper.
private HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
HMCLGameInstance shareState) {
super(snapshot, id, manifest, shareState);
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
this.gameSettings = shareState.gameSettings;
}
@Override
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
/// Returns the HMCL modpack configuration file for this instance.
///
/// @return the `modpack.cfg` path in the instance root
@Override
public Path getModpackConfigurationFile() {
return getLayout().getModpackConfigurationFile(getId());
}
/// Returns whether this instance has an HMCL modpack configuration file.
///
/// @return whether [#getModpackConfigurationFile()] exists
public boolean isModpack() {
return Files.exists(getModpackConfigurationFile());
}
/// Reads this instance's HMCL modpack configuration.
///
/// @return the parsed configuration, or `null` when the file does not exist
/// @throws IOException if the configuration cannot be read
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
Path file = getModpackConfigurationFile();
if (Files.notExists(file)) {
return null;
}
try {
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration: " + file, e);
}
}
@Override
public Path getRunDirectory() {
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
}
/// Returns the loaded instance-local game settings, loading them while this instance is current.
///
/// @return the settings, or `null` when no local settings exist after loading
public @Nullable GameSettings.Instance getSettings() {
ensureGameSettingsLoaded();
return gameSettings;
}
/// Returns the instance-local game settings, creating an empty settings object when absent.
///
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
public @Nullable GameSettings.Instance getSettingsOrCreate() {
GameSettings.Instance setting = getSettings();
if (setting == null) {
setting = createSettings();
}
return setting;
}
/// Resolves this instance's effective settings against its selected parent preset.
///
/// @return the effective settings
public GameSettings.Effective getEffectiveSettings() {
@Nullable GameSettings.Instance setting = getSettings();
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
}
/// Creates empty instance-local game settings when none are loaded.
///
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
public @Nullable GameSettings.Instance createSettings() {
ensureGameSettingsLoaded();
if (gameSettingsReadOnly) {
return null;
}
if (gameSettings != null) {
return gameSettings;
}
return initSettings(new GameSettings.Instance(), true);
}
/// Returns whether the instance-local game settings file cannot be overwritten safely.
///
/// @return whether the settings are loaded in read-only mode
public boolean isSettingsReadOnly() {
ensureGameSettingsLoaded();
return gameSettingsReadOnly;
}
/// Enables instance-local running-directory selection for this instance.
///
/// A blank local running directory resolves to the instance root. This operation is idempotent
/// and schedules a settings save only when it adds the override. It leaves the instance
/// unchanged when its local settings cannot be written safely.
public void enableIsolation() {
if (isSettingsReadOnly()) {
return;
}
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
if (setting != null
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveSettings();
}
}
/// Backs up and overwrites the settings file when this instance still owns its settings.
public void forceOverwriteSettings() {
ensureGameSettingsLoaded();
if (!ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
if (setting == null) {
setting = new GameSettings.Instance();
gameSettings = setting;
gameSettingsLoaded = true;
}
boolean installAutoSave = !setting.isSavable();
Path file = getGameSettingsFile().toAbsolutePath().normalize();
SettingFileUtils.backupInvalidConfig(file);
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
setting.setSavable(true);
setting.setBackupOnNextSave(false);
gameSettingsReadOnly = false;
saveSettings();
if (installAutoSave) {
setting.addListener(a -> saveSettings());
}
}
/// Saves the settings asynchronously when writable and still owned by the current instance.
public void saveSettings() {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
LOG.warning("Failed to create directory: " + file.getParent(), e);
}
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Saves the settings synchronously when writable and still owned by the current instance.
///
/// @throws IOException if saving the file fails
public void saveSettingsSync() throws IOException {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Initializes this instance with the given settings object.
///
/// @param setting the settings to install
/// @param allowSave whether the settings may be written back to disk
/// @return the installed settings
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
normalizeRunningDirectoryOverride(setting);
setting.setSavable(allowSave);
gameSettingsLoaded = true;
gameSettings = setting;
setting.iconProperty().addListener(observable -> invalidateIconImage());
if (allowSave) {
gameSettingsReadOnly = false;
setting.addListener(a -> saveSettings());
} else {
gameSettingsReadOnly = true;
}
return setting;
}
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
/// the effective parent preset when no local settings exist.
///
/// @return a detached copy suitable for installing into another instance
public GameSettings.Instance copySettings() {
@Nullable GameSettings.Instance setting = getSettings();
if (setting != null) {
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
}
GameSettings.Instance copied = new GameSettings.Instance();
copied.parentProperty().setValue(
getEffectiveSettings().getPreset().idProperty().getValue());
return copied;
}
/// Returns the first custom icon file found in this instance's root directory.
///
/// @return the icon file, or empty when no supported icon file exists
public @Nullable Path getIconFile() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
if (Files.exists(file)) {
return file;
}
}
return null;
}
/// Replaces this instance's custom icon file.
///
/// Existing supported icon files are removed before `iconFile` is copied.
///
/// @param iconFile the source icon file
/// @throws IOException if the icon cannot be copied
/// @throws IllegalArgumentException if the file extension is unsupported
public void setIconFile(Path iconFile) throws IOException {
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("Unsupported icon file: " + extension);
}
clearIconFiles();
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
invalidateIconImage();
}
/// Deletes all supported custom icon files for this instance.
///
/// Individual deletion failures are logged and do not stop later files from being attempted.
public void deleteIconFile() {
clearIconFiles();
invalidateIconImage();
}
private void clearIconFiles() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
try {
Files.deleteIfExists(file);
} catch (IOException e) {
LOG.warning("Failed to delete instance icon file: " + file, e);
}
}
}
/// Soft-cached icon image for this instance id.
///
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
private @Nullable WeakCachedIconImageProperty iconImage;
/// Returns the observable icon image for this instance.
///
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
/// pressure. The next [#getIconImage] reloads it.
///
/// @return the icon image property
public ReadOnlyObjectProperty<Image> iconImageProperty() {
if (iconImage == null) {
iconImage = new WeakCachedIconImageProperty();
}
return iconImage;
}
/// Returns the icon image selected for this instance.
///
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
///
/// @return the selected or derived icon image
public Image getIconImage() {
return iconImageProperty().get();
}
/// Drops the soft-cached icon image and notifies observers.
public void invalidateIconImage() {
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
}
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
private @Nullable WeakReference<Image> cache;
@Override
public Object getBean() {
return HMCLGameInstance.this;
}
@Override
public String getName() {
return "iconImage";
}
@Override
public Image get() {
WeakReference<Image> current = cache;
Image image = current != null ? current.get() : null;
if (image != null) {
return image;
}
image = computeIconImage();
cache = new WeakReference<>(image);
return image;
}
/// Computes the icon image from settings, custom files, and the launch manifest.
///
/// @return the selected or derived icon image
private Image computeIconImage() {
@Nullable GameSettings.Instance setting = getSettings();
GameInstanceIconType iconType = setting != null
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
: GameInstanceIconType.DEFAULT;
if (iconType != GameInstanceIconType.DEFAULT) {
return iconType.getIcon();
}
@Nullable Path iconFile = getIconFile();
if (iconFile != null) {
try {
return FXUtils.loadImage(iconFile, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load instance icon for " + getId(), e);
}
}
for (ModLoaderType modLoader : getModLoaders()) {
return GameInstanceIconType.getIconType(modLoader).getIcon();
}
if (hasComponent(GameComponentType.OPTIFINE))
return GameInstanceIconType.OPTIFINE.getIcon();
GameVersionNumber version = getVersion();
if (version.isAprilFools())
return GameInstanceIconType.APRIL_FOOLS.getIcon();
else if (version instanceof GameVersionNumber.LegacySnapshot || version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
return GameInstanceIconType.COMMAND.getIcon();
else if (version instanceof GameVersionNumber.Old)
return GameInstanceIconType.CRAFT_TABLE.getIcon();
else
return GameInstanceIconType.GRASS.getIcon();
}
/// Clears the weak cache and notifies listeners.
void invalidate() {
cache = null;
fireValueChangedEvent();
}
}
/// Creates the marker indicating that the most recent launch ended abnormally.
public void markLaunchedAbnormally() {
try {
Files.createFile(getInstanceRoot().resolve(".abnormal"));
} catch (IOException ignored) {
}
}
/// Deletes the abnormal-launch marker when present.
///
/// @return whether a regular marker file was present
public boolean unmarkLaunchedAbnormally() {
Path file = getInstanceRoot().resolve(".abnormal");
if (!Files.isRegularFile(file)) {
return false;
}
try {
Files.delete(file);
} catch (IOException e) {
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
}
return true;
}
/// Loads settings once while this object is the repository's current instance.
private void ensureGameSettingsLoaded() {
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
loadGameSettings();
}
}
/// Returns whether this instance still owns the settings object used by the current snapshot.
private boolean ownsGameSettings() {
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
boolean owns = current == this
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
if (!owns && gameSettings != null) {
gameSettings.setSavable(false);
}
return owns;
}
private void loadGameSettings() {
gameSettingsLoaded = true;
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
if (result.setting() != null) {
initSettings(result.setting(), result.allowSave());
return;
}
if (!result.allowSave()) {
gameSettingsReadOnly = true;
return;
}
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
if (SettingsManager.getGameSettings(legacyParent) == null) {
legacyParent = null;
}
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
getRepository(), id, legacyParent);
if (migrationResult != null) {
initSettings(migrationResult.setting(), true);
try {
saveSettingsSync();
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + id, e);
}
}
}
private Path getGameSettingsFile() {
return getLayout().getInstanceGameSettingsFile(id);
}
public LaunchOptions.Builder getLaunchOptions(
JavaRuntime javaRuntime,
Path javaExecutable,
Path gameDir,
List<String> javaAgents,
List<String> javaArguments,
boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveSettings();
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = getVersion();
@Nullable Integer maxMemory;
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
SystemInfo.getPhysicalMemoryStatus().available(),
javaRuntime.getPlatform()) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(getId())
.setGameDir(gameDir)
.setJava(javaRuntime)
.setJavaExecutable(javaExecutable)
.setVersionType(Metadata.TITLE)
.setVersionName(getId().id())
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
.setMaxMemory(maxMemory)
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
.setEnvironmentVariables(
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
.stream()
.map(it -> {
int idx = it.indexOf('=');
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
})
.collect(Collectors.toList())
)
)
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
.setProxyOption(getProxyOption())
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
.setNoGeneratedJVMArgs(noJVMOptions)
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
.setRenderer(vs.getRenderer(gameVersionNumber))
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
if (quickPlayOption != null) {
builder.setQuickPlayOption(quickPlayOption);
}
Path json = getModpackConfigurationFile();
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to parse modpack configuration file " + json, e);
}
}
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder;
}
private static ProxyOption getProxyOption() {
return switch (settings().proxyTypeProperty().get()) {
case SYSTEM -> ProxyOption.Default.INSTANCE;
case DIRECT -> ProxyOption.Direct.INSTANCE;
case HTTP, SOCKS -> {
String proxyHost = settings().proxyHostProperty().get();
int proxyPort = settings().proxyPortProperty().get();
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
yield ProxyOption.Default.INSTANCE;
}
String proxyUser = settings().proxyUserProperty().get();
String proxyPass = settings().proxyPasswordProperty().get();
if (StringUtils.isBlank(proxyUser)) {
proxyUser = null;
proxyPass = null;
} else if (proxyPass == null) {
proxyPass = "";
}
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
} else {
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
}
}
};
}
/// Loads a new-format instance game settings file.
private static LoadResult loadGameSettingsFile(Path file) {
if (!Files.exists(file)) {
return new LoadResult(null, true);
}
try {
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
if (jsonObject == null) {
LOG.warning("Instance game settings are empty: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
return new LoadResult(fallback, true);
}
JsonSchema.CompatibilityResult schemaResult =
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
switch (schemaResult.status()) {
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
if (!schemaResult.readable()) {
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setSavable(false);
return new LoadResult(fallback, false);
}
GameSettings.@Nullable Instance setting =
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
if (setting == null) {
LOG.warning("Instance game settings deserialized to null: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
}
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
}
return new LoadResult(setting, schemaResult.allowSave());
} catch (JsonParseException ex) {
LOG.warning("Failed to parse game setting " + file, ex);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
} catch (Exception ex) {
LOG.warning("Failed to load game setting " + file, ex);
return new LoadResult(null, false);
}
}
/// Keeps old local custom running directories effective under the new source-selection model.
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
}
/// Result of loading an instance-specific game settings file.
///
/// @param setting the loaded instance settings, or `null` when unavailable
/// @param allowSave whether the file may be overwritten
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
}
/// Optional reference to an HMCL game instance bound to a repository.
///
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
/// from the current snapshot while preserving repository context.
@NotNullByDefault
public static final class Optional {
private final HMCLGameRepository repository;
private final @Nullable HMCLGameInstance instance;
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
public Optional(HMCLGameRepository repository) {
this.repository = Objects.requireNonNull(repository);
this.instance = null;
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
public Optional(HMCLGameInstance instance) {
this.repository = instance.getRepository();
this.instance = instance;
}
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
///
/// @param repository the repository
/// @param instanceId the instance id, or `null` for an empty optional
/// @return an optional that is empty when `instanceId` is null or not registered
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
if (instanceId == null) {
return new Optional(repository);
}
HMCLGameInstance instance = repository.findInstance(instanceId);
return instance != null ? new Optional(instance) : new Optional(repository);
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
/// @return the optional
public static Optional of(HMCLGameInstance instance) {
return new Optional(instance);
}
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
/// @return the empty optional
public static Optional empty(HMCLGameRepository repository) {
return new Optional(repository);
}
/// Returns the repository associated with this optional.
///
/// @return the repository
public HMCLGameRepository repository() {
return repository;
}
/// Returns the held instance, if any.
///
/// @return the instance, or `null` when empty
@Contract(pure = true)
public @Nullable HMCLGameInstance instance() {
return instance;
}
/// Returns the held instance id, if any.
///
/// @return the instance id, or `null` when empty
@Contract(pure = true)
public @Nullable GameInstanceID instanceId() {
return instance != null ? instance.getId() : null;
}
/// Returns whether an instance is present.
///
/// @return whether [#instance()] is non-null
public boolean isPresent() {
return instance != null;
}
/// Returns whether no instance is present.
///
/// @return whether [#instance()] is null
public boolean isEmpty() {
return instance == null;
}
/// Re-resolves the held instance id from the repository's current snapshot.
///
/// @return this optional when empty; otherwise a fresh optional for the same id
public Optional refreshed() {
if (instance == null) {
return this;
}
return of(repository, instance.getId());
}
}
}
@@ -42,27 +42,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class HMCLGameLauncher extends DefaultLauncher {
/// Creates a launcher with daemon process monitors.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(instance, manifest, authInfo, options, listener, true);
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
this(repository, version, authInfo, options, null);
}
/// Creates a launcher for the given instance and launch plan.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
/// @param daemon whether monitors should be daemon threads
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(instance, manifest, authInfo, options, listener, daemon);
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(repository, version, 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);
}
@Override
@@ -77,7 +66,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isDisableAutoGameOptions())
return;
Path runDir = instance.getRunDirectory();
Path runDir = repository.getRunDirectory(version.getId());
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
@@ -102,8 +91,8 @@ public final class HMCLGameLauncher extends DefaultLauncher {
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
*/
GameVersionNumber gameVersion = instance.getVersion();
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
if (gameVersion.compareTo("1.1") < 0)
return;
String lang = normalizedLanguageTag(locale, gameVersion);
@@ -172,7 +161,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isAllowAutoAgent()
&& !options.isNoGeneratedJVMArgs()
&& !options.isNoGeneratedOptimizingJVMArgs()
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
try {
result.add("-javaagent:" + extractLwjglUnsafeAgent());
@@ -189,9 +178,9 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.artifact().getFileName();
String fileName = library.getArtifact().getFileName();
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
File diff suppressed because it is too large. Load diff
@@ -1,63 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
@NotNullByDefault
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
/// Directory under the instance root that stores HMCL-managed instance metadata.
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
/// Directory under the instance metadata directory that stores instance configuration files.
private static final String INSTANCE_CONFIG_DIRECTORY = "config";
/// Directory under the instance metadata directory that stores instance state files.
private static final String INSTANCE_STATE_DIRECTORY = "state";
/// Current file name for instance-specific game settings.
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
public HMCLGameRepositoryLayout(Path baseDirectory) {
super(baseDirectory);
}
/// Returns the HMCL-managed metadata directory under the instance root.
///
/// This directory stores instance-scoped files owned by HMCL.
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
}
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
}
/// Returns the HMCL-managed state directory under the instance metadata directory.
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
}
/// Returns the current local game settings path under the instance configuration directory.
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
}
}
@@ -1,55 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Collection;
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
@NotNullByDefault
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
/// Creates an empty unsealed HMCL snapshot.
///
/// @param repository the owning repository
/// @param layout the HMCL layout for this snapshot
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
super(repository, layout);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
@Override
protected HMCLGameRepositorySnapshot newEmpty() {
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
}
@SuppressWarnings("unchecked")
@Override
public Collection<HMCLGameInstance> getInstances() {
return (Collection<HMCLGameInstance>) super.getInstances();
}
}
@@ -19,7 +19,7 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.GameBuilder;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
@@ -27,110 +27,53 @@ import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
/// Installs or updates an HMCL modpack by rebuilding its recognized game components.
@NotNullByDefault
public final class HMCLModpackInstallTask extends Task<Void> {
private final Path zipFile;
private final GameInstanceID instanceId;
/// Existing instance selecting update mode, or `null` for a new installation.
private final @Nullable DefaultGameInstance updateTarget;
private final String name;
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);
/// Creates a task that installs a new HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the id of the new instance
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId) {
this(repository, zipFile, modpack, instanceId, null);
}
/// Creates a task that updates an existing HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instance the existing instance to update
/// @throws IllegalArgumentException if `instance` belongs to another repository, has no
/// modpack configuration, or records another provider type
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
DefaultGameInstance instance) {
this(repository, zipFile, modpack, instance.getId(), instance);
}
/// Creates an HMCL modpack task in the mode selected by `updateTarget`.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the target instance id
/// @param updateTarget the existing instance selecting update mode, or `null` for install
/// @throws IllegalArgumentException if an update target belongs to another repository or has
/// no compatible configuration
private HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget) {
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, String name) {
this.repository = repository;
this.dependency = repository.getDependency();
this.zipFile = zipFile;
this.instanceId = instanceId;
this.updateTarget = updateTarget;
this.name = name;
this.modpack = modpack;
if (this.updateTarget != null) {
dependency.validateGameInstance(this.updateTarget);
}
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
if (this.updateTarget != null && Files.notExists(json))
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
Path run = repository.getRunDirectory(name);
Path json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && Files.notExists(json))
throw new IllegalArgumentException("Version " + name + " already exists");
@Nullable ModpackConfiguration<Modpack> config = null;
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
onDone().register(event -> {
if (event.isFailed()) repository.removeVersionFromDisk(name);
});
ModpackConfiguration<Modpack> config = null;
try {
if (this.updateTarget != null && Files.exists(json)) {
if (Files.exists(json)) {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
}
} catch (JsonParseException | IOException ignore) {
}
onDone().register(event -> {
if (this.updateTarget == null && event.isFailed()) {
repository.removeInstanceFromDisk(this.instanceId);
}
});
dependents.add(new ModpackInstallTask<>(zipFile, run, List.of("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
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"));
}
@Override
@@ -143,43 +86,20 @@ public final class HMCLModpackInstallTask extends Task<Void> {
return dependents;
}
/// Reads the legacy pack manifest and schedules one builder for all recognized components.
///
/// The pack manifest is used only for component detection. The builder creates a new
/// patch-structured manifest instead of attempting to mutate the legacy flattened manifest.
@Override
public void execute() throws Exception {
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
.withId(instanceId)
.withJar(null);
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
try (GameBuilder builder = updateTarget == null
? dependency.newGameBuilder(instanceId)
: dependency.newGameBuilder(updateTarget)) {
builder.enableIsolation();
String gameVersion = modpack.getGameVersion();
builder.component(GameComponentType.GAME, gameVersion);
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) {
continue;
}
@Nullable String componentVersion = mark.version();
if (componentVersion != null) {
if (mark.componentType() == GameComponentType.OPTIFINE) {
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
if (matcher.matches()) {
componentVersion = matcher.group("optifine");
}
}
builder.component(mark.componentType(), componentVersion);
}
}
dependencies.add(builder.buildAsync());
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
// reinstall libraries
// libraries of Forge and OptiFine should be obtained by installation.
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId()))
continue;
libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion()));
}
}
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
}
}
@@ -28,11 +28,10 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.Set;
public final class HMCLModpackProvider implements ModpackProvider {
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
@@ -43,17 +42,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
return null;
}
@Override
public Task<?> createUpdateTask(
DefaultDependencyManager dependencyManager,
DefaultGameInstance instance,
Path zipFile,
Modpack modpack,
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -61,34 +55,29 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(repository, zipFile, modpack, name));
}
@Override
public Modpack readManifest(ZipArchiveReader file, Path path) throws IOException, JsonParseException {
public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException {
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class);
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
if (game.jar() == null)
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
if (game.getJar() == 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.jar().id());
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
return manifest;
}
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(
DefaultDependencyManager dependencyManager,
Path zipFile,
GameInstanceID instanceId,
String iconUrl,
@Nullable Set<String> excludedFiles) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, name);
}
}
@@ -25,6 +25,8 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.MaintainTask;
import org.jackhuang.hmcl.download.game.*;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
@@ -32,14 +34,15 @@ import org.jackhuang.hmcl.launch.*;
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
import org.jackhuang.hmcl.util.*;
@@ -47,21 +50,20 @@ 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;
import java.lang.ref.WeakReference;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@@ -80,8 +82,9 @@ public final class LauncherHelper {
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
private final HMCLGameInstance gameInstance;
private final HMCLGameRepository repository;
private Account account;
private final String selectedVersion;
private Path scriptFile;
private final GameSettings.Effective setting;
private LauncherVisibility launcherVisibility;
@@ -89,21 +92,14 @@ public final class LauncherHelper {
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
this.gameInstance = Objects.requireNonNull(gameInstance);
public LauncherHelper(HMCLGameRepository repository, Account account, String selectedVersion) {
this.repository = Objects.requireNonNull(repository);
this.account = Objects.requireNonNull(account);
this.setting = gameInstance.getEffectiveSettings();
this.selectedVersion = Objects.requireNonNull(selectedVersion);
this.setting = repository.getEffectiveGameSettings(selectedVersion);
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("instance.launch"));
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
private HMCLGameRepository repository() {
return gameInstance.getRepository();
this.launchingStepsPane.setTitle(i18n("version.launch"));
}
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
@@ -136,7 +132,7 @@ public final class LauncherHelper {
public void launch() {
FXUtils.checkFxUserThread();
LOG.info("Launching game instance: " + gameInstance.getId());
LOG.info("Launching game version: " + selectedVersion);
Controllers.dialog(launchingStepsPane);
launch0();
@@ -147,72 +143,48 @@ public final class LauncherHelper {
launch();
}
/// Builds and executes the launch pipeline for the captured game instance.
@SuppressWarnings("NonAtomicOperationOnVolatileField")
private void launch0() {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
final var context = new Object() {
volatile GameInstanceManifest launchManifest;
volatile JavaRuntime javaRuntime;
volatile Path javaExecutable;
};
HMCLGameRepository repository = repository();
DefaultDependencyManager dependencyManager = repository.getDependency();
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
Optional<String> gameVersion = repository.getGameVersion(version.get());
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
CountDownLatch launchingLatch = new CountDownLatch(1);
List<String> javaAgents = new ArrayList<>(0);
List<String> javaArguments = new ArrayList<>(0);
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
context.launchManifest = LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest());
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
TaskExecutor executor = checkGameState(gameInstance, setting, context.launchManifest)
TaskExecutor executor = checkGameState(repository, setting, version.get())
.thenComposeAsync(java -> {
context.javaRuntime = Objects.requireNonNull(java);
context.javaExecutable = java.getBinary();
if (java.getPlatform().os() == OperatingSystem.WINDOWS
&& scriptFile == null
&& !setting.getInheritable(GameSettings::notUseJavawProperty)
&& FileUtils.getName(java.getBinary()).equalsIgnoreCase("java.exe")) {
Path javawBinary = java.getBinary().resolveSibling("javaw.exe");
if (Files.isRegularFile(javawBinary)) {
context.javaExecutable = javawBinary;
}
}
context.launchManifest = NativePatcher.patchNative(gameInstance, context.launchManifest, java, setting, javaArguments);
javaVersionRef.set(Objects.requireNonNull(java));
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
if (setting.getInheritable(GameSettings::notCheckGameProperty))
return null;
return Task.allOf(
dependencyManager.checkGameCompletionAsync(gameInstance, context.launchManifest, integrityCheck),
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
Task.composeAsync(() -> {
try {
@Nullable ModpackConfiguration<?> configuration =
gameInstance.readModpackConfiguration();
if (configuration == null) return null;
@Nullable ModpackProvider provider =
ModpackHelper.getProviderByType(configuration.getType());
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
if (provider == null) return null;
else return provider.createCompletionTask(
dependencyManager,
gameInstance);
else return provider.createCompletionTask(dependencyManager, selectedVersion);
} catch (IOException e) {
return null;
}
}),
Task.composeAsync(() -> {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer)
|| renderer.mesaDriverName() == null)
return null;
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
@@ -220,7 +192,7 @@ public final class LauncherHelper {
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
if (GameLibrariesTask.shouldDownloadLibrary(repository, context.launchManifest, lib, integrityCheck)) {
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
.thenRunAsync(() -> javaAgents.add(agent));
} else {
@@ -230,13 +202,13 @@ public final class LauncherHelper {
})
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), context.launchManifest))
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|| !NativePatcher.needPatchMemoryUtil(context.launchManifest, context.javaRuntime.getParsedVersion())) {
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
return Task.completed(null);
} else {
CompletableFuture<Void> future = new CompletableFuture<>();
@@ -255,13 +227,8 @@ public final class LauncherHelper {
})
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
context.javaRuntime,
context.javaExecutable,
repository.getBaseDirectory(),
javaAgents,
javaArguments,
scriptFile != null);
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
@@ -269,48 +236,18 @@ public final class LauncherHelper {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
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(gameInstance.getModsDirectory(), 10));
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
return new HMCLGameLauncher(
gameInstance,
context.launchManifest,
repository,
version.get(),
authInfo,
launchOptions,
launcherVisibility == LauncherVisibility.CLOSE
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
: new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent())
);
}).thenComposeAsync(launcher -> { // launcher is prev task's result
if (scriptFile == null) {
@@ -327,14 +264,14 @@ public final class LauncherHelper {
if (launcherVisibility == LauncherVisibility.CLOSE)
Launcher.stopApplication();
else
launchingStepsPane.setCancel(it -> {
launchingStepsPane.setCancel(new TaskCancellationAction(it -> {
process.stop();
it.fireEvent(new DialogCloseEvent());
});
}));
} else {
runLater(() -> {
launchingStepsPane.fireEvent(new DialogCloseEvent());
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
});
}
}).withFakeProgress(
@@ -373,29 +310,31 @@ 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().name()) + "\n";
if (ex.getCause() instanceof ResponseCodeException rce) {
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
int responseCode = rce.getResponseCode();
String url = rce.getUrl();
String uri = rce.getUri();
if (responseCode == 404)
message += i18n("download.code.404", url);
message += i18n("download.code.404", uri);
else
message += i18n("download.failed", url, responseCode);
message += i18n("download.failed", uri, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException de) {
String url = de.getUrl();
} else if (ex instanceof DownloadException) {
URI uri = ((DownloadException) ex).getUri();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", url);
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
message = i18n("install.failed.downloading.timeout", uri);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -403,13 +342,14 @@ public final class LauncherHelper {
message = i18n("account.failed.injector_download_failure");
} else if (ex instanceof CharacterDeletedException) {
message = i18n("account.failed.character_deleted");
} else if (ex instanceof ResponseCodeException rce) {
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
int responseCode = rce.getResponseCode();
String url = rce.getUrl();
String uri = rce.getUri();
if (responseCode == 404)
message = i18n("download.code.404", url);
message = i18n("download.code.404", uri);
else
message = i18n("download.failed", url, responseCode);
message = i18n("download.failed", uri, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
@@ -433,7 +373,7 @@ public final class LauncherHelper {
message = StringUtils.getStackTrace(ex);
}
Controllers.dialog(message,
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
MessageType.ERROR);
}
}
@@ -446,13 +386,13 @@ public final class LauncherHelper {
executor.start();
}
private static Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
GameVersionNumber gameVersion = gameInstance.getVersion();
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, Version version) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
try {
return setting.getJava(gameVersion, manifest);
return setting.getJava(gameVersion, version);
} catch (InterruptedException e) {
throw new CancellationException();
}
@@ -479,9 +419,9 @@ public final class LauncherHelper {
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
GameJavaVersion minimumJavaVersion = null;
if (gameVersion.compareTo("1.12.2") == 0) {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
}
}
@@ -503,18 +443,18 @@ public final class LauncherHelper {
}
} else {
if (gameVersion.compareTo("1.12.2") == 0) {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
}
}
if (targetJavaVersion == null)
targetJavaVersion = manifest.javaVersion();
targetJavaVersion = version.getJavaVersion();
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
downloadJava(targetJavaVersion, gameInstance.getRepository())
downloadJava(targetJavaVersion, repository)
.whenCompleteAsync((downloadedJava, exception) -> {
if (exception == null) {
future.complete(downloadedJava);
@@ -540,8 +480,8 @@ public final class LauncherHelper {
if (java != null) {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.isMandatory()) {
violatedMandatoryConstraints.add(constraint);
} else {
@@ -557,7 +497,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, manifest);
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
if (suggestedJava != null) {
FXUtils.runInFX(() -> {
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
@@ -577,20 +517,21 @@ public final class LauncherHelper {
} else {
GameJavaVersion gameJavaVersion;
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
.orElse("");
gameJavaVersion = cleanroomVersion != null
gameJavaVersion = !cleanroomVersion.isEmpty()
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = manifest.javaVersion();
gameJavaVersion = version.getJavaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
else
gameJavaVersion = null;
if (gameJavaVersion != null) {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> {
if (throwable == null) {
setting.setJavaAutoSelected();
future.complete(downloadedJava);
@@ -660,7 +601,7 @@ public final class LauncherHelper {
break;
case MODDED_JAVA_16:
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
suggestions.add(i18n("launch.advice.forge37_0_60"));
else
@@ -673,8 +614,8 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
break;
case CLEANROOM: {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null)
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
if (!cleanroomVersion.isEmpty())
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
else
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
@@ -703,20 +644,20 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
}
VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE))
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
.map(VersionNumber::asVersion)
.orElse(null);
// 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 = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
boolean hasLiteLoader = version.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 = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
boolean hasOptiFine = version.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"));
}
@@ -764,11 +705,11 @@ public final class LauncherHelper {
Throwable resolvedException = resolveException(exception);
LOG.warning("Failed to download java", exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
future.completeExceptionally(new CancellationException());
}
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()));
}), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())));
}, () -> future.completeExceptionally(new CancellationException())).build());
return future;
@@ -819,10 +760,7 @@ public final class LauncherHelper {
case HIDE_AND_REOPEN:
runLater(() -> {
Optional.ofNullable(Controllers.getStage())
.ifPresent(stage -> {
stage.setIconified(false);
stage.show();
});
.ifPresent(Stage::show);
});
break;
case KEEP:
@@ -851,12 +789,16 @@ public final class LauncherHelper {
}
}
/// The managed process listener.
/// Guarantee that one Java [Process], one [HMCLProcessListener].
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
/**
* The managed process listener.
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
* Because every time we launched a game, we generates a new [HMCLProcessListener]
*/
private final class HMCLProcessListener implements ProcessListener {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final LaunchOptions launchOptions;
private ManagedProcess process;
private volatile boolean lwjgl;
@@ -868,7 +810,9 @@ public final class LauncherHelper {
private Thread submitLogThread;
private LinkedBlockingQueue<Log> logBuffer;
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.repository = repository;
this.version = version;
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
@@ -951,10 +895,8 @@ public final class LauncherHelper {
runLater(() -> {
// If application was stopped and execution services did not finish termination,
// these codes will be executed.
@Nullable Stage stage = Controllers.getStage();
if (stage != null) {
stage.setIconified(false);
stage.hide();
if (Controllers.getStage() != null) {
Controllers.getStage().hide();
launchingLatch.countDown();
}
});
@@ -995,7 +937,7 @@ public final class LauncherHelper {
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
if (showLogs) {
if (level == null)
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
lock.lock();
@@ -1053,8 +995,8 @@ public final class LauncherHelper {
}
if (exitType != ExitType.NORMAL) {
gameInstance.markLaunchedAbnormally();
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
repository.markVersionLaunchedAbnormally(version.getId());
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
}
checkExit();
@@ -20,12 +20,9 @@ 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.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.ui.versions.ModTranslations;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
@@ -41,28 +38,19 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
@Override
public String getApiBaseUrl() {
return getBackedRemoteModRepository().getApiBaseUrl();
}
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public String getBaseUrl() {
return getBackedRemoteModRepository().getBaseUrl();
}
@Override
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
if (registry == null || !StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (AddonProjectInfo mod : registry.search(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.subName()) ? mod.subName() : mod.mainName()));
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
}
@@ -72,7 +60,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
@@ -81,8 +69,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
SearchResult searchResult = null;
List<RemoteAddon> remoteAddons = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
remoteAddons = searchResult.results().toList();
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
remoteAddons = searchResult.getUnsortedResults().toList();
if (!remoteAddons.isEmpty()) {
break;
}
@@ -94,23 +82,23 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
continue;
}
@Nullable AddonProjectInfo chineseTranslation = registry.getInfoByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.mainName()) && StringUtils.containsChinese(chineseTranslation.mainName())) {
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteAddon;
} else {
searchResultArray[englishIndex--] = remoteAddon;
}
}
totalPages = searchResult.totalPages();
totalPages = searchResult.getTotalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
@Nullable AddonProjectInfo chineseRemoteMod = registry.getInfoByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.mainName()) || !StringUtils.containsChinese(chineseRemoteMod.mainName())) {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
String chineseRemoteModName = chineseRemoteMod.mainName();
String chineseRemoteModName = chineseRemoteMod.getName();
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
}
@@ -125,8 +113,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
return getBackedRemoteModRepository().getCategories(type);
public Stream<Category> getCategories() throws IOException {
return getBackedRemoteModRepository().getCategories();
}
@Override
@@ -135,32 +123,17 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
public RemoteAddon getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
}
@Override
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
}
@Override
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
public RemoteAddon.File getModFile(String modId, String fileId) throws IOException {
return getBackedRemoteModRepository().getModFile(modId, fileId);
}
@Override
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId);
}
@Override
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
return getBackedRemoteModRepository().getVersionPageUrl(version);
}
}
@@ -17,18 +17,15 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -45,24 +42,22 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
PathMatcher logMatcher) {
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
Path runDirectory = options.getGameDir();
List<GameInstanceID> instances = new ArrayList<>();
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
GameInstanceID currentInstanceId = instance.id;
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
String currentVersionId = versionId;
HashSet<String> resolvedSoFar = new HashSet<>();
while (true) {
if (resolvedSoFar.contains(currentInstanceId)) break;
resolvedSoFar.add(currentInstanceId);
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
if (currentInstance == null)
break;
instances.add(currentInstanceId);
if (resolvedSoFar.contains(currentVersionId)) break;
resolvedSoFar.add(currentVersionId);
Version currentVersion = gameRepository.getVersion(currentVersionId);
versions.add(currentVersionId);
if (currentInstance.getManifest().inheritsFrom() != null) {
currentInstanceId = currentInstance.getManifest().inheritsFrom();
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
currentVersionId = currentVersion.getInheritsFrom();
} else {
break;
}
@@ -79,12 +74,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 (GameInstanceID id : instances) {
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
if (currentInstance != null) {
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
}
for (String id : versions) {
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
if (Files.exists(versionJson)) {
zipper.putFile(versionJson, id + ".json");
}
}
} catch (IOException e) {
@@ -18,8 +18,11 @@
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.Unzipper;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -28,10 +31,12 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
private final Path zipFile;
private final Charset charset;
private final String name;
public ManuallyCreatedModpackInstallTask(Path zipFile, String name) {
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
this.zipFile = zipFile;
this.charset = charset;
this.name = name;
setName(i18n("modpack.installing"));
@@ -39,15 +44,19 @@ public class ManuallyCreatedModpackInstallTask extends Task<Path> {
@Override
public void execute() throws Exception {
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
Path subdirectory;
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
}
Path dest = Paths.get("externalgames").resolve(name);
setResult(dest);
new Unzipper(zipFile, dest)
.setSubDirectory(subdirectory)
.setSubDirectory(subdirectory.toString())
.setTerminateIfSubDirectoryNotExists()
.setEncoding(charset)
.unzip();
}
}
@@ -31,7 +31,11 @@ import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameWindowType;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
@@ -42,12 +46,13 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -55,9 +60,10 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.toIterable;
import static org.jackhuang.hmcl.util.Pair.pair;
/// Utilities for reading, installing, and applying modpack-specific game settings.
@@ -89,8 +95,8 @@ public final class ModpackHelper {
return "zip".equals(ext) || "mrpack".equals(ext);
}
public static Modpack readModpackManifest(Path file) throws UnsupportedModpackException, ManuallyCreatedModpackException {
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file)) {
public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException {
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) {
// Order for trying detecting manifest is necessary here.
// Do not change to iterating providers.
for (ModpackProvider provider : new ModpackProvider[]{
@@ -101,15 +107,15 @@ public final class ModpackHelper {
MultiMCModpackProvider.INSTANCE,
ServerModpackProvider.INSTANCE}) {
try {
return provider.readManifest(zipFile, file);
return provider.readManifest(zipFile, file, charset);
} catch (Exception ignored) {
}
}
} catch (IOException ignored) {
}
try {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
throw new ManuallyCreatedModpackException(file);
} catch (IOException e) {
// ignore it
@@ -118,35 +124,28 @@ public final class ModpackHelper {
throw new UnsupportedModpackException(file.toString());
}
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
throws IOException, UnsupportedModpackException {
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
Path root = fs.getPath("/");
if (isMinecraftDirectory(root)) return root;
try (Stream<Path> firstLayer = Files.list(root)) {
for (Path dir : toIterable(firstLayer)) {
if (isMinecraftDirectory(dir)) return dir;
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
if (isMinecraftDirectory(rootDir)) {
return "";
}
for (ArchiveFileTree.Dir<?> firstLayer : rootDir.getSubDirs().values()) {
if (isMinecraftDirectory(firstLayer)) {
return firstLayer.getName();
}
for (ArchiveFileTree.Dir<?> secondLayer : firstLayer.getSubDirs().values()) {
if (isMinecraftDirectory(secondLayer)) {
return firstLayer.getName() + "/" + secondLayer.getName();
try (Stream<Path> secondLayer = Files.list(dir)) {
for (Path subdir : toIterable(secondLayer)) {
if (isMinecraftDirectory(subdir)) return subdir;
}
} catch (IOException ignored) {
}
}
} catch (IOException ignored) {
}
throw new UnsupportedModpackException(modpackName);
}
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
private static boolean isMinecraftDirectory(Path path) {
return Files.isDirectory(path.resolve("versions")) &&
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
}
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
@@ -157,10 +156,16 @@ public final class ModpackHelper {
}
}
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, String name, Modpack modpack) {
repository.markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -170,9 +175,8 @@ public final class ModpackHelper {
}
};
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
@@ -180,13 +184,12 @@ public final class ModpackHelper {
return Files.exists(Paths.get("externalgames").resolve(name));
}
/// Extracts a manually created modpack and selects its new external game directory.
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name) {
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
if (isExternalGameNameConflicts(name)) {
throw new IllegalArgumentException("name existing");
}
return new ManuallyCreatedModpackInstallTask(zipFile, name)
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
.thenAcceptAsync(Schedulers.javafx(), location -> {
GameDirectory newGameDirectory = new GameDirectory(
GameDirectoryManager.newGameDirectoryId(),
@@ -194,35 +197,19 @@ public final class ModpackHelper {
PortablePath.fromPath(location));
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
})
.withStage("hmcl.modpack")
.withStagesHints("hmcl.modpack");
});
}
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
}
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, String name, Modpack modpack, String iconUrl) {
repository.markVersionAsModpack(name);
/// Creates an install task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param instanceId the new instance id
/// @param modpack the parsed modpack
/// @param iconUrl the optional icon URL, or `null`
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the install task
public static Task<?> getInstallTask(
HMCLGameRepository repository,
Path zipFile,
GameInstanceID instanceId,
Modpack modpack,
@Nullable String iconUrl,
@Nullable Set<String> excludedFiles) {
ExceptionalRunnable<?> success = () -> {
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -233,77 +220,45 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.withStage("hmcl.modpack")
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
.withStage("hmcl.modpack")
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStage("hmcl.modpack")
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
/// Updates a server modpack with backup, repository refresh, and modpack stage hints.
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
switch (configuration.getType()) {
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
HMCLGameInstance instance = repository.getInstance(instanceId);
return new ModpackUpdateTask(
instance,
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(repository, name, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name))
.thenComposeAsync(repository.refreshVersionsAsync())
.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, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
return getUpdateTask(repository, zipFile, instanceId, configuration, null);
}
/// Creates an update task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param instanceId the instance to update
/// @param configuration the existing modpack configuration
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the update task
public static Task<?> getUpdateTask(
HMCLGameRepository repository,
Path zipFile,
GameInstanceID instanceId,
ModpackConfiguration<?> configuration,
@Nullable Set<String> excludedFiles)
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile);
@Nullable ModpackProvider provider = getProviderByType(configuration.getType());
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, String name, 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(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(repository.refreshVersionsAsync());
else
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(repository.refreshVersionsAsync());
}
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
@@ -378,25 +333,25 @@ public final class ModpackHelper {
}
}
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
});
}
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
ModpackHelper.toGameSettings(manifest, setting);
});
}
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
GameSettings.Effective effective = repository.getEffectiveGameSettings(version);
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY,
@@ -53,7 +53,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private String idToken;
private OAuthServer(int port) {
super("localhost", port);
super(port);
this.port = port;
@@ -33,7 +33,6 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.Skin;
import org.jackhuang.hmcl.auth.yggdrasil.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -100,7 +99,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
new FileDownloadTask(texture.url(), file).run();
LOG.info("Texture downloaded: " + texture.url());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -26,13 +26,18 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
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.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -310,13 +315,13 @@ public final class JavaManager {
}
@Nullable
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, manifest);
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, version);
}
@Nullable
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null;
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -337,8 +342,8 @@ public final class JavaManager {
boolean violationSuggested = false;
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.isMandatory()) {
violationMandatory = true;
} else {
@@ -433,14 +438,14 @@ public final class JavaManager {
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
}
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
@@ -494,7 +499,7 @@ public final class JavaManager {
it.isJDK() ? "JDK" : "JRE",
it.getVersion(),
it.getPlatform().getArchitecture().getDisplayName(),
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
it.getBinary()))
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
return searcher.javaRuntimes;
@@ -837,7 +842,7 @@ public final class JavaManager {
}
void searchJavaInProgramFiles(String env, String defaultValue) {
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
Path path;
try {
path = Path.of(programFiles);
@@ -47,8 +47,13 @@ import java.util.*;
import static java.util.stream.Collectors.toList;
import static javafx.collections.FXCollections.observableArrayList;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.SettingsManager.getAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.setting.SettingsManager.getUserAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -68,7 +73,7 @@ public final class Accounts {
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
// ==== login type / account factory mapping ====
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
@@ -18,47 +18,44 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.List;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {
}
private static final String DEFAULT_BMCLAPI_ROOT = "https://bmclapi2.bangbang93.com";
private static final String BMCLAPI_ROOT;
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
private static final DownloadProvider MOJANG_PROVIDER;
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
private static final DownloadProvider DEFAULT_PROVIDER;
static {
@Nullable String bmclOverride = System.getProperty("hmcl.bmclapi.override");
if (StringUtils.isBlank(bmclOverride)) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
WebURL url;
try {
url = WebURL.parseBrowserInput(bmclOverride);
} catch (Exception e) {
url = null;
LOG.warning("Invalid BMCLAPI override URL: " + bmclOverride, e);
}
if (url == null) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
BMCLAPI_ROOT = StringUtils.removeSuffix(url.toString(), "/");
}
}
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
MOJANG_PROVIDER = new MojangDownloadProvider();
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
}
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(BMCLAPI_ROOT);
/// Initializes download provider settings and synchronizes download thread settings.
public static void init() {
InvalidationListener onChangeDownloadThreads = observable -> {
@@ -70,15 +67,73 @@ public final class DownloadProviders {
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
onChangeDownloadThreads.invalidated(null);
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
InvalidationListener onChangeDownloadSource = observable -> {
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
settings().versionListSourceProperty().get(),
settings().fileDownloadSourceProperty().get()));
};
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
}
/// Creates a download provider with independent version-list and file download preferences.
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
return new AutoDownloadProvider(
getCandidates(versionListSource),
getCandidates(fileDownloadSource));
}
/// Returns provider candidates ordered by the given source preference.
private static List<DownloadProvider> getCandidates(DownloadSource source) {
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
return switch (normalized) {
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
case OFFICIAL -> List.of(MOJANG_PROVIDER);
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
};
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return PROVIDER;
return PROVIDER_WRAPPER;
}
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", uri);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", uri);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
} else if (exception instanceof CancellationException) {
return i18n("message.cancelled");
}
return StringUtils.getStackTrace(exception);
}
}
@@ -34,6 +34,8 @@ 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
@@ -116,7 +118,7 @@ public final class GameDirectory implements Observable {
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
addPropertyChangedListener(arg -> invalidate());
addPropertyChangedListener(onInvalidating(this::invalidate));
}
/// Returns a debug string containing the game directory path and display metadata.
@@ -17,17 +17,14 @@
*/
package org.jackhuang.hmcl.setting;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
@@ -36,10 +33,17 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnknownNullability;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.*;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Manages the merged runtime view of local and user game directories.
@@ -136,18 +140,13 @@ public final class GameDirectoryManager {
/// The selected game repository, or `null` before the fallback game directory is resolved.
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
/// The selected instance projected from the selected repository's current snapshot.
private static final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance =
new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
/// The selected instance ID projected from the selected repository.
private static final ReadOnlyStringWrapper selectedInstance = new ReadOnlyStringWrapper(GameDirectoryManager.class, "selectedInstance");
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener =
private static final ChangeListener<String> selectedRepositoryInstanceListener =
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
/// Reacts when the selected repository publishes a new snapshot.
private static final ChangeListener<? super HMCLGameRepositorySnapshot> selectedRepositorySnapshotListener =
(observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged();
/// Initializes game directory state from the stores loaded by [SettingsManager].
///
/// This method creates the built-in local and user-home game directories when required, rebuilds
@@ -202,32 +201,25 @@ public final class GameDirectoryManager {
@Nullable HMCLGameRepository oldRepository = selectedRepository.get();
if (oldRepository != null) {
oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener);
oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener);
}
HMCLGameRepository repository = getOrCreateRepository(newValue);
selectedRepository.set(repository);
selectedInstance.set(repository.getSelectedInstance());
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
repository.snapshotProperty().addListener(selectedRepositorySnapshotListener);
if (repository.isLoaded()) {
onSelectedRepositorySnapshotChanged();
}
repository.refreshAsync().start();
repository.refreshVersionsAsync().start();
});
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
}
/// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot.
private static void onSelectedRepositorySnapshotChanged() {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository == null || !repository.isLoaded()) {
return;
}
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners) {
listener.accept(repository);
}
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
runInFX(() -> {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository != null && repository == event.getSource()) {
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners)
listener.accept(repository);
}
});
});
}
/// Creates the built-in game directories only when no game directory exists.
@@ -330,7 +322,6 @@ public final class GameDirectoryManager {
if (source == target) {
gameDirectory.setName(name);
gameDirectory.setPath(path);
rebuildGameDirectories();
return;
}
@@ -488,26 +479,17 @@ public final class GameDirectoryManager {
}
/// Returns the selected instance property projected from the selected repository.
///
/// The value is `null` when the selected repository has no registered selected instance.
///
/// @return the read-only selected-instance property
public static ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
public static ReadOnlyStringProperty selectedInstanceProperty() {
return selectedInstance.getReadOnlyProperty();
}
/// Returns the selected instance from the selected repository's current snapshot.
///
/// @return the selected instance, or `null` when none is registered
public static @Nullable HMCLGameInstance getSelectedInstance() {
/// Returns the selected instance ID for the selected repository.
public static @Nullable String getSelectedInstance() {
return getSelectedRepository().getSelectedInstance();
}
/// Sets the selected instance for the selected repository.
///
/// @param instance the instance to select, or `null` to clear the selection
/// @throws IllegalArgumentException if `instance` belongs to another repository
public static void setSelectedInstance(@Nullable HMCLGameInstance instance) {
/// Sets the selected instance ID for the selected repository.
public static void setSelectedInstance(@Nullable String instance) {
getSelectedRepository().setSelectedInstance(instance);
}
@@ -82,7 +82,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
public static final class Instance extends GameSettings implements JsonSchemaSetting {
/// The JSON schema supported by instance-specific game settings.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 1));
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 0));
/// Creates an empty instance setting.
public Instance() {
@@ -152,10 +152,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The icon of the instance.
@SerializedName("icon")
private final SettingProperty<GameInstanceIconType> icon = newSettingProperty("icon", GameInstanceIconType.DEFAULT);
private final SettingProperty<VersionIconType> icon = newSettingProperty("icon", VersionIconType.DEFAULT);
/// Returns the instance icon property.
public SettingProperty<GameInstanceIconType> iconProperty() {
public SettingProperty<VersionIconType> iconProperty() {
return icon;
}
@@ -388,19 +388,6 @@ public sealed abstract class GameSettings extends ObservableSetting {
return detectedJava;
}
/// Property name for disabling automatic replacement with `javaw.exe` on Windows.
public static final String PROPERTY_NOT_USE_JAVAW = "notUseJavaw";
/// Whether to keep the selected Java executable instead of replacing it with `javaw.exe`; defaults to `false`.
@SerializedName(PROPERTY_NOT_USE_JAVAW)
private final InheritableProperty<Boolean> notUseJavaw = newInheritableProperty(PROPERTY_NOT_USE_JAVAW, false);
/// Returns the property disabling automatic replacement of a Windows Java executable with a sibling `javaw.exe` file.
/// If `true`, the selected executable is used unchanged. This property has no effect on other runtime platforms.
public InheritableProperty<Boolean> notUseJavawProperty() {
return notUseJavaw;
}
/// Property name for customized JVM options.
public static final String PROPERTY_JVM_OPTIONS = "jvmOptions";
@@ -656,19 +643,6 @@ 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";
@@ -826,15 +800,15 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Property name for using native GLFW.
public static final String PROPERTY_USE_NATIVE_GLFW_OR_SDL = "useNativeGLFW";
public static final String PROPERTY_USE_NATIVE_GLFW = "useNativeGLFW";
/// If `true`, HMCL will use native GLFW.
@SerializedName(PROPERTY_USE_NATIVE_GLFW_OR_SDL)
private final InheritableProperty<Boolean> useNativeGLFWorSDL = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW_OR_SDL, false);
@SerializedName(PROPERTY_USE_NATIVE_GLFW)
private final InheritableProperty<Boolean> useNativeGLFW = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW, false);
/// Returns the native GLFW property.
public InheritableProperty<Boolean> useNativeGLFWorSDLProperty() {
return useNativeGLFWorSDL;
public InheritableProperty<Boolean> useNativeGLFWProperty() {
return useNativeGLFW;
}
/// Property name for using native OpenAL.
@@ -942,11 +916,11 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Finds the effective Java runtime.
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable GameInstanceManifest manifest) throws InterruptedException {
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable Version version) throws InterruptedException {
JavaVersionType javaVersionType = getInheritable(GameSettings::javaTypeProperty);
switch (javaVersionType) {
case AUTO:
return JavaManager.findSuitableJava(gameVersion, manifest);
return JavaManager.findSuitableJava(gameVersion, version);
case CUSTOM:
try {
return JavaManager.getJava(Path.of(getInheritable(GameSettings::customJavaPathProperty)));
@@ -956,7 +930,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
case VERSION: {
String javaVersion = getInheritable(GameSettings::customJavaVersionProperty);
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, manifest);
return JavaManager.findSuitableJava(gameVersion, version);
}
int majorVersion = -1;
@@ -974,13 +948,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, manifest);
return JavaManager.findSuitableJava(allJava, gameVersion, version);
}
case DETECTED: {
DetectedJava detectedJava = getInheritable(GameSettings::detectedJavaProperty);
String javaVersion = detectedJava.version();
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, manifest);
return JavaManager.findSuitableJava(gameVersion, version);
}
if (StringUtils.isNotBlank(detectedJava.pathHash())) {
@@ -44,7 +44,7 @@ import java.util.Objects;
public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this game settings preset store.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 1));
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0));
/// Creates an empty game settings preset store.
public GameSettingsPresets() {
@@ -156,14 +156,7 @@ final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
///
/// @param value the settings object to observe
void installAutoSave(T value) {
value.addListener(observable -> {
if (value.shouldSaveImmediately(observable)) {
save(value);
value.setSavePending(false);
} else {
value.setSavePending(true);
}
});
value.addListener(source -> save(value));
}
/// Saves a settings object.
@@ -34,7 +34,6 @@ 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;
@@ -481,19 +480,17 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// Whether UI animations are disabled.
@SerializedName("animationDisabled")
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
FXUtils.REDUCED_MOTION == Boolean.TRUE
|| !JavaRuntime.CURRENT_JIT_ENABLED
|| !FXUtils.GPU_ACCELERATION_ENABLED
);
/// Returns the UI animation disable property.
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
public BooleanProperty 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.
@@ -624,34 +621,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, GameInstanceID> selectedInstance = FXCollections.observableHashMap();
private final ObservableMap<GameDirectoryID, String> 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, GameInstanceID> getSelectedInstance() {
public ObservableMap<GameDirectoryID, String> 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 GameInstanceID getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
public @Nullable String 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 GameInstanceID selectedInstance) {
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable String selectedInstance) {
if (gameDirectoryId == null) {
return;
}
if (selectedInstance != null) {
this.selectedInstance.put(gameDirectoryId, selectedInstance);
} else {
if (StringUtils.isBlank(selectedInstance)) {
this.selectedInstance.remove(gameDirectoryId);
} else {
this.selectedInstance.put(gameDirectoryId, selectedInstance);
}
}
@@ -19,7 +19,6 @@ 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;
@@ -55,15 +54,6 @@ 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 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
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
};
/// Legacy `JavaVersionType` ordinal order used by old game settings.
@@ -130,14 +130,14 @@ 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,
GameInstanceID instanceId,
String instanceId,
@Nullable GameSettingsPresetID parent) {
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
Path instanceRoot = repository.getVersionRoot(instanceId);
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
if (!Files.exists(file)) {
return null;
}
Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId)
Path receiptLocation = repository.getInstanceStateDirectory(instanceId)
.resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME);
if (MigrationReceipt.matches(receiptLocation, file)) {
LOG.info("Skipping already migrated legacy version setting " + file);
@@ -271,7 +271,7 @@ public final class LegacyGameSettingsMigrator {
GameSettings.PROPERTY_NOT_PATCH_NATIVES,
GameSettings.PROPERTY_USE_CUSTOM_NATIVES,
GameSettings.PROPERTY_NATIVES_DIRECTORY,
GameSettings.PROPERTY_USE_NATIVE_GLFW_OR_SDL,
GameSettings.PROPERTY_USE_NATIVE_GLFW,
GameSettings.PROPERTY_USE_NATIVE_OPENAL
));
}
@@ -342,7 +342,7 @@ public final class LegacyGameSettingsMigrator {
boolean useCustomNatives = isLegacyCustomNativesDirectory(source);
target.useCustomNativesProperty().setValue(useCustomNatives);
target.nativesDirectoryProperty().setValue(useCustomNatives ? JsonUtils.getString(source, "nativesDir", "") : "");
target.useNativeGLFWorSDLProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
target.useNativeGLFWProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
target.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false));
}
@@ -488,10 +488,10 @@ public final class LegacyGameSettingsMigrator {
}
/// Parses the legacy icon selection with frozen ordinal order.
private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
private static VersionIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
if (primitive == null) {
return GameInstanceIconType.DEFAULT;
return VersionIconType.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]
: GameInstanceIconType.DEFAULT;
: VersionIconType.DEFAULT;
}
String value = primitive.getAsString();
for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) {
for (VersionIconType iconType : LEGACY_VERSION_ICON_TYPES) {
if (iconType.name().equalsIgnoreCase(value)) {
return iconType;
}
@@ -511,7 +511,7 @@ public final class LegacyGameSettingsMigrator {
} catch (RuntimeException ignored) {
}
return GameInstanceIconType.DEFAULT;
return VersionIconType.DEFAULT;
}
/// Reads an enum property from either ordinal or name.
@@ -55,20 +55,6 @@ 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,12 +28,19 @@ 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
*/
@@ -60,8 +67,23 @@ public final class StyleSheets {
});
}
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 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 getFontStyleSheet() {
@@ -111,7 +133,7 @@ public final class StyleSheets {
builder.append('}');
return toStyleSheetUri(builder.toString());
return toStyleSheetUri(builder.toString(), defaultCss);
}
private static String getBrightnessStyleSheet() {
@@ -153,7 +175,6 @@ public final class StyleSheets {
addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed());
addColor(builder, scheme, ColorRole.PRIMARY, 0.5);
addColor(builder, scheme, ColorRole.PRIMARY, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.8);
@@ -163,7 +184,7 @@ public final class StyleSheets {
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
builder.append("}\n");
return toStyleSheetUri(builder.toString());
return toStyleSheetUri(builder.toString(), blueCss);
}
public static void init(Scene scene) {
@@ -19,11 +19,9 @@ package org.jackhuang.hmcl.setting;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.Nullable;
public enum GameInstanceIconType {
public enum VersionIconType {
DEFAULT("/assets/img/grass.png"),
GRASS("/assets/img/grass.png"),
@@ -44,38 +42,22 @@ public enum GameInstanceIconType {
// Please append new items at last
public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) {
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
return switch (modLoaderType) {
case FORGE -> GameInstanceIconType.FORGE;
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
case FABRIC -> GameInstanceIconType.FABRIC;
case LEGACY_FABRIC -> GameInstanceIconType.LEGACY_FABRIC;
case QUILT -> GameInstanceIconType.QUILT;
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
default -> GameInstanceIconType.COMMAND;
case FORGE -> VersionIconType.FORGE;
case NEO_FORGE -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.FABRIC;
case LEGACY_FABRIC -> VersionIconType.LEGACY_FABRIC;
case QUILT -> VersionIconType.QUILT;
case LITE_LOADER -> VersionIconType.CHICKEN;
case CLEANROOM -> VersionIconType.CLEANROOM;
default -> VersionIconType.COMMAND;
};
}
public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) {
return switch (componentType) {
case GAME -> GameInstanceIconType.GRASS;
case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC;
case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC;
case FORGE -> GameInstanceIconType.FORGE;
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
case LITELOADER -> GameInstanceIconType.CHICKEN;
case OPTIFINE -> GameInstanceIconType.OPTIFINE;
case QUILT, QUILT_API -> GameInstanceIconType.QUILT;
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
default -> null;
};
}
private final String resourceUrl;
GameInstanceIconType(String resourceUrl) {
VersionIconType(String resourceUrl) {
this.resourceUrl = resourceUrl;
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
@@ -30,12 +29,13 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
@@ -50,16 +50,13 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaBundle {
private final Path root;
/// Candidate bundle URLs in attempt order.
private final List<WebURL> links;
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck hash;
private final Map<String, FileDownloadTask.IntegrityCheck> files;
/// Creates a bundle using the supplied download candidates and archive/file integrity metadata.
/// The candidate list and file map are retained without copying.
public TerracottaBundle(Path root, List<WebURL> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
this.root = root;
this.links = links;
this.hash = hash;
@@ -22,7 +22,6 @@ import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.DownloadException;
@@ -43,6 +42,7 @@ import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -288,7 +288,7 @@ public final class TerracottaManager {
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR);
}
return Task.completed(null);
@@ -297,7 +297,7 @@ public final class TerracottaManager {
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return new TerracottaState.Waiting(-1, -1, null);
@@ -317,7 +317,7 @@ public final class TerracottaManager {
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (WebURL node : nodes) {
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
@@ -338,7 +338,7 @@ public final class TerracottaManager {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
for (WebURL node : nodes) {
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
@@ -36,10 +35,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -91,8 +90,8 @@ public final class TerracottaMetadata {
}
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
@Unmodifiable List<WebURL> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> WebURL.parse(options.replace(link)))
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> URI.create(options.replace(link)))
.toList();
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -27,8 +26,9 @@ import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -43,19 +43,17 @@ public final class TerracottaNodeList {
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
WebURL.parse(url);
} catch (IllegalArgumentException e) {
new URI(url);
} catch (URISyntaxException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
/// Cached node URLs, or `null` before the first fetch completes.
private static volatile @Nullable @Unmodifiable List<WebURL> list;
private static volatile List<URI> list;
/// Returns the cached unmodifiable node URLs, fetching once and caching an empty list on failure.
public static @Unmodifiable List<WebURL> fetch() {
@Nullable @Unmodifiable List<WebURL> list = TerracottaNodeList.list;
public static List<URI> fetch() {
List<URI> list = TerracottaNodeList.list;
if (list != null)
return list;
@@ -86,7 +84,7 @@ public final class TerracottaNodeList {
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
})
.map(it -> WebURL.parse(it.url()))
.map(it -> URI.create(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
@@ -26,7 +26,6 @@ import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorStyle;
import org.glavo.monetfx.Contrast;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.setting.LauncherSettings;
@@ -37,8 +36,8 @@ import org.jackhuang.hmcl.util.MathUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.ContentEncoding;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ContentEncoding;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNullByDefault;
@@ -49,6 +48,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
@@ -1497,28 +1497,28 @@ public final class ThemePackManager {
List<Path> temporaryFiles,
ResolvedBackground background) throws IOException {
String url = requireNonBlank(background.networkImageUrl(), "background.url");
WebURL webUrl = WebURL.parse(url);
if (!NetworkUtils.isHttpUri(webUrl)) {
URI uri = NetworkUtils.toURI(url);
if (!NetworkUtils.isHttpUri(uri)) {
throw new IOException("Theme background URL must be HTTP or HTTPS: " + url);
}
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(webUrl);
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(uri);
if (background.networkImageCachePolicy() == NetworkBackgroundImageCachePolicy.ENABLED) {
try {
@Nullable Path cachedFile = new CacheFileTask(webUrl).run();
@Nullable Path cachedFile = new CacheFileTask(uri).run();
if (cachedFile != null && Files.isRegularFile(cachedFile)) {
assets.add(new ThemePackAsset(cachedFile, entryName));
return new ThemeBackground.Image(entryName);
}
} catch (Exception e) {
LOG.warning("Failed to cache theme background, falling back to direct download: " + webUrl, e);
LOG.warning("Failed to cache theme background, falling back to direct download: " + uri, e);
}
}
Path temporaryFile = Files.createTempFile("hmcl-theme-background-", ".tmp");
boolean success = false;
try {
downloadNetworkBackground(webUrl, temporaryFile);
downloadNetworkBackground(uri, temporaryFile);
assets.add(new ThemePackAsset(temporaryFile, entryName));
temporaryFiles.add(temporaryFile);
success = true;
@@ -1535,12 +1535,12 @@ public final class ThemePackManager {
}
/// Downloads one network background into a temporary file without installing it into the persistent image cache.
private static void downloadNetworkBackground(WebURL url, Path outputFile) throws IOException {
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(url));
private static void downloadNetworkBackground(URI uri, Path outputFile) throws IOException {
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(uri));
try {
int responseCode = connection.getResponseCode();
if (responseCode / 100 != 2) {
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + url);
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + uri);
}
ContentEncoding contentEncoding = ContentEncoding.fromConnection(connection);
@@ -1554,8 +1554,8 @@ public final class ThemePackManager {
}
/// Returns a safe theme-pack asset file name for a downloaded network background.
private static String networkBackgroundAssetName(WebURL url) {
String path = Objects.toString(url.getPath(), "");
private static String networkBackgroundAssetName(URI uri) {
String path = Objects.toString(uri.getPath(), "");
@Nullable Path fileNamePath = path.isBlank() ? null : Path.of(path).getFileName();
String fileName = fileNamePath != null ? fileNamePath.toString() : "";
String sanitized = sanitizePathSegment(fileName);
@@ -24,7 +24,8 @@ import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.ObjectExpression;
import javafx.beans.property.*;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.SetChangeListener;
@@ -79,7 +80,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -91,9 +91,6 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Provides the current launcher MonetFX theme and derived color bindings.
@NotNullByDefault
public final class Themes {
/// The stage property retaining the listener for native appearance updates and marking completed registration.
private static final String NATIVE_DARK_MODE_LISTENER = "Themes.applyNativeDarkMode.listener";
/// The seed color extracted from the last loaded wallpaper image.
private static final ReadOnlyObjectWrapper<@Nullable ThemeColor> wallpaperThemeColor = new ReadOnlyObjectWrapper<>();
@@ -286,9 +283,6 @@ public final class Themes {
colorScheme
);
/// Whether the current color scheme brightness is inherited from system.
private static final ReadOnlyBooleanWrapper autoBrightness = new ReadOnlyBooleanWrapper();
/// The current JavaFX launcher background and its node opacity.
private static final ReadOnlyObjectWrapper<LauncherBackground> background = new ReadOnlyObjectWrapper<>(
new LauncherBackground(
@@ -386,35 +380,6 @@ public final class Themes {
}
static {
Supplier<@Nullable Brightness> effectiveBrightnessSupplier = () -> {
try {
return ThemePackManager.resolveCurrentThemeBrightness(ThemePackManager.currentResolveContext());
} catch (IOException | RuntimeException e) {
return null;
}
};
InvalidationListener autoBrightnessListener = o -> {
boolean auto;
if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) {
String val = settings().themeBrightnessModeProperty().get();
auto = val == null || switch (val.trim().toLowerCase(Locale.ROOT)) {
case "light", "dark" -> false;
default -> true;
};
} else {
auto = effectiveBrightnessSupplier.get() == null;
}
autoBrightness.set(auto);
};
settings().selectedThemeProperty().addListener(autoBrightnessListener);
settings().getThemeAppearanceOverrides().addListener(autoBrightnessListener);
settings().themeBrightnessModeProperty().addListener(autoBrightnessListener);
settings().backgroundTypeProperty().addListener(autoBrightnessListener);
if (FXUtils.DARK_MODE != null) {
FXUtils.DARK_MODE.addListener(autoBrightnessListener);
}
autoBrightnessListener.invalidated(null);
ChangeListener<ResolvedTheme> listener = (observable, oldValue, newValue) -> {
if (!Objects.equals(oldValue, newValue)) {
colorScheme.set(newValue.toColorScheme());
@@ -468,7 +433,7 @@ public final class Themes {
}
String themeBrightnessMode = settings().themeBrightnessModeProperty().get();
return switch (Objects.toString(themeBrightnessMode, "").trim().toLowerCase(Locale.ROOT)) {
return switch (Objects.toString(themeBrightnessMode, "").toLowerCase(Locale.ROOT).trim()) {
case "light" -> Brightness.LIGHT;
case "dark" -> Brightness.DARK;
default -> getAutomaticBrightness();
@@ -479,16 +444,24 @@ public final class Themes {
///
/// @return the effective launcher brightness
public static Brightness getCurrentBrightness() {
Brightness contextBrightness = getThemeConditionBrightness();
if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) {
Brightness contextBrightness = getThemeConditionBrightness();
try {
return Objects.requireNonNullElse(
ThemePackManager.resolveCurrentThemeBrightness(ThemeResolveContext.current(contextBrightness)),
contextBrightness);
} catch (IOException | RuntimeException ignored) {
} catch (IOException | RuntimeException e) {
return contextBrightness;
}
}
return contextBrightness;
String themeBrightnessMode = settings().themeBrightnessModeProperty().get();
return switch (Objects.toString(themeBrightnessMode, "").toLowerCase(Locale.ROOT).trim()) {
case "auto" -> getAutomaticBrightness();
case "dark" -> Brightness.DARK;
case "light" -> Brightness.LIGHT;
default -> getAutomaticBrightness();
};
}
/// Returns the brightness requested by the current system or platform settings.
@@ -1146,22 +1119,8 @@ public final class Themes {
return darkMode;
}
/// Returns whether the current color scheme brightness is inherited from system.
public static ReadOnlyBooleanProperty autoBrightnessProperty() {
return autoBrightness.getReadOnlyProperty();
}
/// Registers native dark-mode integration once per stage where the platform supports it.
///
/// Windows updates the stage's native frame; macOS updates the application appearance. The stage retains
/// its listener, which observes theme changes weakly. Repeated calls for an already registered stage do
/// nothing, including after hiding the stage or replacing its scene.
///
/// @param stage the stage retaining the registration, accessed on the JavaFX application thread
/// Applies native dark-mode integration to a JavaFX stage where the platform supports it.
public static void applyNativeDarkMode(Stage stage) {
if (stage.getProperties().containsKey(NATIVE_DARK_MODE_LISTENER)) {
return;
}
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> {
if (stage.isShowing()) {
@@ -1178,7 +1137,7 @@ public final class Themes {
});
}
});
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
if (stage.isShowing()) {
listener.changed(null, false, Themes.darkModeProperty().get());
@@ -1195,7 +1154,7 @@ public final class Themes {
MacOSNativeUtils.setAppearance(darkModeProperty().get());
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance);
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
}
}
@@ -24,39 +24,44 @@ 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.stage.*;
import javafx.scene.paint.Color;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.account.AccountListPage;
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.Decorator;
import org.jackhuang.hmcl.ui.decorator.DecoratorController;
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.instances.GameListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
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.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -68,12 +73,8 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
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;
@@ -89,12 +90,26 @@ public final class Controllers {
public static final String SOFTWARE_RENDERING = "softwareRendering";
public static final String APRIL_FOOLS = "aprilFools";
private static GameInstancePage gameInstancePage;
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 Lazy<GameListPage> gameListPage = new Lazy<>(GameListPage::new);
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
/// The coordinator for the main window's scene graph and navigation stack.
private static @Nullable Decorator decorator;
private static DecoratorController decorator;
private static DownloadPage downloadPage;
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
AccountListPage accountListPage = new AccountListPage();
@@ -118,27 +133,27 @@ public final class Controllers {
void run() throws Exception;
}
/// 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();
public static Scene getScene() {
return scene;
}
public static Stage getStage() {
return stage;
}
@FXThread
public static GameInstancePage getGameInstancePage() {
if (gameInstancePage == null) {
gameInstancePage = new GameInstancePage();
public static VersionPage getVersionPage() {
if (versionPage == null) {
versionPage = new VersionPage();
}
return gameInstancePage;
return versionPage;
}
@FXThread
public static void prepareGameInstancePage() {
if (gameInstancePage == null) {
LOG.info("Prepare the game instance page");
gameInstancePage = FXUtils.prepareNode(new GameInstancePage());
public static void prepareVersionPage() {
if (versionPage == null) {
LOG.info("Prepare the version page");
versionPage = FXUtils.prepareNode(new VersionPage());
}
}
@@ -194,34 +209,71 @@ public final class Controllers {
return terracottaPage.get();
}
/// Returns the initialized main-window decorator.
///
/// @return the application-wide main-window decorator
@FXThread
public static Decorator getDecorator() {
return Objects.requireNonNull(decorator, "Main window is not initialized");
public static DecoratorController getDecorator() {
return decorator;
}
/// Releases stage-specific listeners and retained window ownership before application shutdown.
public static void onApplicationStop() {
if (decorator != null) {
decorator.detachStage();
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()));
}
}
/// Configures the main stage's application identity, shutdown action, and taskbar relaunch information.
///
/// @param stage the unshown main stage
private static void configureMainStage(Stage stage) {
stage.setOnCloseRequest(event -> Launcher.stopApplication());
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(stage);
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;
}
/// 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");
@@ -233,16 +285,102 @@ 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.getPrimary().getOutputScaleX() > 1) {
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) {
LOG.info("Disable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "false");
}
}
configureMainStage(stage);
Controllers.stage = stage;
decorator = new Decorator(getRootPage(), Controllers::configureMainStage);
Scene mainScene = decorator.attachStage(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());
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
getRootPage().getMainPage().showUpdateDialogProperty().bind(
decorator.backableProperty().not()
@@ -258,7 +396,36 @@ public final class Controllers {
Lang.thread(JavaManager::initialize, "Search Java", true);
StyleSheets.init(mainScene);
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);
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 = () -> {
@@ -385,41 +552,6 @@ public final class Controllers {
}, updateShowTips);
}, updateShowTips);
}
tryInstallBundledModpack(GameDirectoryManager.getSelectedRepository());
}
/// Offers automatic install when a package exists under `.hmcl/modpack/`.
///
/// Called from [Controllers#initialize] after the UI is ready. Install does not wait for repository
/// refresh: instance paths come from the selected repository layout. The package file itself is the
/// install signal; it is deleted after a successful install so later startups do not re-prompt.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile == null) {
return;
}
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
);
}
public static void dialog(Region content) {
@@ -453,7 +585,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(
@@ -562,38 +694,6 @@ 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) {
@@ -603,7 +703,7 @@ public final class Controllers {
break;
case "hmcl://game/launch":
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep);
Versions.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep);
break;
}
} else {
@@ -611,48 +711,23 @@ public final class Controllers {
}
}
public static void openUriOrCopy(@Nullable URI url) {
if (url == null) return;
openUriOrCopy(url.toString());
}
public static void openUriOrCopy(@Nullable String url) {
if (url == null) return;
var dialog = new MessageDialogPane.Builder(
i18n("web.open_in_browser", url),
i18n("message.confirm"),
MessageDialogPane.MessageType.QUESTION
)
.addAction(i18n("button.copy"), () -> FXUtils.copyText(url))
.yesOrNo(() -> FXUtils.openLink(url), null)
.build();
dialog(dialog);
}
public static boolean isStopped() {
return decorator == null;
}
/// Releases controller-owned pages, window state, and JavaFX helper resources.
public static void shutdown() {
onApplicationStop();
rootPage = null;
gameInstancePage = null;
versionPage = null;
gameListPage = null;
downloadPage = null;
accountListPage = null;
settingsPage = null;
terracottaPage = null;
decorator = null;
stage = null;
scene = null;
onApplicationStop();
FXUtils.shutdown();
}
public static final boolean AUTO_TRIM_HEAP = !"false".equalsIgnoreCase(System.getenv("HMCL_AUTO_TRIM_HEAP"));
public static void trimHeap() {
if (AUTO_TRIM_HEAP) {
System.gc();
}
}
}
@@ -72,8 +72,6 @@ public class CrashWindow extends Stage {
FXUtils.setIcon(this);
setTitle(i18n("message.error"));
FXUtils.addMacOSCloseWindowHandler(this, null);
setOnCloseRequest(e -> javafx.application.Platform.exit());
}
@@ -49,31 +49,17 @@ 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) {
StackPane dialogContainer = decorator.getDialogContainer();
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showInDecorator(decorator, dialogContainer, content));
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> show(decorator, content));
return;
}
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 -> {
show(decorator.getDrawerWrapper(), content, (dialog) -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingOverlay(dialog);
decorator.capableDraggingWindow(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(dialogContainer);
dialog.setDialogContainer(decorator.getDrawerWrapper());
});
}
@@ -139,18 +125,12 @@ 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) {
StackPane dialogContainer = decorator.getDialogContainer();
Runnable showDialogAction = () -> showInDecorator(decorator, dialogContainer, content);
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showLater(dialogContainer, showDialogAction));
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> showLater(decorator, content));
return;
}
showLater(dialogContainer, showDialogAction);
showLater(decorator.getDrawerWrapper(), () -> show(decorator, content));
}
@SuppressWarnings("unchecked")
@@ -32,7 +32,6 @@ import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableBooleanValue;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.event.Event;
import javafx.event.EventDispatcher;
@@ -45,47 +44,45 @@ import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.skin.TableViewSkin;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.*;
import javafx.stage.FileChooser;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.image.ImageLoader;
import org.jackhuang.hmcl.ui.image.ImageUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.ResourceNotFoundError;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.javafx.Subscription;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jsoup.Jsoup;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
@@ -108,10 +105,12 @@ import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.*;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.ToIntFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -468,31 +467,25 @@ public final class FXUtils {
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
@FXThread
public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
checkFxUserThread();
tooltip.setShowDelay(showDelay);
tooltip.setShowDuration(showDuration);
tooltip.setHideDelay(hideDelay);
Tooltip.install(node, tooltip);
}
@FXThread
public static void installFastTooltip(Node node, Tooltip tooltip) {
installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip);
runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
}
@FXThread
public static void installFastTooltip(Node node, String tooltip) {
installFastTooltip(node, new Tooltip(tooltip));
}
@FXThread
public static void installSlowTooltip(Node node, Tooltip tooltip) {
installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip);
runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
}
@FXThread
public static void installSlowTooltip(Node node, String tooltip) {
installSlowTooltip(node, new Tooltip(tooltip));
}
@@ -617,21 +610,21 @@ public final class FXUtils {
if (link == null)
return;
String url = NetworkUtils.encodeLocation(link);
String uri = NetworkUtils.encodeLocation(link);
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", url});
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri});
return;
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Runtime.getRuntime().exec(new String[]{"open", url});
Runtime.getRuntime().exec(new String[]{"open", uri});
return;
} else {
for (String browser : linuxBrowsers) {
Path path = SystemUtils.which(browser);
if (path != null) {
try {
Runtime.getRuntime().exec(new String[]{path.toString(), url});
Runtime.getRuntime().exec(new String[]{path.toString(), uri});
return;
} catch (Throwable ignored) {
}
@@ -644,7 +637,7 @@ public final class FXUtils {
}
try {
java.awt.Desktop.getDesktop().browse(new URI(url));
java.awt.Desktop.getDesktop().browse(new URI(uri));
} catch (Throwable e) {
LOG.warning("Failed to open link: " + link, e);
}
@@ -732,6 +725,116 @@ public final class FXUtils {
}
}
private static final class EnumBidirectionalBinding<E extends Enum<E>> implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<E>> comboBoxRef;
private final WeakReference<Property<E>> propertyRef;
private final int hashCode;
private boolean updating = false;
private EnumBidirectionalBinding(JFXComboBox<E> comboBox, Property<E> property) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.propertyRef = new WeakReference<>(property);
this.hashCode = System.identityHashCode(comboBox) ^ System.identityHashCode(property);
}
@Override
public void invalidated(Observable sourceProperty) {
if (!updating) {
final JFXComboBox<E> comboBox = comboBoxRef.get();
final Property<E> property = propertyRef.get();
if (comboBox == null || property == null) {
if (comboBox != null) {
comboBox.getSelectionModel().selectedItemProperty().removeListener(this);
}
if (property != null) {
property.removeListener(this);
}
} else {
updating = true;
try {
if (property == sourceProperty) {
E newValue = property.getValue();
comboBox.getSelectionModel().select(newValue);
} else {
E newValue = comboBox.getSelectionModel().getSelectedItem();
property.setValue(newValue);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return comboBoxRef.get() == null || propertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof EnumBidirectionalBinding))
return false;
EnumBidirectionalBinding<?> that = (EnumBidirectionalBinding<?>) o;
final JFXComboBox<E> comboBox = this.comboBoxRef.get();
final Property<E> property = this.propertyRef.get();
final JFXComboBox<?> thatComboBox = that.comboBoxRef.get();
final Property<?> thatProperty = that.propertyRef.get();
if (comboBox == null || property == null || thatComboBox == null || thatProperty == null)
return false;
return comboBox == thatComboBox && property == thatProperty;
}
}
/**
* Bind combo box selection with given enum property bidirectionally.
* You should <b>only and always</b> use {@code bindEnum} as well as {@code unbindEnum} at the same time.
*
* @param comboBox the combo box being bound with {@code property}.
* @param property the property being bound with {@code combo box}.
* @see #unbindEnum(JFXComboBox, Property)
* @see ExtendedProperties#selectedItemPropertyFor(ComboBox)
*/
public static <T extends Enum<T>> void bindEnum(JFXComboBox<T> comboBox, Property<T> property) {
EnumBidirectionalBinding<T> binding = new EnumBidirectionalBinding<>(comboBox, property);
comboBox.getSelectionModel().selectedItemProperty().removeListener(binding);
property.removeListener(binding);
comboBox.getSelectionModel().select(property.getValue());
comboBox.getSelectionModel().selectedItemProperty().addListener(binding);
property.addListener(binding);
}
/**
* Unbind combo box selection with given enum property bidirectionally.
* You should <b>only and always</b> use {@code bindEnum} as well as {@code unbindEnum} at the same time.
*
* @param comboBox the combo box being bound with the property which can be inferred by {@code bindEnum}.
* @see #bindEnum(JFXComboBox, Property)
* @see ExtendedProperties#selectedItemPropertyFor(ComboBox)
*/
public static <T extends Enum<T>> void unbindEnum(JFXComboBox<T> comboBox, Property<T> property) {
EnumBidirectionalBinding<T> binding = new EnumBidirectionalBinding<>(comboBox, property);
comboBox.getSelectionModel().selectedItemProperty().removeListener(binding);
property.removeListener(binding);
}
private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<ColorPicker> colorPickerRef;
private final WeakReference<Property<Paint>> propertyRef;
@@ -826,7 +929,147 @@ public final class FXUtils {
property.addListener(binding);
}
public static List<Subscription> bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<String>> comboBoxRef;
private final WeakReference<IntegerProperty> widthPropertyRef;
private final WeakReference<IntegerProperty> heightPropertyRef;
private final int hashCode;
private boolean updating = false;
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
IntegerProperty widthProperty,
IntegerProperty heightProperty) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.widthPropertyRef = new WeakReference<>(widthProperty);
this.heightPropertyRef = new WeakReference<>(heightProperty);
this.hashCode = System.identityHashCode(comboBox)
^ System.identityHashCode(widthProperty)
^ System.identityHashCode(heightProperty);
}
@Override
public void invalidated(Observable observable) {
if (!updating) {
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null) {
if (comboBox != null) {
comboBox.focusedProperty().removeListener(this);
comboBox.sceneProperty().removeListener(this);
}
if (widthProperty != null)
widthProperty.removeListener(this);
if (heightProperty != null)
heightProperty.removeListener(this);
} else {
updating = true;
try {
int width = widthProperty.get();
int height = heightProperty.get();
if (observable instanceof ReadOnlyProperty<?>
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
String value = comboBox.valueProperty().get();
if (value == null)
value = "";
int idx = value.indexOf('x');
if (idx < 0)
idx = value.indexOf('*');
if (idx < 0) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
String widthStr = value.substring(0, idx).trim();
String heightStr = value.substring(idx + 1).trim();
int newWidth;
int newHeight;
try {
newWidth = Integer.parseInt(widthStr);
newHeight = Integer.parseInt(heightStr);
} catch (NumberFormatException e) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
widthProperty.set(newWidth);
heightProperty.set(newHeight);
} else {
comboBox.setValue(width + "x" + height);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return this.comboBoxRef.get() == null
|| this.widthPropertyRef.get() == null
|| this.heightPropertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof WindowsSizeBidirectionalBinding))
return false;
var that = (WindowsSizeBidirectionalBinding) obj;
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
var thatComboBox = that.comboBoxRef.get();
var thatWidthProperty = that.widthPropertyRef.get();
var thatHeightProperty = that.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
return false;
}
return comboBox == thatComboBox
&& widthProperty == thatWidthProperty
&& heightProperty == thatHeightProperty;
}
}
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().addListener(binding);
comboBox.sceneProperty().addListener(binding);
widthProperty.addListener(binding);
heightProperty.addListener(binding);
}
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().removeListener(binding);
comboBox.sceneProperty().removeListener(binding);
widthProperty.removeListener(binding);
heightProperty.removeListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
int itemCount = children.length;
int childSelectedCount = 0;
for (BooleanProperty child : children) {
@@ -836,46 +1079,49 @@ public final class FXUtils {
allEnabled.set(childSelectedCount == itemCount);
class AllListener {
class Listener implements InvalidationListener {
private int childSelectedCount;
private boolean updating = false;
private final List<Subscription> subscriptions = new ArrayList<>(children.length + 1);
public AllListener(int childSelectedCount) {
public Listener(int childSelectedCount) {
this.childSelectedCount = childSelectedCount;
}
{
subscriptions.add(Subscription.subscribe(allEnabled, () -> {
if (updating) return;
updating = true;
try {
boolean value = allEnabled.get();
@Override
public void invalidated(Observable observable) {
if (updating)
return;
updating = true;
try {
boolean value = ((BooleanProperty) observable).get();
if (observable == allEnabled) {
for (BooleanProperty child : children) {
child.set(value);
child.setValue(value);
}
childSelectedCount = value ? itemCount : 0;
} finally {
updating = false;
} else {
if (value)
childSelectedCount++;
else
childSelectedCount--;
allEnabled.set(childSelectedCount == itemCount);
}
}));
for (var child : children) {
subscriptions.add(Subscription.subscribe(child, () -> {
if (updating) return;
updating = true;
try {
if (child.get()) childSelectedCount++;
else childSelectedCount--;
allEnabled.set(childSelectedCount == itemCount);
} finally {
updating = false;
}
}));
} finally {
updating = false;
}
}
}
return new AllListener(childSelectedCount).subscriptions;
InvalidationListener listener = new Listener(childSelectedCount);
WeakInvalidationListener weakListener = new WeakInvalidationListener(listener);
allEnabled.addListener(listener);
for (BooleanProperty child : children) {
child.addListener(weakListener);
}
}
public static void setIcon(Stage stage) {
@@ -1002,9 +1248,8 @@ public final class FXUtils {
.setSignificance(Task.TaskSignificance.MINOR);
}
/// Creates a task that caches an image from HTTP(S) candidates and loads it with the requested sizing options.
public static Task<Image> getRemoteImageTask(DownloadCandidates candidates, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(candidates)
public static Task<Image> getRemoteImageTask(List<URI> uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uris)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
@@ -1145,6 +1390,20 @@ public final class FXUtils {
};
}
public static <T> Callback<ListView<T>, ListCell<T>> jfxListCellFactory(Function<T, Node> graphicBuilder) {
return view -> new JFXListCell<T>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
if (!empty) {
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
setGraphic(graphicBuilder.apply(item));
}
}
};
}
public static ColumnConstraints getColumnFillingWidth() {
ColumnConstraints constraint = new ColumnConstraints();
constraint.setFillWidth(true);
@@ -1197,27 +1456,6 @@ public final class FXUtils {
});
}
public static void onDoubleClicked(Node node, Runnable action) {
node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
action.run();
e.consume();
}
});
}
public static void addMacOSCloseWindowHandler(Window window, @Nullable Supplier<Boolean> restriction) {
window.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
&& event.isMetaDown()
&& event.getCode() == KeyCode.W
&& (restriction == null || restriction.get())) {
window.fireEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSE_REQUEST));
event.consume();
}
});
}
public static <N extends Parent> N prepareNode(N node) {
Scene dummyScene = new Scene(node);
StyleSheets.init(dummyScene);
@@ -1283,34 +1521,6 @@ public final class FXUtils {
}
}
public static void copyFiles(List<Path> paths) {
copyFiles(paths.stream().map(Path::toFile).toList(), i18n("message.copied"));
}
public static void copyFiles(List<File> files, @Nullable String toastMessage) {
ClipboardContent content = new ClipboardContent();
content.putFiles(files);
Clipboard.getSystemClipboard().setContent(content);
if (toastMessage != null && !Controllers.isStopped()) {
Controllers.showToast(toastMessage);
}
}
public static void copyImage(Image image) {
copyImage(image, i18n("message.copied"));
}
public static void copyImage(Image image, @Nullable String toastMessage) {
ClipboardContent content = new ClipboardContent();
content.putImage(image);
Clipboard.getSystemClipboard().setContent(content);
if (toastMessage != null && !Controllers.isStopped()) {
Controllers.showToast(toastMessage);
}
}
public static List<Node> parseSegment(String segment, Consumer<String> hyperlinkAction) {
if (segment.indexOf('<') < 0)
return Collections.singletonList(new Text(segment));
@@ -1503,112 +1713,4 @@ public final class FXUtils {
e.consume();
});
}
public static TextFlow renderAddonChangelog(String changelogHtml, String baseUri) {
var textFlow = new HTMLRenderer(Controllers::openUriOrCopy).appendNode(Jsoup.parse(changelogHtml, baseUri)).mergeLineBreaks().render();
textFlow.getStyleClass().add("addon-changelog");
return textFlow;
}
/// Be cautious when dealing with large amounts of data
public static <S> TableView<S> newAutoHeightTable(ObservableList<S> items) {
return new AutoHeightTableView<>(items);
}
private static final class AutoHeightTableView<S> extends TableView<S> {
public AutoHeightTableView() {
super();
}
public AutoHeightTableView(ObservableList<S> items) {
super(items);
}
@Override
protected Skin<?> createDefaultSkin() {
return new AutoSizeSkin<>(this);
}
private static final class AutoSizeSkin<S> extends TableViewSkin<S> {
private final VirtualFlow<TableRow<S>> flow;
public AutoSizeSkin(TableView<S> control) {
super(control);
this.flow = getVirtualFlow();
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
double fixedCellSize = getSkinnable().getFixedCellSize();
double w = width - rightInset - leftInset;
int itemCount = getItemCount();
double headerHeight = getTableHeaderRow().prefHeight(w);
double prefHeight = topInset + bottomInset + headerHeight;
if (fixedCellSize > 0 || itemCount == 0) return prefHeight + fixedCellSize * itemCount;
int i = 0;
TableRow<S> r;
while (i < itemCount && !(r = flow.getCell(i)).isEmpty() && r.getItem() != null) {
prefHeight += r.prefHeight(w);
i++;
}
return prefHeight;
}
}
}
public static void chooseDateRange(Consumer<@NotNull Pair<@Nullable LocalDate, @Nullable LocalDate>> onConfirm) {
Controllers.dialog(new DateRangeDialog(onConfirm));
}
private static class DateRangeDialog extends JFXDialogLayout {
private final JFXDatePicker fromPicker, toPicker;
public DateRangeDialog(Consumer<Pair<LocalDate, LocalDate>> onConfirm) {
super();
setHeading(new Label(i18n("button.select_date")));
GridPane body = new GridPane();
body.setHgap(4);
body.setVgap(8);
{
Label fromLabel = new Label(i18n("button.select_date.from"));
this.fromPicker = new JFXDatePicker(LocalDate.now());
fromPicker.setOverLay(true);
fromPicker.setDialogParent(Controllers.getDecorator().getDialogContainer());
body.add(fromLabel, 0, 0);
body.add(fromPicker, 1, 0);
}
{
Label toLabel = new Label(i18n("button.select_date.to"));
this.toPicker = new JFXDatePicker(LocalDate.now());
toPicker.setOverLay(true);
toPicker.setDialogParent(Controllers.getDecorator().getDialogContainer());
body.add(toLabel, 0, 1);
body.add(toPicker, 1, 1);
}
setBody(body);
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
JFXButton okButton = new JFXButton(i18n("button.ok"));
okButton.getStyleClass().add("dialog-accept");
okButton.setOnAction(e -> {
if (onConfirm != null) onConfirm.accept(Pair.pair(fromPicker.getValue(), toPicker.getValue()));
fireEvent(new DialogCloseEvent());
});
setActions(cancelButton, okButton);
onEscPressed(this, cancelButton::fire);
}
}
}
@@ -34,6 +34,7 @@ import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.setting.StyleSheets;
@@ -43,6 +44,7 @@ import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
@@ -71,15 +73,17 @@ 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 HMCLGameInstance gameInstance;
private final Version version;
private final String memory;
private final String total_memory;
private final String java;
private final LibraryAnalyzer analyzer;
private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown")));
private final BooleanProperty loading = new SimpleBooleanProperty();
private final TextFlow feedbackTextFlow = new TextFlow();
private final ManagedProcess managedProcess;
private final DefaultGameRepository repository;
private final ProcessListener.ExitType exitType;
private final LaunchOptions launchOptions;
private final View view;
@@ -87,14 +91,16 @@ public class GameCrashWindow extends Stage {
private final List<Log> logs;
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List<Log> logs) {
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List<Log> logs) {
Themes.applyNativeDarkMode(this);
this.managedProcess = managedProcess;
this.exitType = exitType;
this.gameInstance = gameInstance;
this.repository = repository;
this.version = version;
this.launchOptions = launchOptions;
this.logs = logs;
this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-");
@@ -114,8 +120,6 @@ public class GameCrashWindow extends Stage {
setTitle(i18n("game.crash.title"));
FXUtils.setIcon(this);
FXUtils.addMacOSCloseWindowHandler(this, null);
analyzeCrashReport();
}
@@ -138,8 +142,7 @@ public class GameCrashWindow extends Stage {
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
}), Task.supplyAsync(() -> {
Path runDirectory = gameInstance.getRunDirectory();
Path latestLog = runDirectory.resolve("logs/latest.log");
Path latestLog = repository.getRunDirectory(version.getId()).resolve("logs/latest.log");
if (!Files.isReadable(latestLog)) {
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
}
@@ -288,7 +291,7 @@ public class GameCrashWindow extends Stage {
}
});
return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs,
return LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs,
new CommandBuilder().addAll(managedProcess.getCommands()).toString(),
path -> {
try {
@@ -339,10 +342,10 @@ public class GameCrashWindow extends Stage {
launcher.setTitle(i18n("launcher"));
launcher.setSubtitle(Metadata.VERSION);
TwoLineListItem instance = new TwoLineListItem();
instance.getStyleClass().setAll("two-line-item-second-large");
instance.setTitle(i18n("game.version"));
instance.setSubtitle(GameCrashWindow.this.gameInstance.getId().toString());
TwoLineListItem version = new TwoLineListItem();
version.getStyleClass().setAll("two-line-item-second-large");
version.setTitle(i18n("game.version"));
version.setSubtitle(GameCrashWindow.this.version.getId());
TwoLineListItem total_memory = new TwoLineListItem();
total_memory.getStyleClass().setAll("two-line-item-second-large");
@@ -362,14 +365,14 @@ public class GameCrashWindow extends Stage {
TwoLineListItem os = new TwoLineListItem();
os.getStyleClass().setAll("two-line-item-second-large");
os.setTitle(i18n("system.operating_system"));
os.setSubtitle(Objects.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME));
os.setSubtitle(Lang.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME));
TwoLineListItem arch = new TwoLineListItem();
arch.getStyleClass().setAll("two-line-item-second-large");
arch.setTitle(i18n("system.architecture"));
arch.setSubtitle(Architecture.SYSTEM_ARCH.getDisplayName());
infoPane.getChildren().setAll(launcher, instance, total_memory, memory, java, os, arch);
infoPane.getChildren().setAll(launcher, version, total_memory, memory, java, os, arch);
}
HBox moddedPane = new HBox(8);
@@ -377,13 +380,15 @@ public class GameCrashWindow extends Stage {
moddedPane.setPadding(new Insets(8));
moddedPane.setAlignment(Pos.CENTER_LEFT);
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
if (mark.version() != null) {
TwoLineListItem item = new TwoLineListItem();
item.getStyleClass().setAll("two-line-item-second-large");
item.setTitle(i18n("install.installer." + mark.componentType().getPatchId()));
item.setSubtitle(mark.version());
moddedPane.getChildren().add(item);
for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) {
if (!type.getPatchId().isEmpty()) {
analyzer.getVersion(type).ifPresent(ver -> {
TwoLineListItem item = new TwoLineListItem();
item.getStyleClass().setAll("two-line-item-second-large");
item.setTitle(i18n("install.installer." + type.getPatchId()));
item.setSubtitle(ver);
moddedPane.getChildren().add(item);
});
}
}
}
@@ -17,41 +17,27 @@
*/
package org.jackhuang.hmcl.ui;
import javafx.beans.InvalidationListener;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.Separator;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import java.net.URI;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class HTMLRenderer {
private static final String INDENT = " ";
private static URI resolveLink(Node linkNode) {
String href = linkNode.absUrl("href");
if (href.isEmpty())
@@ -64,42 +50,6 @@ public final class HTMLRenderer {
}
}
/// @see org.jsoup.internal.StringUtil#isWhitespace(int)
public static boolean isWhitespace(int c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\f' || c == '\r';
}
/// @see org.jsoup.internal.StringUtil#isInvisibleChar(int)
public static boolean isInvisibleChar(int c) {
return c == 8203 || c == 173; // zero width sp, soft hyphen
// previously also included zw non join, zw join - but removing those breaks semantic meaning of text
}
/// @see org.jsoup.internal.StringUtil#normaliseWhitespace(String)
/// @see org.jsoup.internal.StringUtil#isActuallyWhitespace(int)
public static String normaliseWhitespace(String str) {
var accum = new StringBuilder();
boolean lastWasWhite = false;
int len = str.length();
int i = 0;
while (i < len) {
int c = str.codePointAt(i);
if (isWhitespace(c)) { // Ignore &nbsp;
if (lastWasWhite) {
i += Character.charCount(c);
continue;
}
accum.append(' ');
lastWasWhite = true;
} else if (!isInvisibleChar(c)) {
accum.appendCodePoint(c);
lastWasWhite = false;
}
i += Character.charCount(c);
}
return accum.toString();
}
private final List<javafx.scene.Node> children = new ArrayList<>();
private final List<Node> stack = new ArrayList<>();
@@ -108,17 +58,12 @@ public final class HTMLRenderer {
private boolean underline;
private boolean strike;
private boolean highlight;
private boolean preformatted;
private boolean code;
private int listDepth;
private int indentLevel;
private String headerLevel;
private Node hyperlink;
private String fxStyle;
private final Consumer<URI> onClickHyperlink;
private boolean rendered;
public HTMLRenderer(Consumer<URI> onClickHyperlink) {
this.onClickHyperlink = onClickHyperlink;
}
@@ -129,32 +74,42 @@ public final class HTMLRenderer {
underline = false;
strike = false;
highlight = false;
preformatted = false;
code = false;
listDepth = 0;
headerLevel = null;
hyperlink = null;
fxStyle = null;
var declarations = new SimpleCssDeclarations();
for (Node node : stack) {
String nodeName = node.nodeName();
switch (nodeName) {
case "b", "strong" -> bold = true;
case "i", "em" -> italic = true;
case "ins" -> underline = true;
case "del" -> strike = true;
case "mark" -> highlight = true;
case "pre" -> preformatted = true;
case "code" -> code = true;
case "a" -> hyperlink = node;
case "h1", "h2", "h3", "h4", "h5", "h6" -> headerLevel = nodeName;
case "li" -> listDepth++;
case "b":
case "strong":
bold = true;
break;
case "i":
case "em":
italic = true;
break;
case "ins":
underline = true;
break;
case "del":
strike = true;
break;
case "mark":
highlight = true;
break;
case "a":
hyperlink = node;
break;
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
headerLevel = nodeName;
break;
}
declarations.add(node.attr("style"));
}
fxStyle = declarations.asString();
}
private void pushNode(Node node) {
@@ -168,14 +123,6 @@ public final class HTMLRenderer {
}
private void applyStyle(Text text) {
if (code) {
text.getStyleClass().add("html-code");
text.setStyle("-fx-font-family: \"%s\";".formatted(Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)));
return;
}
var styleBuilder = new StringBuilder();
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
if (target != null) {
@@ -199,22 +146,12 @@ public final class HTMLRenderer {
if (headerLevel != null)
text.getStyleClass().add("html-" + headerLevel);
if (fxStyle != null)
styleBuilder.append(fxStyle);
text.setStyle(styleBuilder.toString());
}
private void appendText(String text) {
Text textNode = new Text(text);
applyStyle(textNode);
if (code) {
var codeFlow = new CodeFlow(textNode);
codeFlow.getStyleClass().add("html-code-block");
children.add(codeFlow);
} else {
children.add(textNode);
}
children.add(textNode);
}
private void appendAutoLineBreak(String text) {
@@ -248,22 +185,20 @@ public final class HTMLRenderer {
}
try {
ImageView imageView = new ImageView();
imageView.imageProperty().bind(FXUtils.newRemoteImage(src, width, height, true, true));
imageView.setPreserveRatio(true);
ImagePane imagePane = new ImagePane(imageView, alt);
Image image = FXUtils.getRemoteImageTask(src, width, height, true, true)
.run();
if (image == null)
throw new AssertionError("Image loading task returned null");
ImageView imageView = new ImageView(image);
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
if (target != null) {
FXUtils.onClicked(imagePane, () -> onClickHyperlink.accept(target));
imagePane.setCursor(Cursor.HAND);
FXUtils.onClicked(imageView, () -> onClickHyperlink.accept(target));
imageView.setCursor(Cursor.HAND);
}
}
children.add(imagePane);
children.add(imageView);
return;
} catch (Throwable e) {
LOG.warning("Failed to load image: " + src, e);
@@ -274,203 +209,59 @@ public final class HTMLRenderer {
appendText(alt);
}
private void appendTable(Node table) {
var childElements = ((Element) table).children();
List<Element> captions = new ArrayList<>();
List<Element> head = new ArrayList<>();
List<List<Element>> body = new ArrayList<>();
List<Element> foot = new ArrayList<>();
boolean hasHead = false;
boolean hasBody = childElements.stream().map(Element::nodeName).anyMatch("tbody"::equals);
boolean hasFoot = false;
int columnCount = 0;
for (Element child : childElements) {
switch (child.nodeName()) {
case "caption" -> captions.add(child);
case "thead" -> {
if (hasHead) continue;
hasHead = true;
for (Element e : child.children()) {
if (e.nameIs("tr")) {
head.clear();
head.addAll(
e.children().stream()
.filter(n -> n.nameIs("th") || n.nameIs("td"))
.toList()
);
break;
}
if (e.nameIs("th") || e.nameIs("td")) {
head.add(e);
}
}
columnCount = Math.max(columnCount, head.size());
}
case "tbody" -> {
var rows = child.children().stream()
.filter(e -> e.nameIs("tr"))
.map(e ->
e.children().stream().filter(n -> n.nameIs("th") || n.nameIs("td")).toList())
.filter(row -> !row.isEmpty())
.toList();
body.addAll(rows);
columnCount = Math.max(columnCount, rows.stream().mapToInt(List::size).max().orElse(0));
}
case "tfoot" -> {
if (hasFoot) continue;
hasFoot = true;
for (Element e : child.children()) {
if (e.nameIs("tr")) {
foot.clear();
foot.addAll(
e.children().stream()
.filter(n -> n.nameIs("th") || n.nameIs("td"))
.toList()
);
break;
}
if (e.nameIs("th") || e.nameIs("td")) {
foot.add(e);
}
}
columnCount = Math.max(columnCount, foot.size());
}
case "tr" -> {
if (hasBody) continue;
List<Element> row = child.children().stream()
.filter(n -> n.nameIs("th") || n.nameIs("td"))
.toList();
if (!row.isEmpty()) {
body.add(row);
columnCount = Math.max(columnCount, row.size());
}
}
}
}
head = Lang.copyWithSize(head, columnCount, null);
List<List<Element>> rows = new ArrayList<>(hasFoot ? body.size() + 1 : body.size());
for (List<Element> row : body)
rows.add(Lang.copyWithSize(row, columnCount, null));
if (hasFoot)
rows.add(Lang.copyWithSize(foot, columnCount, null));
TableView<List<Element>> tableView = FXUtils.newAutoHeightTable(FXCollections.observableList(rows));
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
for (int i = 0; i < columnCount; i++) {
int finalI = i;
TableColumn<List<Element>, Element> c = new TableColumn<>();
Element e = head.get(i);
if (e != null) {
var box = new VBox(new HTMLRenderer(onClickHyperlink).appendNode(e).mergeLineBreaks().render());
box.setAlignment(Pos.CENTER_LEFT);
c.setGraphic(box);
}
c.setCellFactory(param -> new TableCell<>() {
@Override
protected void updateItem(Element item, boolean empty) {
if (item == getItem()) return;
super.updateItem(item, empty);
if (item == null) {
setGraphic(null);
} else {
setGraphic(new Pane(new HTMLRenderer(onClickHyperlink).appendNode(item).mergeLineBreaks().render()));
}
}
});
c.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().get(finalI)));
tableView.getColumns().add(c);
}
for (Element caption : captions) {
appendAutoLineBreak("\n\n");
appendChildren(caption);
appendAutoLineBreak("\n");
}
children.add(tableView);
}
private void appendOrderedList(Node node) {
pushNode(node);
int ordinal = StringUtils.toInt(node.attr("start")).orElse(1);
for (Node childNode : node.childNodes()) {
if (childNode.nameIs("li")) {
ordinal = StringUtils.toInt(childNode.attr("value")).orElse(ordinal);
appendAutoLineBreak("\n");
appendText(" " + " ".repeat(listDepth) + ordinal++ + ". ");
appendChildren(childNode);
continue;
}
appendNode(childNode);
}
popNode();
}
private void appendChildren(Node node) {
if (node.childNodeSize() > 0) {
if (node.nameIs("table")) {
appendTable(node);
} else if (node.nameIs("ol")) {
appendOrderedList(node);
} else {
pushNode(node);
for (Node childNode : node.childNodes()) {
appendNode(childNode);
}
popNode();
}
}
}
public HTMLRenderer appendNode(Node node) {
if (node instanceof TextNode n) {
appendText(preformatted ? n.getWholeText() : normaliseWhitespace(n.getWholeText()));
public void appendNode(Node node) {
if (node instanceof TextNode) {
appendText(((TextNode) node).text());
}
String name = node.nodeName();
switch (name) {
case "img" -> {
if (!children.isEmpty())
appendAutoLineBreak("\n");
case "img":
appendImage(node);
appendAutoLineBreak("\n");
}
case "li" -> {
appendAutoLineBreak("\n");
appendText(" " + " ".repeat(listDepth) + "\u2022 ");
}
case "dt" -> appendText(" ");
case "p" -> {
var n = node.parent();
if (!children.isEmpty() && (n == null || !n.nameIs("li")))
appendAutoLineBreak("\n\n");
}
case "h1", "h2", "h3", "h4", "h5", "h6" -> {
break;
case "li":
appendText("\n" + INDENT.repeat(indentLevel) + " \u2022 ");
break;
case "dt":
appendText(" ");
break;
case "p":
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
case "tr":
if (!children.isEmpty())
appendAutoLineBreak("\n\n");
}
case "hr" -> {
appendAutoLineBreak("\n\n");
this.children.add(new Separator());
}
break;
}
appendChildren(node);
if (node.childNodeSize() > 0) {
boolean isLiNode = "li".equals(name);
if (isLiNode) indentLevel++;
pushNode(node);
for (Node childNode : node.childNodes()) {
appendNode(childNode);
}
popNode();
if (isLiNode) indentLevel--;
}
switch (name) {
case "br", "dd", "h1", "h2", "h3", "h4", "h5", "h6" -> appendAutoLineBreak("\n");
case "p" -> {
var n = node.parent();
if (n == null || !n.nameIs("li"))
appendAutoLineBreak("\n");
}
case "br":
case "dd":
case "p":
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
appendAutoLineBreak("\n");
break;
}
return this;
}
private static boolean isSpacing(String text) {
@@ -485,7 +276,7 @@ public final class HTMLRenderer {
return true;
}
public HTMLRenderer mergeLineBreaks() {
public void mergeLineBreaks() {
for (int i = 0; i < this.children.size(); i++) {
javafx.scene.Node child = this.children.get(i);
if (child instanceof AutoLineBreak) {
@@ -496,7 +287,7 @@ public final class HTMLRenderer {
if (otherChild instanceof AutoLineBreak) {
lastAutoLineBreak = j;
} else if (otherChild instanceof Text txt && isSpacing(txt.getText())) {
} else if (otherChild instanceof Text && isSpacing(((Text) otherChild).getText())) {
// do nothing
} else {
break;
@@ -512,58 +303,12 @@ public final class HTMLRenderer {
}
}
}
{
// Remove empty lines at the beginning
int size = children.size();
for (int i = 0; i < size; i++) {
var child = children.get(i);
if (child instanceof AutoLineBreak || child instanceof Text txt && isSpacing(txt.getText())) {
// NO-OP
} else {
this.children.subList(0, i).clear();
break;
}
}
}
{
// Remove empty lines and spaces at the end
int size = children.size();
for (int i = size - 1; i > -1; i--) {
var child = children.get(i);
if (child instanceof AutoLineBreak || child instanceof Text txt && isSpacing(txt.getText())) {
// NO-OP
} else {
this.children.subList(i + 1, size).clear();
break;
}
}
}
return this;
}
public TextFlow render() {
if (rendered) throw new IllegalStateException("Should not render twice");
rendered = true;
TextFlow textFlow = new TextFlow();
textFlow.getStyleClass().add("html");
textFlow.getChildren().setAll(children);
for (javafx.scene.Node node : children) {
if (node instanceof ImagePane imgPane) {
ImageView img = imgPane.imageView;
InvalidationListener listener = __ -> img.setFitWidth(Math.min(textFlow.getWidth() * 0.9, img.getImage() == null ? 0D : img.getImage().getWidth()));
textFlow.widthProperty().addListener(listener);
img.imageProperty().addListener(listener);
listener.invalidated(null);
} else if (node instanceof TableView<?> table) {
table.prefWidthProperty().bind(textFlow.widthProperty().multiply(0.8));
} else if (node instanceof CodeFlow codeFlow) {
codeFlow.maxWidthProperty().bind(textFlow.widthProperty().multiply(0.8));
} else if (node instanceof Separator separator) {
separator.prefWidthProperty().bind(textFlow.widthProperty().subtract(24));
}
}
return textFlow;
}
@@ -572,62 +317,4 @@ public final class HTMLRenderer {
super(text);
}
}
private static final class ImagePane extends StackPane {
public final ImageView imageView;
public ImagePane(ImageView imageView, @Nullable String alt) {
super();
this.imageView = imageView;
var txt = new Text(alt);
if (StringUtils.isNotBlank(alt)) getChildren().add(txt);
getChildren().add(imageView);
FXUtils.onChangeAndOperate(imageView.imageProperty(), img -> {
if (img != null) getChildren().remove(txt);
});
}
}
private static final class CodeFlow extends TextFlow {
public CodeFlow(Text text) {
super(text);
}
}
private static final class SimpleCssDeclarations {
@Unmodifiable
private static final Map<String, String> cssPropertyMapping = Map.of(
"color", "-fx-fill",
"font-size", "-fx-font-size"
);
private final Map<String, String> declarations = new HashMap<>();
public void add(String inlineCss) {
if (StringUtils.isBlank(inlineCss)) return;
Arrays.stream(inlineCss.split(";")).filter(StringUtils::isNotBlank).forEach(s -> {
String[] declaration = s.split(":", 2);
if (declaration.length != 2) return;
declaration[0] = declaration[0].trim();
declaration[1] = declaration[1].trim();
String mappedKey = cssPropertyMapping.get(declaration[0].toLowerCase(Locale.ROOT));
if (mappedKey != null) {
declarations.put(mappedKey, declaration[1]);
} // Ignore unsupported fields
});
}
public String asString() {
StringBuilder sb = new StringBuilder();
declarations.forEach((property, value) -> sb.append(property).append(": ").append(value).append(";"));
return sb.toString();
}
}
}
@@ -34,23 +34,27 @@ import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author huangyuhui
*/
public class InstallerItem extends Control {
private final GameComponentType type;
private final GameInstanceIconType iconType;
private final String id;
private final VersionIconType 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);
@@ -79,14 +83,30 @@ public class InstallerItem extends Control {
CARD,
}
public InstallerItem(GameComponentType type, Style style) {
this.type = type;
this.style = style;
this.iconType = GameInstanceIconType.getIconType(type);
public InstallerItem(LibraryAnalyzer.LibraryType id, Style style) {
this(id.getPatchId(), style);
}
public GameComponentType getComponentType() {
return type;
public InstallerItem(String id, Style style) {
this.id = id;
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;
default -> null;
};
}
public String getLibraryId() {
return id;
}
public ObjectProperty<InstalledState> versionProperty() {
@@ -129,7 +149,7 @@ public class InstallerItem extends Control {
public final static class InstallerItemGroup {
private final InstallerItem game;
private final InstallerItem[] components;
private final InstallerItem[] libraries;
private Set<InstallerItem> getIncompatibles(Map<InstallerItem, Set<InstallerItem>> incompatibleMap, InstallerItem item) {
return incompatibleMap.computeIfAbsent(item, it -> new HashSet<>());
@@ -155,19 +175,19 @@ public class InstallerItem extends Control {
}
}
public InstallerItemGroup(GameVersionNumber gameVersion, Style style) {
game = new InstallerItem(GameComponentType.GAME, style);
InstallerItem fabric = new InstallerItem(GameComponentType.FABRIC, style);
InstallerItem fabricApi = new InstallerItem(GameComponentType.FABRIC_API, style);
InstallerItem forge = new InstallerItem(GameComponentType.FORGE, style);
InstallerItem cleanroom = new InstallerItem(GameComponentType.CLEANROOM, style);
InstallerItem legacyfabric = new InstallerItem(GameComponentType.LEGACY_FABRIC, style);
InstallerItem legacyfabricApi = new InstallerItem(GameComponentType.LEGACY_FABRIC_API, style);
InstallerItem neoForge = new InstallerItem(GameComponentType.NEO_FORGE, style);
InstallerItem liteLoader = new InstallerItem(GameComponentType.LITELOADER, style);
InstallerItem optiFine = new InstallerItem(GameComponentType.OPTIFINE, style);
InstallerItem quilt = new InstallerItem(GameComponentType.QUILT, style);
InstallerItem quiltApi = new InstallerItem(GameComponentType.QUILT_API, style);
public InstallerItemGroup(String gameVersion, Style style) {
game = new InstallerItem(MINECRAFT, style);
InstallerItem fabric = new InstallerItem(FABRIC, style);
InstallerItem fabricApi = new InstallerItem(FABRIC_API, style);
InstallerItem forge = new InstallerItem(FORGE, style);
InstallerItem cleanroom = new InstallerItem(CLEANROOM, style);
InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style);
InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style);
InstallerItem neoForge = new InstallerItem(NEO_FORGE, style);
InstallerItem liteLoader = new InstallerItem(LITELOADER, style);
InstallerItem optiFine = new InstallerItem(OPTIFINE, style);
InstallerItem quilt = new InstallerItem(QUILT, style);
InstallerItem quiltApi = new InstallerItem(QUILT_API, style);
Map<InstallerItem, Set<InstallerItem>> incompatibleMap = new HashMap<>();
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric);
@@ -197,7 +217,7 @@ public class InstallerItem extends Control {
for (InstallerItem other : incompatibleItems) {
InstalledState otherVersion = other.versionProperty.get();
if (otherVersion != null) {
return new IncompatibleState(other.type.getPatchId(), otherVersion.version);
return new IncompatibleState(other.id, otherVersion.version);
}
}
@@ -206,7 +226,7 @@ public class InstallerItem extends Control {
}
if (gameVersion != null) {
game.versionProperty.set(new InstalledState(gameVersion.toString(), false, false));
game.versionProperty.set(new InstalledState(gameVersion, false, false));
}
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom};
@@ -215,19 +235,22 @@ public class InstallerItem extends Control {
if (!item.resolvedStateProperty.isBound()) {
item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> {
InstalledState itemVersion = item.versionProperty.get();
return Objects.requireNonNullElse(itemVersion, InstallableState.INSTANCE);
if (itemVersion != null) {
return itemVersion;
}
return InstallableState.INSTANCE;
}, item.versionProperty));
}
}
if (gameVersion == null) {
this.components = all;
} else if (gameVersion.compareTo("1.12.2") == 0) {
this.components = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine};
} else if (gameVersion.compareTo("1.13.2") <= 0) {
this.components = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi};
this.libraries = all;
} else if (gameVersion.equals("1.12.2")) {
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine};
} else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) {
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi};
} else {
this.components = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi};
this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi};
}
}
@@ -235,8 +258,8 @@ public class InstallerItem extends Control {
return game;
}
public InstallerItem[] getComponents() {
return components;
public InstallerItem[] getLibraries() {
return libraries;
}
}
@@ -286,7 +309,7 @@ public class InstallerItem extends Control {
nameLabel.getStyleClass().add("installer-item-name");
nameLabel.setMouseTransparent(true);
pane.getChildren().add(nameLabel);
nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.type.getPatchId()) ? i18n("install.installer." + control.type.getPatchId()) : control.type.getPatchId());
nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.id) ? i18n("install.installer." + control.id) : control.id);
HBox.setMargin(nameLabel, new Insets(0, 4, 0, 4));
Label statusLabel = new Label();
@@ -332,7 +355,7 @@ public class InstallerItem extends Control {
pane.getChildren().add(buttonsContainer);
JFXButton removeButton = FXUtils.newToggleButton4(SVG.CLOSE);
if (control.type == GameComponentType.GAME) {
if (control.id.equals(MINECRAFT.getPatchId())) {
removeButton.setVisible(false);
} else {
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
@@ -40,6 +40,7 @@ import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.CircularArrayList;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
@@ -51,7 +52,10 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
@@ -100,8 +104,6 @@ public final class LogWindow extends Stage {
}
this.gameProcess = gameProcess;
FXUtils.addMacOSCloseWindowHandler(this, () -> !gameProcess.isRunning());
}
public void logLine(Log log) {
@@ -162,7 +164,6 @@ 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);
@@ -173,6 +174,7 @@ 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())));
@@ -322,33 +324,18 @@ public final class LogWindow extends Stage {
listView.scrollTo(listView.getItems().size() - 1);
});
listView.setStyle("-fx-font-family: \"" + Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)
listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)
+ "\"; -fx-font-size: " + settings().logFontSizeProperty().get() + "px;");
listView.setCellFactory(x -> new ListCell<>() {
{
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
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();
});
maxWidthProperty().bind(clippedContainer.widthProperty());
prefWidthProperty().bind(clippedContainer.widthProperty());
}
setPadding(new Insets(2));
wrapTextProperty().bind(control.wrapText);
setWrapText(true);
setGraphic(null);
}
@@ -408,10 +395,6 @@ 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());
@@ -431,7 +414,7 @@ public final class LogWindow extends Stage {
JFXButton clearButton = new JFXButton(i18n("button.clear"));
clearButton.setOnAction(e -> getSkinnable().onClear());
hBox.getChildren().setAll(autoScrollCheckBox, wrapTextCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton);
control.getGameProcess().getProcess()
.onExit()
@@ -18,8 +18,6 @@
package org.jackhuang.hmcl.ui;
import com.sun.jna.Pointer;
import javafx.beans.property.ReadOnlyBooleanProperty;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.platform.macos.ObjectiveCRuntime;
import org.jetbrains.annotations.Nullable;
@@ -58,30 +56,8 @@ public final class MacOSNativeUtils {
return pointer == null || Pointer.nativeValue(pointer) == 0;
}
private static final ReadOnlyBooleanProperty autoBrightness = Themes.autoBrightnessProperty();
static {
FXUtils.onChange(autoBrightness, b -> setAppearance(Themes.darkModeProperty().get()));
}
public static void setAppearance(boolean dark) {
if (autoBrightness.get()) {
clearAppearance();
} else {
setAppearance(dark, false);
}
}
public static void clearAppearance() {
if (nsApp == null) return;
try {
var objc = ObjectiveCRuntime.INSTANCE;
Pointer setSel = objc.sel_registerName("setAppearance:");
objc.objc_msgSend(nsApp, setSel, Pointer.NULL);
} catch (Throwable t) {
LOG.warning("Failed to set macOS appearance", t);
}
setAppearance(dark, false);
}
public static void setAppearance(boolean dark, boolean highContrast) {
@@ -24,7 +24,7 @@ import javafx.scene.shape.SVGPath;
/// All vector icons used in the launcher.
///
/// Unless otherwise stated,
/// these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.set=Material+Symbols">Material Symbols</a>,
/// these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.query=list&icon.set=Material+Symbols">Material Symbols</a>,
/// with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px.
/// The view boxes of all icons are normalized to `0 0 24 24`.
public enum SVG {
@@ -49,7 +49,6 @@ public enum SVG {
CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"),
CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v3H7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"),
CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"),
DATE_RANGE("M7.2875 13.7125Q7 13.425 7 13t0.2875 -0.7125Q7.575 12 8 12t0.7125 0.2875Q9 12.575 9 13t-0.2875 0.7125Q8.425 14 8 14t-0.7125 -0.2875zm4 0Q11 13.425 11 13t0.2875 -0.7125Q11.575 12 12 12t0.7125 0.2875Q13 12.575 13 13t-0.2875 0.7125Q12.425 14 12 14t-0.7125 -0.2875zm4 0Q15 13.425 15 13t0.2875 -0.7125Q15.575 12 16 12t0.7125 0.2875Q17 12.575 17 13t-0.2875 0.7125Q16.425 14 16 14t-0.7125 -0.2875zM5 22q-0.825 0 -1.4125 -0.5875T3 20l0 -14q0 -0.825 0.5875 -1.4125T5 4l1 0l0 -2l2 0l0 2l8 0l0 -2l2 0l0 2l1 0q0.825 0 1.4125 0.5875T21 6l0 14q0 0.825 -0.5875 1.4125T19 22L5 22zm0 -2l14 0l0 -10L5 10l0 10zm0 -12l14 0l0 -2L5 6l0 2zm0 0l0 -2 0 2z"),
DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"),
DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"),
DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"),
@@ -57,7 +56,6 @@ public enum SVG {
DOWNLOAD("M12 16 7 11 8.4 9.55 11 12.15V4H13V12.15L15.6 9.55 17 11 12 16ZM6 20Q5.175 20 4.5875 19.4125T4 18V15H6V18H18V15H20V18Q20 18.825 19.4125 19.4125T18 20H6Z"),
DRESSER("M4 21V5Q4 4.175 4.5875 3.5875T6 3H18Q18.825 3 19.4125 3.5875T20 5V21H18V19H6V21H4ZM6 11H11V5H6V11ZM13 7H18V5H13V7ZM13 11H18V9H13V11ZM10 16H14V14H10V16ZM6 13V17H18V13H6ZM6 13V17 13Z"),
EDIT("M5 19H6.425L16.2 9.225 14.775 7.8 5 17.575V19ZM3 21V16.75L16.2 3.575Q16.5 3.3 16.8625 3.15T17.625 3Q18.025 3 18.4 3.15T19.05 3.6L20.425 5Q20.725 5.275 20.8625 5.65T21 6.4Q21 6.8 20.8625 7.1625T20.425 7.825L7.25 21H3ZM19 6.4 17.6 5 19 6.4ZM15.475 8.525 14.775 7.8 16.2 9.225 15.475 8.525Z"),
EDIT_CALENDAR("M5 22q-0.825 0 -1.4125 -0.5875T3 20l0 -14q0 -0.825 0.5875 -1.4125T5 4l1 0l0 -2l2 0l0 2l8 0l0 -2l2 0l0 2l1 0q0.825 0 1.4125 0.5875T21 6l0 5l-2 0l0 -1L5 10l0 10l7 0l0 2L5 22zm0 -14l14 0l0 -2L5 6l0 2zm0 0l0 -2 0 2zM14 22l0 -3.075l5.525 -5.5q0.225 -0.225 0.5 -0.325t0.55 -0.1q0.3 0 0.575 0.1125t0.5 0.3375l0.925 0.925q0.2 0.225 0.3125 0.5t0.1125 0.55q0 0.275 -0.1 0.5625T22.575 16.5L17.075 22L14 22zm7.5 -6.575 -0.925 -0.925 0.925 0.925zM15.5 20.5l0.95 0l3.025 -3.05 -0.45 -0.475 -0.475 -0.45 -3.05 3.025l0 0.95zm3.525 -3.525 -0.475 -0.45 0.925 0.925 -0.45 -0.475z"),
ERROR("M12 17Q12.425 17 12.7125 16.7125T13 16Q13 15.575 12.7125 15.2875T12 15Q11.575 15 11.2875 15.2875T11 16Q11 16.425 11.2875 16.7125T12 17ZM11 13H13V7H11V13ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
EXPLORE("M12 12Zm0 8q-3.325 0-5.6625-2.3375T4 12Q4 8.675 6.3375 6.3375T12 4q3.325-0 5.6625 2.3375T20 12q0 3.325-2.3375 5.6625T12 20Zm0 2q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22Zm0-8.5q.625 0 1.0625-.4375T13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5Zm-4.5 3 2-7 7-2-2 7-7 2Z"),
EXTENSION("M8.8 21H5Q4.175 21 3.5875 20.4125T3 19V15.2Q4.2 15.2 5.1 14.4375T6 12.5Q6 11.325 5.1 10.5625T3 9.8V6Q3 5.175 3.5875 4.5875T5 4H9Q9 2.95 9.725 2.225T11.5 1.5Q12.55 1.5 13.275 2.225T14 4H18Q18.825 4 19.4125 4.5875T20 6V10Q21.05 10 21.775 10.725T22.5 12.5Q22.5 13.55 21.775 14.275T20 15V19Q20 19.825 19.4125 20.4125T18 21H14.2Q14.2 19.75 13.4125 18.875T11.5 18Q10.375 18 9.5875 18.875T8.8 21ZM5 19H7.125Q7.725 17.35 9.05 16.675T11.5 16Q12.625 16 13.95 16.675T15.875 19H18V13H20Q20.2 13 20.35 12.85T20.5 12.5Q20.5 12.3 20.35 12.15T20 12H18V6H12V4Q12 3.8 11.85 3.65T11.5 3.5Q11.3 3.5 11.15 3.65T11 4V6H5V8.2Q6.35 8.7 7.175 9.875T8 12.5Q8 13.925 7.175 15.1T5 16.8V19ZM11.5 12.5Z"),
@@ -68,7 +66,6 @@ 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"),
@@ -111,7 +108,6 @@ public enum SVG {
SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"),
SCHEMA_FILL("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Z"),
SCREENSHOT_MONITOR("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8ZM4 17H20V5H4V17ZM4 17V5 17Z"),
SCREENSHOT_MONITOR_FILL("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8Z"),
SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material
SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"),
SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"),
@@ -23,7 +23,6 @@ import javafx.animation.Timeline;
import javafx.scene.Parent;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
/// A lightweight wrapper for displaying [SVG] icons.
@@ -109,7 +108,7 @@ public final class SVGContainer extends Parent {
SVG oldIcon = this.icon;
this.icon = newIcon;
if (animationDuration.equals(Duration.ZERO) || !AnimationUtils.isAnimationEnabled()) {
if (animationDuration.equals(Duration.ZERO)) {
path.setContent(newIcon.getPath());
path.setOpacity(1);
if (getChildren().size() != 1)
@@ -20,11 +20,8 @@ package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import javafx.geometry.Insets;
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;
@@ -35,9 +32,10 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
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;
@@ -46,12 +44,10 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class UpgradeDialog extends JFXDialogLayout {
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getScene().widthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7));
Label heading = new Label(i18n("update.changelog"));
heading.setPadding(new Insets(0, 0, 8, 0));
setHeading(heading);
setHeading(new Label(i18n("update.changelog")));
setBody(new JFXSpinner());
String url = CHANGELOG_URL + remoteVersion.channel().channelName + ".html";
@@ -63,21 +59,19 @@ public final class UpgradeDialog extends JFXDialogLayout {
// Downgrade update, no need to display changelog
return null;
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
Document document = Jsoup.parse(new URL(url), 30 * 1000);
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
if (node == null || !"h1".equals(node.nodeName())) {
LOG.warning("Changelog not found");
return null;
}
HTMLRenderer renderer = new HTMLRenderer(uri -> {
LOG.info("Open link: " + uri);
FXUtils.openLink(uri.toString());
});
if (node == null || !"h1".equals(node.nodeName())) {
LOG.warning("Changelog not found");
Element list = new Element("ul");
list.appendElement("li").text(i18n("update.changelog.empty"));
return renderer.appendNode(list).mergeLineBreaks().render();
}
do {
if ("h1".equals(node.nodeName())) {
String changelogVersion = node.attr("data-version");
@@ -47,7 +47,11 @@ public final class WebPage extends SpinnerPane implements DecoratorPage {
Task.supplyAsync(() -> {
Document document = Jsoup.parseBodyFragment(content);
HTMLRenderer renderer = new HTMLRenderer(Controllers::openUriOrCopy);
HTMLRenderer renderer = new HTMLRenderer(uri -> {
Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> {
FXUtils.openLink(uri.toString());
}, null);
});
renderer.appendNode(document);
renderer.mergeLineBreaks();
return renderer.render();
@@ -19,22 +19,10 @@ 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;
@@ -67,145 +55,6 @@ 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,6 +43,7 @@ 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;
@@ -150,7 +151,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 = Controllers.showOpenDialog(chooser);
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (selectedFile == null) {
return null;
}
@@ -18,7 +18,13 @@
package org.jackhuang.hmcl.ui.account;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
@@ -41,6 +47,7 @@ import org.jackhuang.hmcl.ui.construct.ClassTitle;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
@@ -49,6 +56,7 @@ import java.util.Locale;
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage {
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
@@ -165,7 +173,13 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
item.setSubtitle(server.getDisplayHostUrl());
String host = "";
try {
host = NetworkUtils.toURI(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
}
item.subtitleProperty().set(host);
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
FXUtils.installFastTooltip(item, tooltip);
@@ -67,10 +67,11 @@ import static java.util.Collections.emptyList;
import static java.util.Collections.unmodifiableList;
import static javafx.beans.binding.Bindings.bindContent;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor;
public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
private static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$");
@@ -387,22 +388,20 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
add(lblServers, 0, rowIndex);
cboServers = new JFXComboBox<>();
cboServers.setConverter(stringConverter(it -> String.format("%s (%s)", it.getName(), it.getDisplayHostUrl())));
cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl())));
cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName));
bindContent(cboServers.getItems(), getAuthlibInjectorServers());
cboServers.getItems().addListener(onInvalidating(
() -> Platform.runLater( // the selection will not be updated as expected if we call it immediately
cboServers.getSelectionModel()::selectFirst)));
cboServers.getSelectionModel().selectFirst();
Label noServersLabel = new Label(i18n("account.injector.empty"));
cboServers.setPromptText(i18n("account.injector.empty"));
BooleanBinding noServers = createBooleanBinding(cboServers.getItems()::isEmpty, cboServers.getItems());
classPropertyFor(cboServers, "jfx-combo-box-warning").bind(noServers);
classPropertyFor(cboServers, "jfx-combo-box").bind(noServers.not());
HBox.setHgrow(cboServers, Priority.ALWAYS);
HBox.setMargin(cboServers, new Insets(0, 10, 0, 0));
cboServers.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(noServersLabel, Priority.ALWAYS);
HBox.setMargin(noServersLabel, new Insets(0, 10, 0, 0));
noServersLabel.setMaxWidth(Double.MAX_VALUE);
HBox linksContainer = new HBox();
linksContainer.setAlignment(Pos.CENTER);
@@ -420,13 +419,7 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
Controllers.dialog(new AddAuthlibInjectorServerPane());
});
noServersLabel.visibleProperty().bind(noServers);
noServersLabel.managedProperty().bind(noServers);
cboServers.visibleProperty().bind(noServers.not());
cboServers.managedProperty().bind(noServers.not());
HBox boxServers = new HBox(cboServers, noServersLabel, linksContainer, btnAddServer);
boxServers.setAlignment(Pos.CENTER_LEFT);
HBox boxServers = new HBox(cboServers, linksContainer, btnAddServer);
add(boxServers, 1, rowIndex);
rowIndex++;
@@ -29,7 +29,9 @@ import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.SVGPath;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthInfo;
@@ -47,10 +49,10 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.QrCodeUtils;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
@@ -139,22 +141,20 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
setBody(rootContainer);
rootContainer.setAlignment(Pos.TOP_CENTER);
StackPane warningPane = new StackPane();
if (!IntegrityChecker.isOfficial()) {
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHintPane.setSegment(i18n("unofficial.hint"));
warningPane.getChildren().add(unofficialHintPane);
} else if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
var snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
snapshotHint.setSegment(i18n("account.methods.microsoft.snapshot"));
warningPane.getChildren().add(snapshotHint);
rootContainer.getChildren().add(snapshotHint);
btnLogin.setDisable(true);
loginButtonSpinner.setLoading(false);
return;
}
rootContainer.getChildren().add(warningPane);
if (!IntegrityChecker.isOfficial()) {
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHintPane.setSegment(i18n("unofficial.hint"));
rootContainer.getChildren().add(unofficialHintPane);
}
if (currentStep instanceof Step.Init) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
@@ -198,20 +198,6 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
} else if (currentStep instanceof Step.WaitForScanQrCode wait) {
loginButtonSpinner.setLoading(true);
VBox leftVBox = new VBox(6);
leftVBox.setAlignment(Pos.CENTER);
VBox rightVBox = new VBox(6);
HBox hbox = new HBox(leftVBox, rightVBox);
leftVBox.prefWidthProperty().bind(hbox.widthProperty().divide(2));
rightVBox.prefWidthProperty().bind(hbox.widthProperty().divide(2));
leftVBox.setMaxWidth(Double.MAX_VALUE);
rightVBox.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(leftVBox, Priority.ALWAYS);
HBox.setHgrow(rightVBox, Priority.ALWAYS);
String scanUri = "https://www.microsoft.com/link".equals(wait.verificationUri())
? "https://www.microsoft.com/link?otc=" + wait.userCode()
: wait.verificationUri();
@@ -231,7 +217,7 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
var lblCode = new Label(wait.userCode());
lblCode.getStyleClass().add("code-label");
lblCode.setStyle("-fx-font-family: \"" + Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
var codeBox = new StackPane(lblCode);
codeBox.getStyleClass().add("code-box");
@@ -239,10 +225,7 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
FXUtils.onClicked(codeBox, () -> FXUtils.copyText(wait.userCode()));
codeBox.setMaxWidth(USE_PREF_SIZE);
leftVBox.getChildren().setAll(new Group(qrCode), codeBox);
rightVBox.getChildren().setAll(warningPane, deviceHint);
rootContainer.getChildren().setAll(hbox);
rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox);
} else if (currentStep instanceof Step.DeviceLoginCompleted) {
loginButtonSpinner.setLoading(true);
@@ -37,7 +37,7 @@ public final class AnimationUtils {
public static void init() {
}
private static final boolean ENABLED = !settings().isAnimationDisabled();
private static final boolean ENABLED = !settings().animationDisabledProperty().get();
private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD();
public static boolean isAnimationEnabled() {
@@ -19,11 +19,15 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXProgressBar;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -34,11 +38,18 @@ public class DialogPane extends JFXDialogLayout {
protected final SpinnerPane acceptPane = new SpinnerPane();
protected final JFXButton cancelButton = new JFXButton();
protected final Label warningLabel = new Label();
private final JFXProgressBar progressBar = new JFXProgressBar();
public DialogPane() {
Label titleLabel = new Label();
titleLabel.textProperty().bind(title);
setHeading(titleLabel);
getChildren().add(progressBar);
progressBar.setVisible(false);
StackPane.setMargin(progressBar, new Insets(-24.0D, -24.0D, -16.0D, -24.0D));
StackPane.setAlignment(progressBar, Pos.TOP_CENTER);
progressBar.setMaxWidth(Double.MAX_VALUE);
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
acceptButton.setOnAction(e -> onAccept());
@@ -55,6 +66,10 @@ public class DialogPane extends JFXDialogLayout {
setActions(warningLabel, acceptPane, cancelButton);
}
protected JFXProgressBar getProgressBar() {
return progressBar;
}
public String getTitle() {
return title.get();
}
@@ -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 = Controllers.showOpenDialog(chooser);
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
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 = Controllers.showDialog(chooser);
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
if (dir != null) {
String path = FileUtils.getAbsolutePath(dir);
customField.setText(path);
@@ -39,7 +39,7 @@ public final class FontComboBox extends JFXComboBox<String> {
styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\""));
setCellFactory(listView -> new JFXListCell<>() {
setCellFactory(listView -> new JFXListCell<String>() {
@Override
public void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
@@ -64,33 +64,6 @@ public class ImageContainer extends StackPane {
this.getChildren().setAll(imageView);
}
public ImageContainer(Image image, double maxWidth, double maxHeight) {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
double width = maxWidth;
double height = width / image.getWidth() * image.getHeight();
if (height > maxHeight) {
height = maxHeight;
width = height / image.getHeight() * image.getWidth();
}
FXUtils.setLimitWidth(this, width);
FXUtils.setLimitHeight(this, height);
imageView.setPreserveRatio(true);
FXUtils.limitSize(imageView, width, height);
StackPane.setAlignment(imageView, Pos.CENTER);
clip.setWidth(width);
clip.setHeight(height);
updateCornerRadius(getCornerRadius());
this.setClip(clip);
setImage(image);
this.getChildren().setAll(imageView);
}
private void updateCornerRadius(double radius) {
clip.setArcWidth(radius);
clip.setArcHeight(radius);
@@ -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.Window;
import javafx.stage.Stage;
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,9 +57,7 @@ public class LineFileChooserButton extends LineButton {
public void fire() {
super.fire();
Scene scene = this.getScene();
Window owner = scene != null ? scene.getWindow() : null; // TODO: Allow user to set owner stage
Stage owner = Controllers.getStage(); // TODO: Allow user to set owner stage
String windowTitle = getFileChooserTitle();
Path initialDirectory = null;
@@ -21,18 +21,15 @@ import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.DoubleBinding;
import javafx.css.PseudoClass;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
public abstract class MDListCell<T> extends ListCell<T> {
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private static final PseudoClass LAST = PseudoClass.getPseudoClass("last");
private final StackPane container = new StackPane();
private final StackPane root = new StackPane();
private final RipplerContainer ripplerContainer = new RipplerContainer(container);
public MDListCell(JFXListView<T> listView) {
@@ -40,6 +37,7 @@ public abstract class MDListCell<T> extends ListCell<T> {
setGraphic(null);
root.getStyleClass().add("md-list-cell");
RipplerContainer ripplerContainer = new RipplerContainer(container);
root.getChildren().setAll(ripplerContainer);
Region clippedContainer = (Region) listView.lookup(".clipped-container");
@@ -60,32 +58,14 @@ public abstract class MDListCell<T> extends ListCell<T> {
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) {
updateLastPseudoClass(empty);
return;
}
ripplerContainer.releaseRippleImmediately();
if (oldItem == item && oldEmpty == empty) return;
updateControl(item, empty);
if (empty || item == null) {
if (empty) {
setGraphic(null);
} else {
setGraphic(root);
}
updateLastPseudoClass(empty || item == null);
}
/// Updates the `:last` pseudo-class so the trailing divider can be suppressed in CSS.
///
/// @param empty whether this cell is empty
private void updateLastPseudoClass(boolean empty) {
ListView<T> listView = getListView();
boolean last = !empty
&& listView != null
&& getIndex() >= 0
&& getIndex() == listView.getItems().size() - 1;
root.pseudoClassStateChanged(LAST, last);
}
protected StackPane getContainer() {
@@ -98,9 +78,5 @@ public abstract class MDListCell<T> extends ListCell<T> {
});
}
protected void onClicked(Runnable action) {
FXUtils.onClicked(ripplerContainer, action);
}
protected abstract void updateControl(T item, boolean empty);
}
@@ -205,10 +205,6 @@ public class RipplerContainer extends StackPane {
ripplerFillProperty().set(ripplerFill);
}
public void releaseRippleImmediately() {
buttonRippler.releaseRippleImmediately();
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
@@ -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,13 +21,11 @@ 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.*;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.Controllers;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -69,27 +67,25 @@ public class TaskExecutorDialogPane extends BorderPane {
center.getChildren().setAll(lblTitle, taskListPane);
}
HBox bottom = new HBox();
bottom.setAlignment(Pos.CENTER_LEFT);
bottom.setPadding(new Insets(0, 8, 8, 8));
bottom.setSpacing(8);
BorderPane bottom = new BorderPane();
this.setBottom(bottom);
bottom.setPadding(new Insets(0, 8, 8, 8));
{
lblProgress = new Label();
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
bottom.setLeft(lblProgress);
btnCancel = new JFXButton(i18n("button.cancel"));
btnCancel.getStyleClass().add("dialog-cancel");
bottom.getChildren().setAll(lblProgress, spacer, btnCancel);
bottom.setRight(btnCancel);
}
setCancel(cancel);
btnCancel.setDisable(!onCancel.isEnabled());
btnCancel.setDisable(onCancel.getCancellationAction() == null);
btnCancel.setOnAction(e -> {
if (executor != null)
executor.cancel();
onCancel.accept(this);
onCancel.getCancellationAction().accept(this);
});
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
@@ -114,15 +110,7 @@ public class TaskExecutorDialogPane extends BorderPane {
executor.addTaskListener(new TaskListener() {
@Override
public void onStop(boolean success, TaskExecutor executor) {
Platform.runLater(() -> {
fireEvent(new DialogCloseEvent());
if (Controllers.AUTO_TRIM_HEAP) {
// Trim heap memory when tasks finish, allowing GC to return memory to the OS faster,
// prevent excessive peak memory from consuming system resources.
Platform.runLater(Controllers::trimHeap);
}
});
Platform.runLater(() -> fireEvent(new DialogCloseEvent()));
}
});
}
@@ -33,16 +33,45 @@ import javafx.scene.control.ListCell;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameInstallTask;
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltInstallTask;
import org.jackhuang.hmcl.game.HMCLModpackInstallTask;
import org.jackhuang.hmcl.java.JavaInstallTask;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask;
import org.jackhuang.hmcl.modpack.curse.CurseInstallTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthInstallTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackLocalInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.util.Collection;
@@ -133,16 +162,58 @@ public final class TaskListPane extends StackPane {
if (!task.getSignificance().shouldShow() || task.getName() == null)
return;
if (task instanceof GameInstallTask
&& task.getInheritedStage() != null
&& task.getInheritedStage().startsWith("hmcl.install.game")) {
return;
if (task instanceof GameAssetDownloadTask) {
task.setName(i18n("assets.download_all"));
} else if (task instanceof GameInstallTask) {
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
return;
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
} else if (task instanceof CleanroomInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
} else if (task instanceof LegacyFabricInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric")));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.neoforge")));
} else if (task instanceof LiteLoaderInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.liteloader")));
} else if (task instanceof OptiFineInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.optifine")));
} else if (task instanceof FabricInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.fabric")));
} else if (task instanceof FabricAPIInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.fabric-api")));
} else if (task instanceof QuiltInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.quilt")));
} else if (task instanceof QuiltAPIInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.quilt-api")));
} else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) {
task.setName(i18n("modpack.completion"));
} else if (task instanceof ModpackInstallTask) {
task.setName(i18n("modpack.installing"));
} else if (task instanceof ModpackUpdateTask) {
task.setName(i18n("modpack.update"));
} else if (task instanceof CurseInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.curse")));
} else if (task instanceof MultiMCModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.multimc")));
} else if (task instanceof ModrinthInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.modrinth")));
} else if (task instanceof ServerModpackLocalInstallTask) {
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
task.setName(i18n("modpack.export"));
} else if (task instanceof MinecraftInstanceTask) {
task.setName(i18n("modpack.scan"));
} else if (task instanceof MojangJavaDownloadTask) {
task.setName(i18n("download.java"));
} else if (task instanceof JavaInstallTask) {
task.setName(i18n("java.installing"));
}
@Nullable String localizedName = I18n.translateTaskName(task);
if (localizedName != null)
task.setName(localizedName);
Platform.runLater(() -> {
ProgressListNode node = new ProgressListNode(task);
nodes.put(task, node);
@@ -24,21 +24,12 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.OverrunStyle;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.stream.Collectors;
/// Displays a title with optional tags and a subtitle.
public class TwoLineListItem extends VBox {
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
@@ -48,28 +39,14 @@ public class TwoLineListItem extends VBox {
private final Label lblTitle;
private Label lblSubtitle;
/// Creates an empty item whose children do not receive mouse events.
public TwoLineListItem() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
setMouseTransparent(true);
lblTitle = new Label();
lblTitle.getStyleClass().add("title");
lblTitle.setTextOverrun(OverrunStyle.ELLIPSIS);
this.firstLine = new HBox(lblTitle) {
/// Includes the tags' natural width when requesting space from the parent.
@Override
protected double computePrefWidth(double height) {
double width = super.computePrefWidth(height);
for (Node child : getManagedChildren()) {
if (child instanceof TagsBox tagsBox) {
width += snapSizeX(tagsBox.computeNaturalPrefWidth(height));
}
}
return width;
}
};
this.firstLine = new HBox(lblTitle);
firstLine.getStyleClass().add("first-line");
firstLine.setAlignment(Pos.CENTER_LEFT);
@@ -181,29 +158,17 @@ public class TwoLineListItem extends VBox {
return lblSubtitle;
}
private static Label createTag(String tag, PseudoClass pseudoClass) {
var tagLabel = new Label(tag);
tagLabel.getStyleClass().add("tag");
tagLabel.setMinWidth(Label.USE_PREF_SIZE);
if (pseudoClass != null)
tagLabel.pseudoClassStateChanged(pseudoClass, true);
return tagLabel;
}
private ObservableList<Label> tags;
/// Stores the mutable tag list once tag support has been initialized.
private @Nullable ObservableList<Label> tags;
/// Returns the mutable list of tags displayed after the title.
public ObservableList<Label> getTags() {
if (tags == null) {
tags = FXCollections.observableArrayList();
var tagsBox = new TagsBox();
var tagsBox = new HBox(8);
tagsBox.getStyleClass().add("tags");
tagsBox.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(tagsBox, Priority.SOMETIMES);
tagsBox.setMinWidth(0);
HBox.setHgrow(tagsBox, Priority.ALWAYS);
Bindings.bindContent(tagsBox.getChildren(), tags);
var isNotEmpty = Bindings.isNotEmpty(tags);
tagsBox.managedProperty().bind(isNotEmpty);
@@ -211,53 +176,25 @@ public class TwoLineListItem extends VBox {
FXUtils.setOverflowHidden(tagsBox);
HBox.setHgrow(lblTitle, Priority.ALWAYS);
lblTitle.setMinWidth(0);
lblTitle.setMinWidth(Label.USE_PREF_SIZE);
firstLine.getChildren().setAll(lblTitle, tagsBox);
}
return tags;
}
/// Reports its natural width separately from the width used to allocate space within the title row.
@NotNullByDefault
private static final class TagsBox extends HBox {
/// Creates a tag container with zero minimum and preferred allocation widths.
private TagsBox() {
super(8);
// Start at zero during HBox allocation so tags do not compete with the title for space.
setPrefWidth(0);
setMinWidth(0);
}
/// Returns the computed preferred width without applying the zero preferred-width override.
///
/// @param height the available height, or -1 if unspecified
/// @return the natural preferred width of the tags and their container
private double computeNaturalPrefWidth(double height) {
return super.computePrefWidth(height);
}
}
public void addTag(String tag, PseudoClass pseudoClass) {
getTags().add(createTag(tag, pseudoClass));
var tagLabel = new Label(tag);
tagLabel.getStyleClass().add("tag");
tagLabel.setMinWidth(Label.USE_PREF_SIZE);
if (pseudoClass != null)
tagLabel.pseudoClassStateChanged(pseudoClass, true);
getTags().add(tagLabel);
}
public void addTag(String tag) {
addTag(tag, null);
}
public void addTags(Collection<String> tags) {
getTags().addAll(tags.stream().map(tag -> createTag(tag, null)).toList());
}
public void addTagsIfNotExist(Collection<String> tags) {
var current = getTags().stream().map(Label::getText).collect(Collectors.toSet());
var target = new LinkedHashSet<>(tags);
target.removeAll(current);
addTags(target);
}
private static final PseudoClass WARNING_PSEUDO_CLASS = PseudoClass.getPseudoClass("warning");
public void addTagWarning(String tag) {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,253 @@
/*
* 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))));
}
}
@@ -0,0 +1,602 @@
/*
* 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;
}
};
}
}
@@ -1,423 +0,0 @@
/*
* 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 content clip, rounded only for normal windows with custom decoration.
private final Rectangle clip = new Rectangle();
/// The frame containing the title bar and current navigation page.
private final BorderPane frame;
/// The title bar containing page navigation state.
private final BorderPane titleBar;
/// The custom help, minimize, and close buttons shared by both decoration styles.
private final HBox windowButtons;
/// The transition container used when the title-bar state changes.
private final TransitionPane navBarPane;
/// 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);
windowButtons = createWindowButtons();
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(windowButtons);
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
decorator.capableDraggingWindow(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);
}
/// Moves the custom title bar into or out of the native dragging header.
///
/// @param decoration the native header support, or `null` for custom decoration
void setNativeDecoration(@Nullable NativeWindowDecoration decoration) {
frame.setTop(null);
if (decoration == null) {
frame.setTop(titleBar);
} else {
decoration.setContent(titleBar);
frame.setTop(decoration.headerBar);
}
}
/// Enables or disables custom rounding of the content corners.
///
/// Native decoration uses square content bounds so the platform can shape the window outline.
///
/// @param rounded whether to round the content corners
void setWindowCornersRounded(boolean rounded) {
double arc = rounded ? ARC : 0.0;
clip.setArcWidth(arc);
clip.setArcHeight(arc);
}
/// 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);
decorator.capableDraggingWindow(buttons);
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);
decorator.capableDraggingWindow(navBar);
// 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);
decorator.capableDraggingWindow(titleArea);
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);
titleLabel.setMouseTransparent(true);
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)));
}
}
}
@@ -1,245 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.collections.ListChangeListener;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.layout.Region;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Accesses the JavaFX 27 extended-window APIs without linking them on older runtimes.
@NotNullByDefault
final class NativeWindowDecoration {
/// Marks nodes whose title-content descendants are already observed for native hit testing.
private static final Object DRAG_CONFIGURED = new Object();
/// Whether the user explicitly enabled native decoration regardless of automatic platform or brightness exclusions.
private final boolean forced;
/// The platform-supported extended stage style.
final StageStyle style;
/// The header bar that supplies native dragging around the custom title content.
final Region headerBar;
/// Assigns the header bar's center content.
private final MethodHandle setCenter;
/// Assigns a node's native header hit-test behavior.
private final MethodHandle setDragType;
/// Reads explicit native drag exclusions on title nodes.
private final MethodHandle getDragType;
/// Sets the system button height to zero to hide the platform-provided buttons.
private final MethodHandle setSystemButtonHeight;
/// Marks only the specified node as draggable, preserving child control interaction.
private final Object draggable;
/// Excludes a node and its descendants from native dragging.
private final Object notDraggable;
/// Ignores an overlay and its descendants when testing underlying native header areas.
private final Object transparentSubtree;
/// Resolves all required public APIs and creates a detached header bar.
///
/// @param forced whether to bypass the automatic theme brightness exclusion
/// @throws Throwable if a required API cannot be resolved or invoked
private NativeWindowDecoration(boolean forced) throws Throwable {
this.forced = forced;
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
style = (StageStyle) lookup.findStaticGetter(StageStyle.class, "EXTENDED", StageStyle.class).invokeExact();
Class<?> headerClass = Class.forName("javafx.scene.layout.HeaderBar");
Class<?> dragClass = Class.forName("javafx.scene.layout.HeaderDragType");
// Adapt runtime-only types once so cached handles can be invoked with exact, statically known signatures.
setCenter = lookup.findVirtual(headerClass, "setCenter", MethodType.methodType(void.class, Node.class))
.asType(MethodType.methodType(void.class, Region.class, Node.class));
setDragType = lookup.findStatic(headerClass, "setDragType", MethodType.methodType(void.class, Node.class, dragClass))
.asType(MethodType.methodType(void.class, Node.class, Object.class));
getDragType = lookup.findStatic(headerClass, "getDragType", MethodType.methodType(dragClass, Node.class))
.asType(MethodType.methodType(Object.class, Node.class));
setSystemButtonHeight = lookup.findStatic(headerClass, "setSystemButtonHeight",
MethodType.methodType(void.class, Stage.class, double.class));
draggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "DRAGGABLE", dragClass).invoke());
notDraggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "NONE", dragClass).invoke());
transparentSubtree = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "TRANSPARENT_SUBTREE", dragClass).invoke());
headerBar = (Region) lookup.findConstructor(headerClass, MethodType.methodType(void.class)).invoke();
}
/// Creates native decoration support according to the configured preference and runtime capabilities.
///
/// The `hmcl.nativeDecoration` system property takes precedence over `HMCL_NATIVE_DECORATION`.
/// Values are case-insensitive and trimmed: `false` disables native decoration, `true` enables it on
/// any supported platform, and `auto` enables it only on macOS or Windows 11 and later. Missing or
/// unrecognized values use `auto`. All modes require JavaFX 27 or later and extended-window support.
/// The preference is read when this method is called and retained by the returned instance.
///
/// @return the resolved support, or `null` when disabled by policy or unavailable on the runtime
static @Nullable NativeWindowDecoration create() {
@Nullable String preference = System.getProperty("hmcl.nativeDecoration", System.getenv("HMCL_NATIVE_DECORATION"));
if (preference != null) {
preference = preference.trim();
}
if ("false".equalsIgnoreCase(preference)) {
return null;
}
boolean forced = "true".equalsIgnoreCase(preference);
if (!forced && preference != null && !"auto".equalsIgnoreCase(preference)) {
LOG.warning("Invalid native decoration preference: " + preference + "; using auto");
}
if (!forced && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS
&& !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11)) {
return null;
}
try {
int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]);
if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) {
return null;
}
return new NativeWindowDecoration(forced);
} catch (Throwable e) {
LOG.warning("Native window decoration is unavailable; using custom decoration", e);
return null;
}
}
/// Returns whether to use native decoration with the current transparency and theme brightness.
///
/// Transparent windows always use custom decoration. Automatic mode also excludes dark windows on Windows;
/// explicitly enabling native decoration bypasses only that brightness exclusion.
///
/// @param transparent whether the theme requests window transparency
/// @param dark whether the current theme is dark
/// @return whether this instance permits native decoration for the supplied theme settings
boolean isPreferred(boolean transparent, boolean dark) {
return !transparent && (forced || !(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && dark));
}
/// Sets or removes the header content; the caller must first detach it from any other parent.
///
/// Configures native dragging on non-interactive descendants and observes later child additions.
/// Controls other than labels and nodes explicitly excluded from dragging retain ordinary input handling.
///
/// @param content the title-bar content, or `null` to detach it
void setContent(@Nullable Node content) {
try {
if (content != null) {
configureTitleDragging(content);
}
setCenter.invokeExact(headerBar, content);
} catch (Throwable e) {
throw new IllegalStateException("Cannot update native header content", e);
}
}
/// Marks a node as draggable or explicitly non-draggable during native header hit testing.
///
/// Draggability does not extend to descendants, so interactive title content retains its behavior.
///
/// @param node the node to configure
/// @param enabled whether the node itself may move the window
void setDraggable(Node node, boolean enabled) {
try {
setDragType.invokeExact(node, enabled ? draggable : notDraggable);
} catch (Throwable e) {
throw new IllegalStateException("Cannot configure native header dragging", e);
}
}
/// Lets native header hit testing pass through an overlay and its descendants.
///
/// Ordinary mouse picking is unchanged. This does not create draggable areas outside the underlying
/// header. Descendants excluded with [#setDraggable(Node, boolean)] block native dragging instead.
///
/// @param overlay the overlay above the header
void setDragTransparent(Node overlay) {
try {
setDragType.invokeExact(overlay, transparentSubtree);
} catch (Throwable e) {
throw new IllegalStateException("Cannot configure native header overlay", e);
}
}
/// Configures native hit testing for title content, including subsequently added descendants.
///
/// Containers and label graphics may move the window. Other controls and nodes explicitly excluded with
/// [#setDraggable(Node, boolean)] retain ordinary input handling, including all of their descendants.
///
/// @param node the title content to configure
private void configureTitleDragging(Node node) {
if (node.getProperties().putIfAbsent(DRAG_CONFIGURED, Boolean.TRUE) != null) {
return;
}
try {
if ((Object) getDragType.invokeExact(node) == notDraggable) {
return;
}
} catch (Throwable e) {
throw new IllegalStateException("Cannot read native header dragging", e);
}
if (node instanceof Control && !(node instanceof Label)) {
setDraggable(node, false);
return;
}
// Native hit testing starts at the deepest picked node; marking only its parent is insufficient.
setDraggable(node, true);
if (node instanceof Parent parent) {
parent.getChildrenUnmodifiable().addListener((ListChangeListener<Node>) change -> {
while (change.next()) {
for (Node child : change.getAddedSubList()) {
configureTitleDragging(child);
}
}
});
for (Node child : parent.getChildrenUnmodifiable()) {
configureTitleDragging(child);
}
}
}
/// Hides platform-provided window buttons.
///
/// @param stage the extended stage to configure
void configureStage(Stage stage) {
try {
setSystemButtonHeight.invokeExact(stage, 0.0);
} catch (Throwable e) {
throw new IllegalStateException("Cannot hide native window buttons", e);
}
}
}
Loaded 100 of 540 files, more files were not shown because too many files have changed in this diff. Show more