Compare commits

..
825 changed files with 36231 additions and 97683 deletions

No files matched your search

-66
View File
@@ -1,66 +0,0 @@
# Default: normalize all text files to LF in the repository and on checkout.
# Git auto-detects binary files and leaves them untouched.
* text=auto eol=lf
# ─── Explicitly text – source & build ────────────────────────────────────────
*.java text eol=lf
*.js text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.groovy text eol=lf
*.gradle text eol=lf
# ─── Configuration & data ─────────────────────────────────────────────────────
*.properties text eol=lf
*.toml text eol=lf
*.xml text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.csv text eol=lf
*.html text eol=lf
*.css text eol=lf
*.txt text eol=lf
*.md text eol=lf
*.svg text eol=lf
*.MF text eol=lf
# Gradle dependency-lock files
*.lockfile text eol=lf
# ─── Scripts ──────────────────────────────────────────────────────────────────
# Unix shell scripts must always use LF so they stay executable on Linux/macOS.
*.sh text eol=lf
gradlew text eol=lf
# CI pipeline scripts
Jenkinsfile text eol=lf
# Windows batch files must keep CRLF so cmd.exe can parse them.
*.bat text eol=crlf
# ─── Binary files ─────────────────────────────────────────────────────────────
# Raster / animated images
*.png binary
*.apng binary
*.jpg binary
*.jpeg binary
*.webp binary
*.gif binary
*.ico binary
# Archives & compiled artifacts
*.jar binary
*.zip binary
*.class binary
# Cryptographic / certificate files
*.der binary
# Minecraft schematic
*.litematic binary
# Compiled / intermediate build artefacts
*.bin binary
*.probe binary
-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
+1 -8
View File
@@ -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
@@ -49,9 +48,3 @@ jobs:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: HMCL/build/libs/HMCL-*.sh
archive: false
- name: Upload DEB
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-deb
path: HMCL/build/libs/HMCL-*.deb
archive: false
-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 });
-11
View File
@@ -46,20 +46,16 @@ jobs:
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb.sha256"
- name: Verify artifacts
if: ${{ env.continue == 'true' }}
run: |
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
export DEB_SHA256=$(cat HMCL-$HMCL_VERSION.deb.sha256 | tr -d '\n')
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
echo "$DEB_SHA256 HMCL-$HMCL_VERSION.deb" | sha256sum -c
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -71,7 +67,6 @@ jobs:
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.deb]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> RELEASE_NOTE
# CNB Release Note
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
@@ -81,8 +76,6 @@ jobs:
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.deb]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> CNB_RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
@@ -93,7 +86,6 @@ jobs:
"HMCL-${{ env.HMCL_VERSION }}.exe" \
"HMCL-${{ env.HMCL_VERSION }}.jar" \
"HMCL-${{ env.HMCL_VERSION }}.sh" \
"HMCL-${{ env.HMCL_VERSION }}.deb" \
--target "${{ env.HMCL_COMMIT_SHA }}" \
--title "${{ env.HMCL_TAG_NAME }}" \
--notes-file RELEASE_NOTE \
@@ -128,9 +120,6 @@ jobs:
echo "Uploading HMCL-$HMCL_VERSION.sh"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
echo "Uploading HMCL-$HMCL_VERSION.deb"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.deb"
env:
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
CNB_REPO: HMCL-dev/HMCL
+1 -13
View File
@@ -4,10 +4,7 @@
hs_err_pid*
*.hprof
*.jfr
.gradle
gradle.properties
*.lck
*.1
@@ -19,8 +16,6 @@ NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
terracotta-log-*
hmcl-mod-update-list-*
*.hmcl-theme
/.java/
/.local/
/.cache/
@@ -48,7 +43,6 @@ language-subtag-registry
# eclipse
/bin/
/HMCL/bin/
/HMCLBoot/bin/
/HMCLCore/bin/
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
@@ -70,10 +64,4 @@ language-subtag-registry
# test
/hmcl.json
/.hmcl.json
/.hmcl/
# AI
/.gemini/
!/.gemini/config.yaml
.codex
/.gradle-*/
/.hmcl/
-6
View File
@@ -1,6 +0,0 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Hello Minecraft! Launcher&#10;Copyright (C) &amp;#36;today.year huangyuhui &lt;huanghongxun2008@126.com&gt; and contributors&#10;&#10;This program is free software: you can redistribute it and/or modify&#10;it under the terms of the GNU General Public License as published by&#10;the Free Software Foundation, either version 3 of the License, or&#10;(at your option) any later version.&#10;&#10;This program is distributed in the hope that it will be useful,&#10;but WITHOUT ANY WARRANTY; without even the implied warranty of&#10;MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10;GNU General Public License for more details.&#10;&#10;You should have received a copy of the GNU General Public License&#10;along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;." />
<option name="myName" value="HMCL" />
</copyright>
</component>
-8
View File
@@ -1,8 +0,0 @@
<component name="CopyrightManager">
<settings default="HMCL">
<LanguageOptions name="JAVA">
<option name="fileTypeOverride" value="3" />
<option name="addBlankAfter" value="false" />
</LanguageOptions>
</settings>
</component>
-25
View File
@@ -1,25 +0,0 @@
# Java Code Style Requirements
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.
## Immutability
- Immutable arrays and collections must be explicitly annotated with JetBrains Annotations `@Unmodifiable` or `@UnmodifiableView` as appropriate.
- For arrays, use type-use syntax such as `String @Unmodifiable []`.
## 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.
- Add concise implementation comments inside complex logic whenever they materially improve readability or explain non-obvious behavior.
+18 -64
View File
@@ -4,11 +4,8 @@ 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
import java.net.URI
import java.nio.file.FileSystems
@@ -53,27 +50,22 @@ if (buildNumber != null) {
}
}
val embedResources = configurations.register("embedResources")
val embedResources by configurations.registering
dependencies {
implementation(project(":HMCLCore"))
implementation(project(":HMCLBoot"))
implementation("libs:JFoenix")
implementation(libs.jwebp)
implementation(libs.fxsvgimage)
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
implementation(libs.monet.fx)
implementation(libs.nayuki.qrcodegen)
implementation(libs.uuid.tools)
testImplementation(libs.jimfs)
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
embedResources(libs.authlib.injector)
embedResources(libs.lwjgl.unsafe.agent)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -163,11 +155,10 @@ val hmclProperties = buildList {
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
add("hmcl.lwjgl-unsafe-agent.version" to libs.lwjgl.unsafe.agent.get().version!!)
}
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) }
@@ -201,7 +192,7 @@ tasks.shadowJar {
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
listOf(
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*",
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
).forEach { exclude("com/sun/jna/$it/**") }
@@ -257,22 +248,20 @@ 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)
inputs.file(jarPath)
outputs.files(extensions.map { artifactFile(it) })
outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
doLast {
val jarContent = jarPath.readBytes()
ZipFile(jarPath).use { zipFile ->
for (extension in extensions) {
val output = artifactFile(extension)
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
?: throw GradleException("HMCLauncher.$extension not found")
@@ -287,32 +276,8 @@ val makeExecutables = tasks.register("makeExecutables") {
}
}
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
dependsOn(makeExecutables)
val debFile = layout.file(provider { artifactFile("deb") })
val debChannel = when (versionType) {
"stable" -> ReleaseType.STABLE
"dev" -> ReleaseType.DEVELOPMENT
else -> ReleaseType.NIGHTLY
}
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)
doLast {
createChecksum(debFile.get().asFile)
}
}
tasks.build {
dependsOn(makeExecutables)
dependsOn(makeDeb)
}
fun parseToolOptions(options: String?): MutableList<String> {
@@ -409,14 +374,12 @@ val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgra
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(
listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
)
)
classifiers.set(listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
))
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
@@ -431,37 +394,28 @@ 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") {
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -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
@@ -23,13 +23,21 @@ import com.jfoenix.controls.events.JFXDialogEvent;
import com.jfoenix.converters.DialogTransitionConverter;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
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.DefaultProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.css.*;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableObjectProperty;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.css.StyleableProperty;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
@@ -41,6 +49,7 @@ import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
@@ -146,6 +155,7 @@ public class JFXDialog extends StackPane {
});
contentHolder = new StackPane();
contentHolder.setBackground(new Background(new BackgroundFill(Color.WHITE, new CornerRadii(2), null)));
JFXDepthManager.setDepth(contentHolder, 4);
contentHolder.setPickOnBounds(false);
// ensure stackpane is never resized beyond it's preferred size
@@ -371,14 +381,6 @@ public class JFXDialog extends StackPane {
setCycleDuration(Duration.seconds(0.4));
setDelay(Duration.ZERO);
}
@Override
protected void starting() {
}
@Override
protected void stopping() {
}
}
/***************************************************************************
@@ -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);
@@ -38,6 +38,7 @@ import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
@@ -67,16 +68,12 @@ public class JFXPasswordField extends PasswordField {
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
useJFXContextMenu(this);
}
if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) {
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";");
}
/// Prevents the legacy JFoenix skin from continuously requesting another layout pass.
// https://github.com/HMCL-dev/HMCL/issues/5822
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
@Override
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
useJFXContextMenu(this);
}
/**
@@ -297,3 +294,4 @@ public class JFXPasswordField extends PasswordField {
}
}
@@ -231,11 +231,10 @@ public class JFXRippler extends StackPane {
*
* @return the ripple radius size
*/
protected double computeRippleRadius(double centerX, double centerY) {
double dx = Math.max(centerX, control.getLayoutBounds().getWidth() - centerX);
double dy = Math.max(centerY, control.getLayoutBounds().getHeight() - centerY);
return (Math.sqrt(dx * dx + dy * dy) / 0.9) + 2;
protected double computeRippleRadius() {
double width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth();
double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight();
return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5;
}
protected void setOverLayBounds(Rectangle overlay) {
@@ -278,10 +277,6 @@ public class JFXRippler extends StackPane {
rippler.releaseRipple();
}
public void releaseRippleImmediately() {
rippler.releaseRippleImmediately();
}
/**
* creates Ripple effect in the center of the control
*
@@ -409,21 +404,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;
}
@@ -493,7 +473,7 @@ public class JFXRippler extends StackPane {
super(centerX,
centerY,
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius(centerX, centerY) : getRipplerRadius(), null);
computeRippleRadius() : getRipplerRadius(), null);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
@@ -538,7 +518,7 @@ public class JFXRippler extends StackPane {
new KeyValue(opacityProperty(),
1,
RIPPLE_INTERPOLATOR)
), new KeyFrame(Duration.millis(600), inKeyValues));
), new KeyFrame(Duration.millis(900), inKeyValues));
setScaleX(0);
setScaleY(0);
@@ -54,8 +54,7 @@ public class JFXSnackbarLayout extends BorderPane {
StackPane toastContainer = new StackPane(toast);
toastContainer.setPadding(new Insets(20));
actionContainer = new StackPane();
actionContainer.setVisible(false);
actionContainer.setManaged(false);
actionContainer.setPadding(new Insets(0, 10, 0, 0));
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
if (getPrefWidth() == -1) {
@@ -79,7 +78,6 @@ public class JFXSnackbarLayout extends BorderPane {
actionContainer.getChildren().add(action);
if (!actionText.isEmpty()) {
actionContainer.setPadding(new Insets(0, 10, 0, 0));
action.setVisible(true);
actionContainer.setVisible(true);
actionContainer.setManaged(true);
@@ -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,20 +12,31 @@ 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();
@@ -169,41 +180,21 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
};
}
/// Animates a selection change, or applies it immediately when animations are disabled.
private void playSelectAnimation(boolean selection) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
private void playSelectAnimation(Boolean selection) {
if (selection == null) {
selection = false;
}
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))));
if (!AnimationUtils.isAnimationEnabled()) {
finishSelectionAnimation();
return;
}
this.transition.setRate(selection ? 1.0 : -1.0);
this.select.setRate(selection ? 1.0 : -1.0);
this.transition.play();
this.select.play();
}
/// Stops selection animations and immediately displays the checkbox's current selected state.
///
/// The check mark, fill, and border are updated even if no animation is running.
/// This also suppresses the initial selection animation on the first layout.
/// The skin must not have been disposed.
public void finishSelectionAnimation() {
transition.stop();
select.stop();
invalid = false;
boolean selected = getSkinnable().isSelected();
mark.setVisible(selected);
mark.setScaleX(selected ? 1.0 : 0.0);
mark.setScaleY(selected ? 1.0 : 0.0);
updateColors();
}
private void createFillTransition() {
@@ -1,307 +0,0 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ComboBoxListViewSkin;
import com.jfoenix.concurrency.JFXUtilities;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventTarget;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBoxBase;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.text.Text;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import java.util.List;
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
protected final ObjectProperty<Paint> promptTextFill;
private boolean invalid = true;
private final StackPane customPane;
private final StackPane line = new StackPane();
private final StackPane focusedLine = new StackPane();
private final Text promptText = new Text();
private final double initScale = 0.05;
private final Scale scale;
private final Timeline linesAnimation;
private ParallelTransition transition;
private CachedTransition promptTextUpTransition;
private CachedTransition promptTextDownTransition;
private CachedTransition promptTextColorTransition;
private final Scale promptTextScale;
private Paint oldPromptTextFill;
private final BooleanBinding usePromptText;
public JFXComboBoxListViewSkin(JFXComboBox<T> comboBox) {
super(comboBox);
this.scale = new Scale(this.initScale, 1.0F);
this.linesAnimation = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(this.scale.xProperty(), this.initScale, Interpolator.EASE_BOTH), new KeyValue(this.focusedLine.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1.0F), new KeyValue(this.focusedLine.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(160.0F), new KeyValue(this.scale.xProperty(), 1, Interpolator.EASE_BOTH)));
this.promptTextScale = new Scale(1.0F, 1.0F, 0.0F, 0.0F);
this.promptTextFill = new SimpleObjectProperty(Color.valueOf("#B2B2B2"));
this.usePromptText = Bindings.createBooleanBinding(this::usePromptText, ((JFXComboBox) this.getSkinnable()).valueProperty(), this.getSkinnable().promptTextProperty());
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
this.promptText.textProperty().bind(comboBox.promptTextProperty());
this.promptText.fillProperty().bind(this.promptTextFill);
this.promptText.getStyleClass().addAll("text", "prompt-text");
this.promptText.getTransforms().add(this.promptTextScale);
if (!comboBox.isLabelFloat()) {
this.promptText.visibleProperty().bind(this.usePromptText);
}
this.customPane = new StackPane();
this.customPane.setMouseTransparent(true);
this.customPane.getStyleClass().add("combo-box-button-container");
this.customPane.backgroundProperty().bindBidirectional(this.getSkinnable().backgroundProperty());
this.customPane.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
this.customPane.getChildren().add(this.promptText);
this.getChildren().add(0, this.customPane);
StackPane.setAlignment(this.promptText, Pos.CENTER_LEFT);
this.line.getStyleClass().add("input-line");
this.focusedLine.getStyleClass().add("input-focused-line");
this.getChildren().add(this.line);
this.getChildren().add(this.focusedLine);
this.line.setPrefHeight(1.0F);
this.line.setTranslateY(1.0F);
this.line.setManaged(false);
this.line.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
if (this.getSkinnable().isDisabled()) {
this.line.setBorder(new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(1.0F))));
this.line.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
}
this.focusedLine.setPrefHeight(2.0F);
this.focusedLine.setTranslateY(0.0F);
this.focusedLine.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
this.focusedLine.setOpacity(0.0F);
this.focusedLine.getTransforms().add(this.scale);
this.focusedLine.setManaged(false);
if (comboBox.isEditable()) {
comboBox.getEditor().setStyle("-fx-background-color:TRANSPARENT;-fx-padding: 4 8 4 8");
comboBox.getEditor().promptTextProperty().unbind();
comboBox.getEditor().setPromptText(null);
comboBox.getEditor().textProperty().addListener((o, oldVal, newVal) -> {
this.usePromptText.invalidate();
comboBox.setValue(this.getConverter().fromString(newVal));
});
}
comboBox.labelFloatProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.promptText.visibleProperty().unbind();
JFXUtilities.runInFX(() -> this.createFloatingAnimation());
} else {
this.promptText.visibleProperty().bind(this.usePromptText);
}
this.createFocusTransition();
});
comboBox.focusColorProperty().addListener((o, oldVal, newVal) -> {
if (newVal != null) {
this.focusedLine.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, newVal, Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(160.0F));
}
protected void starting() {
super.starting();
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
}
};
this.transition = null;
}
}
});
comboBox.unFocusColorProperty().addListener((o, oldVal, newVal) -> {
if (newVal != null) {
this.line.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
}
});
comboBox.disabledProperty().addListener((o, oldVal, newVal) -> {
this.line.setBorder(newVal ? new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(this.line.getHeight()))) : Border.EMPTY);
this.line.setBackground(new Background(new BackgroundFill(newVal ? Color.TRANSPARENT : ((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
});
comboBox.focusedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.focus();
} else {
this.unFocus();
}
});
comboBox.valueProperty().addListener((o, oldVal, newVal) -> {
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
this.animateFloatingLabel(newVal != null && !newVal.toString().isEmpty());
}
});
for (Node child : getChildren()) {
if (child instanceof ListView<?> listView && "list-view".equals(listView.getId())) {
// The default hideOnClick logic of ComboBoxListViewSkin doesn't work well with FloatScrollBarSkin.
// We implement hideOnClick ourselves to prevent the popup from
// hiding when releasing the mouse on the scrollbar.
this.setHideOnClick(false);
listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> {
EventTarget target = t.getTarget();
if (target instanceof Node targetNode) {
List<String> s = targetNode.getStyleClass();
if (s.contains("thumb")
|| s.contains("track")
|| s.contains("decrement-arrow")
|| s.contains("increment-arrow")) {
return;
}
}
comboBox.hide();
});
}
}
}
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
this.customPane.resizeRelocate(x, y, w, h);
if (this.invalid) {
this.invalid = false;
if (!this.getSkinnable().isEditable()) {
Text javaPromptText = (Text) super.getDisplayNode().lookup(".text");
if (javaPromptText != null) {
this.promptTextFill.set(javaPromptText.getFill());
}
}
this.createFloatingAnimation();
if (((ComboBoxBase) this.getSkinnable()).getValue() != null) {
this.animateFloatingLabel(true);
}
}
this.focusedLine.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.focusedLine.prefHeight(-1.0F));
this.line.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.line.prefHeight(-1.0F));
this.scale.setPivotX(w / (double) 2.0F);
}
private void createFloatingAnimation() {
this.promptTextUpTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), -this.customPane.getHeight() + 6.05, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 0.85, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 0.85, Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(240.0F));
}
};
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, ((JFXComboBox) this.getSkinnable()).getFocusColor(), Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(160.0F));
}
protected void starting() {
super.starting();
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
}
};
this.promptTextDownTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 1, Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(240.0F));
}
};
this.promptTextDownTransition.setOnFinished((finish) -> {
this.promptText.setTranslateY(0.0F);
this.promptTextScale.setX(1.0F);
this.promptTextScale.setY(1.0F);
});
}
private void focus() {
if (this.transition == null) {
this.createFocusTransition();
}
this.transition.play();
}
private void animateFloatingLabel(boolean up) {
if (this.promptText == null) {
Platform.runLater(() -> this.animateFloatingLabel(up));
} else {
if (this.transition != null) {
this.transition.stop();
this.transition.getChildren().remove(this.promptTextUpTransition);
this.transition.getChildren().remove(this.promptTextColorTransition);
this.transition = null;
}
if (up && this.promptText.getTranslateY() == (double) 0.0F) {
this.promptTextDownTransition.stop();
this.promptTextUpTransition.play();
if (this.getSkinnable().isFocused()) {
this.promptTextColorTransition.play();
}
} else if (!up) {
this.promptTextUpTransition.stop();
if (this.getSkinnable().isFocused()) {
this.promptTextFill.set(this.oldPromptTextFill);
}
this.promptTextDownTransition.play();
}
}
}
private void createFocusTransition() {
this.transition = new ParallelTransition();
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
this.transition.getChildren().add(this.promptTextUpTransition);
this.transition.getChildren().add(this.promptTextColorTransition);
}
this.transition.getChildren().add(this.linesAnimation);
}
private void unFocus() {
if (this.transition != null) {
this.transition.stop();
}
this.scale.setX(this.initScale);
this.focusedLine.setOpacity(0.0F);
if (((JFXComboBox) this.getSkinnable()).isLabelFloat() && this.oldPromptTextFill != null) {
this.promptTextFill.set(this.oldPromptTextFill);
if (this.usePromptText()) {
this.promptTextDownTransition.play();
}
}
}
private boolean usePromptText() {
Object txt = ((JFXComboBox) this.getSkinnable()).getValue();
String promptTxt = this.getSkinnable().getPromptText();
return (txt == null || txt.toString().isEmpty()) && promptTxt != null && !promptTxt.isEmpty() && !this.promptTextFill.get().equals(Color.TRANSPARENT);
}
}
@@ -40,16 +40,12 @@ import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.util.Duration;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Shadi Shaheen
*/
@@ -82,7 +78,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
// create JFX Decorator
pickerDecorator = new JFXDecorator(dialog, this, false, false, false);
pickerDecorator.setOnCloseButtonAction(this::close);
pickerDecorator.setOnCloseButtonAction(this::updateColor);
pickerDecorator.setPickOnBounds(false);
customScene = new Scene(pickerDecorator, Color.TRANSPARENT);
StyleSheets.init(customScene);
@@ -157,22 +153,6 @@ public class JFXCustomColorPickerDialog extends StackPane {
paraTransition.play();
});
container.getChildren().add(tabs);
HBox actionsHBox = new HBox();
actionsHBox.getStyleClass().add("jfx-color-dialog-actions");
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
acceptButton.setOnAction(event -> updateColor());
acceptButton.getStyleClass().add("jfx-color-dialog-accept");
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.setOnAction(event -> close());
cancelButton.getStyleClass().add("jfx-color-dialog-cancel");
actionsHBox.getChildren().addAll(acceptButton, cancelButton);
container.getChildren().add(actionsHBox);
initRun = () -> {
// change tabs labels font color according to the selected color
pane.backgroundProperty().addListener((o, oldVal, newVal) -> {
@@ -199,7 +179,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
String rgb = String.format("rgb(%d, %d, %d)",
String rgb = String.format("rgba(%d, %d, %d, 1)",
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
@@ -252,13 +232,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
acceptButton.textFillProperty().bind(Bindings.createObjectBinding(() -> {
Color fill = (Color) pane.getBackground().getFills().get(0).getFill();
return ColorScheme.newBuilder(Themes.getColorScheme())
.setPrimaryColorSeed(fill)
.build()
.getPrimary();
}, pane.backgroundProperty()));
((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty()
.bind(Bindings.createObjectBinding(() -> {
@@ -288,6 +262,9 @@ public class JFXCustomColorPickerDialog extends StackPane {
});
};
container.getChildren().add(tabs);
this.getChildren().add(container);
this.setPadding(new Insets(0));
@@ -317,11 +294,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
if (!systemChange) {
userChange = true;
try {
Color color = Color.valueOf(colorWebString);
if (color.getOpacity() != 1.0) {
color = Color.color(color.getRed(), color.getGreen(), color.getBlue());
}
curvedColorPicker.setColor(color);
curvedColorPicker.setColor(Color.valueOf(colorWebString));
} catch (IllegalArgumentException ignored) {
// if color is not valid then do nothing
}
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.swing.JOptionPane;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
@@ -48,9 +47,9 @@ public final class EntryPoint {
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
createHMCLDirectories();
LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs"));
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
checkWine();
setupJavaFXVMOptions();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
@@ -72,30 +71,95 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
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 {
Files.createDirectories(Metadata.HMCL_LOCAL_HOME);
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_CURRENT_DIRECTORY)) {
try {
Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY);
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
Files.setAttribute(Metadata.HMCL_LOCAL_HOME, "dos:hidden", true);
Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true);
} catch (IOException e) {
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_LOCAL_HOME, e);
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e);
}
}
} catch (IOException e) {
// Logger has not been started yet, so print directly to System.err
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_LOCAL_HOME);
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_LOCAL_HOME));
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
}
}
if (!Files.isDirectory(Metadata.HMCL_USER_HOME)) {
if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) {
try {
Files.createDirectories(Metadata.HMCL_USER_HOME);
Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY);
} catch (IOException e) {
LOG.warning("Failed to create HMCL user home " + Metadata.HMCL_USER_HOME, e);
LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e);
}
}
}
@@ -156,13 +220,6 @@ public final class EntryPoint {
}
}
private static void checkWine() {
if (OperatingSystem.isRunningUnderWine()) {
LOG.warning("HMCL is running under Wine or its distributions!");
showWarning(i18n("fatal.wine_warning"));
}
}
private static void addEnableNativeAccess() {
if (JavaRuntime.CURRENT_VERSION > 21) {
try {
@@ -199,21 +256,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,48 +17,38 @@
*/
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;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ObservableBooleanValue;
import javafx.geometry.Rectangle2D;
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;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.CrashReporter;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
import org.jackhuang.hmcl.util.platform.windows.Shell32;
import org.jackhuang.hmcl.util.platform.windows.User32;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.NativeUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.CookieHandler;
@@ -68,19 +58,16 @@ 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;
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;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// The main JavaFX application entry point.
public final class Launcher extends Application {
public static final CookieManager COOKIE_MANAGER = new CookieManager();
@@ -108,32 +95,42 @@ public final class Launcher extends Application {
}
try {
initializeSettingsRuntime();
try {
ConfigHolder.init();
} 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", ConfigHolder.configLocation().getParent()));
EntryPoint.exit(1);
}
// https://lapcatsoftware.com/articles/app-translocation.html
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
&& SettingsManager.isNewlyCreated()
&& ConfigHolder.isNewlyCreated()
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
if (showAlert(AlertType.WARNING, i18n("fatal.mac_app_translocation"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
return;
} else {
checkConfigInTempDir();
}
if (SettingsManager.isOwnerChanged()) {
if (ConfigHolder.isOwnerChanged()) {
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
return;
}
if (SettingsManager.hasReadOnlyCoreSettings()) {
if (ConfigHolder.isUnsupportedVersion()) {
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
}
if (Metadata.HMCL_LOCAL_HOME.toString().indexOf('=') >= 0) {
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
}
// runLater to ensure SettingsManager.init() finished initialization
// runLater to ensure ConfigHolder.init() finished initialization
Platform.runLater(() -> {
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
// Stage.show() cannot work again because JavaFX Toolkit have already shut down.
@@ -149,26 +146,6 @@ public final class Launcher extends Application {
}
}
/// Initializes modules and runtime services that depend on loaded settings.
private static void initializeSettingsRuntime() {
DownloadProviders.init();
ProxyManager.init();
Accounts.init();
GameDirectoryManager.init();
AuthlibInjectorServers.init();
AnimationUtils.init();
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
String commonDirectory = settings().getResolvedCommonDirectory();
if (commonDirectory != null && FileUtils.canCreateDirectory(commonDirectory)) {
return commonDirectory;
} else {
return LauncherSettings.getDefaultCommonDirectory();
}
}, settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
}
private static void appendScreen(StringBuilder builder, Screen screen) {
Rectangle2D bounds = screen.getBounds();
double scale = screen.getOutputScaleX();
@@ -205,29 +182,8 @@ public final class Launcher extends Application {
return new Alert(alertType, contentText, buttons).showAndWait().orElse(null);
}
private static boolean confirmWithCountdown(Alert.AlertType alertType, String contentText, int seconds) {
Alert alert = new Alert(alertType, contentText, ButtonType.YES, ButtonType.NO);
Button okButton = (Button) alert.getDialogPane().lookupButton(ButtonType.YES);
okButton.setDisable(true);
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
for (int i = 0; i < seconds; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(okButton.textProperty(), i18n("button.ok.countdown", seconds - i)));
}
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
new KeyValue(okButton.textProperty(), i18n("button.ok")),
new KeyValue(okButton.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
alert.setOnShown(e -> timeline.play());
alert.setOnCloseRequest(e -> timeline.stop());
return alert.showAndWait().orElse(null) == ButtonType.YES;
}
private static boolean isConfigInTempDir() {
String configPath = SettingsManager.localConfigDirectory().toString();
String configPath = ConfigHolder.configLocation().toString();
String tmpdir = System.getProperty("java.io.tmpdir");
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
@@ -264,16 +220,55 @@ public final class Launcher extends Application {
}
private static void checkConfigInTempDir() {
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
&& showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) {
EntryPoint.exit(0);
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
String owner;
try {
owner = Files.getOwner(ConfigHolder.configLocation()).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(ConfigHolder.configLocation().toString());
if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY))
files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString());
if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY))
files.add(Metadata.HMCL_CURRENT_DIRECTORY.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();
}
@@ -305,10 +300,10 @@ public final class Launcher extends Application {
LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor"));
LOG.info("Java Home: " + System.getProperty("java.home"));
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 Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY);
LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
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()) {
@@ -326,24 +321,8 @@ public final class Launcher extends Application {
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
}
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);
@@ -376,217 +355,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;
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);
}
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
}
@@ -23,8 +23,8 @@ 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.nio.file.Paths;
import java.util.EnumSet;
/**
@@ -38,9 +38,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;
@@ -49,9 +46,9 @@ public final class Metadata {
public static final int RECOMMENDED_JAVA_VERSION = 21;
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
public static final String ABOUT_URL = PUBLISH_URL + "/about";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
public static final String DOCS_URL = "https://docs.hmcl.net";
public static final String CONTACT_URL = DOCS_URL + "/help.html";
@@ -62,38 +59,34 @@ public final class Metadata {
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
public static final Path CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
public static final Path HMCL_USER_HOME;
public static final Path HMCL_LOCAL_HOME;
public static final Path HMCL_GLOBAL_DIRECTORY;
public static final Path HMCL_CURRENT_DIRECTORY;
public static final Path DEPENDENCIES_DIRECTORY;
static {
String hmclHome = System.getProperty("hmcl.home", System.getenv("HMCL_USER_HOME"));
if (StringUtils.isBlank(hmclHome)) {
String hmclHome = System.getProperty("hmcl.home");
if (hmclHome == null) {
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
String xdgData = System.getenv("XDG_DATA_HOME");
if (StringUtils.isNotBlank(xdgData)) {
HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
} else {
HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
}
} else {
HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl");
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
}
} else {
HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize();
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
}
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir)
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
String hmclCurrentDir = System.getProperty("hmcl.dir");
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
: CURRENT_DIRECTORY.resolve(".hmcl");
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
? Path.of(hmclDependencies).toAbsolutePath().normalize()
: HMCL_LOCAL_HOME.resolve("dependencies");
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
}
public static boolean isStable() {
@@ -134,32 +127,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,20 +1,3 @@
/*
* 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.countly;
import org.jackhuang.hmcl.Metadata;
@@ -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,856 +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 javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveSettings();
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = getVersion();
@Nullable Integer maxMemory;
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
SystemInfo.getPhysicalMemoryStatus().available(),
javaVersion.getPlatform()) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(getId())
.setGameDir(gameDir)
.setJava(javaVersion)
.setVersionType(Metadata.TITLE)
.setVersionName(getId().id())
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
.setMaxMemory(maxMemory)
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
.setEnvironmentVariables(
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
.stream()
.map(it -> {
int idx = it.indexOf('=');
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
})
.collect(Collectors.toList())
)
)
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
.setProxyOption(getProxyOption())
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
.setNoGeneratedJVMArgs(noJVMOptions)
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
.setRenderer(vs.getRenderer(gameVersionNumber))
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
if (quickPlayOption != null) {
builder.setQuickPlayOption(quickPlayOption);
}
Path json = getModpackConfigurationFile();
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to parse modpack configuration file " + json, e);
}
}
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder;
}
private static ProxyOption getProxyOption() {
return switch (settings().proxyTypeProperty().get()) {
case SYSTEM -> ProxyOption.Default.INSTANCE;
case DIRECT -> ProxyOption.Direct.INSTANCE;
case HTTP, SOCKS -> {
String proxyHost = settings().proxyHostProperty().get();
int proxyPort = settings().proxyPortProperty().get();
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
yield ProxyOption.Default.INSTANCE;
}
String proxyUser = settings().proxyUserProperty().get();
String proxyPass = settings().proxyPasswordProperty().get();
if (StringUtils.isBlank(proxyUser)) {
proxyUser = null;
proxyPass = null;
} else if (proxyPass == null) {
proxyPass = "";
}
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
} else {
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
}
}
};
}
/// Loads a new-format instance game settings file.
private static LoadResult loadGameSettingsFile(Path file) {
if (!Files.exists(file)) {
return new LoadResult(null, true);
}
try {
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
if (jsonObject == null) {
LOG.warning("Instance game settings are empty: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
return new LoadResult(fallback, true);
}
JsonSchema.CompatibilityResult schemaResult =
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
switch (schemaResult.status()) {
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
if (!schemaResult.readable()) {
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setSavable(false);
return new LoadResult(fallback, false);
}
GameSettings.@Nullable Instance setting =
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
if (setting == null) {
LOG.warning("Instance game settings deserialized to null: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
}
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
}
return new LoadResult(setting, schemaResult.allowSave());
} catch (JsonParseException ex) {
LOG.warning("Failed to parse game setting " + file, ex);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
} catch (Exception ex) {
LOG.warning("Failed to load game setting " + file, ex);
return new LoadResult(null, false);
}
}
/// Keeps old local custom running directories effective under the new source-selection model.
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
}
/// Result of loading an instance-specific game settings file.
///
/// @param setting the loaded instance settings, or `null` when unavailable
/// @param allowSave whether the file may be overwritten
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
}
/// Optional reference to an HMCL game instance bound to a repository.
///
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
/// from the current snapshot while preserving repository context.
@NotNullByDefault
public static final class Optional {
private final HMCLGameRepository repository;
private final @Nullable HMCLGameInstance instance;
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
public Optional(HMCLGameRepository repository) {
this.repository = Objects.requireNonNull(repository);
this.instance = null;
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
public Optional(HMCLGameInstance instance) {
this.repository = instance.getRepository();
this.instance = instance;
}
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
///
/// @param repository the repository
/// @param instanceId the instance id, or `null` for an empty optional
/// @return an optional that is empty when `instanceId` is null or not registered
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
if (instanceId == null) {
return new Optional(repository);
}
HMCLGameInstance instance = repository.findInstance(instanceId);
return instance != null ? new Optional(instance) : new Optional(repository);
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
/// @return the optional
public static Optional of(HMCLGameInstance instance) {
return new Optional(instance);
}
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
/// @return the empty optional
public static Optional empty(HMCLGameRepository repository) {
return new Optional(repository);
}
/// Returns the repository associated with this optional.
///
/// @return the repository
public HMCLGameRepository repository() {
return repository;
}
/// Returns the held instance, if any.
///
/// @return the instance, or `null` when empty
@Contract(pure = true)
public @Nullable HMCLGameInstance instance() {
return instance;
}
/// Returns the held instance id, if any.
///
/// @return the instance id, or `null` when empty
@Contract(pure = true)
public @Nullable GameInstanceID instanceId() {
return instance != null ? instance.getId() : null;
}
/// Returns whether an instance is present.
///
/// @return whether [#instance()] is non-null
public boolean isPresent() {
return instance != null;
}
/// Returns whether no instance is present.
///
/// @return whether [#instance()] is null
public boolean isEmpty() {
return instance == null;
}
/// Re-resolves the held instance id from the repository's current snapshot.
///
/// @return this optional when empty; otherwise a fresh optional for the same id
public Optional refreshed() {
if (instance == null) {
return this;
}
return of(repository, instance.getId());
}
}
}
@@ -21,20 +21,17 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.launch.DefaultLauncher;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.util.NativePatcher;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -42,27 +39,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
@@ -74,10 +60,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
private void generateOptionsTxt() {
if (options.isDisableAutoGameOptions())
if (config().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 +88,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);
@@ -164,60 +150,4 @@ public final class HMCLGameLauncher extends DefaultLauncher {
generateOptionsTxt();
super.makeLaunchScript(scriptFile);
}
@Override
protected void appendJvmArgs(CommandBuilder result) {
super.appendJvmArgs(result);
if (options.isAllowAutoAgent()
&& !options.isNoGeneratedJVMArgs()
&& !options.isNoGeneratedOptimizingJVMArgs()
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
try {
result.add("-javaagent:" + extractLwjglUnsafeAgent());
} catch (Exception e) {
LOG.warning("Failed to extract lwjgl-unsafe-agent", e);
}
}
}
private Path extractLwjglUnsafeAgent() throws IOException {
String agentVersion = JarUtils.getAttribute("hmcl.lwjgl-unsafe-agent.version", null);
if (agentVersion == null) {
throw new IOException("Missing hmcl.lwjgl-unsafe-agent.version attribute");
}
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.artifact().getFileName();
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
byte[] bytes;
try (InputStream input = DefaultLauncher.class.getResourceAsStream("/assets/" + fileName)) {
if (input == null) {
throw new IOException("/assets/" + fileName + " not found");
}
bytes = input.readAllBytes();
}
if (Files.isRegularFile(agentPath)) {
try {
if (Files.size(agentPath) == bytes.length) {
return agentPath;
}
} catch (IOException e) {
LOG.warning("Failed to check size of " + agentPath, e);
}
}
Files.createDirectories(agentPath.getParent());
FileUtils.saveSafely(agentPath, output -> output.write(bytes));
return 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,118 +19,62 @@ 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.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.mod.MinecraftInstanceTask;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackInstallTask;
import org.jackhuang.hmcl.setting.Profile;
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) {
this.repository = repository;
this.dependency = repository.getDependency();
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
dependency = profile.getDependency();
repository = profile.getRepository();
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, modpack.getEncoding(), List.of("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
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 +87,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));
}
}
@@ -17,8 +17,8 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.modpack.ModpackManifest;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.mod.ModpackManifest;
import org.jackhuang.hmcl.mod.ModpackProvider;
public final class HMCLModpackManifest implements ModpackManifest {
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
@@ -20,20 +20,19 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.mod.ModpackUpdateTask;
import org.jackhuang.hmcl.setting.Profile;
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();
@@ -44,17 +43,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());
@@ -62,7 +56,9 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
Profile profile = repository.getProfile();
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
}
@Override
@@ -70,26 +66,21 @@ public final class HMCLModpackProvider implements ModpackProvider {
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
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()).getProfile(), zipFile, this, name);
}
}
@@ -25,21 +25,21 @@ 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;
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.GameSettings;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.mod.ModpackCompletionException;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.setting.*;
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,18 +47,16 @@ 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.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
@@ -67,43 +65,34 @@ import java.util.stream.Collectors;
import static javafx.application.Platform.runLater;
import static javafx.application.Platform.setImplicitExit;
import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.logging.Logger.TIME_FORMATTER;
import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
public final class LauncherHelper {
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
private final HMCLGameInstance gameInstance;
private final Profile profile;
private Account account;
private final String selectedVersion;
private Path scriptFile;
private final GameSettings.Effective setting;
private final VersionSetting setting;
private LauncherVisibility launcherVisibility;
private boolean showLogs;
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
this.gameInstance = Objects.requireNonNull(gameInstance);
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
this.profile = Objects.requireNonNull(profile);
this.account = Objects.requireNonNull(account);
this.setting = gameInstance.getEffectiveSettings();
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("instance.launch"));
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
private HMCLGameRepository repository() {
return gameInstance.getRepository();
this.selectedVersion = Objects.requireNonNull(selectedVersion);
this.setting = profile.getVersionSetting(selectedVersion);
this.launcherVisibility = setting.getLauncherVisibility();
this.showLogs = setting.isShowLogs();
this.launchingStepsPane.setTitle(i18n("version.launch"));
}
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
@@ -136,7 +125,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,148 +136,89 @@ public final class LauncherHelper {
launch();
}
/// Builds and executes the launch pipeline for the captured game instance.
private void launch0() {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
HMCLGameRepository repository = repository();
DefaultDependencyManager dependencyManager = repository.getDependency();
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest()));
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
HMCLGameRepository repository = profile.getRepository();
DefaultDependencyManager dependencyManager = profile.getDependency();
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);
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
TaskExecutor executor = checkGameState(gameInstance, setting, launchManifest.get())
TaskExecutor executor = checkGameState(profile, setting, version.get())
.thenComposeAsync(java -> {
javaVersionRef.set(Objects.requireNonNull(java));
launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments));
if (setting.getInheritable(GameSettings::notCheckGameProperty))
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
if (setting.isNotCheckGame())
return null;
return Task.allOf(
dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.get(), 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)
|| renderer.mesaDriverName() == null)
return null;
Renderer renderer = setting.getRenderer();
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
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;
}
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
}
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT);
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
.thenRunAsync(() -> javaAgents.add(agent));
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
.thenRunAsync(() -> javaAgents.add(agent));
} else {
javaAgents.add(agent);
return null;
}
} else {
javaAgents.add(agent);
return null;
}
})
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get()))
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|| !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) {
return Task.completed(null);
} else {
CompletableFuture<Void> future = new CompletableFuture<>();
runInFX(() -> {
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
enableAutoAgentForCurrentSetting();
future.complete(null);
}, () -> {
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
future.complete(null);
});
});
return Task.fromCompletableFuture(future);
}
})
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
if (quickPlayOption != null) {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(javaVersionRef.get().getBinary());
try {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object current = reg.queryValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg
);
if (current == null) {
boolean result = reg.setValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg,
"GpuPreference=2;"
);
if (result)
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
else
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
} else {
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
}
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
}
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10));
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
return new HMCLGameLauncher(
gameInstance,
launchManifest.get(),
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) {
@@ -312,7 +242,7 @@ public final class LauncherHelper {
} 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(
@@ -351,29 +281,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");
@@ -381,13 +313,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) {
@@ -411,7 +344,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);
}
}
@@ -424,22 +357,21 @@ 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(Profile profile, VersionSetting setting, Version version) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().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();
}
});
Task<JavaRuntime> task;
JavaVersionType javaVersionType = setting.getInheritable(GameSettings::javaTypeProperty);
if (setting.getInheritable(GameSettings::notCheckJVMProperty)) {
if (setting.isNotCheckJVM()) {
task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault()));
} else if (javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION) {
} else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) {
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
if (java != null) {
return Task.completed(java);
@@ -452,14 +384,14 @@ public final class LauncherHelper {
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
GameJavaVersion targetJavaVersion = null;
if (javaVersionType == JavaVersionType.VERSION) {
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
try {
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
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());
}
}
@@ -481,18 +413,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, profile)
.whenCompleteAsync((downloadedJava, exception) -> {
if (exception == null) {
future.complete(downloadedJava);
@@ -518,8 +450,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 {
@@ -535,7 +467,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"), () -> {
@@ -555,20 +487,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, profile).whenCompleteAsync((downloadedJava, throwable) -> {
if (throwable == null) {
setting.setJavaAutoSelected();
future.complete(downloadedJava);
@@ -581,7 +514,7 @@ public final class LauncherHelper {
}
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)) {
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) {
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
return result;
} else {
@@ -618,7 +551,7 @@ public final class LauncherHelper {
}
// 32-bit JVM cannot make use of too much memory.
if (java.getBits() == Bits.BIT_32 && !setting.getInheritable(GameSettings::autoMemoryProperty) && setting.getMaxMemory() > 1.5 * 1024) {
if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) {
// 1.5 * 1024 is an inaccurate number.
// Actual memory limit depends on operating system and memory.
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
@@ -638,7 +571,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
@@ -651,8 +584,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, ""));
@@ -665,8 +598,8 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.modlauncher8"));
break;
case VANILLA_X86:
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
&& Platform.isSupportedTranslationX86_64()) {
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
&& isCompatibleWithX86Java()) {
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
}
break;
@@ -677,24 +610,24 @@ public final class LauncherHelper {
// Cannot allocate too much memory exceeding free space.
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
if (totalMemorySizeMB > 0 && !setting.getInheritable(GameSettings::autoMemoryProperty) && totalMemorySizeMB < setting.getMaxMemory()) {
if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) {
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"));
}
@@ -726,14 +659,14 @@ public final class LauncherHelper {
return task.withStage("launch.state.java");
}
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, Profile profile) {
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
Controllers.dialog(new MessageDialogPane.Builder(
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
i18n("message.warning"),
MessageType.QUESTION)
.yesOrNo(() -> {
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider();
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
@@ -742,7 +675,7 @@ 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());
}
@@ -797,10 +730,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:
@@ -819,22 +749,16 @@ public final class LauncherHelper {
}
}
private void enableAutoAgentForCurrentSetting() {
GameSettings.Instance instance = setting.getInstance();
if (instance != null
&& instance.getOverrideProperties().contains(GameSettings.PROPERTY_ALLOW_AUTO_AGENT)) {
instance.allowAutoAgentProperty().setValue(true);
} else {
setting.getPreset().allowAutoAgentProperty().setValue(true);
}
}
/// 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;
@@ -846,7 +770,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;
@@ -929,10 +855,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();
}
});
@@ -973,7 +897,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();
@@ -1005,7 +929,7 @@ public final class LauncherHelper {
@Override
public void onExit(int exitCode, ExitType exitType) {
if (showLogs) {
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO));
submitLogThread.interrupt();
try {
submitLogThread.join();
@@ -1031,8 +955,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();
@@ -1,163 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 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.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.ui.instances.ModTranslations;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepository {
private static final int CONTAIN_CHINESE_WEIGHT = 10;
private static final int INITIAL_CAPACITY = 16;
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
@Override
public String getApiBaseUrl() {
return getBackedRemoteModRepository().getApiBaseUrl();
}
@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 {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(type).searchMod(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
}
count++;
if (count >= 3) break;
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
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();
if (!remoteAddons.isEmpty()) {
break;
}
}
for (RemoteAddon remoteAddon : remoteAddons) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
continue;
}
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteAddon;
} else {
searchResultArray[englishIndex--] = remoteAddon;
}
}
totalPages = searchResult.totalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
String chineseRemoteModName = chineseRemoteMod.getName();
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
}
int l = levCalculator.calc(searchFilter, chineseRemoteModName);
for (int i = 0; i < searchFilter.length(); i++) {
if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) {
l -= CONTAIN_CHINESE_WEIGHT;
}
}
return Pair.pair(remoteMod, l);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages);
}
@Override
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
return getBackedRemoteModRepository().getCategories(type);
}
@Override
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
}
@Override
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
}
@Override
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
}
@Override
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
}
@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);
}
}
@@ -0,0 +1,131 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 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.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.ui.versions.ModTranslations;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class LocalizedRemoteModRepository implements RemoteModRepository {
private static final int CONTAIN_CHINESE_WEIGHT = 10;
private static final int INITIAL_CAPACITY = 16;
protected abstract RemoteModRepository getBackedRemoteModRepository();
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
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 HashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
if (englishSearchFiltersSet.contains(englishWord)) {
continue;
}
englishSearchFiltersSet.add(englishWord);
}
count++;
if (count >= 3) break;
}
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
continue;
}
RemoteMod remoteMod = iterator.next();
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteMod;
} else {
searchResultArray[englishIndex--] = remoteMod;
}
}
totalPages = searchResult.getTotalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
String chineseRemoteModName = chineseRemoteMod.getName();
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
}
int l = levCalculator.calc(searchFilter, chineseRemoteModName);
for (int i = 0; i < searchFilter.length(); i++) {
if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) {
l -= CONTAIN_CHINESE_WEIGHT;
}
}
return Pair.pair(remoteMod, l);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages);
}
@Override
public Stream<Category> getCategories() throws IOException {
return getBackedRemoteModRepository().getCategories();
}
@Override
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file);
}
@Override
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
}
@Override
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
return getBackedRemoteModRepository().getModFile(modId, fileId);
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
}
}
@@ -19,18 +19,19 @@ package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.Log4jLevel;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
public final class Log {
public static final int DEFAULT_LOG_LINES = 2000;
public static int getLogLines() {
Integer lines = settings().logLinesProperty().get();
Integer lines = config().getLogLines();
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
}
private final String log;
private Log4jLevel level;
private boolean selected = false;
public Log(String log) {
this.log = log;
@@ -56,6 +57,14 @@ public final class Log {
return level;
}
public boolean isSelected() {
return selected;
}
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public String toString() {
return log;
@@ -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) {
@@ -17,39 +17,43 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.setting.Profile;
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;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
private final Profile profile;
private final Path zipFile;
private final Charset charset;
private final String name;
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) {
this.profile = profile;
this.zipFile = zipFile;
this.charset = charset;
this.name = name;
setName(i18n("modpack.installing"));
}
@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();
@@ -20,49 +20,44 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.modpack.*;
import org.jackhuang.hmcl.modpack.curse.CurseModpackProvider;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackProvider;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackProvider;
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
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.mod.*;
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.mod.server.ServerModpackProvider;
import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.VersionSetting;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
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;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.*;
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.
@NotNullByDefault
public final class ModpackHelper {
private ModpackHelper() {
}
@@ -109,8 +104,8 @@ public final class ModpackHelper {
} 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
@@ -119,35 +114,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 {
@@ -158,10 +146,16 @@ public final class ModpackHelper {
}
}
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
profile.getRepository().markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
HMCLGameRepository repository = profile.getRepository();
repository.refreshVersions();
VersionSetting vs = repository.specializeVersionSetting(name);
repository.undoMark(name);
if (vs != null)
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -171,55 +165,39 @@ public final class ModpackHelper {
}
};
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
}
public static boolean isExternalGameNameConflicts(String name) {
return Files.exists(Paths.get("externalgames").resolve(name));
}
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
if (isExternalGameNameConflicts(name)) {
throw new IllegalArgumentException("name existing");
}
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
.thenAcceptAsync(Schedulers.javafx(), location -> {
GameDirectory newGameDirectory = new GameDirectory(
GameDirectoryManager.newGameDirectoryId(),
LocalizedText.plain(name),
PortablePath.fromPath(location));
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
Profile newProfile = new Profile(name, location);
newProfile.setUseRelativePath(true);
Profiles.getProfiles().add(newProfile);
Profiles.setSelectedProfile(newProfile);
});
}
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(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
profile.getRepository().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();
HMCLGameRepository repository = profile.getRepository();
repository.refreshVersions();
VersionSetting vs = repository.specializeVersionSetting(name);
repository.undoMark(name);
if (vs != null)
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -230,174 +208,114 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
else
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
}
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
public static Task<Void> getUpdateTask(Profile profile, 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())
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
.thenComposeAsync(profile.getRepository().refreshVersionsAsync())
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
default:
throw new UnsupportedModpackException();
}
}
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
return getUpdateTask(repository, zipFile, charset, instanceId, configuration, null);
}
/// Creates an update task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param charset the archive encoding
/// @param instanceId the instance to update
/// @param configuration the existing modpack configuration
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the update task
public static Task<?> getUpdateTask(
HMCLGameRepository repository,
Path zipFile,
Charset charset,
GameInstanceID instanceId,
ModpackConfiguration<?> configuration,
@Nullable Set<String> excludedFiles)
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
public static Task<?> getUpdateTask(Profile profile, 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());
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
else
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(repository.refreshAsync());
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
}
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
vs.setUsesGlobal(false);
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
if (c.isOverrideJavaLocation()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JAVA_TYPE);
setting.getOverrideProperties().add(GameSettings.PROPERTY_CUSTOM_JAVA_PATH);
setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM);
setting.customJavaPathProperty().setValue(Objects.requireNonNullElse(c.getJavaPath(), ""));
vs.setJavaDir(Lang.nonNull(c.getJavaPath(), ""));
}
if (c.isOverrideMemory()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_PERM_SIZE,
GameSettings.PROPERTY_MAX_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY
));
setting.permSizeProperty().setValue(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
vs.setPermSize(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
if (c.getMaxMemory() != null)
setting.maxMemoryProperty().setValue(c.getMaxMemory());
setting.minMemoryProperty().setValue(c.getMinMemory());
vs.setMaxMemory(c.getMaxMemory());
vs.setMinMemory(c.getMinMemory());
}
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
}
if (c.isOverrideConsole()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_SHOW_LOGS);
setting.showLogsProperty().setValue(c.isShowConsole());
vs.setShowLogs(c.isShowConsole());
}
if (c.isOverrideWindow()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_WINDOW_TYPE,
GameSettings.PROPERTY_WIDTH,
GameSettings.PROPERTY_HEIGHT
));
setting.windowTypeProperty().setValue(c.isFullscreen() ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
vs.setFullscreen(c.isFullscreen());
if (c.getWidth() != null)
setting.widthProperty().setValue(c.getWidth().doubleValue());
vs.setWidth(c.getWidth());
if (c.getHeight() != null)
setting.heightProperty().setValue(c.getHeight().doubleValue());
vs.setHeight(c.getHeight());
}
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
}
}
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
});
}
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.toGameSettings(manifest, setting);
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.toVersionSetting(manifest, vs);
});
}
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
private static Task<Void> createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY,
GameSettings.PROPERTY_MAX_MEMORY,
GameSettings.PROPERTY_PERM_SIZE
));
setting.autoMemoryProperty().setValue(effective.getInheritable(GameSettings::autoMemoryProperty));
setting.minMemoryProperty().setValue(effective.getInheritable(GameSettings::minMemoryProperty));
setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory());
setting.permSizeProperty().setValue(effective.getInheritable(GameSettings::permSizeProperty));
}
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory())
vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory());
});
}
}
@@ -30,10 +30,7 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -53,7 +50,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;
@@ -166,16 +163,8 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html);
}
@Override
public void close() {
if (!future.isDone())
future.completeExceptionally(new AuthenticationException("OAuth server is closing"));
stop();
}
public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
@@ -203,11 +192,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
}
@Override
public void loginCompletedDeviceCode() {
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
}
@Override
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
lastlyOpenedURL = url;
@@ -244,12 +228,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
}
}
public static class LoginCompletedDeviceCodeEvent extends Event {
public LoginCompletedDeviceCodeEvent(Object source) {
super(source);
}
}
public static class OpenBrowserEvent extends Event {
private final String url;
@@ -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;
@@ -46,10 +45,7 @@ import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.WeakHashMap;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@@ -69,18 +65,29 @@ public final class TexturesLoader {
}
// ==== Texture Loading ====
public record LoadedTexture(Image image, Map<String, String> metadata) {
public LoadedTexture {
requireNonNull(image);
requireNonNull(metadata);
public static class LoadedTexture {
private final Image image;
private final Map<String, String> metadata;
public LoadedTexture(Image image, Map<String, String> metadata) {
this.image = requireNonNull(image);
this.metadata = requireNonNull(metadata);
}
public Image getImage() {
return image;
}
public Map<String, String> getMetadata() {
return metadata;
}
}
private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS);
private static final Path TEXTURES_DIR = Metadata.HMCL_USER_HOME.resolve("skins");
private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins");
private static Path getTexturePath(Texture texture) {
String url = texture.url();
String url = texture.getUrl();
int slash = url.lastIndexOf('/');
int dot = url.lastIndexOf('.');
if (dot < slash) {
@@ -92,7 +99,7 @@ public final class TexturesLoader {
}
public static LoadedTexture loadTexture(Texture texture) throws Throwable {
if (StringUtils.isBlank(texture.url())) {
if (StringUtils.isBlank(texture.getUrl())) {
throw new IOException("Texture url is empty");
}
@@ -100,14 +107,14 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
LOG.info("Texture downloaded: " + texture.url());
new FileDownloadTask(texture.getUrl(), file).run();
LOG.info("Texture downloaded: " + texture.getUrl());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
// concurrency conflict?
LOG.warning("Failed to download texture " + texture.url() + ", but the file is available", e);
LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e);
} else {
throw new IOException("Failed to download texture " + texture.url());
throw new IOException("Failed to download texture " + texture.getUrl());
}
}
}
@@ -120,7 +127,7 @@ public final class TexturesLoader {
if (img.isError())
throw img.getException();
Map<String, String> metadata = texture.metadata();
Map<String, String> metadata = texture.getMetadata();
if (metadata == null) {
metadata = emptyMap();
}
@@ -151,7 +158,7 @@ public final class TexturesLoader {
}
public static TextureModel getDefaultModel(UUID uuid) {
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).metadata().get("model"))
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model"))
? TextureModel.WIDE
: TextureModel.SLIM;
}
@@ -169,7 +176,7 @@ public final class TexturesLoader {
}
})
.flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN)))
.filter(it -> StringUtils.isNotBlank(it.url())))
.filter(it -> StringUtils.isNotBlank(it.getUrl())))
.asyncMap(it -> {
if (it.isPresent()) {
Texture texture = it.get();
@@ -177,7 +184,7 @@ public final class TexturesLoader {
try {
return loadTexture(texture);
} catch (Throwable e) {
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
return uuidFallback;
}
}, POOL);
@@ -188,29 +195,29 @@ public final class TexturesLoader {
}
public static ObservableValue<LoadedTexture> skinBinding(Account account) {
LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID());
LoadedTexture uuidFallback = getDefaultSkin(account.getUUID());
if (account instanceof OfflineAccount) {
OfflineAccount offlineAccount = (OfflineAccount) account;
SimpleObjectProperty<LoadedTexture> binding = new SimpleObjectProperty<>();
InvalidationListener listener = o -> {
Skin skin = offlineAccount.getSkin();
String profileName = offlineAccount.getProfileName();
String username = offlineAccount.getUsername();
binding.set(uuidFallback);
if (skin != null) {
skin.load(profileName).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to load texture", exception);
} else if (result != null && result.skin() != null && result.skin().image() != null) {
} else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) {
Map<String, String> metadata;
if (result.model() != null) {
metadata = singletonMap("model", result.model().modelName);
if (result.getModel() != null) {
metadata = singletonMap("model", result.getModel().modelName);
} else {
metadata = emptyMap();
}
binding.set(new LoadedTexture(result.skin().image(), metadata));
binding.set(new LoadedTexture(result.getSkin().getImage(), metadata));
}
}).start();
}
@@ -227,12 +234,12 @@ public final class TexturesLoader {
.asyncMap(textures -> {
if (textures.isPresent()) {
Texture texture = textures.get().get(TextureType.SKIN);
if (texture != null && StringUtils.isNotBlank(texture.url())) {
if (texture != null && StringUtils.isNotBlank(texture.getUrl())) {
return CompletableFuture.supplyAsync(() -> {
try {
return loadTexture(texture);
} catch (Throwable e) {
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
return uuidFallback;
}
}, POOL);
@@ -311,7 +318,7 @@ public final class TexturesLoader {
fxAvatarBinding(canvas, skinBinding(account));
else {
unbindAvatar(canvas);
drawAvatar(canvas, getDefaultSkin(account.getProfileID()).image);
drawAvatar(canvas, getDefaultSkin(account.getUUID()).image);
}
}
@@ -99,7 +99,7 @@ public final class HMCLJavaRepository implements JavaRepository {
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
}
private static void getAllJava(List<Path> list, Platform platform, Path platformRoot) {
private static void getAllJava(List<JavaRuntime> list, Platform platform, Path platformRoot, boolean isManaged) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(platformRoot)) {
for (Path file : stream) {
try {
@@ -117,7 +117,8 @@ public final class HMCLJavaRepository implements JavaRepository {
}
if (Files.isDirectory(javaDir)) {
list.add(executable);
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
}
}
} catch (Throwable e) {
@@ -130,23 +131,28 @@ public final class HMCLJavaRepository implements JavaRepository {
}
@Override
public Collection<Path> getAllJava(Platform platform) {
public Collection<JavaRuntime> getAllJava(Platform platform) {
Path platformRoot = getPlatformRoot(platform);
if (!Files.isDirectory(platformRoot))
return Collections.emptyList();
ArrayList<Path> list = new ArrayList<>();
ArrayList<JavaRuntime> list = new ArrayList<>();
getAllJava(list, platform, platformRoot, true);
if (platform.getOperatingSystem() == OperatingSystem.MACOS) {
platformRoot = root.resolve(platform.getOperatingSystem().getMojangName() + "-" + platform.getArchitecture().getCheckedName());
if (Files.isDirectory(platformRoot))
getAllJava(list, platform, platformRoot, false);
}
getAllJava(list, platform, platformRoot);
return list;
}
@Override
public Task<JavaRuntime> getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) {
Path javaDir = getJavaDir(platform, gameJavaVersion);
Path tempDir = getPlatformRoot(platform).resolve(".tmp").resolve(javaDir.getFileName());
return new MojangJavaDownloadTask(downloadProvider, javaDir, tempDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
return new MojangJavaDownloadTask(downloadProvider, javaDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
Path executable;
try {
executable = JavaManager.getExecutable(javaDir).toRealPath();
@@ -159,16 +165,16 @@ public final class HMCLJavaRepository implements JavaRepository {
JavaInfo info;
if (JavaManager.isCompatible(platform))
info = JavaInfoUtils.fromExecutable(executable);
info = JavaInfoUtils.fromExecutable(executable, false);
else
info = new JavaInfo(platform, result.download().version().name(), null);
info = new JavaInfo(platform, result.download.getVersion().getName(), null);
Map<String, Object> update = new LinkedHashMap<>();
update.put("provider", "mojang");
update.put("component", gameJavaVersion.component());
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
result.remoteFiles().files().forEach((path, file) -> {
result.remoteFiles.getFiles().forEach((path, file) -> {
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
if (downloadInfo != null) {
@@ -190,12 +196,12 @@ public final class HMCLJavaRepository implements JavaRepository {
public Task<JavaRuntime> getInstallJavaTask(Platform platform, String name, Map<String, Object> update, Path archiveFile) {
Path javaDir = getJavaDir(platform, name);
return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> {
if (!result.info().getPlatform().equals(platform))
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.info().getPlatform());
if (!result.getInfo().getPlatform().equals(platform))
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
return JavaRuntime.of(executable, result.info(), true);
return JavaRuntime.of(executable, result.getInfo(), true);
});
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.java;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -28,6 +27,7 @@ import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
@@ -39,10 +39,35 @@ public final class JavaInfoUtils {
private JavaInfoUtils() {
}
public static @NotNull JavaInfo fromExecutable(Path executable) throws IOException {
private static Path tryFindReleaseFile(Path executable) {
Path parent = executable.getParent();
if (parent != null && parent.getFileName() != null && parent.getFileName().toString().equals("bin")) {
Path javaHome = parent.getParent();
if (javaHome != null && javaHome.getFileName() != null) {
Path releaseFile = javaHome.resolve("release");
String javaHomeName = javaHome.getFileName().toString();
if ((javaHomeName.contains("jre") || javaHomeName.contains("jdk") || javaHomeName.contains("openj9"))
&& Files.isRegularFile(releaseFile)) {
return releaseFile;
}
}
}
return null;
}
public static @NotNull JavaInfo fromExecutable(Path executable, boolean tryFindReleaseFile) throws IOException {
assert executable.isAbsolute();
Path releaseFile;
if (tryFindReleaseFile && (releaseFile = tryFindReleaseFile(executable)) != null) {
try {
return JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException ignored) {
}
}
Path thisPath = JarUtils.thisJarPath();
if (thisPath == null) {
throw new IOException("Failed to find current HMCL location");
}
@@ -69,6 +94,7 @@ public final class JavaInfoUtils {
? architecture
: Architecture.SYSTEM_ARCH);
return new JavaInfo(platform, result.javaVersion, result.javaVendor);
} catch (IOException e) {
throw e;
@@ -77,9 +103,14 @@ public final class JavaInfoUtils {
}
}
@JsonSerializable
private record Result(@SerializedName("os.name") String osName, @SerializedName("os.arch") String osArch,
@SerializedName("java.version") String javaVersion,
@SerializedName("java.vendor") String javaVendor) {
private static final class Result {
@SerializedName("os.name")
public String osName;
@SerializedName("os.arch")
public String osArch;
@SerializedName("java.version")
public String javaVersion;
@SerializedName("java.vendor")
public String javaVendor;
}
}
@@ -17,28 +17,22 @@
*/
package org.jackhuang.hmcl.java;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.stream.JsonWriter;
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.setting.SettingsManager;
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.ConfigHolder;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
@@ -46,14 +40,9 @@ import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -66,18 +55,8 @@ public final class JavaManager {
private JavaManager() {
}
private static final String[] KNOWN_VENDOR_DIRECTORIES = {
"Java",
"BellSoft",
"AdoptOpenJDK",
"Zulu",
"Microsoft",
"Eclipse Foundation",
"Semeru"
};
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_USER_HOME.resolve("java"));
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
public static String getMojangJavaPlatform(Platform platform) {
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
@@ -124,27 +103,16 @@ public final class JavaManager {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
// Windows x86-64 platform is compatible with x86 programs
return architecture == Architecture.X86;
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
// Since Windows 10 Build 21277, Windows Arm64 has been compatible with x86-64 programs via translation
if (architecture == Architecture.X86_64 && Platform.isSupportedTranslationX86_64())
return true;
// Windows Arm64 is compatible with x86 programs via translation
if (architecture == Architecture.X86)
return true;
return false;
}
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || architecture == Architecture.X86;
break;
case LINUX:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
return architecture == Architecture.X86;
break;
case MACOS:
// macOS Arm64 compatible with x86-64 programs via Rosetta 2.
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
return architecture == Architecture.X86_64;
break;
}
@@ -192,12 +160,12 @@ public final class JavaManager {
return javaRuntime;
}
JavaInfo info = JavaInfoUtils.fromExecutable(executable);
JavaInfo info = JavaInfoUtils.fromExecutable(executable, true);
return JavaRuntime.of(executable, info, false);
}
public static void refresh() {
Task.supplyAsync(() -> searchPotentialJavaExecutables(false)).whenComplete(Schedulers.javafx(), (result, exception) -> {
Task.supplyAsync(JavaManager::searchPotentialJavaExecutables).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (result != null) {
LATCH.await();
allJava = result;
@@ -215,11 +183,9 @@ public final class JavaManager {
String pathString = javaRuntime.getBinary().toString();
if (!SettingsManager.isUserSettingsReadOnly()) {
SettingsManager.userSettings().getDisabledJava().remove(pathString);
if (SettingsManager.userSettings().getUserJava().add(pathString)) {
addJava(javaRuntime);
}
ConfigHolder.globalConfig().getDisabledJava().remove(pathString);
if (ConfigHolder.globalConfig().getUserJava().add(pathString)) {
addJava(javaRuntime);
}
return javaRuntime;
});
@@ -271,7 +237,7 @@ public final class JavaManager {
}
}
@FXThread
// FXThread
public static void addJava(JavaRuntime java) throws InterruptedException {
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
if (!oldMap.containsKey(java.getBinary())) {
@@ -282,12 +248,12 @@ public final class JavaManager {
}
}
@FXThread
// FXThread
public static void removeJava(JavaRuntime java) throws InterruptedException {
removeJava(java.getBinary());
}
@FXThread
// FXThread
public static void removeJava(Path realPath) throws InterruptedException {
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
if (oldMap.containsKey(realPath)) {
@@ -310,13 +276,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 +303,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 {
@@ -360,7 +326,7 @@ public final class JavaManager {
}
public static void initialize() {
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables(true);
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables();
JavaManager.allJava = allJava;
LATCH.countDown();
FXUtils.runInFX(() -> updateAllJavaProperty(allJava));
@@ -368,56 +334,56 @@ public final class JavaManager {
// search java
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables(boolean useCache) {
Searcher searcher = new Searcher(Metadata.HMCL_USER_HOME.resolve("javaCache.json"));
if (useCache)
searcher.loadCache();
searcher.searchAllJavaInRepository(Platform.SYSTEM_PLATFORM);
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables() {
Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
searchAllJavaInRepository(javaRuntimes, Platform.SYSTEM_PLATFORM);
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
if (Platform.isSupportedTranslationX86_64())
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86_64);
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
}
break;
case MACOS:
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
searchAllJavaInRepository(javaRuntimes, Platform.MACOS_X86_64);
break;
}
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
searcher.searchJavaInProgramFiles("ProgramFiles", "C:\\Program Files");
searcher.searchJavaInProgramFiles("ProgramFiles(x86)", "C:\\Program Files (x86)");
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles", "C:\\Program Files");
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(x86)", "C:\\Program Files (x86)");
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(ARM)", "C:\\Program Files (ARM)");
}
break;
case LINUX:
searcher.searchAllJavaInDirectory(Path.of("/usr/java")); // Oracle RPMs
searcher.searchAllJavaInDirectory(Path.of("/usr/lib/jvm")); // General locations
searcher.searchAllJavaInDirectory(Path.of("/usr/lib32/jvm")); // General locations
searcher.searchAllJavaInDirectory(Path.of("/usr/lib64/jvm")); // General locations
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/java")); // Oracle RPMs
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib/jvm")); // General locations
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib32/jvm")); // General locations
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib64/jvm")); // General locations
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
break;
case MACOS:
searcher.searchJavaInMacJavaVirtualMachines(Path.of("/Library/Java/JavaVirtualMachines"));
searcher.searchJavaInMacJavaVirtualMachines(Path.of(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
searcher.tryAddJavaExecutable(Path.of("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
searcher.tryAddJavaExecutable(Path.of("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get("/Library/Java/JavaVirtualMachines"));
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
tryAddJavaExecutable(javaRuntimes, Paths.get("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
tryAddJavaExecutable(javaRuntimes, Paths.get("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
// Homebrew
searcher.tryAddJavaExecutable(Path.of("/opt/homebrew/opt/java/bin/java"));
searcher.searchAllJavaInDirectory(Path.of("/opt/homebrew/Cellar/openjdk"));
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Path.of("/opt/homebrew/Cellar"), "openjdk@*")) {
tryAddJavaExecutable(javaRuntimes, Paths.get("/opt/homebrew/opt/java/bin/java"));
searchAllJavaInDirectory(javaRuntimes, Paths.get("/opt/homebrew/Cellar/openjdk"));
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Paths.get("/opt/homebrew/Cellar"), "openjdk@*")) {
for (Path dir : dirs) {
searcher.searchAllJavaInDirectory(dir);
searchAllJavaInDirectory(javaRuntimes, dir);
}
} catch (IOException e) {
LOG.warning("Failed to get subdirectories of /opt/homebrew/Cellar");
@@ -431,16 +397,16 @@ public final class JavaManager {
// Search Minecraft bundled runtimes
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH.isX86()) {
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false));
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, 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);
searchAllOfficialJava(javaRuntimes, Paths.get(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);
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
}
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
@@ -454,7 +420,7 @@ public final class JavaManager {
}
try {
searcher.tryAddJavaExecutable(Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
} catch (InvalidPathException ignored) {
}
}
@@ -464,17 +430,16 @@ public final class JavaManager {
String[] paths = System.getenv("HMCL_JRES").split(File.pathSeparator);
for (String path : paths) {
try {
searcher.tryAddJavaHome(Path.of(path));
tryAddJavaHome(javaRuntimes, Paths.get(path));
} catch (InvalidPathException ignored) {
}
}
}
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), ".jdks"));
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
for (String javaPath : SettingsManager.userSettings().getUserJava()) {
for (String javaPath : ConfigHolder.globalConfig().getUserJava()) {
try {
searcher.tryAddJavaExecutable(Path.of(javaPath));
tryAddJavaExecutable(javaRuntimes, Paths.get(javaPath));
} catch (InvalidPathException e) {
LOG.warning("Invalid Java path: " + javaPath);
}
@@ -482,406 +447,233 @@ public final class JavaManager {
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
if (currentJava != null
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
&& !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) {
searcher.addResult(currentJava.getBinary(), currentJava);
&& !javaRuntimes.containsKey(currentJava.getBinary())
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
javaRuntimes.put(currentJava.getBinary(), currentJava);
}
searcher.saveCache();
LOG.trace(searcher.javaRuntimes.values().stream().sorted()
LOG.trace(javaRuntimes.values().stream().sorted()
.map(it -> String.format(" - %s %s (%s, %s): %s",
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;
.collect(Collectors.joining("\n", "Finished Java lookup, found " + javaRuntimes.size() + "\n", "")));
return javaRuntimes;
}
private static final class Searcher {
private final Path cacheFile;
final Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
private final LinkedHashMap<Path, JavaInfoCache> caches = new LinkedHashMap<>();
private final Set<Path> failed = new HashSet<>();
private boolean needRefreshCache = false;
Searcher(Path cacheFile) {
this.cacheFile = cacheFile;
private static void tryAddJavaHome(Map<Path, JavaRuntime> javaRuntimes, Path javaHome) {
Path executable = getExecutable(javaHome);
if (!Files.isRegularFile(executable)) {
return;
}
private static final Pattern CACHE_VERSION_PATTERN = Pattern.compile("(?<major>\\d+)(?:\\.(?<minor>\\d+))?");
private static final int CACHE_MAJOR_VERSION = 0;
private static final int CACHE_MINOR_VERSION = 0;
private record JavaInfoCache(String key, JavaInfo info) {
try {
executable = executable.toRealPath();
} catch (IOException e) {
LOG.warning("Failed to resolve path " + executable, e);
return;
}
void loadCache() {
if (Files.notExists(cacheFile))
return;
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
return;
}
JavaInfo info = null;
Path releaseFile = javaHome.resolve("release");
if (Files.exists(releaseFile)) {
try {
JsonObject jsonFile = JsonUtils.fromJsonFile(cacheFile, JsonObject.class);
JsonElement fileVersion = jsonFile.get("version");
Matcher matcher;
if (fileVersion instanceof JsonPrimitive version
&& (matcher = CACHE_VERSION_PATTERN.matcher(version.getAsString())).matches()) {
int major = Integer.parseInt(matcher.group("major"));
String minorString = matcher.group("minor");
int minor = minorString != null ? Integer.parseInt(minorString) : 0;
if (major != CACHE_MAJOR_VERSION || minor < CACHE_MINOR_VERSION)
throw new IOException("Unsupported cache file, version: %s".formatted(version.getAsString()));
} else
throw new IOException("Invalid version JSON: " + fileVersion);
JsonArray cachesArray = jsonFile.getAsJsonArray("caches");
for (JsonElement element : cachesArray) {
try {
var obj = (JsonObject) element;
Path realPath = Path.of(obj.getAsJsonPrimitive("path").getAsString()).toRealPath();
String key = obj.getAsJsonPrimitive("key").getAsString();
OperatingSystem osName = OperatingSystem.parseOSName(obj.getAsJsonPrimitive("os.name").getAsString());
Architecture osArch = Architecture.parseArchName(obj.getAsJsonPrimitive("os.arch").getAsString());
String javaVersion = obj.getAsJsonPrimitive("java.version").getAsString();
JavaInfo.Builder infoBuilder = JavaInfo.newBuilder(Platform.getPlatform(osName, osArch), javaVersion);
if (obj.get("java.vendor") instanceof JsonPrimitive vendor)
infoBuilder.setVendor(vendor.getAsString());
caches.put(realPath, new JavaInfoCache(key, infoBuilder.build()));
} catch (Exception e) {
LOG.warning("Invalid cache: " + element);
needRefreshCache = true;
}
}
} catch (Exception ex) {
LOG.warning("Failed to load cache file: " + cacheFile);
needRefreshCache = true;
}
}
void saveCache() {
if (!needRefreshCache)
return;
needRefreshCache = false;
try {
FileUtils.saveSafely(cacheFile, output -> {
try (var writer = new JsonWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))) {
writer.beginObject();
writer.name("version").value("%d.%d".formatted(CACHE_MAJOR_VERSION, CACHE_MINOR_VERSION));
writer.name("caches");
writer.beginArray();
for (Map.Entry<Path, JavaInfoCache> entry : caches.entrySet()) {
Path path = entry.getKey();
JavaInfoCache cache = entry.getValue();
JavaInfo info = cache.info();
writer.beginObject();
writer.name("path").value(path.toString());
writer.name("key").value(cache.key());
writer.name("os.name").value(info.getPlatform().os().getCheckedName());
writer.name("os.arch").value(info.getPlatform().arch().getCheckedName());
writer.name("java.version").value(info.getVersion());
if (info.getVendor() != null)
writer.name("java.vendor").value(info.getVendor());
writer.endObject();
}
writer.endArray();
writer.endObject();
}
});
} catch (Exception e) {
LOG.warning("Failed to save cache file: " + cacheFile);
}
}
private static @Nullable String createCacheKey(Path realPath) {
Path binDir = realPath.getParent();
if (binDir == null || !FileUtils.getName(binDir).equals("bin"))
return null;
if (Files.isRegularFile(realPath.resolveSibling("ikvm.properties")))
return null;
Path javaHome = binDir.getParent();
if (javaHome == null)
return null;
Path libDir = javaHome.resolve("lib");
if (!Files.isDirectory(libDir))
return null;
BasicFileAttributes launcherAttributes;
String releaseHash = null;
BasicFileAttributes coreLibsAttributes = null;
try {
launcherAttributes = Files.readAttributes(realPath, BasicFileAttributes.class);
Path releaseFile = javaHome.resolve("release");
if (Files.exists(releaseFile)) {
releaseHash = DigestUtils.digestToString("SHA-1", releaseFile);
} else {
Path coreLibsFile = libDir.resolve("rt.jar");
if (!Files.isRegularFile(coreLibsFile)) {
coreLibsFile = javaHome.resolve("jre/lib/rt.jar");
if (!Files.isRegularFile(coreLibsFile))
return null;
coreLibsAttributes = Files.readAttributes(coreLibsFile, BasicFileAttributes.class);
}
}
} catch (Exception e) {
LOG.warning("Failed to create cache key for " + realPath, e);
return null;
}
StringJoiner joiner = new StringJoiner(",");
joiner.add("sz:" + launcherAttributes.size());
joiner.add("lm:" + launcherAttributes.lastModifiedTime().toMillis());
if (releaseHash != null)
joiner.add(releaseHash);
if (coreLibsAttributes != null) {
joiner.add("rsz:" + coreLibsAttributes.size());
joiner.add("rlm:" + coreLibsAttributes.lastModifiedTime().toMillis());
}
return joiner.toString();
}
void addResult(Path realPath, JavaRuntime javaRuntime) {
javaRuntimes.put(realPath, javaRuntime);
}
void tryAddJavaHome(Path javaHome) {
tryAddJavaExecutable(getExecutable(javaHome));
}
void tryAddJavaExecutable(Path executable) {
tryAddJavaExecutable(executable, false);
}
void tryAddJavaExecutable(Path executable, boolean isManaged) {
try {
executable = executable.toRealPath();
info = JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException e) {
return;
LOG.warning("Failed to read release file " + releaseFile, e);
}
}
if (javaRuntimes.containsKey(executable)
|| failed.contains(executable)
|| SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) {
return;
}
String cacheKey = createCacheKey(executable);
if (cacheKey != null) {
JavaInfoCache cache = caches.get(executable);
if (cache != null) {
if (isCompatible(cache.info().getPlatform()) && cacheKey.equals(cache.key())) {
javaRuntimes.put(executable, JavaRuntime.of(executable, cache.info(), isManaged));
return;
} else {
caches.remove(executable);
needRefreshCache = true;
}
}
} else if (caches.remove(executable) != null) {
needRefreshCache = true;
}
JavaInfo info;
if (info == null) {
try {
info = JavaInfoUtils.fromExecutable(executable);
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
failed.add(executable);
return;
}
if (cacheKey != null) {
caches.put(executable, new JavaInfoCache(cacheKey, info));
needRefreshCache = true;
}
javaRuntimes.put(executable, JavaRuntime.of(executable, info, isManaged));
}
void tryAddJavaInComponentDir(String platform, Path component, boolean verify) {
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
if (!Files.isRegularFile(sha1File))
return;
Path dir = component.resolve(platform).resolve(component.getFileName());
if (verify) {
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) continue;
int idx = line.indexOf(" /#//");
if (idx <= 0)
throw new IOException("Illegal line: " + line);
Path file = dir.resolve(line.substring(0, idx));
// Should we check the sha1 of files? This will take a lot of time.
if (Files.notExists(file))
throw new NoSuchFileException(file.toAbsolutePath().toString());
}
} catch (IOException e) {
LOG.warning("Failed to verify Java in " + component, e);
return;
}
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Path macPath = dir.resolve("jre.bundle/Contents/Home");
if (Files.exists(macPath)) {
tryAddJavaHome(macPath);
return;
} else
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
}
tryAddJavaHome(dir);
}
void searchAllJavaInRepository(Platform platform) {
for (Path java : REPOSITORY.getAllJava(platform)) {
tryAddJavaExecutable(java, true);
}
for (Path java : LOCAL_REPOSITORY.getAllJava(platform)) {
tryAddJavaExecutable(java, true);
}
if (platform.os() == OperatingSystem.MACOS) {
// In the past, we used 'osx' as the checked name for macOS
Path platformRoot = REPOSITORY.getPlatformRoot(platform).resolveSibling("osx-" + platform.getArchitecture().getCheckedName());
searchAllJavaInDirectory(platformRoot);
}
}
void searchAllOfficialJava(Path directory, boolean verify) {
if (!Files.isDirectory(directory))
return;
// Examples:
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
if (info != null && isCompatible(info.getPlatform()))
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
}
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
if (javaPlatform != null) {
searchAllOfficialJava(directory, javaPlatform, verify);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
if (Platform.isSupportedTranslationX86_64()) {
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
}
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64()) {
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
}
}
private static void tryAddJavaExecutable(Map<Path, JavaRuntime> javaRuntimes, Path executable) {
try {
executable = executable.toRealPath();
} catch (IOException e) {
return;
}
void searchAllOfficialJava(Path directory, String platform, boolean verify) {
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
for (Path component : dir) {
tryAddJavaInComponentDir(platform, component, verify);
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
return;
}
JavaInfo info = null;
try {
info = JavaInfoUtils.fromExecutable(executable, true);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
}
if (info != null && isCompatible(info.getPlatform())) {
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
}
}
private static void tryAddJavaInComponentDir(Map<Path, JavaRuntime> javaRuntimes, String platform, Path component, boolean verify) {
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
if (!Files.isRegularFile(sha1File))
return;
Path dir = component.resolve(platform).resolve(component.getFileName());
if (verify) {
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) continue;
int idx = line.indexOf(" /#//");
if (idx <= 0)
throw new IOException("Illegal line: " + line);
Path file = dir.resolve(line.substring(0, idx));
// Should we check the sha1 of files? This will take a lot of time.
if (Files.notExists(file))
throw new NoSuchFileException(file.toAbsolutePath().toString());
}
} catch (IOException e) {
LOG.warning("Failed to list java-runtime directory " + directory, e);
LOG.warning("Failed to verify Java in " + component, e);
return;
}
}
void searchAllJavaInDirectory(Path directory) {
if (!Files.isDirectory(directory)) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Path macPath = dir.resolve("jre.bundle/Contents/Home");
if (Files.exists(macPath)) {
tryAddJavaHome(javaRuntimes, macPath);
return;
}
} else
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(subDir);
tryAddJavaHome(javaRuntimes, dir);
}
private static void searchAllJavaInRepository(Map<Path, JavaRuntime> javaRuntimes, Platform platform) {
for (JavaRuntime java : REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
}
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, boolean verify) {
if (!Files.isDirectory(directory))
return;
// Examples:
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
if (javaPlatform != null) {
searchAllOfficialJava(javaRuntimes, directory, javaPlatform, verify);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
}
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
}
}
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, String platform, boolean verify) {
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
for (Path component : dir) {
tryAddJavaInComponentDir(javaRuntimes, platform, component, verify);
}
} catch (IOException e) {
LOG.warning("Failed to list java-runtime directory " + directory, e);
}
}
private static void searchAllJavaInDirectory(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
if (!Files.isDirectory(directory)) {
return;
}
void searchJavaInProgramFiles(String env, String defaultValue) {
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
Path path;
try {
path = Path.of(programFiles);
} catch (InvalidPathException ignored) {
return;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(javaRuntimes, subDir);
}
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
}
}
for (String vendor : KNOWN_VENDOR_DIRECTORIES) {
searchAllJavaInDirectory(path.resolve(vendor));
}
private static void searchJavaInProgramFiles(Map<Path, JavaRuntime> javaRuntimes, String env, String defaultValue) {
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
Path path;
try {
path = Paths.get(programFiles);
} catch (InvalidPathException ignored) {
return;
}
void searchJavaInMacJavaVirtualMachines(Path directory) {
if (!Files.isDirectory(directory)) {
return;
}
for (String vendor : new String[]{"Java", "BellSoft", "AdoptOpenJDK", "Zulu", "Microsoft", "Eclipse Foundation", "Semeru"}) {
searchAllJavaInDirectory(javaRuntimes, path.resolve(vendor));
}
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(subDir.resolve("Contents/Home"));
}
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
}
private static void searchJavaInMacJavaVirtualMachines(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
if (!Files.isDirectory(directory)) {
return;
}
// ==== Windows Registry Support ====
void queryJavaInRegistryKey(WinReg.HKEY hkey, String location) {
WinReg reg = WinReg.INSTANCE;
if (reg == null)
return;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
for (Path subDir : stream) {
tryAddJavaHome(javaRuntimes, subDir.resolve("Contents/Home"));
}
} catch (IOException e) {
LOG.warning("Failed to find Java in " + directory, e);
}
}
for (String java : reg.querySubKeys(hkey, location)) {
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
continue;
if (reg.queryValue(hkey, java, "JavaHome") instanceof String home) {
try {
tryAddJavaHome(Path.of(home));
} catch (InvalidPathException e) {
LOG.warning("Invalid Java path in system registry: " + home);
}
// ==== Windows Registry Support ====
private static void queryJavaInRegistryKey(Map<Path, JavaRuntime> javaRuntimes, WinReg.HKEY hkey, String location) {
WinReg reg = WinReg.INSTANCE;
if (reg == null)
return;
for (String java : reg.querySubKeys(hkey, location)) {
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
continue;
Object home = reg.queryValue(hkey, java, "JavaHome");
if (home instanceof String) {
try {
tryAddJavaHome(javaRuntimes, Paths.get((String) home));
} catch (InvalidPathException e) {
LOG.warning("Invalid Java path in system registry: " + home);
}
}
}
}
}
@@ -34,7 +34,33 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
* @author Glavo
*/
@JsonAdapter(JavaManifest.Serializer.class)
public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
public final class JavaManifest {
private final JavaInfo info;
@Nullable
private final Map<String, Object> update;
@Nullable
private final Map<String, JavaLocalFiles.Local> files;
public JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
this.info = info;
this.update = update;
this.files = files;
}
public JavaInfo getInfo() {
return info;
}
public Map<String, Object> getUpdate() {
return update;
}
public Map<String, JavaLocalFiles.Local> getFiles() {
return files;
}
public static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
@@ -43,16 +69,16 @@ public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update,
@Override
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
JsonObject res = new JsonObject();
res.addProperty("os.name", src.info().getPlatform().getOperatingSystem().getCheckedName());
res.addProperty("os.arch", src.info().getPlatform().getArchitecture().getCheckedName());
res.addProperty("java.version", src.info().getVersion());
res.addProperty("java.vendor", src.info().getVendor());
res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName());
res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName());
res.addProperty("java.version", src.getInfo().getVersion());
res.addProperty("java.vendor", src.getInfo().getVendor());
if (src.update() != null)
res.add("update", context.serialize(src.update()));
if (src.getUpdate() != null)
res.add("update", context.serialize(src.getUpdate()));
if (src.files() != null)
res.add("files", context.serialize(src.files(), LOCAL_FILES_TYPE));
if (src.getFiles() != null)
res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE));
return res;
}
@@ -1,144 +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.setting;
import com.google.gson.JsonObject;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.List;
import java.util.Objects;
/// Stores account metadata records in a detached JSON file.
///
/// The JSON representation is saved under the `config` directory and stores account metadata in the `accounts` list.
/// Private account data is persisted separately in [AccountPrivateData].
@JsonAdapter(AccountMetadataStore.Adapter.class)
@NotNullByDefault
@JsonSerializable
final class AccountMetadataStore extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this account metadata store.
static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("accounts", new JsonSchema.Version(1, 0, 0));
/// Creates an empty account metadata store.
AccountMetadataStore() {
tracker.markDirty(schema);
tracker.markDirty(accounts);
register();
}
/// Creates an account metadata store from already serialized account records.
///
/// @param records the serialized account records
/// @return an account metadata store containing the given records
static AccountMetadataStore fromRecords(List<JsonObject> records) {
AccountMetadataStore result = new AccountMetadataStore();
result.getAccounts().setAll(records);
return result;
}
/// Creates a copy of this account metadata store with replacement account records.
///
/// @param records the replacement account records
/// @return a copy preserving this store's schema and unknown top-level members
AccountMetadataStore copyWithRecords(List<JsonObject> records) {
AccountMetadataStore result = new AccountMetadataStore();
result.setSchema(getSchema());
result.getAccounts().setAll(records);
result.unknownFields.putAll(unknownFields);
return result;
}
/// The schema used by this account metadata store file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this account metadata store file.
@Override
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this account metadata store file.
@Override
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this account metadata store may be saved back to its JSON file.
private transient boolean savable = true;
/// Whether the next successful save should back up the current on-disk file first.
private transient boolean backupOnNextSave;
/// Returns whether this account metadata store may be saved back to its JSON file.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this account metadata store may be saved back to its JSON file.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current on-disk file first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current on-disk file first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// Serialized account records.
@SerializedName("accounts")
private final ObservableList<JsonObject> accounts = FXCollections.observableArrayList();
/// Returns serialized account records.
public ObservableList<JsonObject> getAccounts() {
return accounts;
}
/// JSON adapter for [AccountMetadataStore].
static final class Adapter extends ObservableSetting.Adapter<AccountMetadataStore> {
/// Creates an empty account metadata store for deserialization.
@Override
protected AccountMetadataStore createInstance() {
return new AccountMetadataStore();
}
}
}
@@ -1,290 +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.setting;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableMap;
import org.jackhuang.hmcl.auth.AccountID;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/// Stores private account data in a protected payload.
@JsonAdapter(AccountPrivateData.Adapter.class)
@NotNullByDefault
@JsonSerializable
final class AccountPrivateData extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this account private data store.
static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("account-private-data", new JsonSchema.Version(1, 0, 0));
/// JVM system property selecting the account private data protection mode.
static final String PROTECTION_PROPERTY = "hmcl.account.privateData.protection";
/// Creates an empty account private data store.
AccountPrivateData() {
tracker.markDirty(schema);
tracker.markDirty(privateData);
register();
}
/// The schema used by this account private data store file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Private account data keyed by account ID.
private final ObservableMap<AccountID, JsonObject> privateData =
FXCollections.observableMap(new LinkedHashMap<>());
/// Whether this account private data store may be saved back to its JSON file.
private transient boolean savable = true;
/// Whether the next successful save should back up the current on-disk file first.
private transient boolean backupOnNextSave;
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this account private data store file.
@Override
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this account private data store file.
@Override
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Returns private account data keyed by account ID.
ObservableMap<AccountID, JsonObject> getPrivateData() {
return privateData;
}
/// Returns the protection mode selected for writing account private data.
private static ProtectedPayload.ProtectionMode protectionMode() {
return ProtectedPayload.ProtectionMode.fromConfiguredId(System.getProperty(PROTECTION_PROPERTY));
}
/// Returns whether this account private data store may be saved back to its JSON file.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this account private data store may be saved back to its JSON file.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current on-disk file first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current on-disk file first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// Finds the first private data entry matching an account ID.
///
/// @param accountID the stable account ID
/// @param privateDataStores private data stores searched in order
/// @return the first matching private data object, or `null` if no store contains one
static @Nullable JsonObject findPrivateData(
AccountID accountID,
List<AccountPrivateData> privateDataStores) {
for (AccountPrivateData privateDataStore : privateDataStores) {
@Nullable JsonObject accountPrivateData = privateDataStore.privateData.get(accountID);
if (accountPrivateData != null) {
return accountPrivateData;
}
}
return null;
}
/// Returns whether this store contains private data for the account ID.
///
/// @param accountID the stable account ID
/// @return whether this store contains private data for the account ID
boolean containsPrivateData(AccountID accountID) {
return privateData.containsKey(accountID);
}
/// Removes private data for the account ID.
///
/// @param accountID the stable account ID
/// @return whether private data was removed
boolean removePrivateData(AccountID accountID) {
return privateData.remove(accountID) != null;
}
/// Stores private data for the account ID, or removes existing private data when the object is empty.
///
/// @param accountID the stable account ID
/// @param accountPrivateData the private account data
void putPrivateData(AccountID accountID, JsonObject accountPrivateData) {
if (accountPrivateData.isEmpty()) {
privateData.remove(accountID);
} else {
privateData.put(accountID, accountPrivateData);
}
}
/// Replaces this private data store with another store.
///
/// @param other the private data store to copy from
void replaceWith(AccountPrivateData other) {
privateData.clear();
for (Map.Entry<AccountID, JsonObject> entry : other.privateData.entrySet()) {
putPrivateData(entry.getKey(), entry.getValue());
}
}
/// JSON adapter for [AccountPrivateData].
static final class Adapter implements JsonSerializer<AccountPrivateData>,
com.google.gson.JsonDeserializer<AccountPrivateData> {
/// Creates the JSON payload protected inside an account private data file.
///
/// @param accountPrivateData the private data store to serialize
/// @param context the JSON serialization context
/// @return the plain payload before protection
private static JsonArray createPayload(
AccountPrivateData accountPrivateData,
JsonSerializationContext context) {
JsonArray payload = new JsonArray();
for (Map.Entry<AccountID, JsonObject> entry : accountPrivateData.privateData.entrySet()) {
JsonObject item = new JsonObject();
item.add("accountID", context.serialize(entry.getKey(), AccountID.class));
item.add("privateData", entry.getValue());
payload.add(item);
}
return payload;
}
/// Reads an account ID from a private data entry.
private static @Nullable AccountID readAccountID(
@Nullable JsonElement accountIDElement,
JsonDeserializationContext context) {
if (accountIDElement == null || accountIDElement.isJsonNull()) {
return null;
}
try {
return context.deserialize(accountIDElement, AccountID.class);
} catch (JsonParseException | IllegalArgumentException e) {
return null;
}
}
/// Reads the protected payload into the given private data store.
///
/// @param accountPrivateData the private data store to update
/// @param payload the revealed payload
/// @param context the JSON deserialization context
private static void readPayload(
AccountPrivateData accountPrivateData,
JsonArray payload,
JsonDeserializationContext context) {
for (JsonElement itemElement : payload) {
if (!(itemElement instanceof JsonObject item)) {
continue;
}
@Nullable AccountID accountID = readAccountID(item.get("accountID"), context);
if (accountID == null) {
continue;
}
JsonElement privateDataElement = item.get("privateData");
if (privateDataElement == null || !privateDataElement.isJsonObject()) {
continue;
}
accountPrivateData.putPrivateData(accountID, privateDataElement.getAsJsonObject());
}
}
/// Serializes the private data store as a protected payload envelope.
@Override
public JsonElement serialize(
AccountPrivateData accountPrivateData,
Type typeOfSrc,
JsonSerializationContext context) {
JsonObject result = new JsonObject();
result.addProperty(JsonSchema.PROPERTY_SCHEMA, accountPrivateData.getSchema().url());
protectionMode().writePayload(result, createPayload(accountPrivateData, context));
accountPrivateData.unknownFields.forEach(result::add);
return result;
}
/// Deserializes the private data store from a protected payload envelope.
@Override
public @Nullable AccountPrivateData deserialize(
@Nullable JsonElement json,
Type typeOfT,
JsonDeserializationContext context) throws JsonParseException {
if (json == null || json.isJsonNull()) {
return null;
}
if (!(json instanceof JsonObject object)) {
throw new JsonParseException("Account private data is not an object");
}
AccountPrivateData accountPrivateData = new AccountPrivateData();
Map<String, JsonElement> values = new LinkedHashMap<>(object.asMap());
JsonElement schema = values.remove(JsonSchema.PROPERTY_SCHEMA);
if (schema != null && schema.isJsonPrimitive() && schema.getAsJsonPrimitive().isString()) {
accountPrivateData.setSchema(new JsonSchema(schema.getAsString()));
}
values.remove(ProtectedPayload.PROPERTY_PROTECTION);
values.remove(ProtectedPayload.PROPERTY_NONCE);
values.remove(ProtectedPayload.PROPERTY_PAYLOAD);
accountPrivateData.unknownFields.putAll(values);
readPayload(accountPrivateData, ProtectedPayload.read(object, JsonArray.class), context);
return accountPrivateData;
}
}
}
@@ -17,11 +17,11 @@
*/
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonObject;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
@@ -35,22 +35,28 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import org.jetbrains.annotations.Nullable;
import javax.net.ssl.SSLException;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
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.ConfigHolder.config;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
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.gson.JsonUtils.listTypeOf;
import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -68,7 +74,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<>();
@@ -114,6 +120,9 @@ public final class Accounts {
throw new IllegalArgumentException("Failed to determine account type: " + account);
}
private static final String GLOBAL_PREFIX = "$GLOBAL:";
private static final ObservableList<Map<Object, Object>> globalAccountStorages = FXCollections.observableArrayList();
private static final ObservableList<Account> accounts = observableArrayList(account -> new Observable[]{account});
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount");
@@ -122,219 +131,120 @@ public final class Accounts {
*/
private static boolean initialized = false;
private static SerializedAccount serializeAccount(Account account) {
JsonObject metadata = new JsonObject();
metadata.addProperty("type", getLoginType(getAccountFactory(account)));
account.writeMetadata(metadata);
JsonObject privateData = new JsonObject();
account.writePrivateData(privateData);
return new SerializedAccount(metadata, privateData);
private static Map<Object, Object> getAccountStorage(Account account) {
Map<Object, Object> storage = account.toStorage();
storage.put("type", getLoginType(getAccountFactory(account)));
return storage;
}
/// Ensures account IDs are unique across local and shared account metadata records before accounts are instantiated.
private static AccountIDNormalization ensureUniqueAccountIDs() {
Set<String> usedAccountIDs = new HashSet<>();
boolean localChanged = LegacyConfigMigrator.assignAccountIDs(
SettingsManager.gameAccounts(),
usedAccountIDs,
false);
boolean sharedChanged = LegacyConfigMigrator.assignAccountIDs(
SettingsManager.userGameAccounts(),
usedAccountIDs,
true);
return new AccountIDNormalization(localChanged, sharedChanged);
}
/// Result of normalizing account IDs across loaded metadata stores.
///
/// @param localChanged whether the per-workspace account metadata changed
/// @param sharedChanged whether the shared account metadata changed
private record AccountIDNormalization(boolean localChanged, boolean sharedChanged) {
}
/// Returns account IDs from metadata records.
///
/// @param first the first metadata record list
/// @param second the second metadata record list
/// @return account IDs found in the metadata records
private static List<AccountID> getAccountIDs(List<JsonObject> first, List<JsonObject> second) {
ArrayList<AccountID> accountIDs = new ArrayList<>(first.size() + second.size());
addAccountIDs(accountIDs, first);
addAccountIDs(accountIDs, second);
return accountIDs;
}
/// Adds account IDs from metadata records to a target list.
///
/// @param accountIDs the target account ID list
/// @param metadataRecords the metadata records to read
private static void addAccountIDs(List<AccountID> accountIDs, List<JsonObject> metadataRecords) {
for (JsonObject metadata : metadataRecords) {
@Nullable AccountID accountID = Account.getAccountID(metadata);
if (accountID != null && !accountIDs.contains(accountID)) {
accountIDs.add(accountID);
}
}
}
private static void updateAccountMetadataRecords() {
// don't update the underlying account records before data loading is completed
private static void updateAccountStorages() {
// don't update the underlying storage before data loading is completed
// otherwise it might cause data loss
if (!initialized)
return;
ArrayList<JsonObject> globalMetadata = new ArrayList<>();
LinkedHashMap<AccountID, JsonObject> globalPrivateData = new LinkedHashMap<>();
ArrayList<JsonObject> portableMetadata = new ArrayList<>();
LinkedHashMap<AccountID, JsonObject> portablePrivateData = new LinkedHashMap<>();
// update storage
ArrayList<Map<Object, Object>> global = new ArrayList<>();
ArrayList<Map<Object, Object>> portable = new ArrayList<>();
for (Account account : accounts) {
SerializedAccount serialized = serializeAccount(account);
if (account.isPortable()) {
portableMetadata.add(serialized.metadata());
if (!serialized.privateData().isEmpty()) {
portablePrivateData.put(account.getAccountID(), serialized.privateData());
}
} else {
globalMetadata.add(serialized.metadata());
if (!serialized.privateData().isEmpty()) {
globalPrivateData.put(account.getAccountID(), serialized.privateData());
}
Map<Object, Object> storage = getAccountStorage(account);
if (account.isPortable())
portable.add(storage);
else
global.add(storage);
}
if (!global.equals(globalAccountStorages))
globalAccountStorages.setAll(global);
if (!portable.equals(config().getAccountStorages()))
config().getAccountStorages().setAll(portable);
}
private static void loadGlobalAccountStorages() {
Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json");
if (Files.exists(globalAccountsFile)) {
try (Reader reader = Files.newBufferedReader(globalAccountsFile)) {
globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class))));
} catch (Throwable e) {
LOG.warning("Failed to load global accounts", e);
}
}
List<AccountID> retainedAccountIDs = getAccountIDs(globalMetadata, portableMetadata);
if (!SettingsManager.isUserGameAccountsReadOnly())
SettingsManager.updateUserGameAccounts(globalMetadata, globalPrivateData, retainedAccountIDs);
if (!SettingsManager.isGameAccountsReadOnly())
SettingsManager.updateGameAccounts(portableMetadata, portablePrivateData, retainedAccountIDs);
globalAccountStorages.addListener(onInvalidating(() ->
FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages))));
}
/// Returns whether the account metadata and credential files selected by the portability flag are read-only.
///
/// @param portable whether the account is stored in the local account file
public static boolean isAccountFilesReadOnly(boolean portable) {
return portable ? SettingsManager.isGameAccountsReadOnly() : SettingsManager.isUserGameAccountsReadOnly();
}
/// Returns whether the files containing the given account are read-only.
public static boolean isAccountFilesReadOnly(Account account) {
return isAccountFilesReadOnly(account.isPortable());
}
/// Returns whether the given account may be removed from its current account files.
public static boolean canRemoveAccount(Account account) {
return !isAccountFilesReadOnly(account);
}
/// Returns whether the given account may be moved between local and user account files.
public static boolean canMoveAccount(Account account) {
return !SettingsManager.isGameAccountsReadOnly() && !SettingsManager.isUserGameAccountsReadOnly();
}
/// Backs up and overwrites the account metadata and credential files selected by the portability flag.
///
/// @param portable whether the target account files are local
/// @throws IOException if saving either file fails
public static void forceOverwriteAccountFiles(boolean portable) throws IOException {
if (portable) {
SettingsManager.forceOverwriteGameAccounts();
} else {
SettingsManager.forceOverwriteUserGameAccounts();
}
}
/// Backs up and overwrites the account files containing the given account.
///
/// @throws IOException if saving either file fails
public static void forceOverwriteAccountFiles(Account account) throws IOException {
forceOverwriteAccountFiles(account.isPortable());
}
/// Backs up and overwrites both local and user account metadata and credential files.
///
/// @throws IOException if saving either file fails
public static void forceOverwriteAccountFiles() throws IOException {
if (SettingsManager.isGameAccountsReadOnly()) {
SettingsManager.forceOverwriteGameAccounts();
}
if (SettingsManager.isUserGameAccountsReadOnly()) {
SettingsManager.forceOverwriteUserGameAccounts();
}
}
private static Account parseAccount(JsonObject record, boolean portable) {
AccountFactory<?> factory = type2factory.get(JsonUtils.getString(record, "type"));
private static Account parseAccount(Map<Object, Object> storage) {
AccountFactory<?> factory = type2factory.get(storage.get("type"));
if (factory == null) {
LOG.warning("Unrecognized account type: " + describeAccountRecord(record));
LOG.warning("Unrecognized account type: " + storage);
return null;
}
try {
AccountID accountID = Account.readAccountID(record);
return factory.fromStorage(record, SettingsManager.getAccountPrivateData(accountID, portable));
return factory.fromStorage(storage);
} catch (Exception e) {
LOG.warning("Failed to load account: " + describeAccountRecord(record), e);
LOG.warning("Failed to load account: " + storage, e);
return null;
}
}
/// Serialized account metadata and private data.
///
/// @param metadata public metadata stored in `accounts.json`
/// @param privateData private account data stored in account private data
private record SerializedAccount(JsonObject metadata, JsonObject privateData) {
}
/// Returns a safe account record description for diagnostics.
private static String describeAccountRecord(JsonObject record) {
AccountID accountID = Account.getAccountID(record);
if (accountID != null) {
return accountID.toString();
}
String type = JsonUtils.getString(record, "type");
return type != null ? "{type=" + type + "}" : "<unknown>";
}
/// Called when it's ready to load accounts from [SettingsManager#settings()].
public static void init() {
/**
* Called when it's ready to load accounts from {@link ConfigHolder#config()}.
*/
static void init() {
if (initialized)
throw new IllegalStateException("Already initialized");
AccountIDNormalization accountIDNormalization = ensureUniqueAccountIDs();
if (accountIDNormalization.localChanged()) {
SettingsManager.saveGameAccountMetadataRecords();
}
if (accountIDNormalization.sharedChanged()) {
SettingsManager.saveUserGameAccountMetadataRecords();
if (!config().isAddedLittleSkin()) {
AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/");
if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) {
config().getAuthlibInjectorServers().add(0, littleSkin);
}
config().setAddedLittleSkin(true);
}
loadGlobalAccountStorages();
// load accounts
Account selected = null;
for (JsonObject record : getAccountMetadataRecords()) {
Account account = parseAccount(record, true);
for (Map<Object, Object> storage : config().getAccountStorages()) {
Account account = parseAccount(storage);
if (account != null) {
account.setPortable(true);
accounts.add(account);
if (JsonUtils.getBoolean(record, "selected", false)) {
if (Boolean.TRUE.equals(storage.get("selected"))) {
selected = account;
}
}
}
for (JsonObject record : getUserAccountMetadataRecords()) {
Account account = parseAccount(record, false);
for (Map<Object, Object> storage : globalAccountStorages) {
Account account = parseAccount(storage);
if (account != null) {
accounts.add(account);
}
}
AccountID selectedAccountID = settings().selectedAccountProperty().get();
if (selected == null && selectedAccountID != null) {
String selectedAccountIdentifier = config().getSelectedAccount();
if (selected == null && selectedAccountIdentifier != null) {
boolean portable = true;
if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) {
portable = false;
selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length());
}
for (Account account : accounts) {
if (account.getAccountID().equals(selectedAccountID)) {
selected = account;
break;
if (selectedAccountIdentifier.equals(account.getIdentifier())) {
if (portable == account.isPortable()) {
selected = account;
break;
} else if (selected == null) {
selected = account;
}
}
}
}
@@ -343,18 +253,15 @@ public final class Accounts {
selected = accounts.get(0);
}
if (!SettingsManager.isUserSettingsReadOnly()
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
if (!globalConfig().isEnableOfflineAccount())
for (Account account : accounts) {
if (account instanceof MicrosoftAccount) {
UserSettings userSettings = userSettings();
userSettings.enableOfflineAccountProperty().set(true);
globalConfig().setEnableOfflineAccount(true);
break;
}
}
if (!SettingsManager.isUserSettingsReadOnly()
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
if (!globalConfig().isEnableOfflineAccount())
accounts.addListener(new ListChangeListener<Account>() {
@Override
public void onChanged(Change<? extends Account> change) {
@@ -362,8 +269,7 @@ public final class Accounts {
for (Account account : change.getAddedSubList()) {
if (account instanceof MicrosoftAccount) {
accounts.removeListener(this);
UserSettings userSettings = userSettings();
userSettings.enableOfflineAccountProperty().set(true);
globalConfig().setEnableOfflineAccount(true);
return;
}
}
@@ -375,7 +281,7 @@ public final class Accounts {
InvalidationListener listener = o -> {
// this method first checks whether the current selection is valid
// if it's valid, the underlying account records will be updated
// if it's valid, the underlying storage will be updated
// otherwise, the first account will be selected as an alternative(or null if accounts is empty)
Account account = selectedAccount.get();
if (accounts.isEmpty()) {
@@ -398,16 +304,16 @@ public final class Accounts {
selectedAccount.addListener(onInvalidating(() -> {
Account account = selectedAccount.get();
if (account != null)
settings().selectedAccountProperty().set(account.getAccountID());
config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier());
else
settings().selectedAccountProperty().set(null);
config().setSelectedAccount(null);
}));
accounts.addListener(listener);
accounts.addListener(onInvalidating(Accounts::updateAccountMetadataRecords));
accounts.addListener(onInvalidating(Accounts::updateAccountStorages));
initialized = true;
getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
if (selected != null) {
Account finalSelected = selected;
@@ -420,7 +326,7 @@ public final class Accounts {
});
}
for (AuthlibInjectorServer server : getAuthlibInjectorServers()) {
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
continue;
Schedulers.io().execute(() -> {
@@ -467,12 +373,12 @@ public final class Accounts {
}
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
return getAuthlibInjectorServers().stream()
return config().getAuthlibInjectorServers().stream()
.filter(server -> url.equals(server.getUrl()))
.findFirst()
.orElseGet(() -> {
AuthlibInjectorServer server = new AuthlibInjectorServer(url);
getAuthlibInjectorServers().add(server);
config().getAuthlibInjectorServers().add(server);
return server;
});
}
@@ -485,8 +391,7 @@ public final class Accounts {
accounts.stream()
.filter(AuthlibInjectorAccount.class::isInstance)
.map(AuthlibInjectorAccount.class::cast)
.filter(it -> !getAuthlibInjectorServers().contains(it.getServer()))
.filter(Accounts::canRemoveAccount)
.filter(it -> !config().getAuthlibInjectorServers().contains(it.getServer()))
.collect(toList())
.forEach(accounts::remove);
}
@@ -563,9 +468,7 @@ public final class Accounts {
}
} else if (exception instanceof MicrosoftService.XBox400Exception) {
return i18n("account.methods.microsoft.error.wrong_verify_method");
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionLicenseNotFoundException) {
return i18n("account.methods.microsoft.error.no_license");
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionProfileNotFoundException) {
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
return i18n("account.methods.microsoft.error.no_character");
} else if (exception instanceof MicrosoftService.NoXuiException) {
return i18n("account.methods.microsoft.error.add_family");
@@ -1,140 +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.setting;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
/// Stores per-workspace authlib-injector authentication servers.
///
/// The JSON representation is saved as `config/authlib-injector-servers.json`
/// under the current HMCL directory.
///
/// @author Glavo
@JsonAdapter(AuthlibInjectorServerList.Adapter.class)
@NotNullByDefault
@JsonSerializable
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
/// The JSON schema supported by this authlib-injector server list.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
/// Creates an empty authlib-injector server list.
public AuthlibInjectorServerList() {
tracker.markDirty(schema);
register();
}
/// Creates the default authlib-injector server list for a newly created file.
public static AuthlibInjectorServerList createDefault() {
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
result.addLittleSkinIfAbsent();
return result;
}
/// Adds the bundled LittleSkin server when it is not already present.
public void addLittleSkinIfAbsent() {
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
}
}
/// The schema used by this authlib-injector server list file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this authlib-injector server list file.
@Override
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this authlib-injector server list file.
@Override
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this server list may be saved back to `config/authlib-injector-servers.json`.
private transient boolean savable = true;
/// Whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
private transient boolean backupOnNextSave;
/// Returns whether this server list may be saved back to `config/authlib-injector-servers.json`.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this server list may be saved back to `config/authlib-injector-servers.json`.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// Authlib-injector authentication servers available for account login.
@SerializedName("servers")
private final ObservableList<AuthlibInjectorServer> servers =
FXCollections.observableArrayList();
/// Returns authlib-injector authentication servers available for account login.
public ObservableList<AuthlibInjectorServer> getServers() {
return servers;
}
/// JSON adapter for [AuthlibInjectorServerList].
public static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerList> {
/// Creates an empty authlib-injector server list for deserialization.
@Override
protected AuthlibInjectorServerList createInstance() {
return new AuthlibInjectorServerList();
}
}
}
@@ -1,237 +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.setting;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Stores derived authlib-injector server metadata separately from the user-managed server list.
///
/// The JSON representation is saved as `cache/authlib-injector-server-metadata.json`
/// under the current HMCL directory.
@JsonAdapter(AuthlibInjectorServerMetadataCache.Adapter.class)
@NotNullByDefault
@JsonSerializable
final class AuthlibInjectorServerMetadataCache extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this metadata cache.
static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("authlib-injector-server-metadata", new JsonSchema.Version(1, 0, 0));
/// Creates an empty metadata cache.
AuthlibInjectorServerMetadataCache() {
tracker.markDirty(schema);
register();
}
/// The schema used by this metadata cache file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this metadata cache file.
@Override
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this metadata cache file.
@Override
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
private transient boolean savable = true;
/// Whether the next successful save should back up the current metadata cache file first.
private transient boolean backupOnNextSave;
/// Returns whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current metadata cache file first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current metadata cache file first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// Authlib-injector metadata entries.
@SerializedName("servers")
private final ObservableList<Entry> servers = FXCollections.observableArrayList();
/// Returns authlib-injector metadata entries.
ObservableList<Entry> getServers() {
return servers;
}
/// Initializes metadata for a server that is being connected to this cache.
///
/// @param server the server to initialize
/// @param storeExistingMetadata whether metadata already present on the server should replace cached metadata
void initialize(AuthlibInjectorServer server, boolean storeExistingMetadata) {
if (storeExistingMetadata && server.getMetadataResponse().isPresent()) {
store(server);
} else {
restore(server);
}
}
/// Restores cached metadata into the given server when a valid cache entry exists.
void restore(AuthlibInjectorServer server) {
int index = indexOf(server.getUrl());
if (index < 0) {
return;
}
Entry entry = servers.get(index);
String metadataResponse = entry.getMetadataResponse();
if (metadataResponse.isEmpty()) {
servers.remove(index);
return;
}
try {
server.restoreMetadataCache(metadataResponse, entry.getMetadataTimestamp());
} catch (JsonParseException e) {
LOG.warning("Ignoring malformed authlib-injector server metadata cache: " + server.getUrl(), e);
servers.remove(index);
}
}
/// Stores the current metadata response from the given server when it has one.
void store(AuthlibInjectorServer server) {
server.getMetadataResponse().ifPresent(metadataResponse -> {
Entry entry = new Entry(server.getUrl(), metadataResponse, server.getMetadataTimestamp());
int index = indexOf(server.getUrl());
if (index >= 0) {
servers.set(index, entry);
} else {
servers.add(entry);
}
});
}
/// Removes cached metadata for the given server.
void remove(AuthlibInjectorServer server) {
int index = indexOf(server.getUrl());
if (index >= 0) {
servers.remove(index);
}
}
/// Returns the index of the cache entry for the given normalized server URL.
private int indexOf(String url) {
for (int i = 0; i < servers.size(); i++) {
if (url.equals(servers.get(i).getUrl())) {
return i;
}
}
return -1;
}
/// Metadata cached for one authlib-injector server.
@NotNullByDefault
@JsonSerializable
static final class Entry {
/// Normalized authlib-injector server URL.
@SerializedName("url")
private @Nullable String url = "";
/// Raw metadata response returned by the authlib-injector endpoint.
@SerializedName("metadataResponse")
private @Nullable String metadataResponse = "";
/// Wall-clock time when the metadata response was fetched.
@SerializedName("metadataTimestamp")
private long metadataTimestamp;
/// Creates an empty metadata entry for deserialization.
private Entry() {
}
/// Creates a metadata entry.
///
/// @param url normalized authlib-injector server URL
/// @param metadataResponse raw metadata response returned by the authlib-injector endpoint
/// @param metadataTimestamp wall-clock time when the metadata response was fetched
Entry(String url, String metadataResponse, long metadataTimestamp) {
this.url = Objects.requireNonNull(url);
this.metadataResponse = Objects.requireNonNull(metadataResponse);
this.metadataTimestamp = metadataTimestamp;
}
/// Returns the normalized authlib-injector server URL.
String getUrl() {
return Objects.requireNonNullElse(url, "");
}
/// Returns the raw metadata response, or an empty string when the cache entry is incomplete.
String getMetadataResponse() {
return Objects.requireNonNullElse(metadataResponse, "");
}
/// Returns the wall-clock time when the metadata response was fetched.
long getMetadataTimestamp() {
return metadataTimestamp;
}
}
/// JSON adapter for [AuthlibInjectorServerMetadataCache].
@NotNullByDefault
static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerMetadataCache> {
/// Creates an empty metadata cache for deserialization.
@Override
protected AuthlibInjectorServerMetadataCache createInstance() {
return new AuthlibInjectorServerMetadataCache();
}
}
}
@@ -35,8 +35,7 @@ import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonSerializable
@@ -72,7 +71,7 @@ public final class AuthlibInjectorServers implements Validation {
configLocation = Paths.get(CONFIG_FILENAME);
}
if (SettingsManager.isNewlyCreated() && Files.exists(configLocation)) {
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
AuthlibInjectorServers configInstance;
try {
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
@@ -82,11 +81,11 @@ public final class AuthlibInjectorServers implements Validation {
}
if (!configInstance.urls.isEmpty()) {
settings().preferredLoginTypeProperty().set(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
for (String url : configInstance.urls) {
Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url))
.thenAcceptAsync(Schedulers.javafx(), server -> {
getAuthlibInjectorServers().add(server);
config().getAuthlibInjectorServers().add(server);
servers.add(server);
})
.start();
@@ -0,0 +1,786 @@
/*
* 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.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import javafx.scene.paint.Paint;
import org.hildan.fxgson.creators.ObservableListCreator;
import org.hildan.fxgson.creators.ObservableMapCreator;
import org.hildan.fxgson.creators.ObservableSetCreator;
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jetbrains.annotations.Nullable;
import java.net.Proxy;
import java.nio.file.Path;
import java.util.*;
@JsonAdapter(value = Config.Adapter.class)
public final class Config extends ObservableSetting {
public static final int CURRENT_VERSION = 2;
public static final int CURRENT_UI_VERSION = 0;
public static final Gson CONFIG_GSON = new GsonBuilder()
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
.registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType
.registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy
.registerTypeAdapter(Paint.class, new PaintAdapter())
.setPrettyPrinting()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();
@Nullable
public static Config fromJson(String json) throws JsonParseException {
return CONFIG_GSON.fromJson(json, Config.class);
}
public Config() {
tracker.markDirty(configVersion);
tracker.markDirty(uiVersion);
register();
}
public String toJson() {
return CONFIG_GSON.toJson(this);
}
// Properties
@SerializedName("_version")
private final IntegerProperty configVersion = new SimpleIntegerProperty(CURRENT_VERSION);
public IntegerProperty configVersionProperty() {
return configVersion;
}
public int getConfigVersion() {
return configVersion.get();
}
public void setConfigVersion(int configVersion) {
this.configVersion.set(configVersion);
}
/**
* The version of UI that the user have last used.
* If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased.
* When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown,
* then this property is set to the same value as {@link #CURRENT_UI_VERSION}.
* In particular, the property is default to 0, so that whoever open the application for the first time will see the guide.
*/
@SerializedName("uiVersion")
private final IntegerProperty uiVersion = new SimpleIntegerProperty(CURRENT_UI_VERSION);
public IntegerProperty uiVersionProperty() {
return uiVersion;
}
public int getUiVersion() {
return uiVersion.get();
}
public void setUiVersion(int uiVersion) {
this.uiVersion.set(uiVersion);
}
@SerializedName("x")
private final DoubleProperty x = new SimpleDoubleProperty();
public DoubleProperty xProperty() {
return x;
}
public double getX() {
return x.get();
}
public void setX(double x) {
this.x.set(x);
}
@SerializedName("y")
private final DoubleProperty y = new SimpleDoubleProperty();
public DoubleProperty yProperty() {
return y;
}
public double getY() {
return y.get();
}
public void setY(double y) {
this.y.set(y);
}
@SerializedName("width")
private final DoubleProperty width = new SimpleDoubleProperty();
public DoubleProperty widthProperty() {
return width;
}
public double getWidth() {
return width.get();
}
public void setWidth(double width) {
this.width.set(width);
}
@SerializedName("height")
private final DoubleProperty height = new SimpleDoubleProperty();
public DoubleProperty heightProperty() {
return height;
}
public double getHeight() {
return height.get();
}
public void setHeight(double height) {
this.height.set(height);
}
@SerializedName("localization")
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
public ObjectProperty<SupportedLocale> localizationProperty() {
return localization;
}
public SupportedLocale getLocalization() {
return localization.get();
}
public void setLocalization(SupportedLocale localization) {
this.localization.set(localization);
}
@SerializedName("promptedVersion")
private final StringProperty promptedVersion = new SimpleStringProperty();
public String getPromptedVersion() {
return promptedVersion.get();
}
public StringProperty promptedVersionProperty() {
return promptedVersion;
}
public void setPromptedVersion(String promptedVersion) {
this.promptedVersion.set(promptedVersion);
}
@SerializedName("acceptPreviewUpdate")
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
public BooleanProperty acceptPreviewUpdateProperty() {
return acceptPreviewUpdate;
}
public boolean isAcceptPreviewUpdate() {
return acceptPreviewUpdate.get();
}
public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) {
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
}
@SerializedName("disableAutoShowUpdateDialog")
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
public BooleanProperty disableAutoShowUpdateDialogProperty() {
return disableAutoShowUpdateDialog;
}
public boolean isDisableAutoShowUpdateDialog() {
return disableAutoShowUpdateDialog.get();
}
public void setDisableAutoShowUpdateDialog(boolean disableAutoShowUpdateDialog) {
this.disableAutoShowUpdateDialog.set(disableAutoShowUpdateDialog);
}
@SerializedName("disableAprilFools")
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
public BooleanProperty disableAprilFoolsProperty() {
return disableAprilFools;
}
public boolean isDisableAprilFools() {
return disableAprilFools.get();
}
public void setDisableAprilFools(boolean disableAprilFools) {
this.disableAprilFools.set(disableAprilFools);
}
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
public ObservableMap<String, Object> getShownTips() {
return shownTips;
}
@SerializedName("commonDirType")
private final ObjectProperty<EnumCommonDirectory> commonDirType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
public ObjectProperty<EnumCommonDirectory> commonDirTypeProperty() {
return commonDirType;
}
public EnumCommonDirectory getCommonDirType() {
return commonDirType.get();
}
public void setCommonDirType(EnumCommonDirectory commonDirType) {
this.commonDirType.set(commonDirType);
}
@SerializedName("commonpath")
private final StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString());
public StringProperty commonDirectoryProperty() {
return commonDirectory;
}
public String getCommonDirectory() {
return commonDirectory.get();
}
public void setCommonDirectory(String commonDirectory) {
this.commonDirectory.set(commonDirectory);
}
@SerializedName("logLines")
private final ObjectProperty<Integer> logLines = new SimpleObjectProperty<>();
public ObjectProperty<Integer> logLinesProperty() {
return logLines;
}
public Integer getLogLines() {
return logLines.get();
}
public void setLogLines(Integer logLines) {
this.logLines.set(logLines);
}
// UI
@SerializedName("themeBrightness")
private final StringProperty themeBrightness = new SimpleStringProperty("light");
public StringProperty themeBrightnessProperty() {
return themeBrightness;
}
public String getThemeBrightness() {
return themeBrightness.get();
}
public void setThemeBrightness(String themeBrightness) {
this.themeBrightness.set(themeBrightness);
}
@SerializedName("theme")
private final ObjectProperty<ThemeColor> themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
public ObjectProperty<ThemeColor> themeColorProperty() {
return themeColor;
}
public ThemeColor getThemeColor() {
return themeColor.get();
}
public void setThemeColor(ThemeColor themeColor) {
this.themeColor.set(themeColor);
}
@SerializedName("fontFamily")
private final StringProperty fontFamily = new SimpleStringProperty();
public StringProperty fontFamilyProperty() {
return fontFamily;
}
public String getFontFamily() {
return fontFamily.get();
}
public void setFontFamily(String fontFamily) {
this.fontFamily.set(fontFamily);
}
@SerializedName("fontSize")
private final DoubleProperty fontSize = new SimpleDoubleProperty(12);
public DoubleProperty fontSizeProperty() {
return fontSize;
}
public double getFontSize() {
return fontSize.get();
}
public void setFontSize(double fontSize) {
this.fontSize.set(fontSize);
}
@SerializedName("launcherFontFamily")
private final StringProperty launcherFontFamily = new SimpleStringProperty();
public StringProperty launcherFontFamilyProperty() {
return launcherFontFamily;
}
public String getLauncherFontFamily() {
return launcherFontFamily.get();
}
public void setLauncherFontFamily(String launcherFontFamily) {
this.launcherFontFamily.set(launcherFontFamily);
}
@SerializedName("animationDisabled")
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
FXUtils.REDUCED_MOTION == Boolean.TRUE
|| !JavaRuntime.CURRENT_JIT_ENABLED
|| !FXUtils.GPU_ACCELERATION_ENABLED
);
public BooleanProperty animationDisabledProperty() {
return animationDisabled;
}
public boolean isAnimationDisabled() {
return animationDisabled.get();
}
public void setAnimationDisabled(boolean animationDisabled) {
this.animationDisabled.set(animationDisabled);
}
@SerializedName("titleTransparent")
private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false);
public BooleanProperty titleTransparentProperty() {
return titleTransparent;
}
public boolean isTitleTransparent() {
return titleTransparent.get();
}
public void setTitleTransparent(boolean titleTransparent) {
this.titleTransparent.set(titleTransparent);
}
@SerializedName("backgroundType")
private final ObjectProperty<EnumBackgroundImage> backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT);
public ObjectProperty<EnumBackgroundImage> backgroundImageTypeProperty() {
return backgroundImageType;
}
public EnumBackgroundImage getBackgroundImageType() {
return backgroundImageType.get();
}
public void setBackgroundImageType(EnumBackgroundImage backgroundImageType) {
this.backgroundImageType.set(backgroundImageType);
}
@SerializedName("bgpath")
private final StringProperty backgroundImage = new SimpleStringProperty();
public StringProperty backgroundImageProperty() {
return backgroundImage;
}
public String getBackgroundImage() {
return backgroundImage.get();
}
public void setBackgroundImage(String backgroundImage) {
this.backgroundImage.set(backgroundImage);
}
@SerializedName("bgurl")
private final StringProperty backgroundImageUrl = new SimpleStringProperty();
public StringProperty backgroundImageUrlProperty() {
return backgroundImageUrl;
}
public String getBackgroundImageUrl() {
return backgroundImageUrl.get();
}
public void setBackgroundImageUrl(String backgroundImageUrl) {
this.backgroundImageUrl.set(backgroundImageUrl);
}
@SerializedName("bgpaint")
private final ObjectProperty<Paint> backgroundPaint = new SimpleObjectProperty<>();
public Paint getBackgroundPaint() {
return backgroundPaint.get();
}
public ObjectProperty<Paint> backgroundPaintProperty() {
return backgroundPaint;
}
public void setBackgroundPaint(Paint backgroundPaint) {
this.backgroundPaint.set(backgroundPaint);
}
@SerializedName("bgImageOpacity")
private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100);
public IntegerProperty backgroundImageOpacityProperty() {
return backgroundImageOpacity;
}
public int getBackgroundImageOpacity() {
return backgroundImageOpacity.get();
}
public void setBackgroundImageOpacity(int backgroundImageOpacity) {
this.backgroundImageOpacity.set(backgroundImageOpacity);
}
// Networks
@SerializedName("autoDownloadThreads")
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
public BooleanProperty autoDownloadThreadsProperty() {
return autoDownloadThreads;
}
public boolean getAutoDownloadThreads() {
return autoDownloadThreads.get();
}
public void setAutoDownloadThreads(boolean autoDownloadThreads) {
this.autoDownloadThreads.set(autoDownloadThreads);
}
@SerializedName("downloadThreads")
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
public IntegerProperty downloadThreadsProperty() {
return downloadThreads;
}
public int getDownloadThreads() {
return downloadThreads.get();
}
public void setDownloadThreads(int downloadThreads) {
this.downloadThreads.set(downloadThreads);
}
@SerializedName("downloadType")
private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID);
public StringProperty downloadTypeProperty() {
return downloadType;
}
public String getDownloadType() {
return downloadType.get();
}
public void setDownloadType(String downloadType) {
this.downloadType.set(downloadType);
}
@SerializedName("autoChooseDownloadType")
private final BooleanProperty autoChooseDownloadType = new SimpleBooleanProperty(true);
public BooleanProperty autoChooseDownloadTypeProperty() {
return autoChooseDownloadType;
}
public boolean isAutoChooseDownloadType() {
return autoChooseDownloadType.get();
}
public void setAutoChooseDownloadType(boolean autoChooseDownloadType) {
this.autoChooseDownloadType.set(autoChooseDownloadType);
}
@SerializedName("versionListSource")
private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID);
public StringProperty versionListSourceProperty() {
return versionListSource;
}
public String getVersionListSource() {
return versionListSource.get();
}
public void setVersionListSource(String versionListSource) {
this.versionListSource.set(versionListSource);
}
@SerializedName("hasProxy")
private final BooleanProperty hasProxy = new SimpleBooleanProperty();
public BooleanProperty hasProxyProperty() {
return hasProxy;
}
public boolean hasProxy() {
return hasProxy.get();
}
public void setHasProxy(boolean hasProxy) {
this.hasProxy.set(hasProxy);
}
@SerializedName("hasProxyAuth")
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
public BooleanProperty hasProxyAuthProperty() {
return hasProxyAuth;
}
public boolean hasProxyAuth() {
return hasProxyAuth.get();
}
public void setHasProxyAuth(boolean hasProxyAuth) {
this.hasProxyAuth.set(hasProxyAuth);
}
@SerializedName("proxyType")
private final ObjectProperty<Proxy.Type> proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP);
public ObjectProperty<Proxy.Type> proxyTypeProperty() {
return proxyType;
}
public Proxy.Type getProxyType() {
return proxyType.get();
}
public void setProxyType(Proxy.Type proxyType) {
this.proxyType.set(proxyType);
}
@SerializedName("proxyHost")
private final StringProperty proxyHost = new SimpleStringProperty();
public StringProperty proxyHostProperty() {
return proxyHost;
}
public String getProxyHost() {
return proxyHost.get();
}
public void setProxyHost(String proxyHost) {
this.proxyHost.set(proxyHost);
}
@SerializedName("proxyPort")
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
public IntegerProperty proxyPortProperty() {
return proxyPort;
}
public int getProxyPort() {
return proxyPort.get();
}
public void setProxyPort(int proxyPort) {
this.proxyPort.set(proxyPort);
}
@SerializedName("proxyUserName")
private final StringProperty proxyUser = new SimpleStringProperty();
public StringProperty proxyUserProperty() {
return proxyUser;
}
public String getProxyUser() {
return proxyUser.get();
}
public void setProxyUser(String proxyUser) {
this.proxyUser.set(proxyUser);
}
@SerializedName("proxyPassword")
private final StringProperty proxyPass = new SimpleStringProperty();
public StringProperty proxyPassProperty() {
return proxyPass;
}
public String getProxyPass() {
return proxyPass.get();
}
public void setProxyPass(String proxyPass) {
this.proxyPass.set(proxyPass);
}
// Game
@SerializedName("disableAutoGameOptions")
private final BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false);
public BooleanProperty disableAutoGameOptionsProperty() {
return disableAutoGameOptions;
}
public boolean isDisableAutoGameOptions() {
return disableAutoGameOptions.get();
}
public void setDisableAutoGameOptions(boolean disableAutoGameOptions) {
this.disableAutoGameOptions.set(disableAutoGameOptions);
}
// Accounts
@SerializedName("authlibInjectorServers")
private final ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server});
public ObservableList<AuthlibInjectorServer> getAuthlibInjectorServers() {
return authlibInjectorServers;
}
@SerializedName("addedLittleSkin")
private final BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false);
public BooleanProperty addedLittleSkinProperty() {
return addedLittleSkin;
}
public boolean isAddedLittleSkin() {
return addedLittleSkin.get();
}
public void setAddedLittleSkin(boolean addedLittleSkin) {
this.addedLittleSkin.set(addedLittleSkin);
}
/**
* The preferred login type to use when the user wants to add an account.
*/
@SerializedName("preferredLoginType")
private final StringProperty preferredLoginType = new SimpleStringProperty();
public StringProperty preferredLoginTypeProperty() {
return preferredLoginType;
}
public String getPreferredLoginType() {
return preferredLoginType.get();
}
public void setPreferredLoginType(String preferredLoginType) {
this.preferredLoginType.set(preferredLoginType);
}
@SerializedName("selectedAccount")
private final StringProperty selectedAccount = new SimpleStringProperty();
public StringProperty selectedAccountProperty() {
return selectedAccount;
}
public String getSelectedAccount() {
return selectedAccount.get();
}
public void setSelectedAccount(String selectedAccount) {
this.selectedAccount.set(selectedAccount);
}
@SerializedName("accounts")
private final ObservableList<Map<Object, Object>> accountStorages = FXCollections.observableArrayList();
public ObservableList<Map<Object, Object>> getAccountStorages() {
return accountStorages;
}
// Configurations
@SerializedName("last")
private final StringProperty selectedProfile = new SimpleStringProperty("");
public StringProperty selectedProfileProperty() {
return selectedProfile;
}
public String getSelectedProfile() {
return selectedProfile.get();
}
public void setSelectedProfile(String selectedProfile) {
this.selectedProfile.set(selectedProfile);
}
@SerializedName("configurations")
private final SimpleMapProperty<String, Profile> configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>()));
public MapProperty<String, Profile> getConfigurations() {
return configurations;
}
public static final class Adapter extends ObservableSetting.Adapter<Config> {
@Override
protected Config createInstance() {
return new Config();
}
}
}
@@ -0,0 +1,212 @@
/*
* 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.setting;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.IOException;
import java.nio.file.*;
import java.util.Locale;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ConfigHolder {
private ConfigHolder() {
}
public static final String CONFIG_FILENAME = "hmcl.json";
public static final String CONFIG_FILENAME_LINUX = ".hmcl.json";
public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json");
private static Path configLocation;
private static Config configInstance;
private static GlobalConfig globalConfigInstance;
private static boolean newlyCreated;
private static boolean ownerChanged = false;
private static boolean unsupportedVersion = false;
public static Config config() {
if (configInstance == null) {
throw new IllegalStateException("Configuration hasn't been loaded");
}
return configInstance;
}
public static GlobalConfig globalConfig() {
if (globalConfigInstance == null) {
throw new IllegalStateException("Configuration hasn't been loaded");
}
return globalConfigInstance;
}
public static Path configLocation() {
return configLocation;
}
public static boolean isNewlyCreated() {
return newlyCreated;
}
public static boolean isOwnerChanged() {
return ownerChanged;
}
public static boolean isUnsupportedVersion() {
return unsupportedVersion;
}
public static void init() throws IOException {
if (configInstance != null) {
throw new IllegalStateException("Configuration is already loaded");
}
configLocation = locateConfig();
LOG.info("Config location: " + configLocation);
configInstance = loadConfig();
if (!unsupportedVersion)
configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson()));
globalConfigInstance = loadGlobalConfig();
globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson()));
Locale.setDefault(config().getLocalization().getLocale());
I18n.setLocale(configInstance.getLocalization());
LOG.setLogRetention(globalConfig().getLogRetention());
Settings.init();
if (newlyCreated) {
LOG.info("Creating config file " + configLocation);
FileUtils.saveSafely(configLocation, configInstance.toJson());
}
if (!Files.isWritable(configLocation)) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& configLocation.getFileSystem() == FileSystems.getDefault()
&& configLocation.toFile().canWrite()) {
LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug");
// There are some serious problems with the implementation of Samba or OpenJDK
throw new SambaException();
} else {
// the config cannot be saved
// throw up the error now to prevent further data loss
throw new IOException("Config at " + configLocation + " is not writable");
}
}
}
private static Path locateConfig() {
Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME);
if (Files.isRegularFile(defaultConfigFile))
return defaultConfigFile;
try {
Path jarPath = JarUtils.thisJarPath();
if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) {
jarPath = jarPath.getParent();
Path config = jarPath.resolve(CONFIG_FILENAME);
if (Files.isRegularFile(config))
return config;
Path dotConfig = jarPath.resolve(CONFIG_FILENAME_LINUX);
if (Files.isRegularFile(dotConfig))
return dotConfig;
}
} catch (Throwable ignore) {
}
Path config = Paths.get(CONFIG_FILENAME);
if (Files.isRegularFile(config))
return config;
Path dotConfig = Paths.get(CONFIG_FILENAME_LINUX);
if (Files.isRegularFile(dotConfig))
return dotConfig;
// create new
return defaultConfigFile;
}
private static Config loadConfig() throws IOException {
if (Files.exists(configLocation)) {
try {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
&& "root".equals(System.getProperty("user.name"))
&& !"root".equals(Files.getOwner(configLocation).getName())) {
ownerChanged = true;
}
} catch (IOException e1) {
LOG.warning("Failed to get owner");
}
try {
String content = Files.readString(configLocation);
Config deserialized = Config.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
} else {
int configVersion = deserialized.getConfigVersion();
if (configVersion < Config.CURRENT_VERSION) {
ConfigUpgrader.upgradeConfig(deserialized, content);
} else if (configVersion > Config.CURRENT_VERSION) {
unsupportedVersion = true;
LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion));
}
return deserialized;
}
} catch (JsonParseException e) {
LOG.warning("Malformed config.", e);
}
}
newlyCreated = true;
return new Config();
}
// Global Config
private static GlobalConfig loadGlobalConfig() throws IOException {
if (Files.exists(GLOBAL_CONFIG_PATH)) {
try {
String content = Files.readString(GLOBAL_CONFIG_PATH);
GlobalConfig deserialized = GlobalConfig.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
} else {
return deserialized;
}
} catch (JsonParseException e) {
LOG.warning("Malformed config.", e);
}
}
LOG.info("Creating an empty global config");
return new GlobalConfig();
}
}
@@ -0,0 +1,96 @@
/*
* 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.setting;
import com.google.gson.Gson;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
final class ConfigUpgrader {
private ConfigUpgrader() {
}
/**
* This method is for the compatibility with old HMCL versions.
*
* @param deserialized deserialized config settings
* @param rawContent raw json content of the config settings without modification
*/
static void upgradeConfig(Config deserialized, String rawContent) {
int configVersion = deserialized.getConfigVersion();
if (configVersion >= Config.CURRENT_VERSION)
return;
LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION));
Map<?, ?> rawJson = Collections.unmodifiableMap(new Gson().<Map<?, ?>>fromJson(rawContent, Map.class));
if (configVersion < 1) {
// Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important.
tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> {
tryCast(auth.get("offline"), Map.class).ifPresent(offline -> {
String selected = rawJson.containsKey("selectedAccount") ? null
: tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null);
tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> {
((Map<?, ?>) uuidMap).forEach((key, value) -> {
Map<Object, Object> storage = new HashMap<>();
storage.put("type", "offline");
storage.put("username", key);
storage.put("uuid", value);
if (key.equals(selected)) {
storage.put("selected", true);
}
deserialized.getAccountStorages().add(storage);
});
});
});
});
// Upgrade configuration of HMCL earlier than 3.1.70
if (!rawJson.containsKey("commonDirType"))
deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM);
if (!rawJson.containsKey("backgroundType"))
deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT);
if (!rawJson.containsKey("hasProxy"))
deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost()));
if (!rawJson.containsKey("hasProxyAuth"))
deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser()));
if (!rawJson.containsKey("downloadType")) {
tryCast(rawJson.get("downloadtype"), Number.class)
.map(Number::intValue)
.ifPresent(id -> {
if (id == 0) {
deserialized.setDownloadType("mojang");
} else if (id == 1) {
deserialized.setDownloadType("bmclapi");
}
});
}
}
deserialized.setConfigVersion(Config.CURRENT_VERSION);
}
}
@@ -18,67 +18,135 @@
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.Lang;
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 static org.jackhuang.hmcl.setting.SettingsManager.settings;
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.Map;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
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;
public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced";
public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang";
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
private static final DownloadProvider DEFAULT_PROVIDER;
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
public static final Map<String, DownloadProvider> AUTO_PROVIDERS;
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);
}
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot);
if (url == null) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
BMCLAPI_ROOT = StringUtils.removeSuffix(url.toString(), "/");
}
}
DownloadProvider mojang = new MojangDownloadProvider();
DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang);
DEFAULT_PROVIDER = mojang;
DIRECT_PROVIDERS = Lang.mapOf(
pair("mojang", mojang),
pair("bmclapi", bmclapi)
);
AUTO_PROVIDERS = Lang.mapOf(
pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang),
pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider(
List.of(mojang, bmclapiRaw),
List.of(bmclapiRaw, mojang)
) : mojang),
pair("mirror", bmclapi)
);
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() {
static void init() {
InvalidationListener onChangeDownloadThreads = observable -> {
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads()
? DEFAULT_CONCURRENCY
: settings().downloadThreadsProperty().get());
: config().getDownloadThreads());
};
settings().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
config().downloadThreadsProperty().addListener(onChangeDownloadThreads);
onChangeDownloadThreads.invalidated(null);
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
InvalidationListener onChangeDownloadSource = observable -> {
if (config().isAutoChooseDownloadType()) {
String versionListSource = config().getVersionListSource();
DownloadProvider downloadProvider = versionListSource != null
? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER)
: DEFAULT_PROVIDER;
PROVIDER_WRAPPER.setProvider(downloadProvider);
} else {
String downloadType = config().getDownloadType();
PROVIDER_WRAPPER.setProvider(downloadType != null
? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER)
: DEFAULT_PROVIDER);
}
};
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
config().downloadTypeProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
}
/**
* 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);
}
}
@@ -1,33 +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.setting;
import org.jetbrains.annotations.NotNullByDefault;
/// Preferred source selection for download providers.
@NotNullByDefault
public enum DownloadSource {
/// Automatically chooses the best source for the current environment.
DEFAULT,
/// Prefers official download sources.
OFFICIAL,
/// Prefers mirror download sources.
MIRROR,
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
* 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
@@ -15,10 +15,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
public record Point3I(int x, int y, int z) {
public static final Point3I ZERO = new Point3I(0, 0, 0);
package org.jackhuang.hmcl.setting;
public enum EnumBackgroundImage {
DEFAULT,
CUSTOM,
CLASSIC,
NETWORK,
TRANSLUCENT, // Deprecated
PAINT
}
@@ -37,7 +37,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -56,17 +56,17 @@ public final class FontManager {
// Recommended
font = tryLoadLocalizedFont(Metadata.HMCL_LOCAL_HOME.resolve("font"));
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
if (font != null)
return font;
font = tryLoadLocalizedFont(Metadata.HMCL_USER_HOME.resolve("font"));
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
if (font != null)
return font;
// Legacy
font = tryLoadDefaultFont(Metadata.HMCL_LOCAL_HOME);
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
if (font != null)
return font;
@@ -74,7 +74,7 @@ public final class FontManager {
if (font != null)
return font;
font = tryLoadDefaultFont(Metadata.HMCL_USER_HOME);
font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY);
if (font != null)
return font;
@@ -103,7 +103,7 @@ public final class FontManager {
}
private static void updateFont() {
String fontFamily = settings().launcherFontFamilyProperty().get();
String fontFamily = config().getLauncherFontFamily();
if (fontFamily == null)
fontFamily = System.getProperty("hmcl.font.override");
if (fontFamily == null)
@@ -236,7 +236,7 @@ public final class FontManager {
}
public static void setFontFamily(String fontFamily) {
settings().launcherFontFamilyProperty().set(fontFamily);
config().setLauncherFontFamily(fontFamily);
updateFont();
}
@@ -1,147 +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.setting;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
/// Stores game directories independently from the main config file.
///
/// The JSON representation is saved under the `config` directory in either the current
/// HMCL directory or the user HMCL directory.
///
/// @author Glavo
@JsonAdapter(GameDirectories.Adapter.class)
@NotNullByDefault
@JsonSerializable
public final class GameDirectories extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this game directory store.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("game-directories", new JsonSchema.Version(1, 0, 0));
/// Creates an empty game directory store.
public GameDirectories() {
tracker.markDirty(schema);
register();
}
/// The schema used by this game directory store file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this game directory store file.
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this game directory store file.
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this game directory store may be saved back to its JSON file.
private transient boolean savable = true;
/// Whether the next successful save should back up the current on-disk file first.
private transient boolean backupOnNextSave;
/// Returns whether this game directory store may be saved back to its JSON file.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this game directory store may be saved back to its JSON file.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current on-disk file first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current on-disk file first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// Game directories stored in this file.
@SerializedName("directories")
private final ObservableList<GameDirectory> gameDirectories =
FXCollections.observableArrayList(profile -> new Observable[] { profile });
/// Whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
private transient boolean userFile;
/// Returns the game directories stored in this file.
public ObservableList<GameDirectory> getGameDirectories() {
return gameDirectories;
}
/// Sets whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
void setUserFile(boolean userFile) {
this.userFile = userFile;
}
/// Returns whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
boolean isUserFile() {
return userFile;
}
/// Whether this store was created because its JSON file did not exist.
private transient boolean newlyCreated;
/// Returns whether this store was created because its JSON file did not exist.
boolean isNewlyCreated() {
return newlyCreated;
}
/// Sets whether this store was created because its JSON file did not exist.
void setNewlyCreated(boolean newlyCreated) {
this.newlyCreated = newlyCreated;
}
/// JSON adapter for [GameDirectories].
public static final class Adapter extends ObservableSetting.Adapter<GameDirectories> {
/// Creates an empty game directory store for deserialization.
@Override
protected GameDirectories createInstance() {
return new GameDirectories();
}
}
}
@@ -1,210 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
import java.util.Objects;
/// Persistent configuration for a game directory.
@JsonAdapter(GameDirectory.Serializer.class)
@NotNullByDefault
public final class GameDirectory implements Observable {
/// The stable game directory ID.
private final GameDirectoryID id;
/// Returns the stable game directory ID.
public GameDirectoryID getId() {
return id;
}
/// The game directory path.
private final ObjectProperty<PortablePath> path;
/// Returns the game directory path property.
public ObjectProperty<PortablePath> pathProperty() {
return path;
}
/// Returns the game directory path.
public PortablePath getPath() {
return path.get();
}
/// Sets the game directory path.
public void setPath(PortablePath path) {
this.path.set(Objects.requireNonNull(path));
}
/// The custom localized game directory name, or `null` when no name is stored.
private final ObjectProperty<@Nullable LocalizedText> name;
/// Returns the custom localized game directory name property.
public ObjectProperty<@Nullable LocalizedText> nameProperty() {
return name;
}
/// Returns the custom localized game directory name, or `null` when no name is stored.
public @Nullable LocalizedText getName() {
return name.get();
}
/// Sets the custom localized game directory name.
public void setName(@Nullable LocalizedText name) {
this.name.set(name);
}
/// The migrated legacy game settings preset ID, or `null` when this game directory uses the default preset.
private final ObjectProperty<@Nullable GameSettingsPresetID> legacyGameSettings;
/// Returns the migrated legacy game settings preset ID property.
public ObjectProperty<@Nullable GameSettingsPresetID> legacyGameSettingsProperty() {
return legacyGameSettings;
}
/// Returns the migrated legacy game settings preset ID, or `null` when this game directory uses the default preset.
public @Nullable GameSettingsPresetID getLegacyGameSettings() {
return legacyGameSettings.get();
}
/// Sets the migrated legacy game settings preset ID.
public void setLegacyGameSettings(@Nullable GameSettingsPresetID legacyGameSettings) {
this.legacyGameSettings.set(legacyGameSettings);
}
/// Creates a game directory.
public GameDirectory(GameDirectoryID id, @Nullable LocalizedText name, PortablePath path) {
this(id, name, path, null);
}
/// Creates a game directory.
public GameDirectory(
GameDirectoryID id,
@Nullable LocalizedText name,
PortablePath path,
@Nullable GameSettingsPresetID legacyGameSettings) {
this.id = Objects.requireNonNull(id);
this.name = new SimpleObjectProperty<>(this, "name", name);
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
addPropertyChangedListener(arg -> invalidate());
}
/// Returns a debug string containing the game directory path and display metadata.
@Override
public String toString() {
return new ToStringBuilder(this)
.append("path", getPath())
.append("name", getName())
.toString();
}
/// Registers a listener that invalidates this game directory when any stored property changes.
private void addPropertyChangedListener(InvalidationListener listener) {
name.addListener(listener);
path.addListener(listener);
legacyGameSettings.addListener(listener);
}
/// Helper that stores and dispatches invalidation listeners for this game directory.
private final ObservableHelper observableHelper = new ObservableHelper(this);
/// Adds an invalidation listener.
@Override
public void addListener(InvalidationListener listener) {
observableHelper.addListener(listener);
}
/// Removes an invalidation listener.
@Override
public void removeListener(InvalidationListener listener) {
observableHelper.removeListener(listener);
}
/// Notifies game directory observers on the JavaFX thread when the toolkit is available.
private void invalidate() {
try {
Platform.runLater(observableHelper::invalidate);
} catch (IllegalStateException e) {
observableHelper.invalidate();
}
}
/// Serializes and deserializes game directories.
@NotNullByDefault
public static final class Serializer implements JsonSerializer<GameDirectory>, JsonDeserializer<GameDirectory> {
/// Serializes a game directory to JSON.
@Override
public JsonElement serialize(@Nullable GameDirectory src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null)
return JsonNull.INSTANCE;
JsonObject jsonObject = new JsonObject();
jsonObject.add("id", context.serialize(src.getId(), GameDirectoryID.class));
if (src.getName() != null) {
JsonElement name = context.serialize(src.getName(), LocalizedText.class);
if (name != null && !name.isJsonNull()) {
jsonObject.add("name", name);
}
}
jsonObject.add("path", context.serialize(src.getPath(), PortablePath.class));
if (src.getLegacyGameSettings() != null) {
jsonObject.add("legacyGameSettings", context.serialize(src.getLegacyGameSettings(), GameSettingsPresetID.class));
}
return jsonObject;
}
/// Deserializes a game directory from JSON.
@Override
public @Nullable GameDirectory deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject obj)) return null;
GameDirectoryID id = context.deserialize(obj.get("id"), GameDirectoryID.class);
if (id == null) {
throw new JsonParseException("Game directory ID cannot be null");
} else if (GameDirectoryID.NIL.equals(id)) {
throw new JsonParseException("Game directory ID cannot be nil");
}
PortablePath path = context.deserialize(obj.get("path"), PortablePath.class);
if (path == null) {
throw new JsonParseException("Game directory path cannot be null");
}
@Nullable LocalizedText name = context.deserialize(obj.get("name"), LocalizedText.class);
return new GameDirectory(id,
name,
path,
context.deserialize(obj.get("legacyGameSettings"), GameSettingsPresetID.class));
}
}
}
@@ -1,70 +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.setting;
import com.google.gson.annotations.JsonAdapter;
import org.glavo.uuid.UUIDs;
import org.jackhuang.hmcl.util.TypedID;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
import java.util.UUID;
/// Stable identifier for a persisted game directory entry.
///
/// @param uuid the UUID payload
@JsonAdapter(GameDirectoryID.Adapter.class)
@JsonSerializable
@NotNullByDefault
public record GameDirectoryID(UUID uuid) implements TypedID {
/// The serialized game directory ID prefix.
public static final String PREFIX = "game-directory";
/// The nil identifier value.
public static final GameDirectoryID NIL = new GameDirectoryID(UUIDs.NIL);
/// Creates a game directory ID.
public GameDirectoryID {
Objects.requireNonNull(uuid);
}
/// Parses a game directory ID from a prefixed UUID string.
public static GameDirectoryID parse(String value) {
return new GameDirectoryID(TypedID.parseUUID(PREFIX, value));
}
/// Generates a new time-ordered game directory ID.
public static GameDirectoryID generate() {
return new GameDirectoryID(UUIDs.generateV7());
}
/// Returns the prefixed game directory ID string.
@Override
public String toString() {
return TypedID.format(PREFIX, uuid);
}
/// Gson adapter for [GameDirectoryID].
public static final class Adapter extends TypedID.Adapter<GameDirectoryID> {
/// Creates a game directory ID adapter.
public Adapter() {
super(PREFIX, GameDirectoryID::new);
}
}
}
@@ -1,524 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.setting;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnknownNullability;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.*;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Manages the merged runtime view of local and user game directories.
///
/// Local game directories take precedence over user game directories with the same ID. This class is
/// the owner of the currently selected game directory and selected instance bookkeeping;
/// callers should use this API instead of mutating the game-directory selection fields in
/// [LauncherSettings] directly.
@NotNullByDefault
public final class GameDirectoryManager {
/// The stable ID shared by newly created and migrated current-workspace game directories.
static final GameDirectoryID DEFAULT_GAME_DIRECTORY_ID =
new GameDirectoryID(new UUID(0x7105bc1f490e5e8cL, 0x878cf5844c3d4bc3L));
/// The stable ID shared by newly created and migrated user-home game directories.
static final GameDirectoryID HOME_GAME_DIRECTORY_ID =
new GameDirectoryID(new UUID(0xf3eafde8506e5a77L, 0xbc88f24b4728dfb2L));
/// The default current-workspace game directory path.
private static final PortablePath CURRENT_GAME_DIRECTORY_PATH = PortablePath.of(".minecraft");
/// The default user-home game directory path.
private static final PortablePath HOME_GAME_DIRECTORY_PATH = PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY);
/// Prevents instantiation.
private GameDirectoryManager() {
}
/// Creates a game directory ID that does not collide with existing game directories.
public static GameDirectoryID newGameDirectoryId() {
GameDirectoryID id;
do {
id = GameDirectoryID.generate();
} while (hasGameDirectoryId(id));
return id;
}
/// Returns whether an existing game directory uses the given ID.
private static boolean hasGameDirectoryId(GameDirectoryID id) {
return localGameDirectories().getGameDirectories().stream()
.anyMatch(gameDirectory -> gameDirectory.getId().equals(id))
|| userGameDirectories().getGameDirectories().stream()
.anyMatch(gameDirectory -> gameDirectory.getId().equals(id));
}
/// Returns the display name for the given game directory.
public static String getGameDirectoryDisplayName(GameDirectory gameDirectory) {
String name = getGameDirectoryCustomName(gameDirectory);
if (name != null) {
return name;
}
if (isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
return i18n("game_directory.default");
}
if (isGameDirectoryPath(gameDirectory, HOME_GAME_DIRECTORY_PATH)) {
return i18n("game_directory.home");
}
return gameDirectory.getId().toString();
}
/// Returns the custom game directory name in the current locale.
public static @Nullable String getGameDirectoryCustomName(GameDirectory gameDirectory) {
@Nullable LocalizedText name = gameDirectory.getName();
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Returns whether the game directory uses the given path.
private static boolean isGameDirectoryPath(GameDirectory gameDirectory, PortablePath expectedPath) {
PortablePath actualPath = gameDirectory.getPath();
return actualPath.isAbsolute() == expectedPath.isAbsolute()
&& actualPath.getPath().equals(expectedPath.getPath());
}
/// True if [#init()] hasn't been called.
private static boolean initialized = false;
/// The mutable merged game directory list used by UI bindings and game directory selection.
private static final ObservableList<GameDirectory> mergedGameDirectories =
FXCollections.observableArrayList(gameDirectory -> new javafx.beans.Observable[]{gameDirectory});
/// Read-only view of [#mergedGameDirectories] exposed to callers.
private static final @UnmodifiableView ObservableList<GameDirectory> mergedGameDirectoriesUnmodifiable =
FXCollections.unmodifiableObservableList(mergedGameDirectories);
/// Runtime repositories keyed by game directory object identity.
private static final Map<GameDirectory, HMCLGameRepository> repositories = new IdentityHashMap<>();
/// The selected game directory, or `null` before the fallback game directory is resolved.
private static final ObjectProperty<@UnknownNullability GameDirectory> selectedGameDirectory = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedGameDirectory");
/// 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");
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener =
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
/// Reacts when the selected repository publishes a new snapshot.
private static final ChangeListener<? super HMCLGameRepositorySnapshot> selectedRepositorySnapshotListener =
(observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged();
/// Initializes game directory state from the stores loaded by [SettingsManager].
///
/// This method creates the built-in local and user-home game directories when required, rebuilds
/// the merged game directory view, and restores the selected game directory from [LauncherSettings].
public static void init() {
if (initialized)
throw new IllegalStateException("Already initialized");
initialized = true;
rebuildGameDirectories();
boolean needRebuildGameDirectories = false;
if (localGameDirectories().isNewlyCreated() && localGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = localGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
}
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = userGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
}
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
if (needRebuildGameDirectories) {
rebuildGameDirectories();
}
assert !mergedGameDirectories.isEmpty();
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
GameDirectory currentGameDirectory = null;
if (selectedId != null) {
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (gameDirectory.getId().equals(selectedId)) {
currentGameDirectory = gameDirectory;
break;
}
}
}
selectedGameDirectory.addListener((a, b, newValue) -> {
if (newValue == null) {
throw new IllegalStateException("selectedGameDirectory cannot be null");
}
settings().selectedGameDirectoryProperty().set(newValue.getId());
@Nullable HMCLGameRepository oldRepository = selectedRepository.get();
if (oldRepository != null) {
oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener);
oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener);
}
HMCLGameRepository repository = getOrCreateRepository(newValue);
selectedRepository.set(repository);
selectedInstance.set(repository.getSelectedInstance());
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
repository.snapshotProperty().addListener(selectedRepositorySnapshotListener);
if (repository.isLoaded()) {
onSelectedRepositorySnapshotChanged();
}
repository.refreshAsync().start();
});
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
}
/// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot.
private static void onSelectedRepositorySnapshotChanged() {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository == null || !repository.isLoaded()) {
return;
}
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners) {
listener.accept(repository);
}
}
/// Creates the built-in game directories only when no game directory exists.
private static boolean createDefaultGameDirectoriesIfEmpty() {
if (localGameDirectories().getGameDirectories().isEmpty()
&& userGameDirectories().getGameDirectories().isEmpty()) {
localGameDirectories().getGameDirectories()
.add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
userGameDirectories().getGameDirectories()
.add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
return true;
} else {
return false;
}
}
/// Rebuilds the merged runtime game directory view from the two backing stores.
private static void rebuildGameDirectories() {
GameDirectories userGameDirectories = userGameDirectories();
GameDirectories localGameDirectories = localGameDirectories();
Map<GameDirectoryID, GameDirectory> visibleGameDirectories = new LinkedHashMap<>();
for (GameDirectory gameDirectory : localGameDirectories.getGameDirectories()) {
GameDirectoryID id = gameDirectory.getId();
visibleGameDirectories.put(id, gameDirectory);
}
for (GameDirectory gameDirectory : userGameDirectories.getGameDirectories()) {
GameDirectoryID id = gameDirectory.getId();
visibleGameDirectories.putIfAbsent(id, gameDirectory);
}
mergedGameDirectories.setAll(visibleGameDirectories.values());
}
/// Returns the read-only merged game directory list.
public static @UnmodifiableView ObservableList<GameDirectory> getGameDirectories() {
return mergedGameDirectoriesUnmodifiable;
}
/// Returns the repository for the given game directory, creating it when needed.
private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirectory) {
Objects.requireNonNull(gameDirectory);
return repositories.computeIfAbsent(gameDirectory, HMCLGameRepository::new);
}
/// Adds a game directory to the per-workspace store.
public static void addLocalGameDirectory(GameDirectory gameDirectory) {
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
throw new IllegalStateException("Local game directories are read-only");
}
addGameDirectory(localGameDirectories(), gameDirectory);
}
/// Adds a game directory to the user store.
public static void addUserGameDirectory(GameDirectory gameDirectory) {
if (SettingsManager.isUserGameDirectoriesReadOnly()) {
throw new IllegalStateException("User game directories are read-only");
}
addGameDirectory(userGameDirectories(), gameDirectory);
}
/// Returns whether an existing game directory can be updated to the given path.
///
/// @param gameDirectory the game directory to update
/// @param path the new game directory path
/// @return whether both the current and target stores may be edited
public static boolean canUpdateGameDirectory(GameDirectory gameDirectory, PortablePath path) {
GameDirectories source = requireGameDirectoryStore(gameDirectory);
GameDirectories target = getGameDirectoryStore(path);
return !isGameDirectoriesReadOnly(source) && !isGameDirectoriesReadOnly(target);
}
/// Backs up and overwrites read-only game directory files required to update a game directory.
///
/// @param gameDirectory the game directory to update
/// @param path the new game directory path
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory, PortablePath path) {
GameDirectories source = requireGameDirectoryStore(gameDirectory);
GameDirectories target = getGameDirectoryStore(path);
forceOverwriteGameDirectoriesIfReadOnly(source);
if (target != source) {
forceOverwriteGameDirectoriesIfReadOnly(target);
}
}
/// Updates a game directory, moving it between local and user stores when the path type changes.
///
/// @param gameDirectory the game directory to update
/// @param name the new custom game directory name, or `null` for an unnamed game directory
/// @param path the new game directory path
public static void updateGameDirectory(GameDirectory gameDirectory, @Nullable LocalizedText name, PortablePath path) {
GameDirectories source = requireGameDirectoryStore(gameDirectory);
GameDirectories target = getGameDirectoryStore(path);
if (isGameDirectoriesReadOnly(source) || isGameDirectoriesReadOnly(target)) {
throw new IllegalStateException("Game directories are read-only");
}
if (source == target) {
gameDirectory.setName(name);
gameDirectory.setPath(path);
rebuildGameDirectories();
return;
}
source.getGameDirectories().remove(gameDirectory);
gameDirectory.setName(name);
gameDirectory.setPath(path);
addGameDirectory(target, gameDirectory);
}
/// Returns whether the game directory can be removed from all stores containing it.
///
/// @param gameDirectory the game directory to remove
/// @return whether all containing stores may be edited
public static boolean canRemoveGameDirectory(GameDirectory gameDirectory) {
boolean localContains = localGameDirectories().getGameDirectories().contains(gameDirectory);
boolean userContains = userGameDirectories().getGameDirectories().contains(gameDirectory);
return (!localContains || !SettingsManager.isLocalGameDirectoriesReadOnly())
&& (!userContains || !SettingsManager.isUserGameDirectoriesReadOnly());
}
/// Backs up and overwrites read-only game directory files required to remove a game directory.
///
/// @param gameDirectory the game directory to remove
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory) {
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
forceOverwriteGameDirectoriesIfReadOnly(localGameDirectories());
}
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
forceOverwriteGameDirectoriesIfReadOnly(userGameDirectories());
}
}
/// Adds a game directory to the given store, replacing a game directory with the same ID in that store.
private static void addGameDirectory(GameDirectories gameDirectories, GameDirectory gameDirectory) {
Objects.requireNonNull(gameDirectory);
ObservableList<GameDirectory> entries = gameDirectories.getGameDirectories();
GameDirectoryID id = gameDirectory.getId();
for (int i = 0; i < entries.size(); i++) {
if (entries.get(i).getId().equals(id)) {
repositories.remove(entries.get(i));
settings().setSelectedInstance(id, null);
entries.set(i, gameDirectory);
rebuildGameDirectories();
return;
}
}
entries.add(gameDirectory);
rebuildGameDirectories();
}
/// Removes a game directory and recreates the built-in game directories when the list becomes empty.
public static void removeGameDirectory(GameDirectory gameDirectory) {
if (!canRemoveGameDirectory(gameDirectory)) {
throw new IllegalStateException("Game directories are read-only");
}
userGameDirectories().getGameDirectories().remove(gameDirectory);
localGameDirectories().getGameDirectories().remove(gameDirectory);
repositories.remove(gameDirectory);
createDefaultGameDirectoriesIfEmpty();
rebuildGameDirectories();
if (!mergedGameDirectories.contains(selectedGameDirectory.get())) {
setSelectedGameDirectory(mergedGameDirectories.get(0));
}
}
/// Returns the store that should own game directories with the given path.
private static GameDirectories getGameDirectoryStore(PortablePath path) {
Objects.requireNonNull(path);
return path.isAbsolute() ? userGameDirectories() : localGameDirectories();
}
/// Returns the store currently containing the game directory.
private static @Nullable GameDirectories findGameDirectoryStore(GameDirectory gameDirectory) {
Objects.requireNonNull(gameDirectory);
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
return localGameDirectories();
}
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
return userGameDirectories();
}
return null;
}
/// Returns the store currently containing the game directory, or throws when it is detached.
private static GameDirectories requireGameDirectoryStore(GameDirectory gameDirectory) {
@Nullable GameDirectories gameDirectories = findGameDirectoryStore(gameDirectory);
if (gameDirectories == null) {
throw new IllegalArgumentException("Game directory does not belong to a game directory store");
}
return gameDirectories;
}
/// Returns whether the given game directory store is read-only.
private static boolean isGameDirectoriesReadOnly(GameDirectories gameDirectories) {
return gameDirectories.isUserFile()
? SettingsManager.isUserGameDirectoriesReadOnly()
: SettingsManager.isLocalGameDirectoriesReadOnly();
}
/// Backs up and overwrites the given game directory store when it is read-only.
private static void forceOverwriteGameDirectoriesIfReadOnly(GameDirectories gameDirectories) {
if (!isGameDirectoriesReadOnly(gameDirectories)) {
return;
}
if (gameDirectories.isUserFile()) {
SettingsManager.forceOverwriteUserGameDirectories();
} else {
SettingsManager.forceOverwriteLocalGameDirectories();
}
}
/// Returns the selected game directory, creating built-in game directories first if the list is empty.
public static GameDirectory getSelectedGameDirectory() {
GameDirectory gameDirectory = selectedGameDirectory.get();
if (gameDirectory == null) {
throw new IllegalStateException("Selected game directory cannot be null");
}
return gameDirectory;
}
/// Returns the selected game repository.
public static HMCLGameRepository getSelectedRepository() {
HMCLGameRepository repository = selectedRepository.get();
if (repository == null) {
throw new IllegalStateException("Selected repository cannot be null");
}
return repository;
}
/// Sets the selected game directory.
public static void setSelectedGameDirectory(GameDirectory gameDirectory) {
Objects.requireNonNull(gameDirectory);
if (!mergedGameDirectories.contains(gameDirectory)) {
throw new IllegalArgumentException("Unknown game directory: " + gameDirectory);
}
selectedGameDirectory.set(gameDirectory);
}
/// Returns the selected game directory property.
///
/// The property is exposed for UI binding. Values should be changed through
/// [#setSelectedGameDirectory(GameDirectory)] or by bidirectional bindings that only select
/// game directories from [#getGameDirectories()].
public static ObjectProperty<GameDirectory> selectedGameDirectoryProperty() {
return selectedGameDirectory;
}
/// Returns the selected game repository property.
public static ObjectProperty<HMCLGameRepository> selectedRepositoryProperty() {
return selectedRepository;
}
/// 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() {
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() {
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) {
getSelectedRepository().setSelectedInstance(instance);
}
/// Listeners notified after the selected repository has loaded versions.
private static final List<Consumer<HMCLGameRepository>> versionsListeners = new ArrayList<>(4);
/// Registers a listener that is notified when the selected repository's versions are available.
public static void registerVersionsListener(Consumer<HMCLGameRepository> listener) {
HMCLGameRepository repository = getSelectedRepository();
if (repository.isLoaded())
listener.accept(repository);
versionsListeners.add(listener);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,70 +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.setting;
import com.google.gson.annotations.JsonAdapter;
import org.glavo.uuid.UUIDs;
import org.jackhuang.hmcl.util.TypedID;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
import java.util.UUID;
/// Stable identifier for a persisted game settings preset.
///
/// @param uuid the UUID payload
@JsonAdapter(GameSettingsPresetID.Adapter.class)
@JsonSerializable
@NotNullByDefault
public record GameSettingsPresetID(UUID uuid) implements TypedID {
/// The serialized game settings preset ID prefix.
public static final String PREFIX = "game-settings-preset";
/// The nil identifier value.
public static final GameSettingsPresetID NIL = new GameSettingsPresetID(UUIDs.NIL);
/// Creates a game settings preset ID.
public GameSettingsPresetID {
Objects.requireNonNull(uuid);
}
/// Parses a game settings preset ID from a prefixed UUID string.
public static GameSettingsPresetID parse(String value) {
return new GameSettingsPresetID(TypedID.parseUUID(PREFIX, value));
}
/// Generates a new time-ordered game settings preset ID.
public static GameSettingsPresetID generate() {
return new GameSettingsPresetID(UUIDs.generateV7());
}
/// Returns the prefixed game settings preset ID string.
@Override
public String toString() {
return TypedID.format(PREFIX, uuid);
}
/// Gson adapter for [GameSettingsPresetID].
public static final class Adapter extends TypedID.Adapter<GameSettingsPresetID> {
/// Creates a game settings preset ID adapter.
public Adapter() {
super(PREFIX, GameSettingsPresetID::new);
}
}
}
@@ -1,169 +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.setting;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// Stores reusable game settings presets independently from the main config file.
///
/// The JSON representation is saved as `config/game-settings.json` under the current HMCL
/// directory.
///
/// @author Glavo
@JsonAdapter(GameSettingsPresets.Adapter.class)
@NotNullByDefault
@JsonSerializable
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, 0));
/// Creates an empty game settings preset store.
public GameSettingsPresets() {
tracker.markDirty(schema);
register();
}
/// Copies another preset store into this instance.
void copyFrom(GameSettingsPresets source) {
if (source == this) {
return;
}
presets.setAll(source.getPresets());
}
/// The schema used by this game settings preset store file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this game settings preset store file.
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this game settings preset store file.
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this preset store may be saved back to `config/game-settings.json`.
private transient boolean savable = true;
/// Whether the next successful save should back up the current `config/game-settings.json` first.
private transient boolean backupOnNextSave;
/// Returns whether this preset store may be saved back to `config/game-settings.json`.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this preset store may be saved back to `config/game-settings.json`.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current `config/game-settings.json` first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current `config/game-settings.json` first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// Reusable game setting presets.
@SerializedName("presets")
private final ObservableList<GameSettings.Preset> presets =
FXCollections.observableArrayList(setting -> new Observable[] { setting });
/// Returns the reusable game setting presets.
public ObservableList<GameSettings.Preset> getPresets() {
return presets;
}
/// Creates a preset ID that does not collide with existing presets.
public GameSettingsPresetID newPresetId() {
GameSettingsPresetID id;
do {
id = GameSettingsPresetID.generate();
} while (getPreset(id) != null);
return id;
}
/// Returns the first automatic preset number that is not used by existing presets.
public int newPresetAutoNameNumber() {
mainLoop:
for (int index = 1; ; index++) {
for (GameSettings.Preset setting : getPresets()) {
Integer autoNameNumber = setting.autoNameNumberProperty().getValue();
if ((autoNameNumber != null && autoNameNumber == index)) {
continue mainLoop;
}
}
return index;
}
}
/// Returns the preset with the given ID.
public GameSettings.@Nullable Preset getPreset(@Nullable GameSettingsPresetID id) {
if (id == null) {
return null;
}
for (GameSettings.Preset setting : presets) {
if (id.equals(setting.idProperty().getValue())) {
return setting;
}
}
return null;
}
/// JSON adapter for [GameSettingsPresets].
public static final class Adapter extends ObservableSetting.Adapter<GameSettingsPresets> {
/// Creates an empty preset store for deserialization.
@Override
protected GameSettingsPresets createInstance() {
return new GameSettingsPresets();
}
}
}
@@ -1,32 +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.setting;
/// The initial display mode of the game window.
///
/// @author Glavo
public enum GameWindowType {
/// Start the game in a normal window.
WINDOWED,
/// Start the game in fullscreen mode.
FULLSCREEN,
/// Start the game in a maximized window.
MAXIMIZED,
}
@@ -0,0 +1,157 @@
/*
* 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.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.Nullable;
import java.util.*;
@JsonAdapter(GlobalConfig.Adapter.class)
public final class GlobalConfig extends ObservableSetting {
@Nullable
public static GlobalConfig fromJson(String json) throws JsonParseException {
return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
}
public GlobalConfig() {
register();
}
public String toJson() {
return Config.CONFIG_GSON.toJson(this);
}
@SerializedName("agreementVersion")
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
public IntegerProperty agreementVersionProperty() {
return agreementVersion;
}
public int getAgreementVersion() {
return agreementVersion.get();
}
public void setAgreementVersion(int agreementVersion) {
this.agreementVersion.set(agreementVersion);
}
@SerializedName("terracottaAgreementVersion")
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
public IntegerProperty terracottaAgreementVersionProperty() {
return terracottaAgreementVersion;
}
public int getTerracottaAgreementVersion() {
return terracottaAgreementVersion.get();
}
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
}
@SerializedName("platformPromptVersion")
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
public IntegerProperty platformPromptVersionProperty() {
return platformPromptVersion;
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
public void setPlatformPromptVersion(int platformPromptVersion) {
this.platformPromptVersion.set(platformPromptVersion);
}
@SerializedName("logRetention")
private final IntegerProperty logRetention = new SimpleIntegerProperty(20);
public IntegerProperty logRetentionProperty() {
return logRetention;
}
public int getLogRetention() {
return logRetention.get();
}
public void setLogRetention(int logRetention) {
this.logRetention.set(logRetention);
}
@SerializedName("enableOfflineAccount")
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
public BooleanProperty enableOfflineAccountProperty() {
return enableOfflineAccount;
}
public boolean isEnableOfflineAccount() {
return enableOfflineAccount.get();
}
public void setEnableOfflineAccount(boolean value) {
enableOfflineAccount.set(value);
}
@SerializedName("fontAntiAliasing")
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
public StringProperty fontAntiAliasingProperty() {
return fontAntiAliasing;
}
public String getFontAntiAliasing() {
return fontAntiAliasing.get();
}
public void setFontAntiAliasing(String value) {
this.fontAntiAliasing.set(value);
}
@SerializedName("userJava")
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
public ObservableSet<String> getUserJava() {
return userJava;
}
@SerializedName("disabledJava")
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
public ObservableSet<String> getDisabledJava() {
return disabledJava;
}
static final class Adapter extends ObservableSetting.Adapter<GlobalConfig> {
@Override
protected GlobalConfig createInstance() {
return new GlobalConfig();
}
}
}
@@ -17,27 +17,9 @@
*/
package org.jackhuang.hmcl.setting;
import org.jetbrains.annotations.NotNullByDefault;
/// Selects how HMCL resolves the Java runtime used to launch a game.
///
/// Additional payload for these modes is stored in `GameSettings`:
/// - [VERSION] uses `customJavaVersion` as the user-entered Java version selector.
/// - [DETECTED] uses `detectedJava` as the detected runtime reference.
/// - [CUSTOM] uses `customJavaPath` as the executable path selected by the user.
///
/// @author Glavo
@NotNullByDefault
/**
* @author Glavo
*/
public enum JavaVersionType {
/// Automatically selects a suitable Java runtime for the target game version.
AUTO,
/// Selects an installed Java runtime by major version.
VERSION,
/// Selects one runtime from the Java runtimes detected by HMCL.
DETECTED,
/// Uses a user-provided Java executable path.
CUSTOM
DEFAULT, AUTO, VERSION, DETECTED, CUSTOM
}
@@ -1,51 +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.setting;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jetbrains.annotations.NotNullByDefault;
/// Marks a detached settings object that stores its own JSON schema URL.
///
/// @author Glavo
@NotNullByDefault
interface JsonSchemaSetting {
/// Returns the schema used by this JSON settings file.
JsonSchema getSchema();
/// Sets the schema used by this JSON settings file.
///
/// @param schema the schema to store
void setSchema(JsonSchema schema);
/// Returns whether this settings object may be saved back to its JSON file.
boolean isSavable();
/// Sets whether this settings object may be saved back to its JSON file.
///
/// @param savable whether this settings object may be saved
void setSavable(boolean savable);
/// Returns whether the next successful save should back up the current on-disk file first.
boolean isBackupOnNextSave();
/// Sets whether the next successful save should back up the current on-disk file first.
///
/// @param backupOnNextSave whether the next successful save should back up the current on-disk file first
void setBackupOnNextSave(boolean backupOnNextSave);
}
@@ -1,225 +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.setting;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jackhuang.hmcl.util.io.FileUtils;
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.Objects;
import java.util.function.Supplier;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Loads, saves, and validates a detached JSON settings file with a [JsonSchema] marker.
///
/// @param <T> the settings object type
/// @author Glavo
@NotNullByDefault
final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
/// The settings file location.
private final Path location;
/// The human-readable file type name used in logs.
private final String displayName;
/// The settings object type.
private final Class<T> type;
/// The JSON schema supported by the current code.
private final JsonSchema expectedSchema;
/// Creates a default settings object.
private final Supplier<T> createDefault;
/// Creates a detached JSON settings file helper.
///
/// @param location the settings file location
/// @param displayName the human-readable file type name used in logs
/// @param type the settings object type
/// @param expectedSchema the JSON schema supported by the current code
/// @param createDefault creates a default settings object
JsonSettingFile(
Path location,
String displayName,
Class<T> type,
JsonSchema expectedSchema,
Supplier<T> createDefault) {
this.location = Objects.requireNonNull(location);
this.displayName = Objects.requireNonNull(displayName);
this.type = Objects.requireNonNull(type);
this.expectedSchema = Objects.requireNonNull(expectedSchema);
this.createDefault = Objects.requireNonNull(createDefault);
}
/// Loads the settings file, falling back to migrated data or a default object when absent.
///
/// @param migrated migrated settings data used when the file is absent
/// @return the loaded settings object
/// @throws IOException if reading the file fails
LoadResult<T> load(@Nullable T migrated) throws IOException {
if (Files.exists(location)) {
try {
JsonObject jsonObject = JsonUtils.fromJsonFile(location, JsonObject.class);
if (jsonObject == null) {
LOG.warning(displayName + " are empty: " + location);
return result(createDefault.get(), true);
} else {
JsonSchema.CompatibilityResult schemaResult =
JsonSchema.check(jsonObject, expectedSchema);
switch (schemaResult.status()) {
case MISSING -> LOG.warning("Missing schema in " + displayName + ": " + location);
case INVALID -> LOG.warning("Invalid schema in " + displayName + ": "
+ location + ", Actual: " + schemaResult.invalidValue());
case UNPARSEABLE -> LOG.warning("Unparseable schema in " + displayName + ": "
+ location + ", Actual: " + schemaResult.actual());
case UNEXPECTED_ID -> LOG.warning("Unexpected " + displayName + " schema. Expected: "
+ expectedSchema + ", Actual: " + schemaResult.actual());
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported " + displayName
+ " schema. Expected: " + expectedSchema + ", Actual: " + schemaResult.actual());
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
if (!schemaResult.readable()) {
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
}
T deserialized = LauncherSettings.SETTINGS_GSON.<@Nullable T>fromJson(jsonObject, type);
if (deserialized != null) {
// Patch-compatible files keep their original schema because unknown members are preserved.
if (!schemaResult.preserveSchema() && !expectedSchema.equals(deserialized.getSchema())) {
deserialized.setSchema(expectedSchema);
}
return result(deserialized, schemaResult.allowSave()
? SettingFileAccess.READ_WRITE
: SettingFileAccess.READ_ONLY);
}
LOG.warning(displayName + " deserialized to null: " + location);
}
} catch (JsonParseException e) {
LOG.warning("Malformed " + displayName + ".", e);
return result(createDefault.get(), true, true);
}
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
}
return result(Objects.requireNonNullElseGet(migrated, createDefault), true);
}
/// Creates a load result and stores the saveability metadata on the settings object.
private LoadResult<T> result(T value, boolean savable) {
return result(value, savable, false);
}
/// Creates a load result and stores the saveability metadata on the settings object.
private LoadResult<T> result(T value, boolean savable, boolean backupOnNextSave) {
value.setSavable(savable);
value.setBackupOnNextSave(backupOnNextSave);
return new LoadResult<>(value, SettingFileAccess.READ_WRITE);
}
/// Creates a load result and stores the access metadata on the settings object.
private LoadResult<T> result(T value, SettingFileAccess access) {
value.setSavable(access.canSave());
value.setBackupOnNextSave(false);
return new LoadResult<>(value, access);
}
/// Installs an automatic save listener on a settings object.
///
/// @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);
}
});
}
/// Saves a settings object.
///
/// @param value the settings object to save
void save(T value) {
if (value.isBackupOnNextSave()) {
value.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(location);
}
FileSaver.save(location, LauncherSettings.SETTINGS_GSON.toJson(value, type));
}
/// Saves a settings object synchronously.
///
/// @param value the settings object to save
/// @throws IOException if saving the file fails
void saveSync(T value) throws IOException {
if (value.isBackupOnNextSave()) {
value.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(location);
}
FileUtils.saveSafely(location, LauncherSettings.SETTINGS_GSON.toJson(value, type));
}
/// Prepares a settings object for overwriting a detached settings file.
///
/// @param value the replacement settings object
private void prepareOverwrite(T value) {
SettingFileUtils.backupInvalidConfig(location);
value.setSchema(expectedSchema);
value.setSavable(true);
value.setBackupOnNextSave(false);
}
/// Backs up the current file and overwrites it with the given value using the current schema.
///
/// @param value the replacement settings object
void backupAndOverwrite(T value) {
prepareOverwrite(value);
save(value);
}
/// Backs up the current file and overwrites it synchronously with the given value using the current schema.
///
/// @param value the replacement settings object
/// @throws IOException if saving the file fails
void backupAndOverwriteSync(T value) throws IOException {
prepareOverwrite(value);
saveSync(value);
}
/// Result of loading a detached JSON settings file.
///
/// @param value the loaded settings object
/// @param access whether the source file may be read and overwritten
record LoadResult<T extends ObservableSetting & JsonSchemaSetting>(T value, SettingFileAccess access) {
}
}
@@ -1,686 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import org.glavo.monetfx.ColorStyle;
import org.hildan.fxgson.creators.ObservableListCreator;
import org.hildan.fxgson.creators.ObservableMapCreator;
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;
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.ThemeReference;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.*;
/// Stores the current workspace's main launcher settings.
///
/// This file keeps launcher-level choices such as UI preferences, network settings, selected game directory,
/// selected instances, and account selection. Larger domain-specific stores, such as game directories,
/// game settings presets, accounts, launcher state, and authlib-injector servers, are persisted in detached
/// JSON files managed by [SettingsManager].
@JsonAdapter(value = LauncherSettings.Adapter.class)
@NotNullByDefault
public final class LauncherSettings extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this launcher settings class.
public static final JsonSchema CURRENT_SCHEMA = new JsonSchema("launcher-settings", new JsonSchema.Version(1, 0, 0));
/// The JSON property name for the default game setting preset ID.
static final String PROPERTY_DEFAULT_GAME_SETTINGS_PRESET = "defaultGameSettingsPreset";
/// The JSON property name for the selected game directory ID.
static final String PROPERTY_SELECTED_GAME_DIRECTORY = "selectedGameDirectory";
/// The JSON property name for selected instance IDs keyed by game directory ID.
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
/// Default launcher theme used when no stored theme reference is available.
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
/// Theme appearance override key for theme brightness mode.
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
/// Theme appearance override key for theme color seed.
public static final String THEME_APPEARANCE_COLOR = "themeColor";
/// Theme appearance override key for theme color style.
public static final String THEME_APPEARANCE_COLOR_STYLE = "themeColorStyle";
/// Theme appearance override key for title-bar transparency.
public static final String THEME_APPEARANCE_TITLE_BAR_TRANSPARENT = "titleBarTransparent";
/// Theme appearance override key for window transparency.
public static final String THEME_APPEARANCE_WINDOW_TRANSPARENT = "windowTransparent";
/// Theme appearance override key for the primary background source.
public static final String THEME_APPEARANCE_BACKGROUND = "background";
/// Theme appearance override key for background opacity.
public static final String THEME_APPEARANCE_BACKGROUND_OPACITY = "backgroundOpacity";
/// Gson instance used for launcher settings and related settings objects that depend on JavaFX properties.
public static final Gson SETTINGS_GSON = new GsonBuilder()
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
.registerTypeAdapter(Paint.class, new PaintAdapter())
.setPrettyPrinting()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();
/// Deserializes launcher settings from JSON.
///
/// @param json the JSON object to read
/// @return the deserialized launcher settings
/// @throws JsonParseException if the JSON cannot be deserialized as launcher settings
public static LauncherSettings fromJson(JsonObject json) throws JsonParseException {
return SETTINGS_GSON.fromJson(json, LauncherSettings.class);
}
/// Creates empty launcher settings using current defaults.
public LauncherSettings() {
tracker.markDirty(schema);
register();
}
/// Serializes these launcher settings to formatted JSON.
public String toJson() {
return SETTINGS_GSON.toJson(this);
}
// Properties
/// The schema used by this launcher settings file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this launcher settings file.
@Override
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this launcher settings file.
@Override
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this launcher settings object may be saved back to `config/launcher-settings.json`.
private transient boolean savable = true;
/// Whether the next successful save should back up the current `config/launcher-settings.json` first.
private transient boolean backupOnNextSave = false;
/// Returns whether this launcher settings object may be saved back to `config/launcher-settings.json`.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this launcher settings object may be saved back to `config/launcher-settings.json`.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current `config/launcher-settings.json` first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current `config/launcher-settings.json` first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// The launcher UI language.
@SerializedName("language")
private final ObjectProperty<SupportedLocale> language = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
/// Returns the launcher UI language property.
public ObjectProperty<SupportedLocale> languageProperty() {
return language;
}
/// Whether preview builds are accepted by update checks.
@SerializedName("acceptPreviewUpdate")
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
/// Returns the preview update opt-in property.
public BooleanProperty acceptPreviewUpdateProperty() {
return acceptPreviewUpdate;
}
/// Whether automatic update dialogs are disabled.
@SerializedName("disableAutoShowUpdateDialog")
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
/// Returns the automatic update dialog disable property.
public BooleanProperty disableAutoShowUpdateDialogProperty() {
return disableAutoShowUpdateDialog;
}
/// Whether April Fools features are disabled.
@SerializedName("disableAprilFools")
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
/// Returns the April Fools disable property.
public BooleanProperty disableAprilFoolsProperty() {
return disableAprilFools;
}
/// The common Minecraft directory selection mode.
@SerializedName("commonDirectoryType")
private final ObjectProperty<EnumCommonDirectory> commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
/// Returns the common Minecraft directory selection mode property.
public ObjectProperty<EnumCommonDirectory> commonDirectoryTypeProperty() {
return commonDirectoryType;
}
/// The custom common Minecraft directory path.
@SerializedName("commonDirectory")
private final StringProperty commonDirectory = new SimpleStringProperty();
/// Returns the custom common Minecraft directory property.
public StringProperty commonDirectoryProperty() {
return commonDirectory;
}
/// Returns the default common Minecraft directory path.
public static String getDefaultCommonDirectory() {
return Metadata.MINECRAFT_DIRECTORY.toString();
}
/// Resolves the effective common Minecraft directory from the current directory settings.
///
/// @return the effective directory path, or `null` when the configured mode is not recognized
public String getResolvedCommonDirectory() {
EnumCommonDirectory type = commonDirectoryType.get();
String customPath = commonDirectory.get();
return type == EnumCommonDirectory.CUSTOM && StringUtils.isNotBlank(customPath)
? customPath
: getDefaultCommonDirectory();
}
/// The maximum number of log lines kept in log views.
@SerializedName("logLines")
private final ObjectProperty<@Nullable Integer> logLines = new SimpleObjectProperty<>();
/// Returns the log line limit property.
public ObjectProperty<@Nullable Integer> logLinesProperty() {
return logLines;
}
// UI
// Theme selection
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
@SerializedName("selectedTheme")
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
/// Returns the selected installed theme property.
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
return selectedTheme;
}
/// Returns the selected installed theme, falling back to the built-in default theme.
public ThemeReference getSelectedThemeOrDefault() {
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
}
// Theme appearance overrides
/// Theme appearance setting keys overridden by the launcher.
@SerializedName("themeAppearanceOverrides")
private final ObservableSet<String> themeAppearanceOverrides = FXCollections.observableSet();
/// Returns the theme appearance setting keys overridden by the launcher.
public ObservableSet<String> getThemeAppearanceOverrides() {
return themeAppearanceOverrides;
}
// Theme appearance values
/// The configured theme brightness mode identifier.
@SerializedName("themeBrightnessMode")
private final StringProperty themeBrightnessMode = new SimpleStringProperty("auto");
/// Returns the theme brightness mode property.
public StringProperty themeBrightnessModeProperty() {
return themeBrightnessMode;
}
/// The custom launcher theme color preserved when dynamic color extraction is used.
@SerializedName("customThemeColor")
private final ObjectProperty<ThemeColor> customThemeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
/// Returns the custom launcher theme color property.
public ObjectProperty<ThemeColor> customThemeColorProperty() {
return customThemeColor;
}
/// The source used to choose the launcher Monet theme color seed.
@SerializedName("themeColorType")
private final ObjectProperty<ThemeColorType> themeColorType = new RawPreservingObjectProperty<>(ThemeColorType.DEFAULT);
/// Returns the launcher theme color source type property.
public ObjectProperty<ThemeColorType> themeColorTypeProperty() {
return themeColorType;
}
/// The MonetFX color style used to generate the launcher color scheme.
@SerializedName("themeColorStyle")
private final ObjectProperty<ColorStyle> themeColorStyle = new RawPreservingObjectProperty<>(ColorStyle.FIDELITY);
/// Returns the launcher theme color style property.
public ObjectProperty<ColorStyle> themeColorStyleProperty() {
return themeColorStyle;
}
/// Whether the launcher title bar is transparent.
@SerializedName("titleBarTransparent")
private final BooleanProperty titleBarTransparent = new SimpleBooleanProperty(false);
/// Returns the transparent title-bar property.
public BooleanProperty titleBarTransparentProperty() {
return titleBarTransparent;
}
/// Whether translucent launcher backgrounds reveal content behind the window.
@SerializedName("windowTransparent")
private final BooleanProperty windowTransparent = new SimpleBooleanProperty(false);
/// Returns the window transparency property.
public BooleanProperty windowTransparentProperty() {
return windowTransparent;
}
// Background source
/// The launcher background source type.
@SerializedName("backgroundType")
private final ObjectProperty<BackgroundType> backgroundType = new RawPreservingObjectProperty<>(BackgroundType.DEFAULT);
/// Returns the launcher background source type property.
public ObjectProperty<BackgroundType> backgroundTypeProperty() {
return backgroundType;
}
/// The selected built-in launcher wallpaper ID.
@SerializedName("builtinBackgroundId")
private final StringProperty builtinBackgroundId = new SimpleStringProperty(BuiltinBackground.FALLBACK.id());
/// Returns the selected built-in launcher wallpaper ID property.
public StringProperty builtinBackgroundIdProperty() {
return builtinBackgroundId;
}
/// The local custom launcher background image path.
@SerializedName("customBackgroundImagePath")
private final StringProperty customBackgroundImagePath = new SimpleStringProperty();
/// Returns the local custom launcher background image path property.
public StringProperty customBackgroundImagePathProperty() {
return customBackgroundImagePath;
}
/// The remote network launcher background image URL.
@SerializedName("networkBackgroundImageUrl")
private final StringProperty networkBackgroundImageUrl = new SimpleStringProperty();
/// Returns the remote network launcher background image URL property.
public StringProperty networkBackgroundImageUrlProperty() {
return networkBackgroundImageUrl;
}
/// The custom launcher background paint.
@SerializedName("customBackgroundPaint")
private final ObjectProperty<@Nullable Paint> customBackgroundPaint = new SimpleObjectProperty<>();
/// Returns the custom launcher background paint property.
public ObjectProperty<@Nullable Paint> customBackgroundPaintProperty() {
return customBackgroundPaint;
}
// Background appearance
/// The launcher background opacity value.
@SerializedName("backgroundOpacity")
private final DoubleProperty backgroundOpacity = new SimpleDoubleProperty(1.0);
/// Returns the custom launcher background opacity value property.
public DoubleProperty backgroundOpacityProperty() {
return backgroundOpacity;
}
// Background loading
/// The URL image cache policy for network launcher backgrounds.
@SerializedName("networkBackgroundImageCachePolicy")
private final ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicy =
new RawPreservingObjectProperty<>(NetworkBackgroundImageCachePolicy.ENABLED);
/// Returns the URL image cache policy for network launcher backgrounds.
public ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicyProperty() {
return networkBackgroundImageCachePolicy;
}
/// The fallback source used when the selected launcher background cannot be loaded.
@SerializedName("backgroundFallbackType")
private final ObjectProperty<BackgroundType> backgroundFallbackType =
new RawPreservingObjectProperty<>(BackgroundType.BUILTIN);
/// Returns the launcher background fallback source type property.
public ObjectProperty<BackgroundType> backgroundFallbackTypeProperty() {
return backgroundFallbackType;
}
/// The fallback paint used when the selected launcher background cannot be loaded.
@SerializedName("backgroundFallbackPaint")
private final ObjectProperty<Paint> backgroundFallbackPaint = new SimpleObjectProperty<>(Color.WHITE);
/// Returns the launcher background fallback paint property.
public ObjectProperty<Paint> backgroundFallbackPaintProperty() {
return backgroundFallbackPaint;
}
/// How the launcher displays its window while the selected background is loading.
@SerializedName("backgroundLoadPolicy")
private final ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicy =
new RawPreservingObjectProperty<>(BackgroundLoadPolicy.WAIT_FOR_BACKGROUND);
/// Returns the launcher background loading policy property.
public ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicyProperty() {
return backgroundLoadPolicy;
}
// Fonts
/// The font family used by launcher log views.
@SerializedName("logFontFamily")
private final StringProperty logFontFamily = new SimpleStringProperty();
/// Returns the launcher log font family property.
public StringProperty logFontFamilyProperty() {
return logFontFamily;
}
/// The launcher log font size.
@SerializedName("logFontSize")
private final DoubleProperty logFontSize = new SimpleDoubleProperty(12);
/// Returns the launcher log font size property.
public DoubleProperty logFontSizeProperty() {
return logFontSize;
}
/// The font family used by launcher chrome.
@SerializedName("launcherFontFamily")
private final StringProperty launcherFontFamily = new SimpleStringProperty();
/// Returns the launcher chrome font family property.
public StringProperty launcherFontFamilyProperty() {
return launcherFontFamily;
}
// General UI
/// Whether UI animations are disabled.
@SerializedName("animationDisabled")
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
/// Returns the UI animation disable property.
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
return animationDisabled;
}
public boolean isAnimationDisabled() {
// Avoid accessing FXUtils too early during startup
return Objects.requireNonNullElseGet(animationDisabled.get(), () ->
FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED);
}
// Networks
/// Whether HMCL automatically selects the number of download threads.
@SerializedName("autoDownloadThreads")
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
/// Returns the automatic download thread count property.
public BooleanProperty autoDownloadThreadsProperty() {
return autoDownloadThreads;
}
/// The configured number of download threads.
@SerializedName("downloadThreads")
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
/// Returns the download thread count property.
public IntegerProperty downloadThreadsProperty() {
return downloadThreads;
}
/// The selected game version list download source.
@SerializedName("versionListSource")
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
/// Returns the selected game version list download source property.
public ObjectProperty<DownloadSource> versionListSourceProperty() {
return versionListSource;
}
/// The selected file download source.
@SerializedName("fileDownloadSource")
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
/// Returns the selected file download source property.
public ObjectProperty<DownloadSource> fileDownloadSourceProperty() {
return fileDownloadSource;
}
/// The selected default add-on source ID.
@SerializedName("defaultAddonSource")
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
/// Returns the selected default add-on source ID property.
public StringProperty defaultAddonSourceProperty() {
return defaultAddonSource;
}
/// Whether proxy authentication is enabled.
@SerializedName("hasProxyAuth")
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
/// Returns the proxy authentication enable property.
public BooleanProperty hasProxyAuthProperty() {
return hasProxyAuth;
}
/// The configured network proxy selection mode.
@SerializedName("proxyType")
private final ObjectProperty<ProxyType> proxyType = new SimpleObjectProperty<>(ProxyType.SYSTEM);
/// Returns the network proxy selection mode property.
public ObjectProperty<ProxyType> proxyTypeProperty() {
return proxyType;
}
/// The configured network proxy host.
@SerializedName("proxyHost")
private final StringProperty proxyHost = new SimpleStringProperty();
/// Returns the network proxy host property.
public StringProperty proxyHostProperty() {
return proxyHost;
}
/// The configured network proxy port.
@SerializedName("proxyPort")
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
/// Returns the network proxy port property.
public IntegerProperty proxyPortProperty() {
return proxyPort;
}
/// The configured proxy authentication username.
@SerializedName("proxyUser")
private final StringProperty proxyUser = new SimpleStringProperty();
/// Returns the proxy authentication username property.
public StringProperty proxyUserProperty() {
return proxyUser;
}
/// The configured proxy authentication password.
@SerializedName("proxyPassword")
private final StringProperty proxyPassword = new SimpleStringProperty();
/// Returns the proxy authentication password property.
public StringProperty proxyPasswordProperty() {
return proxyPassword;
}
/// The selected game directory ID.
///
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
@SerializedName(PROPERTY_SELECTED_GAME_DIRECTORY)
private final ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectory =
new SimpleObjectProperty<>(this, PROPERTY_SELECTED_GAME_DIRECTORY);
/// Returns the selected game directory ID property.
///
/// This property is exposed for persistence and [GameDirectoryManager] integration. Code outside [GameDirectoryManager]
/// should use `GameDirectoryManager.setSelectedGameDirectory` instead of modifying this property directly.
public ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectoryProperty() {
return selectedGameDirectory;
}
/// The default game setting preset ID.
@SerializedName(PROPERTY_DEFAULT_GAME_SETTINGS_PRESET)
private final ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPreset =
new SimpleObjectProperty<>(this, PROPERTY_DEFAULT_GAME_SETTINGS_PRESET);
/// Returns the default game setting preset ID property.
public ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPresetProperty() {
return defaultGameSettingsPreset;
}
/// Selected instance IDs keyed by game directory ID.
///
/// 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();
/// 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() {
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) {
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) {
if (gameDirectoryId == null) {
return;
}
if (selectedInstance != null) {
this.selectedInstance.put(gameDirectoryId, selectedInstance);
} else {
this.selectedInstance.remove(gameDirectoryId);
}
}
// Accounts
/// The preferred login type to use when the user wants to add an account.
@SerializedName("preferredLoginType")
private final StringProperty preferredLoginType = new SimpleStringProperty();
/// Returns the preferred login type property.
public StringProperty preferredLoginTypeProperty() {
return preferredLoginType;
}
/// The selected account reference.
@SerializedName("selectedAccount")
private final ObjectProperty<@Nullable AccountID> selectedAccount = new SimpleObjectProperty<>();
/// Returns the selected account reference property.
public ObjectProperty<@Nullable AccountID> selectedAccountProperty() {
return selectedAccount;
}
/// JSON adapter for [LauncherSettings].
public static final class Adapter extends ObservableSetting.Adapter<LauncherSettings> {
/// Creates empty launcher settings for deserialization.
@Override
protected LauncherSettings createInstance() {
return new LauncherSettings();
}
}
}
@@ -1,228 +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.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;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableMap;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// Stores per-workspace launcher runtime state independently from the main settings file.
///
/// The JSON representation is saved as `state/launcher-state.json` under the current HMCL directory.
///
/// @author Glavo
@JsonAdapter(LauncherState.Adapter.class)
@NotNullByDefault
@JsonSerializable
public final class LauncherState extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this launcher state store.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("launcher-state", new JsonSchema.Version(1, 0, 0));
/// Creates an empty launcher state store.
public LauncherState() {
tracker.markDirty(schema);
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);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this launcher state file.
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this launcher state file.
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this launcher state may be saved back to `state/launcher-state.json`.
private transient boolean savable = true;
/// Whether the next successful save should back up the current `state/launcher-state.json` first.
private transient boolean backupOnNextSave;
/// Returns whether this launcher state may be saved back to `state/launcher-state.json`.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this launcher state may be saved back to `state/launcher-state.json`.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current `state/launcher-state.json` first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current `state/launcher-state.json` first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// The normalized launcher window content X position.
@SerializedName("x")
private final DoubleProperty x = new SimpleDoubleProperty();
/// Returns the normalized launcher window content X position property.
public DoubleProperty xProperty() {
return x;
}
/// Returns the normalized launcher window content X position.
public double getX() {
return x.get();
}
/// Sets the normalized launcher window content X position.
public void setX(double x) {
this.x.set(x);
}
/// The normalized launcher window content Y position.
@SerializedName("y")
private final DoubleProperty y = new SimpleDoubleProperty();
/// Returns the normalized launcher window content Y position property.
public DoubleProperty yProperty() {
return y;
}
/// Returns the normalized launcher window content Y position.
public double getY() {
return y.get();
}
/// Sets the normalized launcher window content Y position.
public void setY(double y) {
this.y.set(y);
}
/// The launcher window content width.
@SerializedName("width")
private final DoubleProperty width = new SimpleDoubleProperty();
/// Returns the launcher window content width property.
public DoubleProperty widthProperty() {
return width;
}
/// Returns the launcher window content width.
public double getWidth() {
return width.get();
}
/// Sets the launcher window content width.
public void setWidth(double width) {
this.width.set(width);
}
/// The launcher window content height.
@SerializedName("height")
private final DoubleProperty height = new SimpleDoubleProperty();
/// Returns the launcher window content height property.
public DoubleProperty heightProperty() {
return height;
}
/// Returns the launcher window content height.
public double getHeight() {
return height.get();
}
/// Sets the launcher window content height.
public void setHeight(double height) {
this.height.set(height);
}
/// The latest update version that has already prompted the user.
@SerializedName("promptedVersion")
private final StringProperty promptedVersion = new SimpleStringProperty();
/// Returns the latest prompted update version.
public @Nullable String getPromptedVersion() {
return promptedVersion.get();
}
/// Returns the latest prompted update version property.
public StringProperty promptedVersionProperty() {
return promptedVersion;
}
/// Sets the latest prompted update version.
public void setPromptedVersion(@Nullable String promptedVersion) {
this.promptedVersion.set(promptedVersion);
}
/// Tip markers that prevent repeated prompts.
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
/// Returns tip markers that prevent repeated prompts.
public ObservableMap<String, Object> getShownTips() {
return shownTips;
}
/// JSON adapter for [LauncherState].
public static final class Adapter extends ObservableSetting.Adapter<LauncherState> {
/// Creates an empty launcher state store for deserialization.
@Override
protected LauncherState createInstance() {
return new LauncherState();
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,557 +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.setting;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.property.InheritableProperty;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.jetbrains.annotations.VisibleForTesting;
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.function.BiFunction;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Converts legacy game settings JSON into `GameSettings` models.
@NotNullByDefault
public final class LegacyGameSettingsMigrator {
/// Legacy file name used by old per-version `VersionSetting` data.
static final String LEGACY_INSTANCE_SETTINGS_FILENAME = "hmclversion.cfg";
/// Receipt file name used to record successful legacy per-version settings migration.
private static final String LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME =
"instance-game-settings.migration-receipt.json";
/// Directory under the version root that stores HMCL-managed instance metadata.
static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
/// Directory under the instance metadata directory that stores instance configuration files.
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.
static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
/// Legacy game directory modes stored by old configuration files.
private enum GameDirectoryType {
/// Use the root `.minecraft` folder.
ROOT_FOLDER,
/// Use the version-specific folder.
VERSION_FOLDER,
/// Use a custom game directory.
CUSTOM
}
/// 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
};
/// Legacy `JavaVersionType` ordinal order used by old game settings.
private static final JavaVersionType @Unmodifiable [] LEGACY_JAVA_VERSION_TYPES = {
// Legacy ordinal 0 was DEFAULT and ordinal 1 was AUTO; both now migrate to AUTO.
JavaVersionType.AUTO,
JavaVersionType.AUTO,
JavaVersionType.VERSION,
JavaVersionType.DETECTED,
JavaVersionType.CUSTOM
};
/// Legacy native library mode ordinal order used by old game settings.
private static final String @Unmodifiable [] LEGACY_NATIVES_DIRECTORY_TYPES = {
"VERSION_FOLDER",
"CUSTOM"
};
/// Prevents instantiation.
private LegacyGameSettingsMigrator() {
}
/// Converts a legacy profile-level setting JSON object into a preset with the given ID.
public static GameSettings.Preset toPreset(GameSettingsPresetID id, int autoNameNumber, @Nullable JsonObject source) {
GameSettings.Preset target = new GameSettings.Preset(id);
target.autoNameNumberProperty().setValue(autoNameNumber);
if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.VERSION_FOLDER) {
target.defaultIsolationTypeProperty().setValue(DefaultIsolationType.ALWAYS);
}
if (source != null) {
copyCommonProperties(source, target, GameSettings.DetectedJava::ofLegacyPath);
}
return target;
}
/// Migrates a legacy per-version game setting file into an instance setting.
///
/// @param parent the migrated parent preset ID for the profile
/// @return the migrated instance setting, or `null` when no legacy file can be migrated
public static @Nullable InstanceMigrationResult migrateInstanceGameSettings(
HMCLGameRepository repository,
GameInstanceID instanceId,
@Nullable GameSettingsPresetID parent) {
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
if (!Files.exists(file)) {
return null;
}
Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId)
.resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME);
if (MigrationReceipt.matches(receiptLocation, file)) {
LOG.info("Skipping already migrated legacy version setting " + file);
return null;
}
try {
JsonObject legacySettingJson = JsonUtils.fromJsonFile(file, JsonObject.class);
if (legacySettingJson == null) {
return null;
}
boolean inheritsLegacyParent = JsonUtils.getBoolean(legacySettingJson, "usesGlobal", false);
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
GameSettings.Instance setting = toInstance(
parent,
parentSetting,
legacySettingJson,
inheritsLegacyParent,
GameSettings.DetectedJava::ofLegacyPath);
if (!inheritsLegacyParent) {
Path baseDirectory = repository.getBaseDirectory();
if (parentSetting != null
&& getLegacyGameDirType(legacySettingJson, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.ROOT_FOLDER
&& StringUtils.isNotBlank(parentSetting.runningDirectoryProperty().getValue())) {
setting.runningDirectoryProperty().setValue(baseDirectory.toString());
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
}
return new InstanceMigrationResult(file, receiptLocation, setting);
} catch (Exception ex) {
LOG.warning("Failed to migrate legacy version setting " + file, ex);
return null;
}
}
/// Converts a legacy local setting JSON object into an instance game setting.
///
/// @param parent the migrated parent preset ID for the instance
/// @param source the legacy local setting JSON object
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
/// carrying its own copied values
public static GameSettings.Instance toInstance(
@Nullable GameSettingsPresetID parent,
@Nullable JsonObject source,
boolean inheritsLegacyParent) {
return toInstance(parent, source, inheritsLegacyParent, GameSettings.DetectedJava::ofLegacyPath);
}
/// Converts a legacy local setting JSON object into an instance game setting with a custom
/// detected Java converter.
///
/// @param parent the migrated parent preset ID for the instance
/// @param source the legacy local setting JSON object
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
/// carrying its own copied values
/// @param detectedJavaFactory converts legacy detected Java version and path fields into the new
/// detected Java reference
@VisibleForTesting
static GameSettings.Instance toInstance(
@Nullable GameSettingsPresetID parent,
@Nullable JsonObject source,
boolean inheritsLegacyParent,
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
return toInstance(parent, null, source, inheritsLegacyParent, detectedJavaFactory);
}
/// Converts a legacy local setting JSON object into an instance game setting with parent context.
///
/// @param parent the migrated parent preset ID for the instance
/// @param parentSetting the migrated parent preset, if available during migration
/// @param source the legacy local setting JSON object
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
/// carrying its own copied values
/// @param detectedJavaFactory converts legacy detected Java version and path fields into the new
/// detected Java reference
@VisibleForTesting
static GameSettings.Instance toInstance(
@Nullable GameSettingsPresetID parent,
@Nullable GameSettings.Preset parentSetting,
@Nullable JsonObject source,
boolean inheritsLegacyParent,
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
GameSettings.Instance target = new GameSettings.Instance();
target.parentProperty().setValue(parent);
target.iconProperty().setValue(parseLegacyVersionIconType(source));
if (inheritsLegacyParent
&& parentSetting != null
&& parentSetting.defaultIsolationTypeProperty().getValue() == DefaultIsolationType.ALWAYS
&& StringUtils.isBlank(parentSetting.runningDirectoryProperty().getValue())) {
target.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
if (source != null && !inheritsLegacyParent) {
copyCommonProperties(source, target, detectedJavaFactory);
if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) != GameDirectoryType.ROOT_FOLDER) {
target.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
target.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_JAVA_TYPE,
GameSettings.PROPERTY_CUSTOM_JAVA_VERSION,
GameSettings.PROPERTY_CUSTOM_JAVA_PATH,
GameSettings.PROPERTY_DETECTED_JAVA,
GameSettings.PROPERTY_JVM_OPTIONS,
GameSettings.PROPERTY_NO_JVM_OPTIONS,
GameSettings.PROPERTY_NO_OPTIMIZING_JVM_OPTIONS,
GameSettings.PROPERTY_NOT_CHECK_JVM,
GameSettings.PROPERTY_NOT_CHECK_GAME,
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY,
GameSettings.PROPERTY_MAX_MEMORY,
GameSettings.PROPERTY_PERM_SIZE,
GameSettings.PROPERTY_WINDOW_TYPE,
GameSettings.PROPERTY_WIDTH,
GameSettings.PROPERTY_HEIGHT,
GameSettings.PROPERTY_PROCESS_PRIORITY,
GameSettings.PROPERTY_LAUNCHER_VISIBILITY,
GameSettings.PROPERTY_GAME_ARGS,
GameSettings.PROPERTY_GRAPHICS_BACKEND,
GameSettings.PROPERTY_OPENGL_RENDERER,
GameSettings.PROPERTY_VULKAN_RENDERER,
GameSettings.PROPERTY_ENVIRONMENT_VARIABLES,
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND,
GameSettings.PROPERTY_POST_EXIT_COMMAND,
GameSettings.PROPERTY_QUICK_PLAY,
GameSettings.PROPERTY_QUICK_PLAY_MULTIPLAYER,
GameSettings.PROPERTY_QUICK_PLAY_SINGLEPLAYER,
GameSettings.PROPERTY_QUICK_PLAY_REALMS,
GameSettings.PROPERTY_SHOW_LOGS,
GameSettings.PROPERTY_ENABLE_DEBUG_LOG_OUTPUT,
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_OPENAL
));
}
return target;
}
/// Returns the legacy game directory type from a setting JSON object.
private static GameDirectoryType getLegacyGameDirType(@Nullable JsonObject source, GameDirectoryType defaultValue) {
return parseEnum(source, "gameDirType", GameDirectoryType.class, defaultValue);
}
/// Copies shared legacy properties into the target setting.
private static void copyCommonProperties(
JsonObject source,
GameSettings target,
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
JavaVersionType javaVersionType = parseLegacyJavaVersionType(source);
String legacyJavaVersion = Objects.requireNonNullElse(parseLegacyJavaVersion(source), "");
target.javaTypeProperty().setValue(javaVersionType);
if (javaVersionType == JavaVersionType.VERSION) {
target.customJavaVersionProperty().setValue(legacyJavaVersion);
} else if (javaVersionType == JavaVersionType.DETECTED && StringUtils.isNotBlank(legacyJavaVersion)) {
target.detectedJavaProperty().setValue(detectedJavaFactory.apply(
legacyJavaVersion,
JsonUtils.getString(source, "defaultJavaPath", "")));
}
target.customJavaPathProperty().setValue(JsonUtils.getString(source, "javaDir", ""));
target.jvmOptionsProperty().setValue(JsonUtils.getString(source, "javaArgs", ""));
target.noJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noJVMArgs", false));
target.noOptimizingJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noOptimizingJVMArgs", false));
target.notCheckJVMProperty().setValue(JsonUtils.getBoolean(source, "notCheckJVM", false));
target.notCheckGameProperty().setValue(JsonUtils.getBoolean(source, "notCheckGame", false));
target.autoMemoryProperty().setValue(JsonUtils.getBoolean(source, "autoMemory", true));
target.minMemoryProperty().setValue(JsonUtils.getNullableInt(source, "minMemory"));
int maxMemory = JsonUtils.getInt(source, "maxMemory", GameSettings.SUGGESTED_MEMORY);
target.maxMemoryProperty().setValue(maxMemory > 0 ? maxMemory : GameSettings.SUGGESTED_MEMORY);
target.permSizeProperty().setValue(JsonUtils.getString(source, "permSize", ""));
target.windowTypeProperty().setValue(JsonUtils.getBoolean(source, "fullscreen", false) ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
target.widthProperty().setValue((double) JsonUtils.getInt(source, "width", 0));
target.heightProperty().setValue((double) JsonUtils.getInt(source, "height", 0));
target.runningDirectoryProperty().setValue(getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.CUSTOM
? JsonUtils.getString(source, "gameDir", "")
: "");
target.processPriorityProperty().setValue(parseEnum(source, "processPriority", ProcessPriority.class, ProcessPriority.NORMAL));
target.launcherVisibilityProperty().setValue(parseEnum(source, "launcherVisibility", LauncherVisibility.class, LauncherVisibility.HIDE));
target.gameArgumentsProperty().setValue(JsonUtils.getString(source, "minecraftArgs", ""));
migrateLegacyRenderer(source, target);
target.environmentVariablesProperty().setValue(JsonUtils.getString(source, "environmentVariables", ""));
target.commandWrapperProperty().setValue(JsonUtils.getString(source, "wrapper", ""));
target.preLaunchCommandProperty().setValue(JsonUtils.getString(source, "precalledCommand", ""));
target.postExitCommandProperty().setValue(JsonUtils.getString(source, "postExitCommand", ""));
String serverIp = JsonUtils.getString(source, "serverIp", "");
if (StringUtils.isBlank(serverIp)) {
target.quickPlayProperty().setValue(QuickPlayType.NONE);
} else {
target.quickPlayProperty().setValue(QuickPlayType.MULTIPLAYER);
target.quickPlayMultiplayerProperty().setValue(serverIp);
}
target.showLogsProperty().setValue(JsonUtils.getBoolean(source, "showLogs", false));
target.enableDebugLogOutputProperty().setValue(JsonUtils.getBoolean(source, "enableDebugLogOutput", false));
target.notPatchNativesProperty().setValue(JsonUtils.getBoolean(source, "notPatchNatives", false));
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.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false));
}
/// Returns whether old game settings selected user-managed native libraries.
private static boolean isLegacyCustomNativesDirectory(JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "nativesDirType");
if (primitive == null) {
return false;
}
try {
if (primitive.isNumber()) {
int index = primitive.getAsInt();
return index >= 0
&& index < LEGACY_NATIVES_DIRECTORY_TYPES.length
&& "CUSTOM".equals(LEGACY_NATIVES_DIRECTORY_TYPES[index]);
}
return "CUSTOM".equalsIgnoreCase(primitive.getAsString());
} catch (RuntimeException ignored) {
return false;
}
}
/// Migrates the legacy renderer fields into current renderer properties.
private static void migrateLegacyRenderer(JsonObject source, GameSettings setting) {
Renderer renderer = parseLegacyRenderer(source);
GraphicsAPI graphicsBackend = parseGraphicsBackend(source, renderer);
setting.graphicsBackendProperty().setValue(graphicsBackend);
if (renderer instanceof Renderer.Driver driver) {
rendererPropertyForApi(setting, driver.api()).setValue(renderer);
if (graphicsBackend == GraphicsAPI.DEFAULT) {
setting.graphicsBackendProperty().setValue(driver.api());
}
return;
}
if (graphicsBackend == GraphicsAPI.OPENGL || graphicsBackend == GraphicsAPI.VULKAN) {
rendererPropertyForApi(setting, graphicsBackend).setValue(renderer);
} else {
setting.openGLRendererProperty().setValue(renderer);
setting.vulkanRendererProperty().setValue(renderer);
}
}
/// Returns the renderer property for the given non-default graphics API.
private static InheritableProperty<Renderer> rendererPropertyForApi(GameSettings setting, GraphicsAPI api) {
return switch (api) {
case OPENGL -> setting.openGLRendererProperty();
case VULKAN -> setting.vulkanRendererProperty();
case DEFAULT -> throw new IllegalArgumentException("The default graphics API has no renderer property");
};
}
/// Parses the legacy Java selection mode.
private static JavaVersionType parseLegacyJavaVersionType(JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "javaVersionType");
if (primitive != null) {
try {
if (primitive.isNumber()) {
int index = primitive.getAsInt();
return index >= 0 && index < LEGACY_JAVA_VERSION_TYPES.length
? LEGACY_JAVA_VERSION_TYPES[index]
: JavaVersionType.AUTO;
}
return switch (primitive.getAsString().toUpperCase(Locale.ROOT)) {
case "DEFAULT", "AUTO" -> JavaVersionType.AUTO;
case "VERSION" -> JavaVersionType.VERSION;
case "DETECTED" -> JavaVersionType.DETECTED;
case "CUSTOM" -> JavaVersionType.CUSTOM;
default -> JavaVersionType.AUTO;
};
} catch (RuntimeException ignored) {
return JavaVersionType.AUTO;
}
}
return switch (JsonUtils.getString(source, "java", "")) {
case "Default", "Auto" -> JavaVersionType.AUTO;
case "Custom" -> JavaVersionType.CUSTOM;
default -> JavaVersionType.AUTO;
};
}
/// Parses the legacy Java version value.
private static @Nullable String parseLegacyJavaVersion(JsonObject source) {
if (source.has("javaVersionType")) {
return switch (parseLegacyJavaVersionType(source)) {
case VERSION, DETECTED -> JsonUtils.getString(source, "java", null);
case AUTO, CUSTOM -> "";
};
}
String java = JsonUtils.getString(source, "java", "");
return switch (java) {
case "Default", "Auto", "Custom" -> "";
default -> java;
};
}
/// Parses the legacy renderer selection.
private static Renderer parseLegacyRenderer(JsonObject source) {
JsonElement renderer = source.get("renderer");
if (renderer != null && !renderer.isJsonNull()) {
Renderer parsed = parseLegacyRenderer(renderer);
if (parsed != null) {
return parsed;
}
}
return JsonUtils.getBoolean(source, "useSoftwareRenderer", false) ? Renderer.OpenGL.LLVMPIPE : Renderer.DEFAULT;
}
/// Parses a renderer from a legacy JSON element.
private static @Nullable Renderer parseLegacyRenderer(JsonElement element) {
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
return Renderer.of(primitive.getAsString());
}
if (element.isJsonObject()) {
JsonElement name = element.getAsJsonObject().get("name");
if (name instanceof JsonPrimitive primitive && primitive.isString()) {
return Renderer.of(primitive.getAsString());
}
}
return null;
}
/// Parses the graphics API selection with renderer-derived fallback.
private static GraphicsAPI parseGraphicsBackend(JsonObject source, Renderer renderer) {
String name = JsonUtils.getString(source, "graphicsBackend", null);
if (name != null) {
try {
return GraphicsAPI.valueOf(name.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ignored) {
}
}
return renderer instanceof Renderer.Driver driver ? driver.api() : GraphicsAPI.DEFAULT;
}
/// Parses the legacy icon selection with frozen ordinal order.
private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
if (primitive == null) {
return GameInstanceIconType.DEFAULT;
}
try {
if (primitive.isNumber()) {
int index = primitive.getAsInt();
return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length
? LEGACY_VERSION_ICON_TYPES[index]
: GameInstanceIconType.DEFAULT;
}
String value = primitive.getAsString();
for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) {
if (iconType.name().equalsIgnoreCase(value)) {
return iconType;
}
}
} catch (RuntimeException ignored) {
}
return GameInstanceIconType.DEFAULT;
}
/// Reads an enum property from either ordinal or name.
private static <E extends Enum<E>> E parseEnum(@Nullable JsonObject source, String name, Class<E> type, E defaultValue) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, name);
if (primitive == null) {
return defaultValue;
}
E[] constants = type.getEnumConstants();
try {
if (primitive.isNumber()) {
int index = primitive.getAsInt();
return index >= 0 && index < constants.length ? constants[index] : defaultValue;
}
String value = primitive.getAsString();
for (E constant : constants) {
if (constant.name().equalsIgnoreCase(value)) {
return constant;
}
}
} catch (RuntimeException ignored) {
}
return defaultValue;
}
/// Result of migrating a legacy per-version game settings file.
///
/// @param path the legacy per-version game settings path
/// @param receiptLocation the receipt path to write after saving the migrated settings
/// @param setting the migrated instance game settings
public record InstanceMigrationResult(
Path path,
Path receiptLocation,
GameSettings.Instance setting) {
/// Writes the migration receipt when receipt tracking is enabled.
public void saveReceipt() {
MigrationReceipt.save(receiptLocation, path);
}
}
}
@@ -1,97 +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.setting;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
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.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Records successful legacy migrations so deleting a current file does not replay unchanged legacy content.
///
/// @param sourceSize the legacy source file size recorded for diagnostics, or `null` when omitted
/// @param sourceLastModified the legacy source last-modified time recorded for diagnostics, or `null` when omitted
/// @param sourceSHA256 the legacy source SHA-256 hash, or `null` when a malformed receipt omits it
/// @param migratedAt when the migration receipt was written, or `null` before saving or when omitted
@NotNullByDefault
record MigrationReceipt(
@Nullable Long sourceSize,
@Nullable String sourceLastModified,
@Nullable String sourceSHA256,
@Nullable String migratedAt) {
/// Returns whether the receipt records the current legacy source content.
static boolean matches(Path receipt, Path source) {
Objects.requireNonNull(receipt);
Objects.requireNonNull(source);
if (!Files.exists(receipt) || !Files.exists(source)) {
return false;
}
try {
@Nullable MigrationReceipt object = JsonUtils.fromJsonFile(receipt, MigrationReceipt.class);
return object != null && object.matches(create(source));
} catch (IOException | JsonParseException | IllegalStateException e) {
LOG.warning("Failed to read migration receipt " + receipt, e);
return false;
}
}
/// Writes a receipt for a successful migration from the legacy source file.
static void save(Path receipt, Path source) {
Objects.requireNonNull(receipt);
Objects.requireNonNull(source);
try {
FileUtils.saveSafely(receipt, JsonUtils.GSON.toJson(create(source, Instant.now().toString())));
} catch (IOException e) {
LOG.warning("Failed to write migration receipt " + receipt, e);
}
}
/// Returns whether this receipt matches another legacy source content fingerprint.
private boolean matches(MigrationReceipt current) {
return sourceSHA256 != null && Objects.equals(sourceSHA256, current.sourceSHA256);
}
/// Creates receipt content for the current source file state.
private static MigrationReceipt create(Path source) throws IOException {
return create(source, null);
}
/// Creates receipt content for the current source file state and migration time.
private static MigrationReceipt create(Path source, @Nullable String migratedAt) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(source, BasicFileAttributes.class);
return new MigrationReceipt(
attributes.size(),
attributes.lastModifiedTime().toString(),
DigestUtils.digestToString("SHA-256", source),
migratedAt);
}
}
@@ -0,0 +1,257 @@
/*
* 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.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.*;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.EventPriority;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import java.lang.reflect.Type;
import java.nio.file.Path;
import java.util.Optional;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
/**
*
* @author huangyuhui
*/
@JsonAdapter(Profile.Serializer.class)
public final class Profile implements Observable {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private final HMCLGameRepository repository;
private final StringProperty selectedVersion = new SimpleStringProperty();
public StringProperty selectedVersionProperty() {
return selectedVersion;
}
public String getSelectedVersion() {
return selectedVersion.get();
}
public void setSelectedVersion(String selectedVersion) {
this.selectedVersion.set(selectedVersion);
}
private final ObjectProperty<Path> gameDir;
public ObjectProperty<Path> gameDirProperty() {
return gameDir;
}
public Path getGameDir() {
return gameDir.get();
}
public void setGameDir(Path gameDir) {
this.gameDir.set(gameDir);
}
private final ReadOnlyObjectWrapper<VersionSetting> global = new ReadOnlyObjectWrapper<>(this, "global");
public ReadOnlyObjectProperty<VersionSetting> globalProperty() {
return global.getReadOnlyProperty();
}
public VersionSetting getGlobal() {
return global.get();
}
private final SimpleStringProperty name;
public StringProperty nameProperty() {
return name;
}
public String getName() {
return name.get();
}
public void setName(String name) {
this.name.set(name);
}
private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false);
public BooleanProperty useRelativePathProperty() {
return useRelativePath;
}
public boolean isUseRelativePath() {
return useRelativePath.get();
}
public void setUseRelativePath(boolean useRelativePath) {
this.useRelativePath.set(useRelativePath);
}
public Profile(String name) {
this(name, Path.of(".minecraft"));
}
public Profile(String name, Path initialGameDir) {
this(name, initialGameDir, new VersionSetting());
}
public Profile(String name, Path initialGameDir, VersionSetting global) {
this(name, initialGameDir, global, null, false);
}
public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) {
this.name = new SimpleStringProperty(this, "name", name);
gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir);
repository = new HMCLGameRepository(this, initialGameDir);
this.global.set(global == null ? new VersionSetting() : global);
this.selectedVersion.set(selectedVersion);
this.useRelativePath.set(useRelativePath);
gameDir.addListener((a, b, newValue) -> repository.changeDirectory(newValue));
this.selectedVersion.addListener(o -> checkSelectedVersion());
listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> checkSelectedVersion(), EventPriority.HIGHEST));
addPropertyChangedListener(onInvalidating(this::invalidate));
}
private void checkSelectedVersion() {
runInFX(() -> {
if (!repository.isLoaded()) return;
String newValue = selectedVersion.get();
if (!repository.hasVersion(newValue)) {
Optional<String> version = repository.getVersions().stream().findFirst().map(Version::getId);
if (version.isPresent())
selectedVersion.setValue(version.get());
else if (newValue != null)
selectedVersion.setValue(null);
}
});
}
public HMCLGameRepository getRepository() {
return repository;
}
public DefaultDependencyManager getDependency() {
return getDependency(DownloadProviders.getDownloadProvider());
}
public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) {
return new DefaultDependencyManager(repository, downloadProvider, HMCLCacheRepository.REPOSITORY);
}
public VersionSetting getVersionSetting(String id) {
return repository.getVersionSetting(id);
}
@Override
public String toString() {
return new ToStringBuilder(this)
.append("gameDir", getGameDir())
.append("name", getName())
.append("useRelativePath", isUseRelativePath())
.toString();
}
private void addPropertyChangedListener(InvalidationListener listener) {
name.addListener(listener);
global.addListener(listener);
gameDir.addListener(listener);
useRelativePath.addListener(listener);
global.get().addListener(listener);
selectedVersion.addListener(listener);
}
private ObservableHelper observableHelper = new ObservableHelper(this);
@Override
public void addListener(InvalidationListener listener) {
observableHelper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
observableHelper.removeListener(listener);
}
private void invalidate() {
Platform.runLater(observableHelper::invalidate);
}
public static class ProfileVersion {
private final Profile profile;
private final String version;
public ProfileVersion(Profile profile, String version) {
this.profile = profile;
this.version = version;
}
public Profile getProfile() {
return profile;
}
public String getVersion() {
return version;
}
}
public static final class Serializer implements JsonSerializer<Profile>, JsonDeserializer<Profile> {
@Override
public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null)
return JsonNull.INSTANCE;
JsonObject jsonObject = new JsonObject();
jsonObject.add("global", context.serialize(src.getGlobal()));
jsonObject.addProperty("gameDir", src.getGameDir().toString());
jsonObject.addProperty("useRelativePath", src.isUseRelativePath());
jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion());
return jsonObject;
}
@Override
public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject obj)) return null;
String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("");
return new Profile("Default",
Path.of(gameDir),
context.deserialize(obj.get("global"), VersionSetting.class),
Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""),
Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false));
}
}
}
@@ -0,0 +1,216 @@
/*
* 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.setting;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.TreeMap;
import java.util.function.Consumer;
import static javafx.collections.FXCollections.observableArrayList;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class Profiles {
public static final String DEFAULT_PROFILE = "Default";
public static final String HOME_PROFILE = "Home";
private Profiles() {
}
public static String getProfileDisplayName(Profile profile) {
return switch (profile.getName()) {
case Profiles.DEFAULT_PROFILE -> i18n("profile.default");
case Profiles.HOME_PROFILE -> i18n("profile.home");
default -> profile.getName();
};
}
private static final ObservableList<Profile> profiles = observableArrayList(profile -> new Observable[] { profile });
private static final ReadOnlyListWrapper<Profile> profilesWrapper = new ReadOnlyListWrapper<>(profiles);
private static final ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<Profile>() {
{
profiles.addListener(onInvalidating(this::invalidated));
}
@Override
protected void invalidated() {
if (!initialized)
return;
Profile profile = get();
if (profiles.isEmpty()) {
if (profile != null) {
set(null);
return;
}
} else {
if (!profiles.contains(profile)) {
set(profiles.get(0));
return;
}
}
config().setSelectedProfile(profile == null ? "" : profile.getName());
if (profile != null) {
if (profile.getRepository().isLoaded())
selectedVersion.bind(profile.selectedVersionProperty());
else {
selectedVersion.unbind();
selectedVersion.set(null);
// bind when repository was reloaded.
profile.getRepository().refreshVersionsAsync().start();
}
} else {
selectedVersion.unbind();
selectedVersion.set(null);
}
}
};
private static void checkProfiles() {
if (profiles.isEmpty()) {
Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true);
Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY);
Platform.runLater(() -> profiles.addAll(current, home));
}
}
/**
* True if {@link #init()} hasn't been called.
*/
private static boolean initialized = false;
static {
profiles.addListener(onInvalidating(Profiles::updateProfileStorages));
profiles.addListener(onInvalidating(Profiles::checkProfiles));
selectedProfile.addListener((a, b, newValue) -> {
if (newValue != null)
newValue.getRepository().refreshVersionsAsync().start();
});
}
private static void updateProfileStorages() {
// don't update the underlying storage before data loading is completed
// otherwise it might cause data loss
if (!initialized)
return;
// update storage
TreeMap<String, Profile> newConfigurations = new TreeMap<>();
for (Profile profile : profiles) {
newConfigurations.put(profile.getName(), profile);
}
config().getConfigurations().setValue(FXCollections.observableMap(newConfigurations));
}
/**
* Called when it's ready to load profiles from {@link ConfigHolder#config()}.
*/
static void init() {
if (initialized)
throw new IllegalStateException("Already initialized");
HashSet<String> names = new HashSet<>();
config().getConfigurations().forEach((name, profile) -> {
if (!names.add(name)) return;
profiles.add(profile);
profile.setName(name);
});
checkProfiles();
// Platform.runLater is necessary or profiles will be empty
// since checkProfiles adds 2 base profile later.
Platform.runLater(() -> {
initialized = true;
selectedProfile.set(
profiles.stream()
.filter(it -> it.getName().equals(config().getSelectedProfile()))
.findFirst()
.orElse(profiles.get(0)));
});
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
runInFX(() -> {
Profile profile = selectedProfile.get();
if (profile != null && profile.getRepository() == event.getSource()) {
selectedVersion.bind(profile.selectedVersionProperty());
for (Consumer<Profile> listener : versionsListeners)
listener.accept(profile);
}
});
});
}
public static ObservableList<Profile> getProfiles() {
return profiles;
}
public static ReadOnlyListProperty<Profile> profilesProperty() {
return profilesWrapper.getReadOnlyProperty();
}
public static Profile getSelectedProfile() {
return selectedProfile.get();
}
public static void setSelectedProfile(Profile profile) {
selectedProfile.set(profile);
}
public static ObjectProperty<Profile> selectedProfileProperty() {
return selectedProfile;
}
private static final ReadOnlyStringWrapper selectedVersion = new ReadOnlyStringWrapper();
public static ReadOnlyStringProperty selectedVersionProperty() {
return selectedVersion.getReadOnlyProperty();
}
// Guaranteed that the repository is loaded.
public static String getSelectedVersion() {
return selectedVersion.get();
}
private static final List<Consumer<Profile>> versionsListeners = new ArrayList<>(4);
public static void registerVersionsListener(Consumer<Profile> listener) {
Profile profile = getSelectedProfile();
if (profile != null && profile.getRepository().isLoaded())
listener.accept(profile);
versionsListeners.add(listener);
}
}
@@ -1,362 +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.setting;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
/// Stores a JSON payload using HMCL's portable protection envelope.
///
/// This class is responsible for wrapping payloads with a protection marker and revealing them according to that
/// marker. Protection modes define whether and how a payload is stored.
@NotNullByDefault
final class ProtectedPayload {
/// The JSON member containing the protection marker.
static final String PROPERTY_PROTECTION = "protection";
/// The JSON member containing the envelope payload.
static final String PROPERTY_PAYLOAD = "payload";
/// The JSON member containing the Base64-encoded encryption nonce.
static final String PROPERTY_NONCE = "nonce";
/// Prevents instantiation.
private ProtectedPayload() {
}
/// Selects how a protected payload is stored in its JSON envelope.
@NotNullByDefault
enum ProtectionMode {
/// Stores the payload as plain JSON for development and diagnostics.
PLAIN("plain") {
/// Writes the payload into the given envelope.
@Override
protected void writePayload(JsonObject envelope, JsonElement payload) {
envelope.addProperty(PROPERTY_PROTECTION, id());
envelope.add(PROPERTY_PAYLOAD, payload);
}
/// Reads the payload from the given envelope.
@Override
protected JsonElement readPayload(JsonObject envelope) {
JsonElement payload = envelope.get(PROPERTY_PAYLOAD);
if (payload == null) {
throw new JsonParseException("Missing payload");
}
return payload;
}
},
/// Stores the payload as a portable weakly encrypted envelope.
///
/// The payload is encrypted with a built-in application key, Base64-encoded, and split into padded strings. This
/// avoids storing private data as directly readable JSON, but it should not be treated as device-bound secret
/// storage. The nonce is stored separately, while the payload lanes store `ciphertext || authentication tag`.
OBFUSCATED_V1("hmcl-obfuscated-v1") {
/// The number of lanes used by the obfuscated payload.
private static final int OBFUSCATED_LANE_COUNT = 4;
/// The number of padding elements stored before each lane.
private static final int LANE_PADDING_COUNT = 63;
/// The number of elements written in an obfuscated payload array.
private static final int WRITTEN_OBFUSCATED_PAYLOAD_SIZE = 256;
/// The JCA transformation used for payload encryption.
private static final String CIPHER_TRANSFORMATION = "ChaCha20-Poly1305";
/// The ChaCha20-Poly1305 nonce size in bytes.
private static final int NONCE_SIZE = 12;
/// The random source used to create payload nonces.
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/// The built-in application key used by this weak portable protection mode.
static final SecretKeySpec PROTECTION_KEY = new SecretKeySpec(new byte[]{
(byte) 0x3c, (byte) 0xd8, (byte) 0xa2, (byte) 0x22,
(byte) 0x11, (byte) 0xd2, (byte) 0x8d, (byte) 0x89,
(byte) 0xb4, (byte) 0xf7, (byte) 0xd9, (byte) 0xb0,
(byte) 0x65, (byte) 0xbc, (byte) 0x14, (byte) 0x8a,
(byte) 0x6e, (byte) 0xb0, (byte) 0xa9, (byte) 0x4d,
(byte) 0xeb, (byte) 0x93, (byte) 0x99, (byte) 0x6f,
(byte) 0x84, (byte) 0x07, (byte) 0x5a, (byte) 0x9e,
(byte) 0xbd, (byte) 0xc8, (byte) 0xd1, (byte) 0xeb
}, "ChaCha20");
/// Encrypts the plain payload bytes.
///
/// @param payload the plain payload bytes
/// @param nonce the encryption nonce
/// @return the encrypted payload bytes with the authentication tag appended
/// @throws JsonParseException if the cipher is not available
private byte[] encryptPayload(byte[] payload, byte[] nonce) {
try {
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, PROTECTION_KEY, new IvParameterSpec(nonce));
return cipher.doFinal(payload);
} catch (GeneralSecurityException e) {
throw new JsonParseException("Failed to protect JSON payload", e);
}
}
/// Decrypts the protected payload bytes.
///
/// @param payload the encrypted payload bytes with the authentication tag appended
/// @param nonce the encryption nonce
/// @return the plain payload bytes
/// @throws JsonParseException if the payload cannot be decrypted
private byte[] decryptPayload(byte[] payload, byte[] nonce) {
try {
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, PROTECTION_KEY, new IvParameterSpec(nonce));
return cipher.doFinal(payload);
} catch (GeneralSecurityException e) {
throw new JsonParseException("Failed to reveal protected JSON payload", e);
}
}
/// Returns the payload array index storing one lane in an effective payload window.
///
/// @param laneIndex the lane index
/// @param payloadSize the effective payload window size
/// @return the payload array index
private static int lanePayloadIndex(int laneIndex, int payloadSize) {
int segmentSize = payloadSize / OBFUSCATED_LANE_COUNT;
return (laneIndex + 1) * segmentSize - 1;
}
/// Splits a Base64 payload into padded lanes.
///
/// @param payload the Base64 payload to split
/// @return the padded payload lanes
private static JsonArray splitObfuscatedPayload(String payload) {
int laneLength = payload.length() / OBFUSCATED_LANE_COUNT;
JsonArray result = new JsonArray(WRITTEN_OBFUSCATED_PAYLOAD_SIZE);
for (int laneIndex = 0; laneIndex < OBFUSCATED_LANE_COUNT; laneIndex++) {
int start = laneIndex * laneLength;
String lane = payload.substring(start, start + laneLength);
for (int i = 0; i < LANE_PADDING_COUNT; i++) {
result.add(JsonNull.INSTANCE);
}
result.add(lane);
}
return result;
}
/// Joins Base64 payload lanes from the envelope.
///
/// @param envelope the envelope object to read from
/// @return the restored Base64 payload
/// @throws JsonParseException if the payload lanes are missing or malformed
private static String joinObfuscatedPayload(JsonObject envelope) {
if (!(envelope.get(PROPERTY_PAYLOAD) instanceof JsonArray lanes)
|| lanes.size() < OBFUSCATED_LANE_COUNT) {
throw new JsonParseException("Missing payload or payload array is too small");
}
int effectivePayloadSize = Integer.highestOneBit(lanes.size());
String[] laneTexts = new String[OBFUSCATED_LANE_COUNT];
int totalLength = 0;
for (int i = 0; i < OBFUSCATED_LANE_COUNT; i++) {
int payloadIndex = lanePayloadIndex(i, effectivePayloadSize);
JsonElement lane = lanes.get(payloadIndex);
if (!lane.isJsonPrimitive() || !lane.getAsJsonPrimitive().isString()) {
throw new JsonParseException("Protected payload lane is not a string");
}
laneTexts[i] = lane.getAsString();
totalLength += laneTexts[i].length();
}
StringBuilder result = new StringBuilder(totalLength);
for (String laneText : laneTexts) {
result.append(laneText);
}
return result.toString();
}
/// Writes the payload into the given envelope.
@Override
protected void writePayload(JsonObject envelope, JsonElement payload) {
byte[] nonce = new byte[NONCE_SIZE];
SECURE_RANDOM.nextBytes(nonce);
writePayload(envelope, payload, nonce);
}
/// Writes the payload into the given envelope with a caller-provided nonce.
@Override
protected void writePayload(JsonObject envelope, JsonElement payload, byte[] nonce) {
if (nonce.length != NONCE_SIZE) {
throw new JsonParseException("Protected payload nonce has invalid length");
}
String payloadText = JsonUtils.UGLY_GSON.toJson(payload);
byte[] payloadBytes = payloadText.getBytes(StandardCharsets.UTF_8);
byte[] encryptedPayload = encryptPayload(payloadBytes, nonce);
String actualPayload = Base64.getEncoder().encodeToString(encryptedPayload);
envelope.addProperty(PROPERTY_PROTECTION, id());
envelope.add(PROPERTY_PAYLOAD, splitObfuscatedPayload(actualPayload));
envelope.addProperty(PROPERTY_NONCE, Base64.getEncoder().encodeToString(nonce));
}
/// Reads the payload from the given envelope.
@Override
protected JsonElement readPayload(JsonObject envelope) {
try {
String encodedNonce = JsonUtils.getString(envelope, PROPERTY_NONCE);
if (encodedNonce == null) {
throw new JsonParseException("Missing protected payload member: nonce");
}
byte[] nonce = Base64.getDecoder().decode(encodedNonce);
if (nonce.length != NONCE_SIZE) {
throw new JsonParseException("Protected payload nonce has invalid length");
}
String encodedPayload = joinObfuscatedPayload(envelope);
byte[] encryptedPayload = Base64.getDecoder().decode(encodedPayload);
byte[] payloadBytes = decryptPayload(encryptedPayload, nonce);
return JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8));
} catch (IllegalArgumentException e) {
throw new JsonParseException("Failed to reveal protected JSON payload", e);
}
}
};
/// The serialized protection marker.
private final String id;
/// Creates a protection mode.
///
/// @param id the serialized protection marker
ProtectionMode(String id) {
this.id = id;
}
/// Returns the serialized protection marker.
///
/// @return the serialized protection marker
String id() {
return id;
}
/// Writes the payload into the given envelope.
///
/// @param envelope the envelope object to write into
/// @param payload the plain JSON payload
/// @throws JsonParseException if the payload cannot be protected
protected abstract void writePayload(JsonObject envelope, JsonElement payload);
/// Writes the payload into the given envelope with a caller-provided nonce.
///
/// @param envelope the envelope object to write into
/// @param payload the plain JSON payload
/// @param nonce the nonce to use for modes that require one
/// @throws JsonParseException if the payload cannot be protected
protected void writePayload(JsonObject envelope, JsonElement payload, byte[] nonce) {
writePayload(envelope, payload);
}
/// Reads the payload from the given envelope.
///
/// @param envelope the envelope object to read from
/// @return the revealed JSON payload
/// @throws JsonParseException if the envelope is malformed or cannot be revealed
protected abstract JsonElement readPayload(JsonObject envelope);
/// Reads the payload from the given envelope and checks its JSON element type.
///
/// @param envelope the envelope object to read from
/// @param payloadType the expected JSON element type
/// @return the revealed JSON payload
/// @param <T> the expected JSON element type
/// @throws JsonParseException if the envelope is malformed, cannot be revealed, or has the wrong payload type
public final <T extends JsonElement> T read(JsonObject envelope, Class<T> payloadType) {
Objects.requireNonNull(payloadType);
JsonElement payload = readPayload(envelope);
if (!payloadType.isInstance(payload)) {
throw new JsonParseException("Protected payload is not a " + payloadType.getSimpleName());
}
return payloadType.cast(payload);
}
/// Returns the write mode selected by a configuration value.
///
/// Unknown values intentionally fall back to obfuscation so opt-in plain storage cannot be enabled by typos.
///
/// @param id the configured protection marker
/// @return the selected write mode
static ProtectionMode fromConfiguredId(@Nullable String id) {
for (ProtectionMode mode : values()) {
if (mode.id.equals(id)) {
return mode;
}
}
return OBFUSCATED_V1;
}
/// Reads the protection mode from an envelope.
///
/// @param envelope the envelope object to inspect
/// @return the protection mode declared by the envelope
/// @throws JsonParseException if the declared protection mode is unsupported
static ProtectionMode fromEnvelope(JsonObject envelope) {
String protection = JsonUtils.getString(envelope, PROPERTY_PROTECTION);
if (protection == null) {
throw new JsonParseException("Missing protected payload member: protection");
}
for (ProtectionMode mode : values()) {
if (mode.id.equals(protection)) {
return mode;
}
}
throw new JsonParseException("Unsupported protected payload: " + protection);
}
}
/// Reads and reveals a protected JSON payload from an envelope object.
///
/// @param envelope the envelope object to read from
/// @param payloadType the expected JSON element type
/// @return the revealed JSON payload
/// @param <T> the expected JSON element type
/// @throws JsonParseException if the envelope is malformed or cannot be revealed
static <T extends JsonElement> T read(JsonObject envelope, Class<T> payloadType) throws JsonParseException {
return ProtectionMode.fromEnvelope(envelope).read(envelope, payloadType);
}
}
@@ -29,7 +29,7 @@ import java.net.*;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ProxyManager {
@@ -48,32 +48,28 @@ public final class ProxyManager {
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
private static ProxySelector getProxySelector() {
ProxyType proxyType = settings().proxyTypeProperty().get();
return switch (proxyType) {
case SYSTEM -> ProxyManager.SYSTEM_DEFAULT;
case DIRECT -> NO_PROXY;
case HTTP, SOCKS -> {
String host = settings().proxyHostProperty().get();
int port = settings().proxyPortProperty().get();
if (config().hasProxy()) {
Proxy.Type proxyType = config().getProxyType();
String host = config().getProxyHost();
int port = config().getProxyPort();
if (StringUtils.isBlank(host)) {
yield NO_PROXY;
} else if (port < 0 || port > 0xFFFF) {
LOG.warning("Illegal proxy port: " + port);
yield NO_PROXY;
} else {
yield new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(
Objects.requireNonNull(proxyType.jdkType()),
new InetSocketAddress(host, port))));
}
if (proxyType == Proxy.Type.DIRECT || StringUtils.isBlank(host)) {
return NO_PROXY;
} else if (port < 0 || port > 0xFFFF) {
LOG.warning("Illegal proxy port: " + port);
return NO_PROXY;
} else {
return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port))));
}
};
} else {
return ProxyManager.SYSTEM_DEFAULT;
}
}
private static SimpleAuthenticator getAuthenticator() {
if (settings().proxyTypeProperty().get().usesCustomAddress() && settings().hasProxyAuthProperty().get()) {
String username = settings().proxyUserProperty().get();
String password = settings().proxyPasswordProperty().get();
if (config().hasProxy() && config().hasProxyAuth()) {
String username = config().getProxyUser();
String password = config().getProxyPass();
if (username != null || password != null)
return new SimpleAuthenticator(
@@ -86,8 +82,7 @@ public final class ProxyManager {
return null;
}
/// Installs proxy and authentication handlers backed by launcher settings.
public static void init() {
static void init() {
ProxySelector.setDefault(new ProxySelector() {
@Override
public List<Proxy> select(URI uri) {
@@ -109,16 +104,17 @@ public final class ProxyManager {
defaultProxySelector = getProxySelector();
InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector();
settings().proxyTypeProperty().addListener(updateProxySelector);
settings().proxyHostProperty().addListener(updateProxySelector);
settings().proxyPortProperty().addListener(updateProxySelector);
config().proxyTypeProperty().addListener(updateProxySelector);
config().proxyHostProperty().addListener(updateProxySelector);
config().proxyPortProperty().addListener(updateProxySelector);
config().hasProxyProperty().addListener(updateProxySelector);
defaultAuthenticator = getAuthenticator();
InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator();
settings().proxyTypeProperty().addListener(updateAuthenticator);
settings().hasProxyAuthProperty().addListener(updateAuthenticator);
settings().proxyUserProperty().addListener(updateAuthenticator);
settings().proxyPasswordProperty().addListener(updateAuthenticator);
config().hasProxyProperty().addListener(updateAuthenticator);
config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator);
FetchTask.notifyInitialized();
}
@@ -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.setting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.net.Proxy;
/// Launcher proxy selection mode.
///
/// @author Glavo
@NotNullByDefault
public enum ProxyType {
/// Use the proxy selector provided by the host JVM or operating system.
SYSTEM(null),
/// Disable proxy usage explicitly.
DIRECT(Proxy.Type.DIRECT),
/// Use a manually configured HTTP proxy.
HTTP(Proxy.Type.HTTP),
/// Use a manually configured SOCKS proxy.
SOCKS(Proxy.Type.SOCKS);
/// The matching JDK proxy type, or `null` when this mode does not map to a concrete proxy.
private final @Nullable Proxy.Type jdkType;
/// Creates a proxy selection mode.
///
/// @param jdkType the matching JDK proxy type, or `null` when this mode does not map to a concrete proxy
ProxyType(@Nullable Proxy.Type jdkType) {
this.jdkType = jdkType;
}
/// Returns the matching JDK proxy type.
///
/// @return the JDK proxy type, or `null` when this mode does not map to a concrete proxy
public @Nullable Proxy.Type jdkType() {
return jdkType;
}
/// Returns whether this mode needs a custom proxy host and port.
public boolean usesCustomAddress() {
return this == HTTP || this == SOCKS;
}
}
@@ -1,20 +1,3 @@
/*
* 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.setting;
public final class SambaException extends RuntimeException {
@@ -1,43 +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.setting;
import org.jetbrains.annotations.NotNullByDefault;
/// Describes whether a settings file can be read and safely overwritten by this HMCL version.
@NotNullByDefault
public enum SettingFileAccess {
/// The file can be read and overwritten safely.
READ_WRITE,
/// The file can be read but must not be overwritten automatically.
READ_ONLY,
/// The file cannot be read safely and must not be overwritten automatically.
UNREADABLE;
/// Returns whether this access mode permits saving the file.
public boolean canSave() {
return this == READ_WRITE;
}
/// Returns whether this access mode blocks normal editing of the owning feature.
public boolean blocksEditing() {
return this != READ_WRITE;
}
}
@@ -1,58 +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.setting;
import org.jetbrains.annotations.NotNullByDefault;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Utility methods shared by launcher settings files.
@NotNullByDefault
public final class SettingFileUtils {
/// Prevents instantiation.
private SettingFileUtils() {
}
/// Moves an invalid config file to a numbered backup path (for example `launcher-settings.json.1`)
/// so the original bytes are preserved for diagnosis before the next successful overwrite.
public static void backupInvalidConfig(Path location) {
try {
Path backup = null;
for (int i = 1; i < Integer.MAX_VALUE; i++) {
Path candidate = location.resolveSibling(location.getFileName() + "." + i);
if (!Files.exists(candidate)) {
backup = candidate;
break;
}
}
if (backup == null) {
LOG.warning("Could not find an available backup path for " + location);
return;
}
Files.move(location, backup, StandardCopyOption.REPLACE_EXISTING);
LOG.info("Backed up invalid config to " + backup);
} catch (IOException e) {
LOG.warning("Failed to back up invalid config " + location, e);
}
}
}
@@ -0,0 +1,79 @@
/*
* 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.setting;
import javafx.beans.binding.Bindings;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.io.FileUtils;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
public final class Settings {
private static Settings instance;
public static Settings instance() {
if (instance == null) {
throw new IllegalStateException("Settings hasn't been initialized");
}
return instance;
}
/**
* Should be called from {@link ConfigHolder#init()}.
*/
static void init() {
instance = new Settings();
}
private Settings() {
DownloadProviders.init();
ProxyManager.init();
Accounts.init();
Profiles.init();
AuthlibInjectorServers.init();
AnimationUtils.init();
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
if (FileUtils.canCreateDirectory(getCommonDirectory())) {
return getCommonDirectory();
} else {
return getDefaultCommonDirectory();
}
}, config().commonDirectoryProperty(), config().commonDirTypeProperty()));
}
public static String getDefaultCommonDirectory() {
return Metadata.MINECRAFT_DIRECTORY.toString();
}
public String getCommonDirectory() {
switch (config().getCommonDirType()) {
case DEFAULT:
return getDefaultCommonDirectory();
case CUSTOM:
return config().getCommonDirectory();
default:
return null;
}
}
}
File diff suppressed because it is too large. Load diff
@@ -25,15 +25,22 @@ import javafx.scene.paint.Color;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.ResolvedTheme;
import org.jackhuang.hmcl.theme.Theme;
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() {
@@ -139,7 +161,7 @@ public final class StyleSheets {
private static String getThemeStyleSheet() {
final String blueCss = "/assets/css/blue.css";
if (ResolvedTheme.DEFAULT.equals(Themes.getTheme()))
if (Theme.DEFAULT.equals(Themes.getTheme()))
return blueCss;
ColorScheme scheme = Themes.getColorScheme();
@@ -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) {
@@ -1,36 +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.setting;
import org.jetbrains.annotations.NotNullByDefault;
/// Selects the source used to choose the launcher Monet theme color seed.
@NotNullByDefault
public enum ThemeColorType {
/// Uses the built-in launcher default theme color.
DEFAULT,
/// Follows the accent color provided by the operating system.
SYSTEM,
/// Uses the custom launcher theme color selected by the user.
CUSTOM,
/// Extracts the launcher theme color from the current background when possible.
BACKGROUND
}
@@ -1,167 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.*;
/// Stores launcher settings shared by all workspaces for the current user.
@NotNullByDefault
@JsonAdapter(UserSettings.Adapter.class)
@JsonSerializable
public final class UserSettings extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this user settings store.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("user-settings", new JsonSchema.Version(1, 0, 0));
/// Deserializes user settings from JSON.
///
/// @param json the JSON content to parse
/// @return the parsed settings, or {@code null} when the JSON value is {@code null}
public static @Nullable UserSettings fromJson(String json) throws JsonParseException {
return JsonUtils.fromJson(JsonUtils.GSON, json, UserSettings.class);
}
/// Creates empty user settings with default values.
public UserSettings() {
tracker.markDirty(schema);
register();
}
/// Serializes these settings to JSON.
public String toJson() {
return JsonUtils.GSON.toJson(this);
}
/// The schema used by this user settings file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this user settings file.
@Override
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this user settings file.
@Override
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this user settings store may be saved back to `config/user-settings.json`.
private transient boolean savable = true;
/// Whether the next successful save should back up the current `config/user-settings.json` first.
private transient boolean backupOnNextSave;
/// Returns whether this user settings store may be saved back to `config/user-settings.json`.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this user settings store may be saved back to `config/user-settings.json`.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current `config/user-settings.json` first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current `config/user-settings.json` first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// The number of launcher log files to retain.
@SerializedName("logRetention")
private final IntegerProperty logRetention = new SimpleIntegerProperty(20);
/// Returns the log retention property.
public IntegerProperty logRetentionProperty() {
return logRetention;
}
/// Whether offline accounts are enabled for this user.
@SerializedName("enableOfflineAccount")
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
/// Returns the offline account enablement property.
public BooleanProperty enableOfflineAccountProperty() {
return enableOfflineAccount;
}
/// The JavaFX font antialiasing mode override.
@SerializedName("fontAntiAliasing")
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
/// Returns the JavaFX font antialiasing mode override property.
public StringProperty fontAntiAliasingProperty() {
return fontAntiAliasing;
}
/// User-added Java executable paths.
@SerializedName("userJava")
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
/// Returns user-added Java executable paths.
public ObservableSet<String> getUserJava() {
return userJava;
}
/// Disabled Java executable paths.
@SerializedName("disabledJava")
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
/// Returns disabled Java executable paths.
public ObservableSet<String> getDisabledJava() {
return disabledJava;
}
/// Gson adapter for observable user settings.
static final class Adapter extends ObservableSetting.Adapter<UserSettings> {
/// Creates an empty user settings instance during deserialization.
@Override
protected UserSettings createInstance() {
return new UserSettings();
}
}
}
@@ -1,150 +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.setting;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// Stores launcher state shared by all workspaces for the current user.
@NotNullByDefault
@JsonAdapter(UserState.Adapter.class)
@JsonSerializable
public final class UserState extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this user state store.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("user-state", new JsonSchema.Version(1, 0, 0));
/// Deserializes user state from JSON.
///
/// @param json the JSON content to parse
/// @return the parsed state, or {@code null} when the JSON value is {@code null}
public static @Nullable UserState fromJson(String json) throws JsonParseException {
return JsonUtils.fromJson(JsonUtils.GSON, json, UserState.class);
}
/// Creates empty user state with default values.
public UserState() {
tracker.markDirty(schema);
register();
}
/// Serializes this state to JSON.
public String toJson() {
return JsonUtils.GSON.toJson(this);
}
/// The schema used by this user state file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
/// Returns the schema property.
public ObjectProperty<JsonSchema> schemaProperty() {
return schema;
}
/// Returns the schema used by this user state file.
@Override
public JsonSchema getSchema() {
return schema.get();
}
/// Sets the schema used by this user state file.
@Override
public void setSchema(JsonSchema schema) {
this.schema.set(Objects.requireNonNull(schema));
}
/// Whether this user state store may be saved back to `state/user-state.json`.
private transient boolean savable = true;
/// Whether the next successful save should back up the current `state/user-state.json` first.
private transient boolean backupOnNextSave;
/// Returns whether this user state store may be saved back to `state/user-state.json`.
@Override
public boolean isSavable() {
return savable;
}
/// Sets whether this user state store may be saved back to `state/user-state.json`.
@Override
public void setSavable(boolean savable) {
this.savable = savable;
}
/// Returns whether the next successful save should back up the current `state/user-state.json` first.
@Override
public boolean isBackupOnNextSave() {
return backupOnNextSave;
}
/// Sets whether the next successful save should back up the current `state/user-state.json` first.
@Override
public void setBackupOnNextSave(boolean backupOnNextSave) {
this.backupOnNextSave = backupOnNextSave;
}
/// The accepted launcher agreement version.
@SerializedName("agreementVersion")
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
/// Returns the accepted launcher agreement version property.
public IntegerProperty agreementVersionProperty() {
return agreementVersion;
}
/// The accepted Terracotta agreement version.
@SerializedName("terracottaAgreementVersion")
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
/// Returns the accepted Terracotta agreement version property.
public IntegerProperty terracottaAgreementVersionProperty() {
return terracottaAgreementVersion;
}
/// The platform prompt version shown to the user.
@SerializedName("platformPromptVersion")
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
/// Returns the platform prompt version property.
public IntegerProperty platformPromptVersionProperty() {
return platformPromptVersion;
}
/// Gson adapter for observable user state.
static final class Adapter extends ObservableSetting.Adapter<UserState> {
/// Creates an empty user state instance during deserialization.
@Override
protected UserState createInstance() {
return new UserState();
}
}
}
@@ -18,12 +18,10 @@
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.mod.ModLoaderType;
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,21 @@ 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_FORGED -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.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;
}
@@ -0,0 +1,944 @@
/*
* 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.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.*;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jackhuang.hmcl.util.javafx.PropertyUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.InvalidPathException;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author huangyuhui
*/
@JsonAdapter(VersionSetting.Serializer.class)
public final class VersionSetting implements Cloneable, Observable {
private static final int SUGGESTED_MEMORY;
static {
double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
SUGGESTED_MEMORY = totalMemoryMB >= 32768
? 8192
: Integer.max((int) (Math.round(totalMemoryMB / 4.0 / 128.0) * 128), 256);
}
private final transient ObservableHelper helper = new ObservableHelper(this);
public VersionSetting() {
PropertyUtils.attachListener(this, helper);
}
private final BooleanProperty usesGlobalProperty = new SimpleBooleanProperty(this, "usesGlobal", true);
public BooleanProperty usesGlobalProperty() {
return usesGlobalProperty;
}
/**
* HMCL Version Settings have been divided into 2 parts.
* 1. Global settings.
* 2. Version settings.
* If a version claims that it uses global settings, its version setting will be disabled.
* <p>
* Defaults false because if one version uses global first, custom version file will not be generated.
*/
public boolean isUsesGlobal() {
return usesGlobalProperty.get();
}
public void setUsesGlobal(boolean usesGlobal) {
usesGlobalProperty.set(usesGlobal);
}
// java
private final ObjectProperty<JavaVersionType> javaVersionTypeProperty = new SimpleObjectProperty<>(this, "javaVersionType", JavaVersionType.AUTO);
public ObjectProperty<JavaVersionType> javaVersionTypeProperty() {
return javaVersionTypeProperty;
}
public JavaVersionType getJavaVersionType() {
return javaVersionTypeProperty.get();
}
public void setJavaVersionType(JavaVersionType javaVersionType) {
javaVersionTypeProperty.set(javaVersionType);
}
private final StringProperty javaVersionProperty = new SimpleStringProperty(this, "javaVersion", "");
public StringProperty javaVersionProperty() {
return javaVersionProperty;
}
public String getJavaVersion() {
return javaVersionProperty.get();
}
public void setJavaVersion(String java) {
javaVersionProperty.set(java);
}
public void setUsesCustomJavaDir() {
setJavaVersionType(JavaVersionType.CUSTOM);
setJavaVersion("");
setDefaultJavaPath(null);
}
public void setJavaAutoSelected() {
setJavaVersionType(JavaVersionType.AUTO);
setJavaVersion("");
setDefaultJavaPath(null);
}
private final StringProperty defaultJavaPathProperty = new SimpleStringProperty(this, "defaultJavaPath", "");
/**
* Path to Java executable, or null if user customizes java directory.
* It's used to determine which JRE to use when multiple JREs match the selected Java version.
*/
public String getDefaultJavaPath() {
return defaultJavaPathProperty.get();
}
public StringProperty defaultJavaPathPropertyProperty() {
return defaultJavaPathProperty;
}
public void setDefaultJavaPath(String defaultJavaPath) {
defaultJavaPathProperty.set(defaultJavaPath);
}
/**
* 0 - .minecraft/versions/&lt;version&gt;/natives/<br/>
*/
private final ObjectProperty<NativesDirectoryType> nativesDirTypeProperty = new SimpleObjectProperty<>(this, "nativesDirType", NativesDirectoryType.VERSION_FOLDER);
public ObjectProperty<NativesDirectoryType> nativesDirTypeProperty() {
return nativesDirTypeProperty;
}
public NativesDirectoryType getNativesDirType() {
return nativesDirTypeProperty.get();
}
public void setNativesDirType(NativesDirectoryType nativesDirType) {
nativesDirTypeProperty.set(nativesDirType);
}
// Path to lwjgl natives directory
private final StringProperty nativesDirProperty = new SimpleStringProperty(this, "nativesDirProperty", "");
public StringProperty nativesDirProperty() {
return nativesDirProperty;
}
public String getNativesDir() {
return nativesDirProperty.get();
}
public void setNativesDir(String nativesDir) {
nativesDirProperty.set(nativesDir);
}
private final StringProperty javaDirProperty = new SimpleStringProperty(this, "javaDir", "");
public StringProperty javaDirProperty() {
return javaDirProperty;
}
/**
* User customized java directory or null if user uses system Java.
*/
public String getJavaDir() {
return javaDirProperty.get();
}
public void setJavaDir(String javaDir) {
javaDirProperty.set(javaDir);
}
private final StringProperty wrapperProperty = new SimpleStringProperty(this, "wrapper", "");
public StringProperty wrapperProperty() {
return wrapperProperty;
}
/**
* The command to launch java, i.e. optirun.
*/
public String getWrapper() {
return wrapperProperty.get();
}
public void setWrapper(String wrapper) {
wrapperProperty.set(wrapper);
}
private final StringProperty permSizeProperty = new SimpleStringProperty(this, "permSize", "");
public StringProperty permSizeProperty() {
return permSizeProperty;
}
/**
* The permanent generation size of JVM garbage collection.
*/
public String getPermSize() {
return permSizeProperty.get();
}
public void setPermSize(String permSize) {
permSizeProperty.set(permSize);
}
private final IntegerProperty maxMemoryProperty = new SimpleIntegerProperty(this, "maxMemory", SUGGESTED_MEMORY);
public IntegerProperty maxMemoryProperty() {
return maxMemoryProperty;
}
/**
* The maximum memory/MB that JVM can allocate for heap.
*/
public int getMaxMemory() {
return maxMemoryProperty.get();
}
public void setMaxMemory(int maxMemory) {
maxMemoryProperty.set(maxMemory);
}
/**
* The minimum memory that JVM can allocate for heap.
*/
private final ObjectProperty<Integer> minMemoryProperty = new SimpleObjectProperty<>(this, "minMemory", null);
public ObjectProperty<Integer> minMemoryProperty() {
return minMemoryProperty;
}
public Integer getMinMemory() {
return minMemoryProperty.get();
}
public void setMinMemory(Integer minMemory) {
minMemoryProperty.set(minMemory);
}
private final BooleanProperty autoMemory = new SimpleBooleanProperty(this, "autoMemory", true);
public boolean isAutoMemory() {
return autoMemory.get();
}
public BooleanProperty autoMemoryProperty() {
return autoMemory;
}
public void setAutoMemory(boolean autoMemory) {
this.autoMemory.set(autoMemory);
}
private final StringProperty preLaunchCommandProperty = new SimpleStringProperty(this, "precalledCommand", "");
public StringProperty preLaunchCommandProperty() {
return preLaunchCommandProperty;
}
/**
* The command that will be executed before launching the Minecraft.
* Operating system relevant.
*/
public String getPreLaunchCommand() {
return preLaunchCommandProperty.get();
}
public void setPreLaunchCommand(String preLaunchCommand) {
preLaunchCommandProperty.set(preLaunchCommand);
}
private final StringProperty postExitCommand = new SimpleStringProperty(this, "postExitCommand", "");
public StringProperty postExitCommandProperty() {
return postExitCommand;
}
/**
* The command that will be executed after game exits.
* Operating system relevant.
*/
public String getPostExitCommand() {
return postExitCommand.get();
}
public void setPostExitCommand(String postExitCommand) {
this.postExitCommand.set(postExitCommand);
}
// options
private final StringProperty javaArgsProperty = new SimpleStringProperty(this, "javaArgs", "");
public StringProperty javaArgsProperty() {
return javaArgsProperty;
}
/**
* The user customized arguments passed to JVM.
*/
public String getJavaArgs() {
return javaArgsProperty.get();
}
public void setJavaArgs(String javaArgs) {
javaArgsProperty.set(javaArgs);
}
private final StringProperty minecraftArgsProperty = new SimpleStringProperty(this, "minecraftArgs", "");
public StringProperty minecraftArgsProperty() {
return minecraftArgsProperty;
}
/**
* The user customized arguments passed to Minecraft.
*/
public String getMinecraftArgs() {
return minecraftArgsProperty.get();
}
public void setMinecraftArgs(String minecraftArgs) {
minecraftArgsProperty.set(minecraftArgs);
}
private final StringProperty environmentVariablesProperty = new SimpleStringProperty(this, "environmentVariables", "");
public StringProperty environmentVariablesProperty() {
return environmentVariablesProperty;
}
public String getEnvironmentVariables() {
return environmentVariablesProperty.get();
}
public void setEnvironmentVariables(String env) {
environmentVariablesProperty.set(env);
}
private final BooleanProperty noJVMArgsProperty = new SimpleBooleanProperty(this, "noJVMArgs", false);
public BooleanProperty noJVMArgsProperty() {
return noJVMArgsProperty;
}
/**
* True if disallow HMCL use default JVM arguments.
*/
public boolean isNoJVMArgs() {
return noJVMArgsProperty.get();
}
public void setNoJVMArgs(boolean noJVMArgs) {
noJVMArgsProperty.set(noJVMArgs);
}
private final BooleanProperty noOptimizingJVMArgsProperty = new SimpleBooleanProperty(this, "noOptimizingJVMArgs", false);
public BooleanProperty noOptimizingJVMArgsProperty() {
return noOptimizingJVMArgsProperty;
}
public boolean isNoOptimizingJVMArgs() {
return noOptimizingJVMArgsProperty.get();
}
public void setNoOptimizingJVMArgs(boolean noOptimizingJVMArgs) {
noOptimizingJVMArgsProperty.set(noOptimizingJVMArgs);
}
private final BooleanProperty notCheckJVMProperty = new SimpleBooleanProperty(this, "notCheckJVM", false);
public BooleanProperty notCheckJVMProperty() {
return notCheckJVMProperty;
}
/**
* True if HMCL does not check JVM validity.
*/
public boolean isNotCheckJVM() {
return notCheckJVMProperty.get();
}
public void setNotCheckJVM(boolean notCheckJVM) {
notCheckJVMProperty.set(notCheckJVM);
}
private final BooleanProperty notCheckGameProperty = new SimpleBooleanProperty(this, "notCheckGame", false);
public BooleanProperty notCheckGameProperty() {
return notCheckGameProperty;
}
/**
* True if HMCL does not check game's completeness.
*/
public boolean isNotCheckGame() {
return notCheckGameProperty.get();
}
public void setNotCheckGame(boolean notCheckGame) {
notCheckGameProperty.set(notCheckGame);
}
private final BooleanProperty notPatchNativesProperty = new SimpleBooleanProperty(this, "notPatchNatives", false);
public BooleanProperty notPatchNativesProperty() {
return notPatchNativesProperty;
}
public boolean isNotPatchNatives() {
return notPatchNativesProperty.get();
}
public void setNotPatchNatives(boolean notPatchNatives) {
notPatchNativesProperty.set(notPatchNatives);
}
private final BooleanProperty showLogsProperty = new SimpleBooleanProperty(this, "showLogs", false);
public BooleanProperty showLogsProperty() {
return showLogsProperty;
}
/**
* True if show the logs after game launched.
*/
public boolean isShowLogs() {
return showLogsProperty.get();
}
public void setShowLogs(boolean showLogs) {
showLogsProperty.set(showLogs);
}
private final BooleanProperty enableDebugLogOutputProperty = new SimpleBooleanProperty(this, "enableDebugLogOutput", false);
public BooleanProperty enableDebugLogOutputProperty() {
return enableDebugLogOutputProperty;
}
public boolean isEnableDebugLogOutput() {
return enableDebugLogOutputProperty.get();
}
public void setEnableDebugLogOutput(boolean u) {
this.enableDebugLogOutputProperty.set(u);
}
// Minecraft settings.
private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", "");
public StringProperty serverIpProperty() {
return serverIpProperty;
}
/**
* The server ip that will be entered after Minecraft successfully loaded ly.
* <p>
* Format: ip:port or without port.
*/
public String getServerIp() {
return serverIpProperty.get();
}
public void setServerIp(String serverIp) {
serverIpProperty.set(serverIp);
}
private final BooleanProperty fullscreenProperty = new SimpleBooleanProperty(this, "fullscreen", false);
public BooleanProperty fullscreenProperty() {
return fullscreenProperty;
}
/**
* True if Minecraft started in fullscreen mode.
*/
public boolean isFullscreen() {
return fullscreenProperty.get();
}
public void setFullscreen(boolean fullscreen) {
fullscreenProperty.set(fullscreen);
}
private final IntegerProperty widthProperty = new SimpleIntegerProperty(this, "width", 854);
public IntegerProperty widthProperty() {
return widthProperty;
}
/**
* The width of Minecraft window, defaults 800.
* <p>
* The field saves int value.
* String type prevents unexpected value from JsonParseException.
* We can only reset this field instead of recreating the whole setting file.
*/
public int getWidth() {
return widthProperty.get();
}
public void setWidth(int width) {
widthProperty.set(width);
}
private final IntegerProperty heightProperty = new SimpleIntegerProperty(this, "height", 480);
public IntegerProperty heightProperty() {
return heightProperty;
}
/**
* The height of Minecraft window, defaults 480.
* <p>
* The field saves int value.
* String type prevents unexpected value from JsonParseException.
* We can only reset this field instead of recreating the whole setting file.
*/
public int getHeight() {
return heightProperty.get();
}
public void setHeight(int height) {
heightProperty.set(height);
}
/**
* 0 - .minecraft<br/>
* 1 - .minecraft/versions/&lt;version&gt;/<br/>
*/
private final ObjectProperty<GameDirectoryType> gameDirTypeProperty = new SimpleObjectProperty<>(this, "gameDirType", GameDirectoryType.ROOT_FOLDER);
public ObjectProperty<GameDirectoryType> gameDirTypeProperty() {
return gameDirTypeProperty;
}
public GameDirectoryType getGameDirType() {
return gameDirTypeProperty.get();
}
public void setGameDirType(GameDirectoryType gameDirType) {
gameDirTypeProperty.set(gameDirType);
}
/**
* Your custom gameDir
*/
private final StringProperty gameDirProperty = new SimpleStringProperty(this, "gameDir", "");
public StringProperty gameDirProperty() {
return gameDirProperty;
}
public String getGameDir() {
return gameDirProperty.get();
}
public void setGameDir(String gameDir) {
gameDirProperty.set(gameDir);
}
private final ObjectProperty<ProcessPriority> processPriorityProperty = new SimpleObjectProperty<>(this, "processPriority", ProcessPriority.NORMAL);
public ObjectProperty<ProcessPriority> processPriorityProperty() {
return processPriorityProperty;
}
public ProcessPriority getProcessPriority() {
return processPriorityProperty.get();
}
public void setProcessPriority(ProcessPriority processPriority) {
processPriorityProperty.set(processPriority);
}
private final ObjectProperty<Renderer> rendererProperty = new SimpleObjectProperty<>(this, "renderer", Renderer.DEFAULT);
public Renderer getRenderer() {
return rendererProperty.get();
}
public ObjectProperty<Renderer> rendererProperty() {
return rendererProperty;
}
public void setRenderer(Renderer renderer) {
this.rendererProperty.set(renderer);
}
private final BooleanProperty useNativeGLFW = new SimpleBooleanProperty(this, "nativeGLFW", false);
public boolean isUseNativeGLFW() {
return useNativeGLFW.get();
}
public BooleanProperty useNativeGLFWProperty() {
return useNativeGLFW;
}
public void setUseNativeGLFW(boolean useNativeGLFW) {
this.useNativeGLFW.set(useNativeGLFW);
}
private final BooleanProperty useNativeOpenAL = new SimpleBooleanProperty(this, "nativeOpenAL", false);
public boolean isUseNativeOpenAL() {
return useNativeOpenAL.get();
}
public BooleanProperty useNativeOpenALProperty() {
return useNativeOpenAL;
}
public void setUseNativeOpenAL(boolean useNativeOpenAL) {
this.useNativeOpenAL.set(useNativeOpenAL);
}
private final ObjectProperty<VersionIconType> versionIcon = new SimpleObjectProperty<>(this, "versionIcon", VersionIconType.DEFAULT);
public VersionIconType getVersionIcon() {
return versionIcon.get();
}
public ObjectProperty<VersionIconType> versionIconProperty() {
return versionIcon;
}
public void setVersionIcon(VersionIconType versionIcon) {
this.versionIcon.set(versionIcon);
}
// launcher settings
/**
* 0 - Close the launcher when the game starts.<br/>
* 1 - Hide the launcher when the game starts.<br/>
* 2 - Keep the launcher open.<br/>
*/
private final ObjectProperty<LauncherVisibility> launcherVisibilityProperty = new SimpleObjectProperty<>(this, "launcherVisibility", LauncherVisibility.HIDE);
public ObjectProperty<LauncherVisibility> launcherVisibilityProperty() {
return launcherVisibilityProperty;
}
public LauncherVisibility getLauncherVisibility() {
return launcherVisibilityProperty.get();
}
public void setLauncherVisibility(LauncherVisibility launcherVisibility) {
launcherVisibilityProperty.set(launcherVisibility);
}
public JavaRuntime getJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
switch (getJavaVersionType()) {
case DEFAULT:
return JavaRuntime.getDefault();
case AUTO:
return JavaManager.findSuitableJava(gameVersion, version);
case CUSTOM:
try {
return JavaManager.getJava(Paths.get(getJavaDir()));
} catch (IOException | InvalidPathException e) {
return null; // Custom Java not found
}
case VERSION: {
String javaVersion = getJavaVersion();
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
}
int majorVersion = -1;
try {
majorVersion = Integer.parseInt(javaVersion);
} catch (NumberFormatException ignored) {
}
if (majorVersion < 0) {
LOG.warning("Invalid Java version: " + javaVersion);
return null;
}
final int finalMajorVersion = majorVersion;
Collection<JavaRuntime> allJava = JavaManager.getAllJava().stream()
.filter(it -> it.getParsedVersion() == finalMajorVersion)
.collect(Collectors.toList());
return JavaManager.findSuitableJava(allJava, gameVersion, version);
}
case DETECTED: {
String javaVersion = getJavaVersion();
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
}
try {
String defaultJavaPath = getDefaultJavaPath();
if (StringUtils.isNotBlank(defaultJavaPath)) {
JavaRuntime java = JavaManager.getJava(Paths.get(defaultJavaPath).toRealPath());
if (java != null && java.getVersion().equals(javaVersion)) {
return java;
}
}
} catch (IOException | InvalidPathException ignored) {
}
for (JavaRuntime java : JavaManager.getAllJava()) {
if (java.getVersion().equals(javaVersion)) {
return java;
}
}
return null;
}
default:
throw new AssertionError("JavaVersionType: " + getJavaVersionType());
}
}
@Override
public void addListener(InvalidationListener listener) {
helper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
helper.removeListener(listener);
}
@Override
public VersionSetting clone() {
VersionSetting cloned = new VersionSetting();
PropertyUtils.copyProperties(this, cloned);
return cloned;
}
public static class Serializer implements JsonSerializer<VersionSetting>, JsonDeserializer<VersionSetting> {
@Override
public JsonElement serialize(VersionSetting src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null) return JsonNull.INSTANCE;
JsonObject obj = new JsonObject();
obj.addProperty("usesGlobal", src.isUsesGlobal());
obj.addProperty("javaArgs", src.getJavaArgs());
obj.addProperty("minecraftArgs", src.getMinecraftArgs());
obj.addProperty("environmentVariables", src.getEnvironmentVariables());
obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? SUGGESTED_MEMORY : src.getMaxMemory());
obj.addProperty("minMemory", src.getMinMemory());
obj.addProperty("autoMemory", src.isAutoMemory());
obj.addProperty("permSize", src.getPermSize());
obj.addProperty("width", src.getWidth());
obj.addProperty("height", src.getHeight());
obj.addProperty("javaDir", src.getJavaDir());
obj.addProperty("precalledCommand", src.getPreLaunchCommand());
obj.addProperty("postExitCommand", src.getPostExitCommand());
obj.addProperty("serverIp", src.getServerIp());
obj.addProperty("wrapper", src.getWrapper());
obj.addProperty("fullscreen", src.isFullscreen());
obj.addProperty("noJVMArgs", src.isNoJVMArgs());
obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs());
obj.addProperty("notCheckGame", src.isNotCheckGame());
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
obj.addProperty("showLogs", src.isShowLogs());
obj.addProperty("enableDebugLogOutput", src.isEnableDebugLogOutput());
obj.addProperty("gameDir", src.getGameDir());
obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal());
obj.addProperty("processPriority", src.getProcessPriority().ordinal());
obj.addProperty("useNativeGLFW", src.isUseNativeGLFW());
obj.addProperty("useNativeOpenAL", src.isUseNativeOpenAL());
obj.addProperty("gameDirType", src.getGameDirType().ordinal());
obj.addProperty("defaultJavaPath", src.getDefaultJavaPath());
obj.addProperty("nativesDir", src.getNativesDir());
obj.addProperty("nativesDirType", src.getNativesDirType().ordinal());
obj.addProperty("versionIcon", src.getVersionIcon().ordinal());
obj.addProperty("javaVersionType", src.getJavaVersionType().name());
String java;
switch (src.getJavaVersionType()) {
case DEFAULT:
java = "Default";
break;
case AUTO:
java = "Auto";
break;
case CUSTOM:
java = "Custom";
break;
default:
java = src.getJavaVersion();
break;
}
obj.addProperty("java", java);
obj.addProperty("renderer", src.getRenderer().name());
if (src.getRenderer() == Renderer.LLVMPIPE)
obj.addProperty("useSoftwareRenderer", true);
return obj;
}
@Override
public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject))
return null;
JsonObject obj = (JsonObject) json;
int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), SUGGESTED_MEMORY);
if (maxMemoryN <= 0) maxMemoryN = SUGGESTED_MEMORY;
VersionSetting vs = new VersionSetting();
vs.setUsesGlobal(Optional.ofNullable(obj.get("usesGlobal")).map(JsonElement::getAsBoolean).orElse(false));
vs.setJavaArgs(Optional.ofNullable(obj.get("javaArgs")).map(JsonElement::getAsString).orElse(""));
vs.setMinecraftArgs(Optional.ofNullable(obj.get("minecraftArgs")).map(JsonElement::getAsString).orElse(""));
vs.setEnvironmentVariables(Optional.ofNullable(obj.get("environmentVariables")).map(JsonElement::getAsString).orElse(""));
vs.setMaxMemory(maxMemoryN);
vs.setMinMemory(Optional.ofNullable(obj.get("minMemory")).map(JsonElement::getAsInt).orElse(null));
vs.setAutoMemory(Optional.ofNullable(obj.get("autoMemory")).map(JsonElement::getAsBoolean).orElse(true));
vs.setPermSize(Optional.ofNullable(obj.get("permSize")).map(JsonElement::getAsString).orElse(""));
vs.setWidth(Optional.ofNullable(obj.get("width")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0));
vs.setHeight(Optional.ofNullable(obj.get("height")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0));
vs.setJavaDir(Optional.ofNullable(obj.get("javaDir")).map(JsonElement::getAsString).orElse(""));
vs.setPreLaunchCommand(Optional.ofNullable(obj.get("precalledCommand")).map(JsonElement::getAsString).orElse(""));
vs.setPostExitCommand(Optional.ofNullable(obj.get("postExitCommand")).map(JsonElement::getAsString).orElse(""));
vs.setServerIp(Optional.ofNullable(obj.get("serverIp")).map(JsonElement::getAsString).orElse(""));
vs.setWrapper(Optional.ofNullable(obj.get("wrapper")).map(JsonElement::getAsString).orElse(""));
vs.setGameDir(Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse(""));
vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse(""));
vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setEnableDebugLogOutput(Optional.ofNullable(obj.get("enableDebugLogOutput")).map(JsonElement::getAsBoolean).orElse(false));
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false));
vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER));
vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null));
vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT));
if (obj.get("javaVersionType") != null) {
JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO);
vs.setJavaVersionType(javaVersionType);
vs.setJavaVersion(Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(null));
} else {
String java = Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse("");
switch (java) {
case "Default":
vs.setJavaVersionType(JavaVersionType.DEFAULT);
break;
case "Auto":
vs.setJavaVersionType(JavaVersionType.AUTO);
break;
case "Custom":
vs.setJavaVersionType(JavaVersionType.CUSTOM);
break;
default:
vs.setJavaVersion(java);
}
}
vs.setRenderer(Optional.ofNullable(obj.get("renderer")).map(JsonElement::getAsString)
.flatMap(name -> {
try {
return Optional.of(Renderer.valueOf(name.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException ignored) {
return Optional.empty();
}
}).orElseGet(() -> {
boolean useSoftwareRenderer = Optional.ofNullable(obj.get("useSoftwareRenderer")).map(JsonElement::getAsBoolean).orElse(false);
return useSoftwareRenderer ? Renderer.LLVMPIPE : Renderer.DEFAULT;
}));
return vs;
}
private int parseJsonPrimitive(JsonPrimitive primitive) {
return parseJsonPrimitive(primitive, 0);
}
private int parseJsonPrimitive(JsonPrimitive primitive, int defaultValue) {
if (primitive == null)
return defaultValue;
else if (primitive.isNumber())
return primitive.getAsInt();
else
return Lang.parseInt(primitive.getAsString(), defaultValue);
}
private <E extends Enum<E>> E parseJsonPrimitive(JsonPrimitive primitive, Class<E> clazz, E defaultValue) {
if (primitive == null)
return defaultValue;
else {
E[] enumConstants = clazz.getEnumConstants();
if (primitive.isNumber()) {
int index = primitive.getAsInt();
return index >= 0 && index < enumConstants.length ? enumConstants[index] : defaultValue;
} else {
String name = primitive.getAsString();
for (E enumConstant : enumConstants) {
if (enumConstant.name().equalsIgnoreCase(name)) {
return enumConstant;
}
}
return defaultValue;
}
}
}
}
}
@@ -1,28 +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.setting.property;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.UnknownNullability;
/// A setting value whose instance override state is stored in `GameSettings.Instance.overrideProperties`.
///
/// @author Glavo
@NotNullByDefault
public interface InheritableProperty<T extends @UnknownNullability Object> extends SettingProperty<T> {
}
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff. Show more