Compare commits

..
583 changed files with 16661 additions and 53354 deletions

No files matched your search

-65
View File
@@ -1,65 +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
*.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
-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);
}
}
-6
View File
@@ -48,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
sparse-checkout-cone-mode: true
- name: Label PR by filtered changed lines
uses: actions/github-script@v9
with:
script: |
const script = require("./.github/scripts/pr-size-label.js");
await script({ github, context, core });
-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 -10
View File
@@ -5,7 +5,6 @@ hs_err_pid*
*.hprof
.gradle
gradle.properties
*.lck
*.1
@@ -17,7 +16,6 @@ NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
terracotta-log-*
*.hmcl-theme
/.java/
/.local/
/.cache/
@@ -45,7 +43,6 @@ language-subtag-registry
# eclipse
/bin/
/HMCL/bin/
/HMCLBoot/bin/
/HMCLCore/bin/
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
@@ -67,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>
-23
View File
@@ -1,23 +0,0 @@
# Java Code Style Requirements
These rules apply to all Java code written or modified in this repository.
## Nullability
- 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.
+16 -51
View File
@@ -6,8 +6,6 @@ import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
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
@@ -52,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)
@@ -162,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) }
@@ -200,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/**") }
@@ -256,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")
@@ -286,31 +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)
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> {
@@ -407,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")
@@ -438,19 +403,19 @@ tasks.register<CheckTranslations>("checkTranslations") {
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

@@ -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() {
}
}
/***************************************************************************
@@ -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) {
@@ -474,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);
@@ -519,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);
@@ -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,43 +17,36 @@
*/
package org.jackhuang.hmcl;
import com.sun.jna.Pointer;
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.theme.Themes;
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.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.File;
import java.io.IOException;
import java.lang.management.ManagementFactory;
@@ -65,15 +58,15 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
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;
public final class Launcher extends Application {
public static final CookieManager COOKIE_MANAGER = new CookieManager();
@@ -102,41 +95,48 @@ 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.
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
Themes.applyNativeDarkMode(primaryStage);
UpdateChecker.init();
primaryStage.show();
@@ -146,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();
@@ -202,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))
@@ -261,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();
}
@@ -302,8 +300,8 @@ 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 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()));
@@ -323,23 +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();
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);
@@ -372,158 +355,5 @@ public final class Launcher extends Application {
});
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
setUpAnimationFrameRate:
{
if (System.getProperty("javafx.animation.pulse") != null) {
break setUpAnimationFrameRate;
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
int value = Integer.parseInt(animationFrameRate);
if (value <= 0)
throw new NumberFormatException(animationFrameRate);
if (value != 60)
System.setProperty("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
break setUpAnimationFrameRate;
}
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
break setUpAnimationFrameRate;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
if (pointer != null) {
try {
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
if (refreshRate > 0) {
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
if (refreshRate >= 90) {
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
}
}
} finally {
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
}
}
}
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
SwingUtils.initLookAndFeel();
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
int result = JOptionPane.showOptionDialog(null,
i18n("fatal.config_loading_failure.unix", owner, command),
i18n("message.error"),
JOptionPane.DEFAULT_OPTION,
JOptionPane.ERROR_MESSAGE,
null,
options,
options[0]);
if (result == 0) {
try {
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
} catch (Throwable e) {
LOG.warning("Failed to copy command to clipboard", e);
}
}
EntryPoint.exit(1);
}
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
}
@@ -24,6 +24,7 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.EnumSet;
/**
@@ -45,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";
@@ -58,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() {
@@ -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;
@@ -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;
/**
@@ -63,7 +60,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
private void generateOptionsTxt() {
if (options.isDisableAutoGameOptions())
if (config().isDisableAutoGameOptions())
return;
Path runDir = repository.getRunDirectory(version.getId());
@@ -153,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(version, 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.getArtifact().getFileName();
Path agentPath = repository.getLibraryFile(version, 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;
}
}
@@ -17,39 +17,25 @@
*/
package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.LauncherSettings;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.setting.DefaultIsolationType;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameWindowType;
import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.ProxyType;
import org.jackhuang.hmcl.setting.SettingFileUtils;
import org.jackhuang.hmcl.setting.GameSettingsPresetID;
import org.jackhuang.hmcl.mod.ModAdviser;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.VersionSetting;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -57,10 +43,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.Proxy;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
@@ -69,136 +55,53 @@ import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// HMCL game repository implementation backed by a GameDirectory and per-instance game settings.
@NotNullByDefault
public final class HMCLGameRepository extends DefaultGameRepository {
/// References an optional game instance in a repository.
///
/// @param repository the owning game repository
/// @param instanceId the game instance ID, or `null` when only repository context is available
@NotNullByDefault
public record InstanceReference(HMCLGameRepository repository, @Nullable String instanceId) {
}
private final Profile profile;
/// Directory under the version 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";
/// The persistent game directory for this repository.
private final GameDirectory gameDirectory;
/// The selected instance ID persisted for this repository's game directory.
private final StringBinding selectedInstance;
// instance game settings
private final Map<String, GameSettings.Instance> instanceGameSettings = new HashMap<>();
/// Instance IDs whose local game settings file has already been checked.
private final Set<String> loadedInstanceGameSettings = new HashSet<>();
private final Set<String> readOnlyInstanceGameSettings = new HashSet<>();
// local version settings
private final Map<String, VersionSetting> localVersionSettings = new HashMap<>();
private final Set<String> beingModpackVersions = new HashSet<>();
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
/// Creates a repository backed by the given game directory.
public HMCLGameRepository(GameDirectory gameDirectory) {
super(gameDirectory.getPath().toPath());
this.gameDirectory = gameDirectory;
this.selectedInstance = Bindings.stringValueAt(settings().getSelectedInstance(), gameDirectory.getId());
gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath()));
public HMCLGameRepository(Profile profile, Path baseDirectory) {
super(baseDirectory);
this.profile = profile;
}
/// Returns the persistent game directory for this repository.
public GameDirectory getGameDirectory() {
return gameDirectory;
public Profile getProfile() {
return profile;
}
/// Returns the selected instance ID property for this repository's game directory.
public StringBinding selectedInstanceProperty() {
return selectedInstance;
}
/// Returns the selected instance ID for this repository's game directory.
public @Nullable String getSelectedInstance() {
return selectedInstance.get();
}
/// Sets the selected instance ID for this repository's game directory.
public void setSelectedInstance(@Nullable String instance) {
settings().setSelectedInstance(gameDirectory.getId(), instance);
}
/// Refreshes the selected instance ID after versions are loaded.
public void refreshSelectedInstance() {
@Nullable String selectedInstance = settings().getSelectedInstance(gameDirectory.getId());
@Nullable String refreshedInstance = selectedInstance;
if (!hasVersion(refreshedInstance)) {
refreshedInstance = getVersions().isEmpty() ? null : getVersions().iterator().next().getId();
@Override
public GameDirectoryType getGameDirectoryType(String id) {
if (beingModpackVersions.contains(id) || isModpack(id)) {
return GameDirectoryType.VERSION_FOLDER;
} else {
return getVersionSetting(id).getGameDirType();
}
if (!Objects.equals(selectedInstance, refreshedInstance)) {
setSelectedInstance(refreshedInstance);
}
}
/// Returns a dependency manager using the currently selected download provider.
public DefaultDependencyManager getDependency() {
return getDependency(DownloadProviders.getDownloadProvider());
}
/// Returns a dependency manager using the given download provider.
public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) {
return new DefaultDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY);
}
@Override
public Path getRunDirectory(String id) {
if (beingModpackVersions.contains(id) || isModpack(id)) {
return getVersionRoot(id);
switch (getGameDirectoryType(id)) {
case VERSION_FOLDER:
return getVersionRoot(id);
case ROOT_FOLDER:
return super.getRunDirectory(id);
case CUSTOM:
try {
return Path.of(getVersionSetting(id).getGameDir());
} catch (InvalidPathException ignored) {
return getVersionRoot(id);
}
default:
throw new AssertionError("Unreachable");
}
GameSettings.Instance localSetting = getInstanceGameSettings(id);
boolean useInstanceRunningDirectory =
localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY);
String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory);
if (StringUtils.isBlank(runningDirectory)) {
return useInstanceRunningDirectory ? getVersionRoot(id) : super.getRunDirectory(id);
}
try {
return Path.of(runningDirectory);
} catch (InvalidPathException ignored) {
return getVersionRoot(id);
}
}
/// Returns the running directory string selected by the current source.
private String getSelectedRunningDirectory(
@Nullable GameSettings.Instance localSetting,
boolean useInstanceRunningDirectory) {
if (useInstanceRunningDirectory) {
if (localSetting == null) {
return "";
}
//noinspection DataFlowIssue
return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), "");
}
GameSettings.Preset parent = getParentGameSettings(localSetting);
//noinspection DataFlowIssue
return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), "");
}
public Stream<Version> getDisplayVersions() {
@@ -210,11 +113,14 @@ public final class HMCLGameRepository extends DefaultGameRepository {
@Override
protected void refreshVersionsImpl() {
instanceGameSettings.clear();
loadedInstanceGameSettings.clear();
readOnlyInstanceGameSettings.clear();
localVersionSettings.clear();
super.refreshVersionsImpl();
versions.keySet().forEach(this::loadInstanceGameSettings);
versions.keySet().forEach(this::loadLocalVersionSetting);
versions.keySet().forEach(version -> {
if (isModpack(version)) {
specializeVersionSetting(version);
}
});
try {
Path file = getBaseDirectory().resolve("launcher_profiles.json");
@@ -242,19 +148,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
clean(getRunDirectory(id));
}
/// Removes a version from disk and drops any cached instance settings for that version.
@Override
public boolean removeVersionFromDisk(String id) {
boolean removed = super.removeVersionFromDisk(id);
if (removed) {
instanceGameSettings.remove(id);
loadedInstanceGameSettings.remove(id);
readOnlyInstanceGameSettings.remove(id);
beingModpackVersions.remove(id);
}
return removed;
}
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
Path srcDir = getVersionRoot(srcId);
Path dstDir = getVersionRoot(dstId);
@@ -284,285 +177,89 @@ public final class HMCLGameRepository extends DefaultGameRepository {
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
boolean copyOriginalGameDir;
try {
copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getVersionRoot(srcId));
} catch (IOException e) {
copyOriginalGameDir = true;
}
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
oldVersionSetting.setUsesGlobal(false);
oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
saveVersionSetting(dstId);
Path srcGameDir = getRunDirectory(srcId);
GameSettings.Instance newGameSettings = copyInstanceGameSettings(srcId);
newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
newGameSettings.runningDirectoryProperty().setValue("");
initInstanceGameSettings(dstId, newGameSettings);
saveGameSettingsSync(dstId);
Path dstGameDir = getRunDirectory(dstId);
if (copyOriginalGameDir)
if (originalGameDirType != GameDirectoryType.VERSION_FOLDER)
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
}
private GameSettings.Instance copyInstanceGameSettings(String id) {
GameSettings.Instance setting = getInstanceGameSettings(id);
if (setting != null) {
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
}
GameSettings.Instance copied = new GameSettings.Instance();
copied.parentProperty().setValue(getEffectiveGameSettings(id).getPreset().idProperty().getValue());
return copied;
private Path getLocalVersionSettingFile(String id) {
return getVersionRoot(id).resolve("hmclversion.cfg");
}
/// Returns the HMCL-managed metadata directory under the version root.
///
/// This directory stores instance-scoped files owned by HMCL.
public Path getInstanceMetadataDirectory(String id) {
return getVersionRoot(id).resolve(INSTANCE_METADATA_DIRECTORY);
}
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
public Path getInstanceConfigDirectory(String id) {
return getInstanceMetadataDirectory(id).resolve(INSTANCE_CONFIG_DIRECTORY);
}
/// Returns the HMCL-managed state directory under the instance metadata directory.
public Path getInstanceStateDirectory(String id) {
return getInstanceMetadataDirectory(id).resolve(INSTANCE_STATE_DIRECTORY);
}
/// Returns the current local game settings path under the instance configuration directory.
private Path getInstanceGameSettingsFile(String id) {
return getInstanceConfigDirectory(id).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
}
private void loadInstanceGameSettings(String id) {
loadedInstanceGameSettings.add(id);
InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(id));
if (result.setting() != null) {
initInstanceGameSettings(id, result.setting(), result.allowSave());
return;
}
if (!result.allowSave()) {
readOnlyInstanceGameSettings.add(id);
return;
}
@Nullable GameSettingsPresetID legacyParent = gameDirectory.getLegacyGameSettings();
if (SettingsManager.getGameSettings(legacyParent) == null) {
legacyParent = null;
}
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
this, id,
legacyParent);
if (migrationResult != null) {
initInstanceGameSettings(id, migrationResult.setting());
private void loadLocalVersionSetting(String id) {
Path file = getLocalVersionSettingFile(id);
if (Files.exists(file))
try {
saveGameSettingsSync(id);
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + id, e);
VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class);
initLocalVersionSetting(id, versionSetting);
} catch (Exception ex) {
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
initLocalVersionSetting(id, new VersionSetting());
}
return;
}
}
/// Loads a new-format instance game settings file.
private InstanceGameSettingsLoadResult loadGameSettingsFile(Path file) {
if (!Files.exists(file)) {
return new InstanceGameSettingsLoadResult(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 InstanceGameSettingsLoadResult(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: "
+ file + ", Actual: " + schemaResult.invalidValue());
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: "
+ file + ", Actual: " + schemaResult.actual());
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: "
+ GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual());
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA ->
LOG.warning("Unsupported instance game settings schema. Expected: "
+ GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual());
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
if (!schemaResult.readable()) {
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setSavable(false);
return new InstanceGameSettingsLoadResult(fallback, false);
}
GameSettings.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 InstanceGameSettingsLoadResult(fallback, true);
}
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
}
return new InstanceGameSettingsLoadResult(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 InstanceGameSettingsLoadResult(fallback, true);
} catch (Exception ex) {
LOG.warning("Failed to load game setting " + file, ex);
return new InstanceGameSettingsLoadResult(null, false);
}
}
public @Nullable GameSettings.Instance createInstanceGameSettings(String id) {
if (!hasVersion(id)) {
/**
* Create new version setting if version id has no version setting.
*
* @param id the version id.
* @return new version setting, null if given version does not exist.
*/
public VersionSetting createLocalVersionSetting(String id) {
if (!hasVersion(id))
return null;
}
if (readOnlyInstanceGameSettings.contains(id)) {
return null;
}
if (instanceGameSettings.containsKey(id)) {
return getInstanceGameSettings(id);
}
GameSettings.Instance setting = new GameSettings.Instance();
return initInstanceGameSettings(id, setting);
if (localVersionSettings.containsKey(id))
return getLocalVersionSetting(id);
else
return initLocalVersionSetting(id, new VersionSetting());
}
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting) {
return initInstanceGameSettings(id, setting, true);
private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) {
localVersionSettings.put(id, vs);
vs.addListener(a -> saveVersionSetting(id));
return vs;
}
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting, boolean allowSave) {
normalizeRunningDirectoryOverride(setting);
setting.setSavable(allowSave);
loadedInstanceGameSettings.add(id);
instanceGameSettings.put(id, setting);
if (allowSave) {
readOnlyInstanceGameSettings.remove(id);
setting.addListener(a -> saveGameSettings(id));
} else {
readOnlyInstanceGameSettings.add(id);
}
/**
* Get the version setting for version id.
*
* @param id version id
* @return corresponding version setting, null if the version has no its own version setting.
*/
@Nullable
public VersionSetting getLocalVersionSetting(String id) {
if (!localVersionSettings.containsKey(id))
loadLocalVersionSetting(id);
VersionSetting setting = localVersionSettings.get(id);
if (setting != null && isModpack(id))
setting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
return setting;
}
/// Keeps old local custom running directories effective under the new source-selection model.
private void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
}
@Nullable
public GameSettings.Instance getInstanceGameSettings(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
public VersionSetting getLocalVersionSettingOrCreate(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs == null) {
vs = createLocalVersionSetting(id);
}
return instanceGameSettings.get(id);
return vs;
}
@Nullable
public GameSettings.Instance getInstanceGameSettingsOrCreate(String id) {
GameSettings.Instance setting = getInstanceGameSettings(id);
if (setting == null) {
setting = createInstanceGameSettings(id);
}
return setting;
}
/// Returns whether the instance-specific game settings file cannot be overwritten safely.
///
/// @param id the instance ID
/// @return whether the instance settings are loaded in read-only mode
public boolean isInstanceGameSettingsReadOnly(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
}
return readOnlyInstanceGameSettings.contains(id);
}
/// Backs up and overwrites the instance-specific game settings file with the currently loaded settings.
///
/// @param id the instance ID
public void forceOverwriteInstanceGameSettings(String id) {
if (!loadedInstanceGameSettings.contains(id)) {
loadInstanceGameSettings(id);
}
GameSettings.Instance setting = instanceGameSettings.get(id);
if (setting == null) {
setting = new GameSettings.Instance();
instanceGameSettings.put(id, setting);
loadedInstanceGameSettings.add(id);
}
boolean installAutoSave = !setting.isSavable();
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
SettingFileUtils.backupInvalidConfig(file);
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
setting.setSavable(true);
setting.setBackupOnNextSave(false);
readOnlyInstanceGameSettings.remove(id);
saveGameSettings(id);
if (installAutoSave) {
setting.addListener(a -> saveGameSettings(id));
}
}
/// Returns the explicit parent preset of the instance, falling back to the default preset.
public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) {
@Nullable GameSettingsPresetID parent = instance != null ? instance.parentProperty().getValue() : null;
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
}
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
public boolean shouldIsolateNewInstance(boolean modded) {
GameSettings.Preset preset = getParentGameSettings(null);
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
return switch (type) {
case NEVER -> false;
case ALWAYS -> true;
case MODDED -> modded;
};
}
/// Applies default isolation to a new instance before the version metadata is saved.
public void applyDefaultIsolationSettingForNewInstance(String id, boolean modded) {
if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(id)) {
return;
}
GameSettings.Instance setting = getInstanceGameSettings(id);
if (setting == null) {
setting = initInstanceGameSettings(id, new GameSettings.Instance());
}
if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveGameSettings(id);
}
}
public GameSettings.Effective getEffectiveGameSettings(String id) {
GameSettings.Instance instance = getInstanceGameSettings(id);
return GameSettings.resolve(getParentGameSettings(instance), instance);
public VersionSetting getVersionSetting(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs == null || vs.isUsesGlobal()) {
profile.getGlobal().setUsesGlobal(true);
return profile.getGlobal();
} else
return vs;
}
public Optional<Path> getVersionIconFile(String id) {
@@ -601,19 +298,19 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public Image getVersionIconImage(@Nullable String id) {
public Image getVersionIconImage(String id) {
if (id == null || !isLoaded())
return VersionIconType.DEFAULT.getIcon();
GameSettings.Instance setting = getInstanceGameSettings(id);
VersionIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
VersionSetting vs = getLocalVersionSettingOrCreate(id);
VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
if (iconType == VersionIconType.DEFAULT) {
Version version = getVersion(id).resolve(this);
Optional<Path> iconFile = getVersionIconFile(id);
if (iconFile.isPresent()) {
try {
return FXUtils.loadImage(iconFile.get(), 64, 64, true, true);
return FXUtils.loadImage(iconFile.get());
} catch (Exception e) {
LOG.warning("Failed to load version icon of " + id, e);
}
@@ -656,73 +353,45 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public void saveGameSettings(String id) {
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id))
public void saveVersionSetting(String id) {
if (!localVersionSettings.containsKey(id))
return;
GameSettings.Instance setting = instanceGameSettings.get(id);
if (setting == null) {
return;
}
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Path file = getLocalVersionSettingFile(id).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));
FileSaver.save(file, GSON.toJson(localVersionSettings.get(id)));
}
/// Saves instance-specific game settings synchronously.
///
/// @param id the instance ID
/// @throws IOException if saving the file fails
private void saveGameSettingsSync(String id) throws IOException {
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id)) {
return;
/**
* Make version use self version settings instead of the global one.
*
* @param id the version id.
* @return specialized version setting, null if given version does not exist.
*/
public VersionSetting specializeVersionSetting(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs == null)
vs = createLocalVersionSetting(id);
if (vs == null)
return null;
if (vs.isUsesGlobal()) {
vs.setUsesGlobal(false);
}
GameSettings.Instance setting = instanceGameSettings.get(id);
if (setting == null) {
return;
}
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
return vs;
}
/// 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 InstanceGameSettingsLoadResult(
@Nullable GameSettings.Instance setting,
boolean allowSave) {
public void globalizeVersionSetting(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs != null)
vs.setUsesGlobal(true);
}
public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveGameSettings(version);
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(version));
@Nullable Integer maxMemory;
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
VersionSetting vs = getVersionSetting(version);
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setGameDir(gameDir)
@@ -730,13 +399,17 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setVersionType(Metadata.TITLE)
.setVersionName(version)
.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)))
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
vs.getMaxMemory() * 1024L * 1024L,
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
vs.isAutoMemory()
) / 1024 / 1024))
.setMinMemory(vs.getMinMemory())
.setMetaspace(Lang.toIntOrNull(vs.getPermSize()))
.setEnvironmentVariables(
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
Lang.mapOf(StringUtils.tokenize(vs.getEnvironmentVariables())
.stream()
.map(it -> {
int idx = it.indexOf('=');
@@ -747,30 +420,26 @@ public final class HMCLGameRepository extends DefaultGameRepository {
)
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
.setFullscreen(vs.isFullscreen())
.setWrapper(vs.getWrapper())
.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))
.setUseNativeGLFW(vs.getInheritable(GameSettings::useNativeGLFWProperty))
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
.setPreLaunchCommand(vs.getPreLaunchCommand())
.setPostExitCommand(vs.getPostExitCommand())
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
.setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs())
.setNativesDirType(vs.getNativesDirType())
.setNativesDir(vs.getNativesDir())
.setProcessPriority(vs.getProcessPriority())
.setRenderer(vs.getRenderer())
.setEnableDebugLogOutput(vs.isEnableDebugLogOutput())
.setUseNativeGLFW(vs.isUseNativeGLFW())
.setUseNativeOpenAL(vs.isUseNativeOpenAL())
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
if (quickPlayOption != null) {
builder.setQuickPlayOption(quickPlayOption);
if (StringUtils.isNotBlank(vs.getServerIp())) {
builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp()));
}
Path json = getModpackConfiguration(version);
@@ -785,7 +454,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder;
@@ -826,6 +495,10 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
private static final Gson GSON = new GsonBuilder()
.setPrettyPrinting()
.create();
private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}";
@@ -841,7 +514,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
return false;
return FileUtils.isNameValidForJar(id);
return FileUtils.isNameValid(id);
}
/**
@@ -861,37 +534,41 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public static long getAutoAllocatedMemory(long available) {
long usable = available - 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
if (usable <= 0) {
return available;
}
public static long getAllocatedMemory(long minimum, long available, boolean auto) {
if (auto) {
available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
if (available <= 0) {
return minimum;
}
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
final long suggested;
if (usable <= threshold)
suggested = (long) (usable * 0.8);
else
suggested = Math.min(
(long) (threshold * 0.8 + (usable - threshold) * 0.2),
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
final long suggested = Math.min(available <= threshold
? (long) (available * 0.8)
: (long) (threshold * 0.8 + (available - threshold) * 0.2),
16L * 1024 * 1024 * 1024);
return suggested;
return Math.max(minimum, suggested);
} else {
return minimum;
}
}
public static ProxyOption getProxyOption() {
return switch (settings().proxyTypeProperty().get()) {
case SYSTEM -> ProxyOption.Default.INSTANCE;
if (!config().hasProxy() || config().getProxyType() == null) {
return ProxyOption.Default.INSTANCE;
}
return switch (config().getProxyType()) {
case DIRECT -> ProxyOption.Direct.INSTANCE;
case HTTP, SOCKS -> {
String proxyHost = settings().proxyHostProperty().get();
int proxyPort = settings().proxyPortProperty().get();
String proxyHost = config().getProxyHost();
int proxyPort = config().getProxyPort();
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
yield ProxyOption.Default.INSTANCE;
}
String proxyUser = settings().proxyUserProperty().get();
String proxyPass = settings().proxyPasswordProperty().get();
String proxyUser = config().getProxyUser();
String proxyPass = config().getProxyPass();
if (StringUtils.isBlank(proxyUser)) {
proxyUser = null;
@@ -900,12 +577,13 @@ public final class HMCLGameRepository extends DefaultGameRepository {
proxyPass = "";
}
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
if (config().getProxyType() == Proxy.Type.HTTP) {
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
} else {
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
}
}
default -> ProxyOption.Default.INSTANCE;
};
}
}
@@ -20,10 +20,11 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
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;
@@ -44,9 +45,9 @@ public final class HMCLModpackInstallTask extends Task<Void> {
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final List<Task<?>> dependents = new ArrayList<>(4);
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, String name) {
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.name = name;
this.modpack = modpack;
@@ -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,10 +20,11 @@ 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;
@@ -55,7 +56,9 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(repository, zipFile, modpack, name));
Profile profile = repository.getProfile();
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
}
@Override
@@ -77,7 +80,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, name);
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
}
}
@@ -31,13 +31,10 @@ 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.DownloadProviders;
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;
@@ -52,7 +49,6 @@ import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.platform.*;
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;
@@ -61,7 +57,6 @@ 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;
@@ -70,36 +65,33 @@ 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 HMCLGameRepository repository;
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(HMCLGameRepository repository, Account account, String selectedVersion) {
this.repository = Objects.requireNonNull(repository);
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
this.profile = Objects.requireNonNull(profile);
this.account = Objects.requireNonNull(account);
this.selectedVersion = Objects.requireNonNull(selectedVersion);
this.setting = repository.getEffectiveGameSettings(selectedVersion);
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.setting = profile.getVersionSetting(selectedVersion);
this.launcherVisibility = setting.getLauncherVisibility();
this.showLogs = setting.isShowLogs();
this.launchingStepsPane.setTitle(i18n("version.launch"));
}
@@ -148,7 +140,8 @@ public final class LauncherHelper {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
DefaultDependencyManager dependencyManager = repository.getDependency();
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);
@@ -158,11 +151,11 @@ public final class LauncherHelper {
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
TaskExecutor executor = checkGameState(repository, setting, version.get())
TaskExecutor executor = checkGameState(profile, setting, version.get())
.thenComposeAsync(java -> {
javaVersionRef.set(Objects.requireNonNull(java));
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
if (setting.getInheritable(GameSettings::notCheckGameProperty))
if (setting.isNotCheckGame())
return null;
return Task.allOf(
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
@@ -177,66 +170,43 @@ public final class LauncherHelper {
}
}),
Task.composeAsync(() -> {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) 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 = 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;
}
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT);
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.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(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|| !NativePatcher.needPatchMemoryUtil(version.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(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
if (quickPlayOption != null) {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
@@ -387,8 +357,8 @@ public final class LauncherHelper {
executor.start();
}
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, Version version) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
private static Task<JavaRuntime> checkGameState(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(() -> {
@@ -399,10 +369,9 @@ public final class LauncherHelper {
}
});
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);
@@ -415,9 +384,9 @@ 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) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
@@ -455,7 +424,7 @@ public final class LauncherHelper {
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
downloadJava(targetJavaVersion, repository)
downloadJava(targetJavaVersion, profile)
.whenCompleteAsync((downloadedJava, exception) -> {
if (exception == null) {
future.complete(downloadedJava);
@@ -532,7 +501,7 @@ public final class LauncherHelper {
gameJavaVersion = null;
if (gameJavaVersion != null) {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> {
if (throwable == null) {
setting.setJavaAutoSelected();
future.complete(downloadedJava);
@@ -545,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 {
@@ -582,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"));
@@ -629,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;
@@ -641,7 +610,7 @@ 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));
}
@@ -690,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) {
@@ -761,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:
@@ -783,16 +749,6 @@ 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 [JavaProcess], one [HMCLProcessListener].
@@ -899,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();
}
});
@@ -975,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();
@@ -18,8 +18,9 @@
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.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;
@@ -31,12 +32,12 @@ import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepository {
public abstract class LocalizedRemoteModRepository implements RemoteModRepository {
private static final int CONTAIN_CHINESE_WEIGHT = 10;
private static final int INITIAL_CAPACITY = 16;
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
protected abstract RemoteModRepository getBackedRemoteModRepository();
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@@ -46,47 +47,38 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
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;
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = null;
List<RemoteAddon> remoteAddons = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
remoteAddons = searchResult.getUnsortedResults().toList();
if (!remoteAddons.isEmpty()) {
break;
}
}
for (RemoteAddon remoteAddon : remoteAddons) {
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;
}
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteAddon.slug());
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++] = remoteAddon;
searchResultArray[chineseIndex++] = remoteMod;
} else {
searchResultArray[englishIndex--] = remoteAddon;
searchResultArray[englishIndex--] = remoteMod;
}
}
totalPages = searchResult.getTotalPages();
@@ -94,7 +86,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
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.slug());
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);
}
@@ -118,22 +110,22 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file);
}
@Override
public RemoteAddon getModById(DownloadProvider downloadProvider, String id) throws IOException {
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
}
@Override
public RemoteAddon.File getModFile(String modId, String fileId) throws IOException {
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
return getBackedRemoteModRepository().getModFile(modId, fileId);
}
@Override
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
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,8 +17,8 @@
*/
package org.jackhuang.hmcl.game;
import kala.encdet.EncodingDetector;
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;
@@ -91,7 +91,7 @@ public final class LogExporter {
for (Path file : stream) {
if (Files.isRegularFile(file)) {
if (logMatcher == null || logMatcher.matches(file)) {
try (BufferedReader reader = EncodingDetector.MODERN_WEB.newBufferedReader(file)) {
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
} catch (IOException e) {
LOG.warning("Failed to read log file: " + file, e);
@@ -17,6 +17,7 @@
*/
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;
@@ -26,20 +27,18 @@ 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
@@ -20,33 +20,28 @@ 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.GameSettings;
import org.jackhuang.hmcl.setting.GameWindowType;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.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.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
@@ -56,18 +51,13 @@ 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.*;
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() {
}
@@ -156,16 +146,16 @@ public final class ModpackHelper {
}
}
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, String name, Modpack modpack) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
profile.getRepository().markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
HMCLGameRepository repository = profile.getRepository();
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
VersionSetting vs = repository.specializeVersionSetting(name);
repository.undoMark(name);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
if (vs != null)
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -175,41 +165,39 @@ public final class ModpackHelper {
}
};
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name)
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, String name, Modpack modpack, String iconUrl) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
profile.getRepository().markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
HMCLGameRepository repository = profile.getRepository();
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
VersionSetting vs = repository.specializeVersionSetting(name);
repository.undoMark(name);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
if (vs != null)
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -220,149 +208,114 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
.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, name, iconUrl)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
else
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
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, String name, 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:
return new ModpackUpdateTask(repository, name, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name))
.thenComposeAsync(repository.refreshVersionsAsync())
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
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, String name, ModpackConfiguration<?> configuration) 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(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(repository.refreshVersionsAsync());
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(), name, zipFile, modpack)
.thenComposeAsync(repository.refreshVersionsAsync());
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, String version) {
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
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, String version) {
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
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, String version) {
private static Task<Void> createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Effective effective = repository.getEffectiveGameSettings(version);
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
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;
@@ -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;
@@ -45,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;
@@ -68,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) {
@@ -91,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");
}
@@ -99,14 +107,14 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(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());
}
}
}
@@ -119,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();
}
@@ -150,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;
}
@@ -168,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();
@@ -176,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);
@@ -187,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();
}
@@ -226,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);
@@ -310,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,11 +17,6 @@
*/
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;
@@ -30,20 +25,14 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.game.JavaVersionConstraint;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.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.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
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;
@@ -51,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;
@@ -71,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) {
@@ -129,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;
}
@@ -197,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;
@@ -220,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;
});
@@ -276,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())) {
@@ -287,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)) {
@@ -365,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));
@@ -373,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");
@@ -436,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(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
.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(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
@@ -459,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) {
}
}
@@ -469,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);
}
@@ -487,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(),
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 = Lang.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,27 +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.settings;
import static org.jackhuang.hmcl.setting.SettingsManager.getAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.setting.SettingsManager.getUserAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
import static org.jackhuang.hmcl.setting.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;
@@ -119,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");
@@ -127,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;
}
}
}
}
@@ -348,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) {
@@ -367,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;
}
}
@@ -380,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()) {
@@ -403,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;
@@ -425,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(() -> {
@@ -472,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;
});
}
@@ -490,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);
}
@@ -568,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();
@@ -1,42 +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 org.jetbrains.annotations.NotNullByDefault;
/// Selects the source used to render the launcher background.
@NotNullByDefault
public enum BackgroundType {
/// Uses the selected theme background when available, otherwise a built-in fallback wallpaper.
DEFAULT,
/// Uses one built-in wallpaper selected by launcher settings.
BUILTIN,
/// Uses a user-selected local image file or a random image from a selected directory.
CUSTOM,
/// Uses an image loaded from a user-provided URL.
NETWORK,
/// Uses the configured paint value as a flat background.
PAINT,
/// Uses the current theme color scheme surface container as a flat background.
THEME_COLOR;
}
@@ -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);
}
}
@@ -21,6 +21,7 @@ import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
@@ -32,70 +33,82 @@ 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.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {
}
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 MOJANG_PROVIDER;
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
private static final DownloadProvider DEFAULT_PROVIDER;
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
public static final Map<String, DownloadProvider> AUTO_PROVIDERS;
static {
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
MOJANG_PROVIDER = new MojangDownloadProvider();
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot);
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);
}
/// 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);
InvalidationListener onChangeDownloadSource = observable -> {
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
settings().versionListSourceProperty().get(),
settings().fileDownloadSourceProperty().get()));
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);
}
};
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
config().downloadTypeProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
}
/// Creates a download provider with independent version-list and file download preferences.
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
return new AutoDownloadProvider(
getCandidates(versionListSource),
getCandidates(fileDownloadSource));
}
/// Returns provider candidates ordered by the given source preference.
private static List<DownloadProvider> getCandidates(DownloadSource source) {
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
return switch (normalized) {
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
case OFFICIAL -> List.of(MOJANG_PROVIDER);
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
};
}
/**
* Get current primary preferred download provider
*/
@@ -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,15 +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.game;
package org.jackhuang.hmcl.setting;
/// @author Glavo
public enum QuickPlayType {
NONE,
MULTIPLAYER,
SINGLEPLAYER,
REALMS,
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,506 +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.*;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.game.HMCLGameRepository;
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.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Manages the merged runtime view of local and user game directories.
///
/// 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 ID projected from the selected repository.
private static final ReadOnlyStringWrapper selectedInstance = new ReadOnlyStringWrapper(GameDirectoryManager.class, "selectedInstance");
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
private static final ChangeListener<String> selectedRepositoryInstanceListener =
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
/// 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);
}
HMCLGameRepository repository = getOrCreateRepository(newValue);
selectedRepository.set(repository);
selectedInstance.set(repository.getSelectedInstance());
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
repository.refreshVersionsAsync().start();
});
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
runInFX(() -> {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository != null && repository == event.getSource()) {
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners)
listener.accept(repository);
}
});
});
}
/// Creates the built-in game directories only when no game directory exists.
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);
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.
public static ReadOnlyStringProperty selectedInstanceProperty() {
return selectedInstance.getReadOnlyProperty();
}
/// Returns the selected instance ID for the selected repository.
public static @Nullable String getSelectedInstance() {
return getSelectedRepository().getSelectedInstance();
}
/// Sets the selected instance ID for the selected repository.
public static void setSelectedInstance(@Nullable String 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,685 +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.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, String> selectedInstance = FXCollections.observableHashMap();
/// Returns selected instance IDs keyed by game directory ID.
///
/// The map stores persisted selected instance values by game directory ID.
public ObservableMap<GameDirectoryID, String> getSelectedInstance() {
return selectedInstance;
}
/// Returns the selected instance ID for the given game directory ID.
///
/// The value is loaded by the game repository for the matching game directory.
public @Nullable String getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null;
}
/// Sets the selected instance ID for the given game directory ID.
///
/// Blank values remove the persisted selected instance entry.
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable String selectedInstance) {
if (gameDirectoryId == null) {
return;
}
if (StringUtils.isBlank(selectedInstance)) {
this.selectedInstance.remove(gameDirectoryId);
} else {
this.selectedInstance.put(gameDirectoryId, selectedInstance);
}
}
// 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 VersionIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
VersionIconType.DEFAULT,
VersionIconType.GRASS,
VersionIconType.CHEST,
VersionIconType.CHICKEN,
VersionIconType.COMMAND,
VersionIconType.OPTIFINE,
VersionIconType.CRAFT_TABLE,
VersionIconType.FABRIC,
VersionIconType.FORGE,
VersionIconType.NEO_FORGE,
VersionIconType.FURNACE,
VersionIconType.QUILT,
VersionIconType.APRIL_FOOLS,
VersionIconType.CLEANROOM,
VersionIconType.LEGACY_FABRIC
};
/// Legacy `JavaVersionType` ordinal order used by old game settings.
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,
String instanceId,
@Nullable GameSettingsPresetID parent) {
Path instanceRoot = repository.getVersionRoot(instanceId);
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
if (!Files.exists(file)) {
return null;
}
Path receiptLocation = repository.getInstanceStateDirectory(instanceId)
.resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME);
if (MigrationReceipt.matches(receiptLocation, file)) {
LOG.info("Skipping already migrated legacy version setting " + file);
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,
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.useNativeGLFWProperty().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 VersionIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
if (primitive == null) {
return VersionIconType.DEFAULT;
}
try {
if (primitive.isNumber()) {
int index = primitive.getAsInt();
return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length
? LEGACY_VERSION_ICON_TYPES[index]
: VersionIconType.DEFAULT;
}
String value = primitive.getAsString();
for (VersionIconType iconType : LEGACY_VERSION_ICON_TYPES) {
if (iconType.name().equalsIgnoreCase(value)) {
return iconType;
}
}
} catch (RuntimeException ignored) {
}
return VersionIconType.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();
@@ -162,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,7 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.ui.FXUtils;
public enum VersionIconType {
@@ -45,7 +45,7 @@ public enum VersionIconType {
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
return switch (modLoaderType) {
case FORGE -> VersionIconType.FORGE;
case NEO_FORGE -> VersionIconType.NEO_FORGE;
case NEO_FORGED -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.FABRIC;
case QUILT -> VersionIconType.QUILT;
case LITE_LOADER -> VersionIconType.CHICKEN;
@@ -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> {
}
@@ -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.property;
import javafx.beans.property.Property;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.util.gson.RawPreservingProperty;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.UnknownNullability;
/// @author Glavo
@NotNullByDefault
public interface SettingProperty<T extends @UnknownNullability Object> extends Property<T>, RawPreservingProperty<T> {
@Override
GameSettings getBean();
T defaultValue();
}
@@ -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.property;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.UnknownNullability;
/// Stores a direct setting value that can be inherited by removing its name from `overrideProperties`.
///
/// @author Glavo
@NotNullByDefault
public final class SimpleInheritableProperty<T extends @UnknownNullability Object>
extends SimpleSettingProperty<T>
implements InheritableProperty<T> {
/// Creates a property with the given owner, serialized name, and direct default value.
public SimpleInheritableProperty(GameSettings bean, String name, T defaultValue) {
super(bean, name, defaultValue);
}
}
@@ -1,67 +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 com.google.gson.JsonElement;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnknownNullability;
/// @author Glavo
@NotNullByDefault
public class SimpleSettingProperty<T extends @UnknownNullability Object> extends SimpleObjectProperty<T>
implements SettingProperty<T> {
private @Nullable JsonElement rawJson;
private final T defaultValue;
public SimpleSettingProperty(GameSettings bean,
String name,
T defaultValue) {
super(bean, name, defaultValue);
this.defaultValue = defaultValue;
}
@Override
public GameSettings getBean() {
return (GameSettings) super.getBean();
}
@Override
public T defaultValue() {
return defaultValue;
}
@Override
public @Nullable JsonElement getRawJson() {
return rawJson;
}
@Override
public void setRawJson(@Nullable JsonElement rawJson) {
this.rawJson = rawJson;
}
/// Clears preserved raw JSON after the property value has been changed.
@Override
protected void invalidated() {
this.rawJson = null;
}
}
@@ -27,15 +27,15 @@ import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
@@ -45,8 +45,6 @@ import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaBundle {
private final Path root;
@@ -68,20 +66,14 @@ public final class TerracottaBundle {
.thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
super.withResult(success);
delegate.withResult(success);
}
@Override
public void reset() throws IOException {
delegate.reset();
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
context.checkCancellation();
@@ -121,7 +113,7 @@ public final class TerracottaBundle {
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
}
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (
InputStream is = tree.getInputStream(archive);
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
@@ -130,8 +122,8 @@ public final class TerracottaBundle {
}
String hash = HexFormat.of().formatHex(digest.digest());
if (!check.checksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.algorithm(), check.checksum(), hash);
if (!check.getChecksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
}
switch (OperatingSystem.CURRENT_OS) {
@@ -164,7 +156,7 @@ public final class TerracottaBundle {
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
LOG.warning("Cannot determine whether legacy versions exist.", e);
Logger.LOG.warning("Cannot determine whether legacy versions exist.", e);
}
return AbstractTerracottaProvider.Status.NOT_EXIST;
}
@@ -180,7 +172,7 @@ public final class TerracottaBundle {
return false;
}
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
int n;
while ((n = is.read(buffer)) >= 0) {
@@ -190,7 +182,7 @@ public final class TerracottaBundle {
}
}
}
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.checksum())) {
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
return false;
}
}
@@ -307,7 +307,7 @@ public final class TerracottaManager {
private static String getPlayerName() {
Account account = Accounts.getSelectedAccount();
return account != null ? account.getProfileName() : i18n("terracotta.player_anonymous");
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
}
public static TerracottaState.HostScanning setScanning() {
@@ -59,7 +59,7 @@ public final class MacOSProvider extends AbstractTerracottaProvider {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_USER_HOME, "terracotta-pkg")
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer));
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
@@ -1,30 +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.theme;
import org.jetbrains.annotations.NotNullByDefault;
/// Controls how the launcher window is shown while the selected background is loading.
@NotNullByDefault
public enum BackgroundLoadPolicy {
/// Loads the selected background before the launcher background property is first exposed to the UI.
WAIT_FOR_BACKGROUND,
/// Shows the fallback background immediately, then replaces it when the selected background loads.
SHOW_FALLBACK_WHILE_LOADING
}
@@ -1,102 +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.theme;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/// Identifies a built-in launcher background wallpaper.
@NotNullByDefault
public enum BuiltinBackground {
/// Built-in launcher wallpaper from 2021-08-26.
WALLPAPER_2021_08_26("2021-08-26", "#3F6AA2"),
/// Built-in launcher wallpaper from 2016-02-25.
WALLPAPER_2016_02_25("2016-02-25", "#354264"),
/// Built-in launcher wallpaper from 2015-06-22.
WALLPAPER_2015_06_22("2015-06-22", "#FBC578"),
;
/// Built-in wallpaper used when a wallpaper ID is missing or unsupported.
public static final BuiltinBackground FALLBACK = WALLPAPER_2021_08_26;
/// Built-in wallpapers keyed by serialized wallpaper ID.
public static final @Unmodifiable Map<String, BuiltinBackground> BUILTIN_BACKGROUNDS;
/// Serialized IDs of launcher built-in wallpapers in display order.
public static final @Unmodifiable List<String> BUILTIN_BACKGROUND_IDS;
static {
var backgrounds = new LinkedHashMap<String, BuiltinBackground>();
for (BuiltinBackground value : values()) {
backgrounds.put(value.id(), value);
}
BUILTIN_BACKGROUNDS = Collections.unmodifiableMap(backgrounds);
BUILTIN_BACKGROUND_IDS = List.copyOf(backgrounds.keySet());
}
/// Serialized wallpaper ID.
private final String id;
/// Precomputed MonetFX seed color extracted from this wallpaper.
private final ThemeColor themeColor;
/// Creates a built-in wallpaper entry.
///
/// @param id the serialized wallpaper ID
/// @param themeColor the precomputed MonetFX seed color
BuiltinBackground(String id, String themeColor) {
this.id = id;
this.themeColor = Objects.requireNonNull(ThemeColor.of(themeColor));
}
/// Returns the serialized wallpaper ID.
public String id() {
return id;
}
/// Returns the precomputed MonetFX seed color extracted from this wallpaper.
public ThemeColor themeColor() {
return themeColor;
}
/// Returns the built-in wallpaper for a serialized ID, or `null` when the ID is not supported.
///
/// @param id the serialized wallpaper ID
/// @return the matching built-in wallpaper, or `null`
public static @Nullable BuiltinBackground fromId(@Nullable String id) {
return BUILTIN_BACKGROUNDS.get(id);
}
/// Returns the built-in wallpaper for a serialized ID, or the fallback wallpaper when the ID is not supported.
///
/// @param id the serialized wallpaper ID
/// @return the matching built-in wallpaper, or the fallback wallpaper
public static BuiltinBackground fromIdOrFallback(@Nullable String id) {
BuiltinBackground background = fromId(id);
return background != null ? background : FALLBACK;
}
}
@@ -1,39 +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.theme;
import javafx.scene.layout.Background;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
/// A launcher background and the opacity applied by its rendering node.
///
/// @param background the JavaFX background rendered by the launcher
/// @param opacity the node opacity in the range `[0, 1]`
@NotNullByDefault
public record LauncherBackground(Background background, double opacity) {
/// Creates a launcher background value.
public LauncherBackground {
Objects.requireNonNull(background);
if (!Double.isFinite(opacity) || opacity < 0.0 || opacity > 1.0) {
throw new IllegalArgumentException("Launcher background opacity must be between 0 and 1: " + opacity);
}
}
}
@@ -1,30 +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.theme;
import org.jetbrains.annotations.NotNullByDefault;
/// Controls whether a remote launcher background image may be stored in the local cache.
@NotNullByDefault
public enum NetworkBackgroundImageCachePolicy {
/// Allows HMCL to cache the image file locally.
ENABLED,
/// Forces HMCL to load the image directly without storing it in the file cache.
DISABLED
}
@@ -1,53 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.theme;
import org.glavo.monetfx.*;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.*;
/// Concrete launcher theme values resolved for MonetFX.
///
/// @param primaryColorSeed Color seed used to generate the MonetFX color scheme.
/// @param brightness Brightness used by the generated color scheme.
/// @param colorStyle MonetFX color style used by the generated color scheme.
/// @param contrast MonetFX contrast level used by the generated color scheme.
@NotNullByDefault
public record ResolvedTheme(ThemeColor primaryColorSeed,
Brightness brightness,
ColorStyle colorStyle,
Contrast contrast) {
/// Default launcher theme used when no custom theme values are configured.
public static final ResolvedTheme DEFAULT = new ResolvedTheme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT);
/// Converts these resolved values to a MonetFX color scheme.
///
/// @return the generated color scheme
public ColorScheme toColorScheme() {
return ColorScheme.newBuilder()
.setPrimaryColorSeed(primaryColorSeed.color())
.setColorStyle(colorStyle)
.setBrightness(brightness)
.setSpecVersion(ColorSpecVersion.SPEC_2025)
.setContrast(contrast)
.build();
}
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2025 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
@@ -17,228 +17,78 @@
*/
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
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.Unmodifiable;
import org.glavo.monetfx.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class Theme {
/// One selectable theme inside a theme pack.
///
/// The theme object's top-level appearance fields provide default values. Each
/// matching override is applied in array order to produce the resolved appearance.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
@NotNullByDefault
public record Theme(
@Nullable String id,
@Nullable LocalizedText name,
@Unmodifiable List<ThemePackAuthor> authors,
@Nullable LocalizedText description,
@Nullable String icon,
ThemeAppearance appearance,
@Unmodifiable List<ThemeOverride> overrides) {
public static final Theme DEFAULT = new Theme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT);
/// Creates a theme.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
public Theme {
if (id != null) {
id = ThemePackManifest.requireThemeId(id);
}
if (name != null && name.mayBeEmpty()) {
throw new IllegalArgumentException("Theme name is empty: " + name);
}
if (icon != null) {
icon = ThemePackAsset.normalizeEntryName(icon);
}
authors = List.copyOf(authors);
Objects.requireNonNull(appearance);
overrides = List.copyOf(overrides);
private final ThemeColor primaryColorSeed;
private final Brightness brightness;
private final ColorStyle colorStyle;
private final Contrast contrast;
public Theme(ThemeColor primaryColorSeed,
Brightness brightness,
ColorStyle colorStyle,
Contrast contrast
) {
this.primaryColorSeed = primaryColorSeed;
this.brightness = brightness;
this.colorStyle = colorStyle;
this.contrast = contrast;
}
/// Parses a theme from JSON.
///
/// @param object the theme JSON object
/// @param requireIdentity whether the theme must declare an explicit ID and name
/// @return the parsed theme
/// @throws JsonParseException if required identity fields are missing or malformed
static Theme fromJson(JsonObject object, boolean requireIdentity) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String id = JsonUtils.getString(object, "id");
if (id != null) {
try {
id = ThemePackManifest.requireThemeId(id);
} catch (IllegalArgumentException e) {
if (requireIdentity) {
throw new JsonParseException(e);
}
LOG.warning("Ignored invalid theme id: " + id, e);
id = null;
}
}
if (id == null && requireIdentity) {
throw new JsonParseException("Theme is missing the id");
}
@Nullable LocalizedText name;
try {
name = LocalizedText.fromJson(object.get("name"));
if (name != null && name.mayBeEmpty()) {
throw new JsonParseException("Theme name is empty: " + name);
}
} catch (JsonParseException | IllegalArgumentException e) {
if (requireIdentity) {
throw e;
}
LOG.warning("Ignored invalid theme name", e);
name = null;
}
if (name == null && requireIdentity) {
throw new JsonParseException("Theme is missing required localized text field: " + "name");
}
List<ThemePackAuthor> authors;
try {
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
} catch (Exception e) {
LOG.warning("Failed to parse authors", e);
authors = List.of();
}
@Nullable LocalizedText description;
try {
description = LocalizedText.fromJson(object.get("description"));
if (description != null) {
description = ThemePackManifest.requireLocalizedText(description, "description");
}
} catch (Exception e) {
LOG.warning("Failed to parse description", e);
description = null;
}
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
JsonElement overridesJson = object.get("overrides");
List<ThemeOverride> overrides;
if (overridesJson == null || overridesJson.isJsonNull()) {
overrides = List.of();
} else if (overridesJson instanceof JsonArray array) {
overrides = new ArrayList<>(array.size());
for (JsonElement overrideJson : array) {
try {
ThemeOverride override = ThemeOverride.fromJson(overrideJson);
if (override != null)
overrides.add(override);
} catch (Exception e) {
LOG.warning("Invalid theme override", e);
}
}
} else {
LOG.warning("Invalid theme overrides: expected an array, got " + overridesJson);
overrides = List.of();
}
@Nullable String icon = JsonUtils.getString(object, "icon");
if (icon != null) {
try {
icon = ThemePackAsset.normalizeEntryName(icon);
} catch (IllegalArgumentException e) {
LOG.warning("Ignored invalid theme icon: " + icon, e);
icon = null;
}
}
return new Theme(id, name, authors, description,
icon,
appearance, overrides);
public ColorScheme toColorScheme() {
return ColorScheme.newBuilder()
.setPrimaryColorSeed(primaryColorSeed.color())
.setColorStyle(colorStyle)
.setBrightness(brightness)
.setSpecVersion(ColorSpecVersion.SPEC_2025)
.setContrast(contrast)
.build();
}
/// Returns the theme display name in the current locale.
///
/// @return the localized display name, or `null` when this theme has no matching display name
public @Nullable String displayName() {
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
public ThemeColor primaryColorSeed() {
return primaryColorSeed;
}
/// Returns the theme description in the current locale.
///
/// @return the localized description, or `null` when this theme has no matching description
public @Nullable String displayDescription() {
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
public Brightness brightness() {
return brightness;
}
/// Resolves this theme against a context by applying matching overrides.
///
/// @param context the resolution context
/// @return the resolved appearance
public ThemeAppearance resolve(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeAppearance resolved = appearance;
for (ThemeOverride override : overrides) {
if (override.matches(context)) {
resolved = resolved.merge(override.appearance());
}
}
return resolved;
public ColorStyle colorStyle() {
return colorStyle;
}
/// Converts this theme to its JSON representation.
///
/// @return the JSON object representing this theme
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
if (id != null) {
object.addProperty("id", id);
}
if (name != null) {
object.add("name", JsonUtils.GSON.toJsonTree(name, LocalizedText.class));
}
object.add("authors", ThemePackAuthor.toJson(authors));
if (description != null) {
object.add("description", JsonUtils.GSON.toJsonTree(description, LocalizedText.class));
}
if (icon != null) {
object.addProperty("icon", icon);
}
appearance.addToJsonObject(object);
public Contrast contrast() {
return contrast;
}
if (!overrides.isEmpty()) {
JsonArray array = new JsonArray();
for (ThemeOverride override : overrides) {
array.add(override.toJsonObject());
}
object.add("overrides", array);
}
return object;
@Override
public boolean equals(Object obj) {
return obj == this || obj instanceof Theme that
&& this.primaryColorSeed.color().equals(that.primaryColorSeed.color())
&& this.brightness.equals(that.brightness)
&& this.colorStyle.equals(that.colorStyle)
&& this.contrast.equals(that.contrast);
}
@Override
public int hashCode() {
return Objects.hash(primaryColorSeed, brightness, colorStyle, contrast);
}
@Override
public String toString() {
return "Theme[" +
"primaryColorSeed=" + primaryColorSeed + ", " +
"brightness=" + brightness + ", " +
"colorStyle=" + colorStyle + ", " +
"contrast=" + contrast + ']';
}
}
@@ -1,389 +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.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorStyle;
import org.glavo.monetfx.Contrast;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Appearance values contributed by a theme or override.
///
/// All fields are optional so the same type can represent both a default appearance
/// and a conditional patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeAppearance(
@Nullable ThemeColorSource color,
@Nullable Brightness brightness,
@Nullable ColorStyle colorStyle,
@Nullable Contrast contrast,
@Nullable ThemeBackgroundSettings background,
@Nullable ThemeTitleBar titleBar,
@Nullable Boolean windowTransparent) {
/// JSON member name for the color seed.
static final String FIELD_COLOR = "color";
/// JSON member name for the controlled launcher brightness.
static final String FIELD_BRIGHTNESS = "brightness";
/// JSON member name for the MonetFX color style.
static final String FIELD_COLOR_STYLE = "colorStyle";
/// JSON member name for the MonetFX contrast level.
static final String FIELD_CONTRAST = "contrast";
/// JSON member name for background settings.
static final String FIELD_BACKGROUND = "background";
/// JSON member name for title-bar settings.
static final String FIELD_TITLE_BAR = "titleBar";
/// JSON member name for window transparency.
static final String FIELD_WINDOW_TRANSPARENT = "windowTransparent";
/// Creates an appearance patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
public ThemeAppearance {
if (background != null && background.isEmpty()) {
background = null;
}
if (titleBar != null && titleBar.isEmpty()) {
titleBar = null;
}
}
/// Parses known appearance fields from a JSON object.
///
/// @param object the JSON object containing appearance fields
/// @return the parsed appearance patch
static ThemeAppearance fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeAppearance(
readColor(object),
readBrightness(object),
readColorStyle(object),
readContrast(object),
readBackground(object),
readTitleBar(object),
readWindowTransparent(object));
}
/// Returns whether this appearance contains no concrete fields.
///
/// @return `true` when every appearance field is inherited
public boolean isEmpty() {
return color == null
&& brightness == null
&& colorStyle == null
&& contrast == null
&& background == null
&& titleBar == null
&& windowTransparent == null;
}
/// Adds this appearance patch's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object) {
Objects.requireNonNull(object);
if (color != null) {
object.add(FIELD_COLOR, color.toJsonElement());
}
if (brightness != null) {
object.addProperty(FIELD_BRIGHTNESS, toJsonBrightness(brightness));
}
if (colorStyle != null) {
object.addProperty(FIELD_COLOR_STYLE, colorStyle.name().toLowerCase(Locale.ROOT));
}
if (contrast != null && !contrast.equals(Contrast.DEFAULT)) {
addContrast(object, contrast);
}
if (background != null) {
object.add(FIELD_BACKGROUND, background.toJsonObject());
}
if (titleBar != null) {
object.add(FIELD_TITLE_BAR, titleBar.toJsonObject());
}
if (windowTransparent != null) {
object.addProperty(FIELD_WINDOW_TRANSPARENT, windowTransparent);
}
}
/// Converts this appearance patch to its JSON representation.
///
/// @return the JSON object representing this appearance patch
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Applies the given patch over this appearance.
///
/// @param patch the patch to apply
/// @return the merged appearance
public ThemeAppearance merge(ThemeAppearance patch) {
Objects.requireNonNull(patch);
return new ThemeAppearance(
patch.color != null ? patch.color : color,
patch.brightness != null ? patch.brightness : brightness,
patch.colorStyle != null ? patch.colorStyle : colorStyle,
patch.contrast != null ? patch.contrast : contrast,
background != null && patch.background != null
? background.merge(patch.background)
: patch.background != null ? patch.background : background,
titleBar != null && patch.titleBar != null
? titleBar.merge(patch.titleBar)
: patch.titleBar != null ? patch.titleBar : titleBar,
patch.windowTransparent != null ? patch.windowTransparent : windowTransparent);
}
/// Converts this appearance to concrete launcher theme values.
///
/// @param context the resolution context that provides fallback brightness when this appearance does not control it
/// @return the concrete theme used by MonetFX
public ResolvedTheme toResolvedTheme(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeColor resolvedColor = color != null ? color.resolveFallback() : ResolvedTheme.DEFAULT.primaryColorSeed();
Brightness resolvedBrightness = brightness != null ? brightness : context.brightness();
ColorStyle resolvedColorStyle = colorStyle != null ? colorStyle : ResolvedTheme.DEFAULT.colorStyle();
Contrast resolvedContrast = contrast != null ? contrast : Contrast.DEFAULT;
return new ResolvedTheme(resolvedColor, resolvedBrightness, resolvedColorStyle, resolvedContrast);
}
/// Reads the optional color field.
private static @Nullable ThemeColorSource readColor(JsonObject object) {
JsonElement element = object.get(FIELD_COLOR);
if (element == null) {
return null;
}
try {
return ThemeColorSource.fromJson(element);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme color: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional controlled brightness field.
private static @Nullable Brightness readBrightness(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_BRIGHTNESS);
if (value == null) {
return null;
}
return parseBrightness(value);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme brightness: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional color style field.
private static @Nullable ColorStyle readColorStyle(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_COLOR_STYLE);
if (value == null) {
return null;
}
String normalized = value.trim().replace('-', '_').replace(' ', '_').toUpperCase(Locale.ROOT);
if ("DEFAULT".equals(normalized)) {
return ColorStyle.DEFAULT;
}
return ColorStyle.valueOf(normalized);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme colorStyle: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional contrast field.
private static @Nullable Contrast readContrast(JsonObject object) {
JsonElement element = object.get(FIELD_CONTRAST);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive)) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
try {
if (primitive.isNumber()) {
return readContrastValue(primitive.getAsDouble());
}
if (!primitive.isString()) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
String value = primitive.getAsString();
String normalized = value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "low" -> Contrast.LOW;
case "standard" -> Contrast.DEFAULT;
case "medium" -> Contrast.MEDIUM;
case "high" -> Contrast.HIGH;
default -> {
try {
yield readContrastValue(Double.parseDouble(normalized));
} catch (NumberFormatException e) {
throw new JsonParseException("Invalid theme contrast: " + value, e);
}
}
};
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme contrast: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional background object.
private static @Nullable ThemeBackgroundSettings readBackground(JsonObject object) {
JsonElement element = object.get(FIELD_BACKGROUND);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject background)) {
LOG.warning("Ignored invalid theme background: expected an object, got " + element);
return null;
}
try {
return ThemeBackgroundSettings.fromJson(background);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme background: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional title-bar object.
private static @Nullable ThemeTitleBar readTitleBar(JsonObject object) {
JsonElement element = object.get(FIELD_TITLE_BAR);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject titleBar)) {
LOG.warning("Ignored invalid theme titleBar: expected an object, got " + element);
return null;
}
try {
return ThemeTitleBar.fromJson(titleBar);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme titleBar: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional window transparency field.
private static @Nullable Boolean readWindowTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_WINDOW_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme windowTransparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a validated contrast value.
private static Contrast readContrastValue(double value) {
try {
return Contrast.of(value);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Theme contrast value must be between -1 and 1: " + value, e);
}
}
/// Adds a canonical contrast value to a JSON object.
private static void addContrast(JsonObject object, Contrast contrast) {
if (contrast.equals(Contrast.LOW)) {
object.addProperty(FIELD_CONTRAST, "low");
} else if (contrast.equals(Contrast.DEFAULT)) {
object.addProperty(FIELD_CONTRAST, "standard");
} else if (contrast.equals(Contrast.MEDIUM)) {
object.addProperty(FIELD_CONTRAST, "medium");
} else if (contrast.equals(Contrast.HIGH)) {
object.addProperty(FIELD_CONTRAST, "high");
} else {
object.addProperty(FIELD_CONTRAST, contrast.getValue());
}
}
/// Parses a serialized theme brightness value.
private static Brightness parseBrightness(String value) {
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "light" -> Brightness.LIGHT;
case "dark" -> Brightness.DARK;
default -> throw new IllegalArgumentException("Unsupported theme brightness: " + value);
};
}
/// Converts a controlled brightness value to its canonical JSON string.
private static String toJsonBrightness(Brightness brightness) {
return switch (brightness) {
case LIGHT -> "light";
case DARK -> "dark";
};
}
}
@@ -1,230 +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.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Background source contributed by a theme-pack appearance.
///
/// The sealed implementations describe where the wallpaper comes from. Rendering
/// parameters such as opacity are stored by [ThemeBackgroundSettings].
@NotNullByDefault
public sealed interface ThemeBackground
permits ThemeBackground.Default, ThemeBackground.Builtin, ThemeBackground.Image,
ThemeBackground.Paint, ThemeBackground.ThemeColor {
/// JSON member name for the background type.
String FIELD_TYPE = "type";
/// JSON member name for the built-in wallpaper ID.
String FIELD_ID = "id";
/// JSON member name for the local theme-pack image path.
String FIELD_PATH = "path";
/// JSON member name for a serialized paint value.
String FIELD_PAINT = "paint";
/// Adds this background source's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object);
/// Converts this background source to its JSON representation.
///
/// @return the JSON object representing this background source
default JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Parses a background source from a JSON object.
///
/// @param object the JSON object
/// @return the parsed background source, or `null` when no source field is present
/// @throws JsonParseException if a known field is malformed
static @Nullable ThemeBackground fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String type = readString(object, FIELD_TYPE);
@Nullable String id = readString(object, FIELD_ID);
@Nullable String path = readString(object, FIELD_PATH);
@Nullable String paint = readString(object, FIELD_PAINT);
if (type == null) {
int sourceFields = 0;
if (id != null) {
sourceFields++;
}
if (path != null) {
sourceFields++;
}
if (paint != null) {
sourceFields++;
}
if (sourceFields > 1) {
throw new JsonParseException("Theme background without type must contain only one source field");
}
if (id != null) {
return new Builtin(id);
}
if (path != null) {
return new Image(path);
}
if (paint != null) {
return new Paint(paint);
}
return null;
}
return switch (type.trim().replace('-', '_').toUpperCase(Locale.ROOT)) {
case "DEFAULT" -> new Default();
case "BUILTIN" -> new Builtin(id);
case "IMAGE" -> new Image(path);
case "PAINT" -> new Paint(paint);
case "THEME_COLOR" -> new ThemeColor();
default -> throw new JsonParseException("Unsupported theme background type: " + type);
};
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme background field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a required non-blank string value.
private static String requireNonBlank(@Nullable String value, String field) {
if (value == null) {
throw new JsonParseException("Theme background field is missing: " + field);
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new JsonParseException("Theme background field is blank: " + field);
}
return trimmed;
}
/// A source that delegates to HMCL's launcher default background resolution.
@NotNullByDefault
record Default() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "default");
}
}
/// A source that uses a launcher built-in wallpaper.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
@NotNullByDefault
record Builtin(@Nullable String id) implements ThemeBackground {
/// Creates a source that uses the fallback built-in wallpaper.
public Builtin() {
this(null);
}
/// Creates a built-in wallpaper source.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
public Builtin {
if (id != null) {
id = requireNonBlank(id, FIELD_ID);
}
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "builtin");
if (id != null) {
object.addProperty(FIELD_ID, id);
}
}
}
/// A source that uses an image stored inside the theme pack.
///
/// @param path the theme-pack relative image path
@NotNullByDefault
record Image(String path) implements ThemeBackground {
/// Creates an image background source.
///
/// @param path the theme-pack relative image path
public Image {
path = requireNonBlank(path, FIELD_PATH);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "image");
object.addProperty(FIELD_PATH, path);
}
}
/// A source that uses a JavaFX paint string.
///
/// @param paint the serialized paint value
@NotNullByDefault
record Paint(String paint) implements ThemeBackground {
/// Creates a paint background source.
///
/// @param paint the serialized paint value
public Paint {
paint = requireNonBlank(paint, FIELD_PAINT);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "paint");
object.addProperty(FIELD_PAINT, paint);
}
}
/// A source that follows the current launcher theme color.
@NotNullByDefault
record ThemeColor() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "theme_color");
}
}
}
Loaded 100 of 583 files, more files were not shown because too many files have changed in this diff. Show more