Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9b1cfec5a |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -72,5 +69,3 @@ language-subtag-registry
|
||||
# AI
|
||||
/.gemini/
|
||||
!/.gemini/config.yaml
|
||||
.codex
|
||||
/.gradle-*/
|
||||
@@ -1,21 +0,0 @@
|
||||
# Java Code Style Requirements
|
||||
|
||||
These rules apply to all Java code written or modified in this repository.
|
||||
|
||||
## Nullability
|
||||
|
||||
- Annotate every class with JetBrains Annotations `@NotNullByDefault`.
|
||||
- Any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
|
||||
- Nullability must never be implicit.
|
||||
|
||||
## 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
|
||||
|
||||
- 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.
|
||||
+10
-42
@@ -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
|
||||
@@ -58,14 +56,11 @@ dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation(project(":HMCLBoot"))
|
||||
implementation("libs:JFoenix")
|
||||
implementation(libs.jwebp)
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.fxsvgimage)
|
||||
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)
|
||||
@@ -200,7 +195,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 +251,20 @@ tasks.processResources {
|
||||
}
|
||||
}
|
||||
|
||||
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
||||
|
||||
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 +279,8 @@ val makeExecutables by tasks.registering {
|
||||
}
|
||||
}
|
||||
|
||||
val makeDeb by tasks.registering(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 +377,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")
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -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,277 +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.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.ComboBoxBase;
|
||||
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;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ 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();
|
||||
|
||||
@@ -140,29 +140,29 @@ public final class EntryPoint {
|
||||
}
|
||||
|
||||
private static void createHMCLDirectories() {
|
||||
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
|
||||
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
|
||||
try {
|
||||
Files.createDirectories(Metadata.HMCL_LOCAL_HOME);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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;
|
||||
@@ -34,18 +33,18 @@ 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.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.FileSaver;
|
||||
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.*;
|
||||
|
||||
@@ -65,7 +64,6 @@ 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;
|
||||
@@ -99,21 +97,20 @@ public final class Launcher extends Application {
|
||||
|
||||
try {
|
||||
try {
|
||||
SettingsManager.init();
|
||||
initializeSettingsRuntime();
|
||||
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", SettingsManager.localConfigDirectory()));
|
||||
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))
|
||||
return;
|
||||
@@ -121,29 +118,26 @@ public final class Launcher extends Application {
|
||||
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();
|
||||
@@ -153,26 +147,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();
|
||||
@@ -231,7 +205,7 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -268,7 +242,7 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
private static void checkConfigInTempDir() {
|
||||
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
|
||||
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
|
||||
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
|
||||
EntryPoint.exit(0);
|
||||
}
|
||||
@@ -279,26 +253,23 @@ public final class Launcher extends Application {
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||
if (!Files.exists(configDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(configDirectory).getName();
|
||||
owner = Files.getOwner(ConfigHolder.configLocation()).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
|
||||
if (Files.isWritable(ConfigHolder.configLocation()) || 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());
|
||||
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))
|
||||
@@ -351,8 +322,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()));
|
||||
@@ -372,8 +343,6 @@ 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);
|
||||
|
||||
launch(Launcher.class, args);
|
||||
|
||||
@@ -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,6 +46,7 @@ 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";
|
||||
@@ -58,38 +60,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() {
|
||||
|
||||
@@ -35,6 +35,7 @@ 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 +64,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
private void generateOptionsTxt() {
|
||||
if (options.isDisableAutoGameOptions())
|
||||
if (config().isDisableAutoGameOptions())
|
||||
return;
|
||||
|
||||
Path runDir = repository.getRunDirectory(version.getId());
|
||||
@@ -158,7 +159,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
protected void appendJvmArgs(CommandBuilder result) {
|
||||
super.appendJvmArgs(result);
|
||||
|
||||
if (options.isAllowAutoAgent()
|
||||
if (config().getAllowAutoAgent()
|
||||
&& !options.isNoGeneratedJVMArgs()
|
||||
&& !options.isNoGeneratedOptimizingJVMArgs()
|
||||
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
|
||||
|
||||
@@ -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");
|
||||
@@ -271,280 +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);
|
||||
saveGameSettings(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();
|
||||
}
|
||||
|
||||
public GameSettings.Effective getEffectiveGameSettings(String id) {
|
||||
GameSettings.Instance instance = getInstanceGameSettings(id);
|
||||
return GameSettings.resolve(getParentGameSettings(instance), instance);
|
||||
}
|
||||
|
||||
public void applyDefaultIsolationSetting(String id) {
|
||||
if (!hasVersion(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance instanceSetting = getInstanceGameSettings(id);
|
||||
GameSettings.Preset preset = getParentGameSettings(instanceSetting);
|
||||
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
|
||||
boolean isolated = switch (type) {
|
||||
case NEVER -> false;
|
||||
case ALWAYS -> true;
|
||||
case MODDED -> LibraryAnalyzer.isModded(this, getVersion(id).resolve(this));
|
||||
};
|
||||
|
||||
if (isolated) {
|
||||
GameSettings.Instance setting = instanceSetting != null ? instanceSetting : getInstanceGameSettingsOrCreate(id);
|
||||
if (setting != null) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -583,12 +298,12 @@ 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);
|
||||
@@ -638,64 +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.get(GameSettings::autoMemoryProperty);
|
||||
GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(version));
|
||||
VersionSetting vs = getVersionSetting(version);
|
||||
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setGameDir(gameDir)
|
||||
@@ -703,17 +399,17 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(version)
|
||||
.setProfileName(Metadata.TITLE)
|
||||
.setGameArguments(StringUtils.tokenize(vs.get(GameSettings::gameArgumentsProperty)))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.get(GameSettings::jvmOptionsProperty)))
|
||||
.setMaxMemory(noJVMOptions && autoMemory ? null : (int) (getAllocatedMemory(
|
||||
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
|
||||
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
|
||||
vs.getMaxMemory() * 1024L * 1024L,
|
||||
SystemInfo.getPhysicalMemoryStatus().available(),
|
||||
autoMemory
|
||||
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
|
||||
vs.isAutoMemory()
|
||||
) / 1024 / 1024))
|
||||
.setMinMemory(vs.get(GameSettings::minMemoryProperty))
|
||||
.setMetaspace(Lang.toIntOrNull(vs.get(GameSettings::permSizeProperty)))
|
||||
.setMinMemory(vs.getMinMemory())
|
||||
.setMetaspace(Lang.toIntOrNull(vs.getPermSize()))
|
||||
.setEnvironmentVariables(
|
||||
Lang.mapOf(StringUtils.tokenize(vs.get(GameSettings::environmentVariablesProperty))
|
||||
Lang.mapOf(StringUtils.tokenize(vs.getEnvironmentVariables())
|
||||
.stream()
|
||||
.map(it -> {
|
||||
int idx = it.indexOf('=');
|
||||
@@ -724,30 +420,27 @@ 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.get(GameSettings::useCustomNativesProperty))
|
||||
.setNativesDir(vs.get(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.get(GameSettings::useNativeGLFWProperty))
|
||||
.setUseNativeOpenAL(vs.get(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())
|
||||
.setGraphicsBackend(vs.getGraphicsBackend())
|
||||
.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);
|
||||
@@ -762,7 +455,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;
|
||||
@@ -803,6 +496,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\"}";
|
||||
|
||||
|
||||
@@ -818,7 +515,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -857,19 +554,22 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -878,12 +578,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;
|
||||
@@ -60,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;
|
||||
@@ -69,36 +65,36 @@ 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.setting.ConfigHolder.config;
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -147,7 +143,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);
|
||||
@@ -157,11 +154,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,7 +174,7 @@ public final class LauncherHelper {
|
||||
}),
|
||||
Task.composeAsync(() -> {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer)
|
||||
|| !(setting.getRenderer() instanceof Renderer.Driver renderer)
|
||||
|| renderer.mesaDriverName() == null)
|
||||
return null;
|
||||
|
||||
@@ -204,21 +201,21 @@ public final class LauncherHelper {
|
||||
}).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))
|
||||
if (config().getAllowAutoAgent()
|
||||
|| setting.isNoJVMArgs()
|
||||
|| setting.isNoOptimizingJVMArgs()
|
||||
|| Boolean.TRUE.equals(config().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();
|
||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
config().setAllowAutoAgent(true);
|
||||
future.complete(null);
|
||||
}, () -> {
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
future.complete(null);
|
||||
});
|
||||
});
|
||||
@@ -228,7 +225,7 @@ public final class LauncherHelper {
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
|
||||
selectedVersion, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
@@ -386,8 +383,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(() -> {
|
||||
@@ -398,10 +395,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);
|
||||
@@ -414,9 +410,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);
|
||||
@@ -454,7 +450,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);
|
||||
@@ -531,7 +527,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);
|
||||
@@ -544,7 +540,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
|
||||
if (!setting.get(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 {
|
||||
@@ -628,8 +624,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modlauncher8"));
|
||||
break;
|
||||
case VANILLA_X86:
|
||||
if (!setting.get(GameSettings::useCustomNativesProperty)
|
||||
&& Platform.isSupportedTranslationX86_64()) {
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
|
||||
&& isCompatibleWithX86Java()) {
|
||||
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
||||
}
|
||||
break;
|
||||
@@ -689,14 +685,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) {
|
||||
@@ -779,16 +775,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].
|
||||
@@ -969,7 +955,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();
|
||||
|
||||
+25
-33
@@ -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,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;
|
||||
@@ -66,8 +61,6 @@ 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 +149,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,7 +168,7 @@ 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")));
|
||||
}
|
||||
@@ -184,32 +177,30 @@ public final class ModpackHelper {
|
||||
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 +211,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))
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.javafx(), success, failure)
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
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())
|
||||
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync())
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
default:
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, 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.get(GameSettings::autoMemoryProperty));
|
||||
setting.minMemoryProperty().setValue(effective.get(GameSettings::minMemoryProperty));
|
||||
setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory());
|
||||
setting.permSizeProperty().setValue(effective.get(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;
|
||||
|
||||
@@ -175,7 +172,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
|
||||
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 +199,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 +235,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
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 +190,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
*/
|
||||
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.*;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
@@ -30,20 +27,16 @@ 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;
|
||||
@@ -81,8 +74,8 @@ public final class JavaManager {
|
||||
"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 +122,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;
|
||||
}
|
||||
@@ -220,11 +202,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 +256,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 +267,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)) {
|
||||
@@ -374,7 +354,7 @@ 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"));
|
||||
Searcher searcher = new Searcher(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("javaCache.json"));
|
||||
if (useCache)
|
||||
searcher.loadCache();
|
||||
|
||||
@@ -384,13 +364,13 @@ public final class JavaManager {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (Platform.isSupportedTranslationX86_64())
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
}
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
|
||||
break;
|
||||
}
|
||||
@@ -477,7 +457,7 @@ public final class JavaManager {
|
||||
|
||||
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));
|
||||
} catch (InvalidPathException e) {
|
||||
@@ -488,7 +468,7 @@ public final class JavaManager {
|
||||
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
|
||||
if (currentJava != null
|
||||
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
|
||||
&& !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
searcher.addResult(currentJava.getBinary(), currentJava);
|
||||
}
|
||||
|
||||
@@ -694,7 +674,7 @@ public final class JavaManager {
|
||||
|
||||
if (javaRuntimes.containsKey(executable)
|
||||
|| failed.contains(executable)
|
||||
|| SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) {
|
||||
|| ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -804,15 +784,13 @@ public final class JavaManager {
|
||||
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()) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
|
||||
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);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-237
@@ -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,816 @@
|
||||
/*
|
||||
* 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("defaultAddonSource")
|
||||
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
|
||||
|
||||
public StringProperty defaultAddonSourceProperty() {
|
||||
return defaultAddonSource;
|
||||
}
|
||||
|
||||
public String getDefaultAddonSource() {
|
||||
return defaultAddonSource.get();
|
||||
}
|
||||
|
||||
public void setDefaultAddonSource(String defaultAddonSource) {
|
||||
this.defaultAddonSource.set(defaultAddonSource);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@SerializedName("allowAutoAgent")
|
||||
private final BooleanProperty allowAutoAgent = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty allowAutoAgentProperty() {
|
||||
return allowAutoAgent;
|
||||
}
|
||||
|
||||
public boolean getAllowAutoAgent() {
|
||||
return allowAutoAgent.get();
|
||||
}
|
||||
|
||||
public void setAllowAutoAgent(boolean allowAutoAgent) {
|
||||
this.allowAutoAgent.set(allowAutoAgent);
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
+8
-11
@@ -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
|
||||
@@ -17,14 +17,11 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/// @author Glavo
|
||||
public enum DefaultIsolationType {
|
||||
/// Default to isolate all instances.
|
||||
ALWAYS,
|
||||
|
||||
/// Default to isolate instances with any modloader.
|
||||
MODDED,
|
||||
|
||||
/// No instances are isolated by default.
|
||||
NEVER,
|
||||
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,212 +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;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
|
||||
/// 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(onInvalidating(this::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,493 +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.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 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(newGameDirectoryId(), null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
}
|
||||
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
|
||||
needRebuildGameDirectories = true;
|
||||
GameDirectories gameDirectories = userGameDirectories();
|
||||
gameDirectories.getGameDirectories().add(new GameDirectory(newGameDirectoryId(), 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(newGameDirectoryId(), null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
userGameDirectories().getGameDirectories()
|
||||
.add(new GameDirectory(newGameDirectoryId(), 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,218 +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(source -> save(value));
|
||||
}
|
||||
|
||||
/// 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,671 +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 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;
|
||||
}
|
||||
|
||||
// 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 BooleanProperty animationDisabled = new SimpleBooleanProperty(
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE
|
||||
|| !JavaRuntime.CURRENT_JIT_ENABLED
|
||||
|| !FXUtils.GPU_ACCELERATION_ENABLED
|
||||
);
|
||||
|
||||
/// Returns the UI animation disable property.
|
||||
public BooleanProperty animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
// 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,218 +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.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();
|
||||
}
|
||||
|
||||
/// 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,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,7 +25,7 @@ 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;
|
||||
@@ -161,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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,962 @@
|
||||
/*
|
||||
* 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/<version>/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/<version>/<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<GraphicsAPI> graphicsBackend = new SimpleObjectProperty<>(this, "graphicsBackend", GraphicsAPI.DEFAULT);
|
||||
|
||||
public ObjectProperty<GraphicsAPI> graphicsBackendProperty() {
|
||||
return graphicsBackend;
|
||||
}
|
||||
|
||||
public GraphicsAPI getGraphicsBackend() {
|
||||
return graphicsBackendProperty().get();
|
||||
}
|
||||
|
||||
public void setGraphicsBackend(GraphicsAPI api) {
|
||||
graphicsBackendProperty().set(api);
|
||||
}
|
||||
|
||||
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("graphicsBackend", src.getGraphicsBackend().name());
|
||||
obj.addProperty("renderer", src.getRenderer().name());
|
||||
if (src.getRenderer() == Renderer.OpenGL.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)
|
||||
.map(Renderer::of).orElseGet(() -> {
|
||||
boolean useSoftwareRenderer = Optional.ofNullable(obj.get("useSoftwareRenderer")).map(JsonElement::getAsBoolean).orElse(false);
|
||||
return useSoftwareRenderer ? Renderer.OpenGL.LLVMPIPE : Renderer.DEFAULT;
|
||||
}));
|
||||
|
||||
vs.setGraphicsBackend(Optional.ofNullable(obj.get("graphicsBackend")).map(JsonElement::getAsString)
|
||||
.flatMap(name -> {
|
||||
try {
|
||||
return Optional.of(GraphicsAPI.valueOf(name.toUpperCase(Locale.ROOT)));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}).orElseGet(() -> vs.getRenderer() instanceof Renderer.Driver renderer ? renderer.api() : GraphicsAPI.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,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,364 +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
|
||||
@NotNullByDefault
|
||||
public record ThemeAppearance(
|
||||
@Nullable ThemeColorSource color,
|
||||
@Nullable Brightness brightness,
|
||||
@Nullable ColorStyle colorStyle,
|
||||
@Nullable Contrast contrast,
|
||||
@Nullable ThemeBackgroundSettings background,
|
||||
@Nullable ThemeTitleBar titleBar) {
|
||||
|
||||
/// 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";
|
||||
|
||||
/// 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
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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 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");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,118 +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.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Background settings contributed by a theme-pack appearance.
|
||||
///
|
||||
/// @param source the background source, or `null` when inherited
|
||||
/// @param opacity the background opacity override, or `null` when inherited
|
||||
@NotNullByDefault
|
||||
public record ThemeBackgroundSettings(
|
||||
@Nullable ThemeBackground source,
|
||||
@Nullable Double opacity) {
|
||||
/// JSON member name for the background opacity.
|
||||
private static final String FIELD_OPACITY = "opacity";
|
||||
|
||||
/// Creates background settings.
|
||||
///
|
||||
/// @param source the background source, or `null` when inherited
|
||||
/// @param opacity the background opacity override, or `null` when inherited
|
||||
public ThemeBackgroundSettings {
|
||||
if (opacity != null && (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity))) {
|
||||
throw new IllegalArgumentException("Theme background opacity must be between 0 and 1: " + opacity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses background settings from a JSON object.
|
||||
///
|
||||
/// @param object the JSON object
|
||||
/// @return the parsed background settings
|
||||
static ThemeBackgroundSettings fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
return new ThemeBackgroundSettings(
|
||||
ThemeBackground.fromJson(object),
|
||||
readOpacity(object));
|
||||
}
|
||||
|
||||
/// Converts these settings to their JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing these background settings
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = source != null ? source.toJsonObject() : new JsonObject();
|
||||
if (opacity != null) {
|
||||
object.addProperty(FIELD_OPACITY, opacity);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns whether these settings contain no concrete fields.
|
||||
///
|
||||
/// @return `true` when every background setting is inherited
|
||||
public boolean isEmpty() {
|
||||
return source == null && opacity == null;
|
||||
}
|
||||
|
||||
/// Returns settings that apply the given patch over these settings.
|
||||
///
|
||||
/// @param patch the patch to apply
|
||||
/// @return the merged settings
|
||||
public ThemeBackgroundSettings merge(ThemeBackgroundSettings patch) {
|
||||
Objects.requireNonNull(patch);
|
||||
|
||||
@Nullable ThemeBackground mergedSource = patch.source != null ? patch.source : source;
|
||||
@Nullable Double mergedOpacity = patch.opacity != null ? patch.opacity : opacity;
|
||||
return new ThemeBackgroundSettings(mergedSource, mergedOpacity);
|
||||
}
|
||||
|
||||
/// Reads the optional opacity field.
|
||||
private static @Nullable Double readOpacity(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_OPACITY);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isNumber()) {
|
||||
LOG.warning("Ignored invalid theme background opacity: expected a number, got " + element);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
double opacity = primitive.getAsDouble();
|
||||
if (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity)) {
|
||||
LOG.warning("Ignored invalid theme background opacity: expected a number between 0 and 1, got "
|
||||
+ opacity);
|
||||
return null;
|
||||
}
|
||||
return opacity;
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Ignored invalid theme background opacity: " + e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +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;
|
||||
|
||||
/// Describes how a theme-pack appearance chooses its Monet seed color.
|
||||
@NotNullByDefault
|
||||
public sealed interface ThemeColorSource permits ThemeColorSource.Default, ThemeColorSource.Custom, ThemeColorSource.Wallpaper {
|
||||
/// JSON member name for the source type.
|
||||
String FIELD_SOURCE = "source";
|
||||
|
||||
/// The launcher default color source.
|
||||
ThemeColorSource DEFAULT = new Default();
|
||||
|
||||
/// Creates a custom color source.
|
||||
///
|
||||
/// @param color the custom color
|
||||
/// @return the custom color source
|
||||
static ThemeColorSource custom(ThemeColor color) {
|
||||
return new Custom(color);
|
||||
}
|
||||
|
||||
/// Creates a wallpaper color source.
|
||||
///
|
||||
/// @return the wallpaper color source
|
||||
static ThemeColorSource wallpaper() {
|
||||
return new Wallpaper();
|
||||
}
|
||||
|
||||
/// Parses a color source from a manifest JSON value.
|
||||
///
|
||||
/// @param element the JSON value
|
||||
/// @return the parsed color source
|
||||
/// @throws JsonParseException if the color source is malformed
|
||||
static ThemeColorSource fromJson(JsonElement element) throws JsonParseException {
|
||||
Objects.requireNonNull(element);
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
String value = primitive.getAsString();
|
||||
if ("default".equals(value.trim().replace('-', '_').toLowerCase(Locale.ROOT))) {
|
||||
return DEFAULT;
|
||||
}
|
||||
@Nullable ThemeColor color = ThemeColor.of(value);
|
||||
if (color == null) {
|
||||
throw new JsonParseException("Invalid theme color: " + value);
|
||||
}
|
||||
return custom(color);
|
||||
}
|
||||
if (!(element instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Theme color must be a string or object");
|
||||
}
|
||||
|
||||
JsonElement sourceElement = object.get(FIELD_SOURCE);
|
||||
if (sourceElement == null) {
|
||||
throw new JsonParseException("Theme color source is missing required field: " + FIELD_SOURCE);
|
||||
}
|
||||
if (!(sourceElement instanceof JsonPrimitive sourcePrimitive) || !sourcePrimitive.isString()) {
|
||||
throw new JsonParseException("Theme color source field must be a string: " + FIELD_SOURCE);
|
||||
}
|
||||
String source = sourcePrimitive.getAsString();
|
||||
String normalized = source.trim().replace('-', '_').toUpperCase(Locale.ROOT);
|
||||
if ("DEFAULT".equals(normalized)) {
|
||||
return DEFAULT;
|
||||
}
|
||||
if ("WALLPAPER".equals(normalized)) {
|
||||
return wallpaper();
|
||||
}
|
||||
throw new JsonParseException("Unsupported theme color source: " + source);
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
///
|
||||
/// @return the color source JSON value
|
||||
JsonElement toJsonElement();
|
||||
|
||||
/// Returns the best available color without accessing wallpaper pixels.
|
||||
///
|
||||
/// @return the custom color or launcher default color
|
||||
ThemeColor resolveFallback();
|
||||
|
||||
/// The launcher default color seed.
|
||||
@NotNullByDefault
|
||||
record Default() implements ThemeColorSource {
|
||||
/// Creates a default color source.
|
||||
public Default {
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
@Override
|
||||
public JsonElement toJsonElement() {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty(FIELD_SOURCE, "default");
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns the launcher default seed color.
|
||||
@Override
|
||||
public ThemeColor resolveFallback() {
|
||||
return ThemeColor.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed color seed supplied by the manifest.
|
||||
///
|
||||
/// @param color the custom seed color
|
||||
@NotNullByDefault
|
||||
record Custom(ThemeColor color) implements ThemeColorSource {
|
||||
/// Creates a custom color source.
|
||||
///
|
||||
/// @param color the custom seed color
|
||||
public Custom {
|
||||
Objects.requireNonNull(color);
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
@Override
|
||||
public JsonElement toJsonElement() {
|
||||
return new JsonPrimitive(color.name());
|
||||
}
|
||||
|
||||
/// Returns the custom seed color.
|
||||
@Override
|
||||
public ThemeColor resolveFallback() {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
/// A dynamic seed color extracted from the effective wallpaper.
|
||||
@NotNullByDefault
|
||||
record Wallpaper() implements ThemeColorSource {
|
||||
/// Creates a wallpaper color source.
|
||||
public Wallpaper {
|
||||
}
|
||||
|
||||
/// Converts this color source to its JSON representation.
|
||||
@Override
|
||||
public JsonElement toJsonElement() {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty(FIELD_SOURCE, "wallpaper");
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns the launcher default color used before wallpaper pixels are available.
|
||||
@Override
|
||||
public ThemeColor resolveFallback() {
|
||||
return ThemeColor.DEFAULT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +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.JsonArray;
|
||||
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.Unmodifiable;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/// A simple JSON-object condition used by theme-pack overrides.
|
||||
///
|
||||
/// A condition is an AND of all members. A string value requires equality, and
|
||||
/// an array value requires the context value to match any listed value. Unknown
|
||||
/// condition keys are accepted for forward compatibility, but they do not match
|
||||
/// contexts produced by this implementation.
|
||||
///
|
||||
/// @param requirements normalized accepted values keyed by condition name
|
||||
@NotNullByDefault
|
||||
public record ThemeCondition(@Unmodifiable Map<String, @Unmodifiable Set<String>> requirements) {
|
||||
/// Condition key for the effective light or dark mode.
|
||||
static final String KEY_BRIGHTNESS = "brightness";
|
||||
|
||||
/// Condition key for the current operating system.
|
||||
static final String KEY_OS = "os";
|
||||
|
||||
/// Condition key for the current UI language.
|
||||
static final String KEY_LANGUAGE = "language";
|
||||
|
||||
/// Supported operating system condition values.
|
||||
private static final Set<String> SUPPORTED_OS_VALUES = Set.of("windows", "macos", "linux", "freebsd", "unknown");
|
||||
|
||||
/// Creates a condition from normalized accepted values.
|
||||
///
|
||||
/// @param requirements normalized accepted values keyed by condition name
|
||||
public ThemeCondition {
|
||||
Objects.requireNonNull(requirements);
|
||||
|
||||
LinkedHashMap<String, Set<String>> copy = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
|
||||
String key = normalizeKey(entry.getKey());
|
||||
Set<String> values = entry.getValue();
|
||||
if (values.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme condition field has no accepted values: " + key);
|
||||
}
|
||||
|
||||
LinkedHashSet<String> valueCopy = new LinkedHashSet<>();
|
||||
for (String value : values) {
|
||||
valueCopy.add(normalizeValue(key, value));
|
||||
}
|
||||
copy.put(key, Collections.unmodifiableSet(valueCopy));
|
||||
}
|
||||
requirements = Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
/// Parses a theme condition from a JSON object.
|
||||
///
|
||||
/// @param object the condition object
|
||||
/// @return the parsed condition
|
||||
/// @throws JsonParseException if the condition contains malformed values
|
||||
public static ThemeCondition fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
LinkedHashMap<String, Set<String>> requirements = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
|
||||
String key = normalizeKey(entry.getKey());
|
||||
requirements.put(key, readAcceptedValues(key, entry.getValue()));
|
||||
}
|
||||
return new ThemeCondition(requirements);
|
||||
}
|
||||
|
||||
/// Returns whether this condition matches the given resolution context.
|
||||
///
|
||||
/// @param context the context to test
|
||||
/// @return `true` if every condition member matches the context
|
||||
public boolean matches(ThemeResolveContext context) {
|
||||
Objects.requireNonNull(context);
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
|
||||
String value = context.conditionValue(entry.getKey());
|
||||
if (value == null || !entry.getValue().contains(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Converts this condition to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this condition
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
|
||||
if (entry.getValue().size() == 1) {
|
||||
object.addProperty(entry.getKey(), entry.getValue().iterator().next());
|
||||
} else {
|
||||
JsonArray array = new JsonArray();
|
||||
for (String value : entry.getValue()) {
|
||||
array.add(value);
|
||||
}
|
||||
object.add(entry.getKey(), array);
|
||||
}
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Reads one condition field value.
|
||||
private static Set<String> readAcceptedValues(String key, JsonElement element) throws JsonParseException {
|
||||
LinkedHashSet<String> values = new LinkedHashSet<>();
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
values.add(normalizeValue(key, primitive.getAsString()));
|
||||
} else if (element instanceof JsonArray array) {
|
||||
if (array.isEmpty()) {
|
||||
throw new JsonParseException("Theme condition array is empty: " + key);
|
||||
}
|
||||
|
||||
for (JsonElement item : array) {
|
||||
if (!(item instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Theme condition array must contain strings: " + key);
|
||||
}
|
||||
values.add(normalizeValue(key, primitive.getAsString()));
|
||||
}
|
||||
} else {
|
||||
throw new JsonParseException("Theme condition value must be a string or string array: " + key);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/// Normalizes and validates a condition key.
|
||||
private static String normalizeKey(String key) {
|
||||
Objects.requireNonNull(key);
|
||||
|
||||
String normalized = key.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new JsonParseException("Theme condition key is blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// Normalizes and validates one condition value.
|
||||
private static String normalizeValue(String key, String value) {
|
||||
Objects.requireNonNull(key);
|
||||
Objects.requireNonNull(value);
|
||||
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new JsonParseException("Empty theme condition value for " + key);
|
||||
}
|
||||
String normalized = trimmed.toLowerCase(Locale.ROOT);
|
||||
|
||||
return switch (key) {
|
||||
case KEY_BRIGHTNESS -> switch (normalized) {
|
||||
case "light", "dark" -> normalized;
|
||||
default -> throw new JsonParseException("Unsupported brightness condition value: " + value);
|
||||
};
|
||||
case KEY_OS -> normalizeOperatingSystemValue(normalized, value);
|
||||
case KEY_LANGUAGE -> normalized;
|
||||
default -> trimmed;
|
||||
};
|
||||
}
|
||||
|
||||
/// Normalizes an operating system condition value.
|
||||
private static String normalizeOperatingSystemValue(String normalized, String original) {
|
||||
String value = switch (normalized) {
|
||||
case "win", "windows" -> "windows";
|
||||
case "mac", "macos", "osx" -> "macos";
|
||||
case "linux" -> "linux";
|
||||
case "freebsd" -> "freebsd";
|
||||
case "unknown", "universal" -> "unknown";
|
||||
default -> normalized;
|
||||
};
|
||||
|
||||
if (!SUPPORTED_OS_VALUES.contains(value)) {
|
||||
throw new JsonParseException("Unsupported os condition value: " + original);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +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 org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// A conditional appearance patch in a theme.
|
||||
///
|
||||
/// @param condition the condition required for this override to apply
|
||||
/// @param appearance the appearance fields applied when the condition matches
|
||||
@NotNullByDefault
|
||||
public record ThemeOverride(ThemeCondition condition, ThemeAppearance appearance) {
|
||||
/// JSON member name for an override condition.
|
||||
private static final String FIELD_CONDITION = "condition";
|
||||
|
||||
/// Creates a conditional theme override.
|
||||
///
|
||||
/// @param condition the condition required for this override to apply
|
||||
/// @param appearance the appearance fields applied when the condition matches
|
||||
public ThemeOverride {
|
||||
Objects.requireNonNull(condition);
|
||||
Objects.requireNonNull(appearance);
|
||||
if (appearance.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme override does not define any appearance fields");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a theme override from JSON.
|
||||
///
|
||||
/// @param element the override object
|
||||
/// @return the parsed override
|
||||
/// @throws JsonParseException if the override is malformed
|
||||
static @Nullable ThemeOverride fromJson(@Nullable JsonElement element) throws JsonParseException {
|
||||
if (element == null || element.isJsonNull())
|
||||
return null;
|
||||
|
||||
if (!(element instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Invalid theme override");
|
||||
}
|
||||
|
||||
JsonElement conditionElement = object.get(FIELD_CONDITION);
|
||||
if (!(conditionElement instanceof JsonObject conditionObject)) {
|
||||
throw new JsonParseException("Theme override must define an object condition");
|
||||
}
|
||||
|
||||
ThemeCondition condition = ThemeCondition.fromJson(conditionObject);
|
||||
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
|
||||
if (appearance.isEmpty()) {
|
||||
throw new JsonParseException("Theme override does not define any appearance fields");
|
||||
}
|
||||
return new ThemeOverride(condition, appearance);
|
||||
}
|
||||
|
||||
/// Returns whether this override matches the given resolution context.
|
||||
///
|
||||
/// @param context the context to test
|
||||
/// @return `true` when the override should be applied
|
||||
public boolean matches(ThemeResolveContext context) {
|
||||
return condition.matches(context);
|
||||
}
|
||||
|
||||
/// Converts this override to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this override
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = appearance.toJsonObject();
|
||||
object.add(FIELD_CONDITION, condition.toJsonObject());
|
||||
return object;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +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 java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/// One resource copied into an exported theme-pack zip.
|
||||
///
|
||||
/// @param source the resource to copy
|
||||
/// @param entryName the normalized zip entry name under `assets/`
|
||||
@NotNullByDefault
|
||||
public record ThemePackAsset(ThemePackResource source, String entryName) {
|
||||
|
||||
/// Required prefix for theme-pack asset entries.
|
||||
private static final String ASSETS_PREFIX = "assets/";
|
||||
|
||||
/// Creates a theme-pack asset from a local file.
|
||||
///
|
||||
/// @param source the local file to copy
|
||||
/// @param entryName the normalized zip entry name under `assets/`
|
||||
public ThemePackAsset(Path source, String entryName) {
|
||||
this(new ThemePackResource.File(source), entryName);
|
||||
}
|
||||
|
||||
/// Creates a theme-pack asset entry.
|
||||
///
|
||||
/// @param source the resource to copy
|
||||
/// @param entryName the normalized zip entry name under `assets/`
|
||||
public ThemePackAsset {
|
||||
Objects.requireNonNull(source);
|
||||
entryName = normalizeEntryName(entryName);
|
||||
}
|
||||
|
||||
/// Normalizes and validates a zip entry name.
|
||||
///
|
||||
/// @param entryName the entry name to validate
|
||||
/// @return the normalized entry name
|
||||
/// @throws IllegalArgumentException if the entry name is unsafe or outside `assets/`
|
||||
static String normalizeEntryName(String entryName) {
|
||||
Objects.requireNonNull(entryName);
|
||||
|
||||
String normalized = entryName.trim().replace('\\', '/');
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry is empty");
|
||||
}
|
||||
if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry must be relative: " + entryName);
|
||||
}
|
||||
if (!normalized.startsWith(ASSETS_PREFIX)) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry must be under assets/: " + entryName);
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry must be a file: " + entryName);
|
||||
}
|
||||
|
||||
for (String segment : normalized.split("/")) {
|
||||
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry contains an unsafe segment: " + entryName);
|
||||
}
|
||||
for (int i = 0; i < segment.length(); i++) {
|
||||
char ch = segment.charAt(i);
|
||||
if (Character.isISOControl(ch) || ch == '\0') {
|
||||
throw new IllegalArgumentException("Theme-pack asset entry contains a control character: " + entryName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +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.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
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 java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Author metadata declared by a theme pack.
|
||||
///
|
||||
/// @param name the localized author display name
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
@JsonAdapter(ThemePackAuthor.Adapter.class)
|
||||
public record ThemePackAuthor(LocalizedText name) {
|
||||
|
||||
/// Parses author metadata from a JSON array.
|
||||
static @Unmodifiable List<ThemePackAuthor> parseAuthors(@Nullable JsonElement element) throws JsonParseException {
|
||||
if (element == null || element.isJsonNull()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (!(element instanceof JsonArray jsonArray)) {
|
||||
throw new JsonParseException("Theme-pack authors must be an array");
|
||||
}
|
||||
|
||||
ArrayList<ThemePackAuthor> authors = new ArrayList<>(jsonArray.size());
|
||||
int index = 0;
|
||||
for (JsonElement authorJson : jsonArray) {
|
||||
try {
|
||||
ThemePackAuthor author = fromJson(authorJson);
|
||||
if (author != null) {
|
||||
authors.add(author);
|
||||
}
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme-pack author at authors[" + index + "]: " + authorJson, e);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return List.copyOf(authors);
|
||||
}
|
||||
|
||||
/// Converts author metadata to a JSON array.
|
||||
static JsonArray toJson(List<ThemePackAuthor> authors) {
|
||||
JsonArray array = new JsonArray();
|
||||
for (ThemePackAuthor author : authors) {
|
||||
array.add(author.toJsonObject());
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/// Parses author metadata from a JSON object or a plain string.
|
||||
///
|
||||
/// @param json the author metadata JSON
|
||||
/// @return the parsed author metadata, or `null` when `json` is `null`
|
||||
/// @throws JsonParseException if `json` is neither an object nor a string author name
|
||||
public static @Nullable ThemePackAuthor fromJson(@Nullable JsonElement json) throws JsonParseException {
|
||||
if (json == null || json instanceof JsonNull)
|
||||
return null;
|
||||
|
||||
if (json instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
try {
|
||||
return new ThemePackAuthor(LocalizedText.plain(primitive.getAsString()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(json instanceof JsonObject jsonObject)) {
|
||||
throw new JsonParseException("Theme-pack author must be an object or a string");
|
||||
}
|
||||
|
||||
JsonElement nameJson = jsonObject.get("name");
|
||||
if (nameJson == null) {
|
||||
throw new JsonParseException("Missing author name: " + json);
|
||||
}
|
||||
|
||||
LocalizedText name = LocalizedText.fromJson(nameJson);
|
||||
if (name == null) {
|
||||
throw new JsonParseException("The author name is null");
|
||||
}
|
||||
|
||||
try {
|
||||
return new ThemePackAuthor(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates theme-pack author metadata.
|
||||
///
|
||||
/// @param name the localized author display name
|
||||
public ThemePackAuthor {
|
||||
if (name.mayBeEmpty()) {
|
||||
throw new IllegalArgumentException("The author name cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the author display name in the current locale.
|
||||
///
|
||||
/// @return the localized author display name
|
||||
public String displayName() {
|
||||
return name.getText(I18n.getLocale().getCandidateLocales());
|
||||
}
|
||||
|
||||
/// Converts this author to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this author
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
object.add("name", name.toJsonElement());
|
||||
return object;
|
||||
}
|
||||
|
||||
static final class Adapter implements JsonSerializer<@Nullable ThemePackAuthor>, JsonDeserializer<@Nullable ThemePackAuthor> {
|
||||
@Override
|
||||
public @Nullable ThemePackAuthor deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return ThemePackAuthor.fromJson(json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(@Nullable ThemePackAuthor src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
return src != null ? src.toJsonObject() : JsonNull.INSTANCE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +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.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/// Writes theme-pack manifests and assets into zip-compatible files.
|
||||
@NotNullByDefault
|
||||
public final class ThemePackExporter {
|
||||
|
||||
/// Recommended file extension for theme-pack files.
|
||||
public static final String FILE_EXTENSION = ".hmcl-theme";
|
||||
|
||||
/// Manifest entry name used by every exported theme pack.
|
||||
public static final String MANIFEST_ENTRY = "manifest.json";
|
||||
|
||||
/// Prevents instantiation.
|
||||
private ThemePackExporter() {
|
||||
}
|
||||
|
||||
/// Exports a theme pack to a zip-compatible file.
|
||||
///
|
||||
/// @param manifest the manifest to write
|
||||
/// @param assets asset files to copy into the zip
|
||||
/// @param outputFile the target theme-pack file
|
||||
/// @throws IOException if a source file cannot be read or the zip cannot be written
|
||||
/// @throws IllegalArgumentException if an asset entry is duplicated or unsafe
|
||||
public static void export(
|
||||
ThemePackManifest manifest,
|
||||
@Unmodifiable List<ThemePackAsset> assets,
|
||||
Path outputFile) throws IOException {
|
||||
Objects.requireNonNull(manifest);
|
||||
Objects.requireNonNull(assets);
|
||||
Objects.requireNonNull(outputFile);
|
||||
|
||||
validateAssets(assets);
|
||||
|
||||
Path output = outputFile.toAbsolutePath().normalize();
|
||||
Path parent = output.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
|
||||
Path temporaryFile = FileUtils.tmpSaveFile(output);
|
||||
try {
|
||||
Files.deleteIfExists(temporaryFile);
|
||||
try (Zipper zipper = new Zipper(temporaryFile)) {
|
||||
zipper.putTextFile(JsonUtils.GSON.toJson(manifest), MANIFEST_ENTRY);
|
||||
for (ThemePackAsset asset : assets) {
|
||||
try (InputStream input = asset.source().openStream()) {
|
||||
zipper.putStream(input, asset.entryName());
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.move(temporaryFile, output, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
try {
|
||||
Files.deleteIfExists(temporaryFile);
|
||||
} catch (IOException suppressed) {
|
||||
e.addSuppressed(suppressed);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates all asset entries and source files before the zip is written.
|
||||
private static void validateAssets(List<ThemePackAsset> assets) throws IOException {
|
||||
Set<String> entries = new HashSet<>();
|
||||
entries.add(MANIFEST_ENTRY);
|
||||
|
||||
for (ThemePackAsset asset : assets) {
|
||||
Objects.requireNonNull(asset);
|
||||
@Nullable Path sourceFile = asset.source().file();
|
||||
if (sourceFile != null && !Files.isRegularFile(sourceFile)) {
|
||||
throw new IOException("Theme-pack asset source is not a regular file: " + sourceFile);
|
||||
}
|
||||
try (InputStream ignored = asset.source().openStream()) {
|
||||
// Validate readability before writing the target zip.
|
||||
}
|
||||
if (!entries.add(asset.entryName())) {
|
||||
throw new IllegalArgumentException("Duplicate theme-pack zip entry: " + asset.entryName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,360 +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.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
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.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 java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Parsed metadata and themes from a theme-pack manifest.
|
||||
///
|
||||
/// @param id the stable package identifier
|
||||
/// @param version the package version string
|
||||
/// @param name the localized display name
|
||||
/// @param authors the package authors
|
||||
/// @param icon the optional theme-pack relative icon path
|
||||
/// @param description the optional localized package description
|
||||
/// @param themes selectable themes declared by the package
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
@JsonAdapter(ThemePackManifest.Adapter.class)
|
||||
public record ThemePackManifest(
|
||||
String id,
|
||||
String version,
|
||||
LocalizedText name,
|
||||
@Unmodifiable List<ThemePackAuthor> authors,
|
||||
@Nullable String icon,
|
||||
@Nullable LocalizedText description,
|
||||
@Unmodifiable List<Theme> themes) {
|
||||
|
||||
/// JSON schema for the current manifest format.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("theme-pack", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Package and theme ID format used by theme-pack manifests.
|
||||
private static final Pattern PACKAGE_ID_PATTERN = Pattern.compile(
|
||||
"[A-Za-z0-9][A-Za-z0-9_-]*(\\.[A-Za-z0-9][A-Za-z0-9_-]*)*"
|
||||
);
|
||||
|
||||
/// Creates a theme-pack manifest.
|
||||
///
|
||||
/// @param id the stable package identifier
|
||||
/// @param version the package version string
|
||||
/// @param name the localized display name
|
||||
/// @param authors the package authors
|
||||
/// @param icon the optional theme-pack relative icon path
|
||||
/// @param description the optional localized package description
|
||||
/// @param themes selectable themes declared by the package
|
||||
public ThemePackManifest {
|
||||
id = requirePackageId(id);
|
||||
version = requireNonBlank(version, "version");
|
||||
name = requireLocalizedText(name, "name");
|
||||
authors = List.copyOf(authors);
|
||||
if (icon != null) {
|
||||
icon = ThemePackAsset.normalizeEntryName(icon);
|
||||
}
|
||||
if (description != null) {
|
||||
description = requireLocalizedText(description, "description");
|
||||
}
|
||||
themes = List.copyOf(themes);
|
||||
if (themes.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme pack must declare at least one theme");
|
||||
}
|
||||
checkThemeIdentities(themes);
|
||||
}
|
||||
|
||||
public boolean isSimpleThemePack() {
|
||||
if (themes.size() == 1) {
|
||||
Theme theme = themes.get(0);
|
||||
return theme.id() == null && theme.name() == null;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the package display name in the current locale.
|
||||
///
|
||||
/// @return the localized display name, or the package ID when no localized text matches
|
||||
public String displayName() {
|
||||
return Objects.requireNonNullElse(name.getText(I18n.getLocale().getCandidateLocales()), id);
|
||||
}
|
||||
|
||||
/// Returns the package description in the current locale.
|
||||
///
|
||||
/// @return the localized description, or `null` when no localized text matches
|
||||
public @Nullable String displayDescription() {
|
||||
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
|
||||
}
|
||||
|
||||
/// Returns the first theme with the given ID.
|
||||
///
|
||||
/// A `null` theme ID matches only a single-theme manifest.
|
||||
///
|
||||
/// @param themeId the theme ID to find, or `null` for an unnamed single-theme manifest
|
||||
/// @return the matching theme, or `null` when no theme matches
|
||||
public @Nullable Theme findTheme(@Nullable String themeId) {
|
||||
if (themeId == null) {
|
||||
return themes.size() == 1 ? themes.get(0) : null;
|
||||
}
|
||||
|
||||
for (Theme theme : themes) {
|
||||
if (themeId.equals(theme.id())) {
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Reads the required theme declaration.
|
||||
private static List<Theme> readThemes(JsonObject object) {
|
||||
boolean hasSingleTheme = object.has("theme");
|
||||
boolean hasMultipleThemes = object.has("themes");
|
||||
if (hasSingleTheme == hasMultipleThemes) {
|
||||
throw new JsonParseException("Theme-pack manifest must declare exactly one of theme or themes");
|
||||
}
|
||||
|
||||
if (hasSingleTheme) {
|
||||
JsonElement element = object.get("theme");
|
||||
if (!(element instanceof JsonObject themeObject)) {
|
||||
throw new JsonParseException("Theme-pack theme must be an object");
|
||||
}
|
||||
return List.of(Theme.fromJson(themeObject, false));
|
||||
}
|
||||
|
||||
JsonElement element = object.get("themes");
|
||||
if (!(element instanceof JsonArray array)) {
|
||||
throw new JsonParseException("Theme-pack manifest is missing themes array");
|
||||
}
|
||||
if (array.isEmpty()) {
|
||||
throw new JsonParseException("Theme-pack themes array must declare at least one theme");
|
||||
}
|
||||
|
||||
ArrayList<Theme> themes = new ArrayList<>(array.size());
|
||||
for (JsonElement item : array) {
|
||||
if (!(item instanceof JsonObject themeObject)) {
|
||||
throw new JsonParseException("Theme-pack theme must be an object");
|
||||
}
|
||||
themes.add(Theme.fromJson(themeObject, true));
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
/// Checks that theme IDs and names are present whenever the manifest needs them for disambiguation.
|
||||
private static void checkThemeIdentities(List<Theme> themes) {
|
||||
if (themes.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Theme theme : themes) {
|
||||
if (theme.id() == null) {
|
||||
throw new IllegalArgumentException("Theme ID is required when a theme pack declares multiple themes");
|
||||
}
|
||||
if (theme.name() == null) {
|
||||
throw new IllegalArgumentException("Theme name is required when a theme pack declares multiple themes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a required string member.
|
||||
private static String requireMemberString(JsonObject object, String fieldName) {
|
||||
JsonElement element = object.get(fieldName);
|
||||
if (element == null) {
|
||||
throw new JsonParseException("Theme-pack manifest is missing " + fieldName);
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Theme-pack manifest field must be a string: " + fieldName);
|
||||
}
|
||||
return requireNonBlank(primitive.getAsString(), fieldName);
|
||||
}
|
||||
|
||||
/// Parses a localized text value.
|
||||
static LocalizedText parseLocalizedText(JsonElement element, String field) {
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
return LocalizedText.plain(requireNonBlank(primitive.getAsString(), field));
|
||||
}
|
||||
if (element instanceof JsonObject localizedObject) {
|
||||
if (localizedObject.isEmpty()) {
|
||||
throw new JsonParseException("Localized text field is empty: " + field);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> localizedValues = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : localizedObject.entrySet()) {
|
||||
JsonElement value = entry.getValue();
|
||||
if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new JsonParseException("Localized text values must be strings: " + field);
|
||||
}
|
||||
localizedValues.put(
|
||||
requireNonBlank(entry.getKey(), field),
|
||||
requireNonBlank(primitive.getAsString(), field));
|
||||
}
|
||||
return new LocalizedText(localizedValues);
|
||||
}
|
||||
throw new JsonParseException("Theme-pack localized text must be a string or object: " + field);
|
||||
}
|
||||
|
||||
/// Returns a validated localized text value.
|
||||
static LocalizedText requireLocalizedText(LocalizedText value, String field) {
|
||||
Objects.requireNonNull(value);
|
||||
|
||||
JsonElement element = JsonUtils.GSON.toJsonTree(value, LocalizedText.class);
|
||||
return parseLocalizedText(element, field);
|
||||
}
|
||||
|
||||
/// Returns a non-blank string value.
|
||||
private static String requireNonBlank(String value, String field) {
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme-pack manifest field is blank: " + field);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/// Returns a package ID matching the theme-pack ID format.
|
||||
static String requirePackageId(String value) {
|
||||
return requireId(value, "id", "Theme-pack manifest ID must follow package ID format: ");
|
||||
}
|
||||
|
||||
/// Returns a theme ID matching the theme-pack ID format.
|
||||
static String requireThemeId(String value) {
|
||||
return requireId(value, "theme id", "Theme ID must follow package ID format: ");
|
||||
}
|
||||
|
||||
/// Returns an ID matching the theme-pack ID format.
|
||||
private static String requireId(String value, String field, String messagePrefix) {
|
||||
String id = requireNonBlank(value, field);
|
||||
if (!PACKAGE_ID_PATTERN.matcher(id).matches()) {
|
||||
throw new IllegalArgumentException(messagePrefix + value);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
static final class Adapter implements JsonSerializer<@Nullable ThemePackManifest>,
|
||||
JsonDeserializer<@Nullable ThemePackManifest> {
|
||||
|
||||
@Override
|
||||
public @Nullable ThemePackManifest deserialize(
|
||||
@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (json == null || json instanceof JsonNull) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(json instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Theme-pack manifest is not a JsonObject");
|
||||
}
|
||||
|
||||
if (!JsonSchema.check(object, CURRENT_SCHEMA).readable()) {
|
||||
throw new JsonParseException("Unsupported theme-pack schema: " + object.get(JsonSchema.PROPERTY_SCHEMA));
|
||||
}
|
||||
|
||||
String id = requireMemberString(object, "id");
|
||||
String version = requireMemberString(object, "version");
|
||||
|
||||
LocalizedText name = context.deserialize(object.get("name"), LocalizedText.class);
|
||||
if (name == null) {
|
||||
throw new JsonParseException("Theme-pack manifest is missing name");
|
||||
}
|
||||
|
||||
@Nullable LocalizedText description;
|
||||
try {
|
||||
description = context.deserialize(object.get("description"), LocalizedText.class);
|
||||
if (description != null) {
|
||||
description = requireLocalizedText(description, "description");
|
||||
}
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme-pack description", e);
|
||||
description = null;
|
||||
}
|
||||
|
||||
List<ThemePackAuthor> authors;
|
||||
try {
|
||||
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
LOG.warning("Ignored invalid theme-pack authors", e);
|
||||
authors = 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-pack icon: " + icon, e);
|
||||
icon = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new ThemePackManifest(
|
||||
id,
|
||||
version,
|
||||
name,
|
||||
authors,
|
||||
icon,
|
||||
description,
|
||||
readThemes(object));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(@Nullable ThemePackManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null) {
|
||||
return JsonNull.INSTANCE;
|
||||
}
|
||||
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty(JsonSchema.PROPERTY_SCHEMA, CURRENT_SCHEMA.url());
|
||||
object.addProperty("id", src.id);
|
||||
object.addProperty("version", src.version);
|
||||
object.add("name", src.name.toJsonElement());
|
||||
object.add("authors", ThemePackAuthor.toJson(src.authors));
|
||||
|
||||
if (src.description != null) {
|
||||
object.add("description", src.description.toJsonElement());
|
||||
}
|
||||
if (src.icon != null) {
|
||||
object.addProperty("icon", src.icon);
|
||||
}
|
||||
|
||||
if (src.isSimpleThemePack()) {
|
||||
object.add("theme", src.themes.get(0).toJsonObject());
|
||||
} else {
|
||||
JsonArray themeArray = new JsonArray();
|
||||
for (Theme theme : src.themes) {
|
||||
themeArray.add(theme.toJsonObject());
|
||||
}
|
||||
object.add("themes", themeArray);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +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 kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/// A readable resource contributed by a theme pack.
|
||||
@NotNullByDefault
|
||||
public sealed interface ThemePackResource
|
||||
permits ThemePackResource.File, ThemePackResource.Zip, ThemePackResource.Builtin {
|
||||
/// Returns a stable resource name used for display and extension detection.
|
||||
///
|
||||
/// @return the resource name
|
||||
String name();
|
||||
|
||||
/// Opens a fresh input stream for this resource.
|
||||
///
|
||||
/// @return a new stream for reading the resource content
|
||||
/// @throws IOException if the resource cannot be opened
|
||||
InputStream openStream() throws IOException;
|
||||
|
||||
/// Returns the backing file when this resource is a regular filesystem file.
|
||||
///
|
||||
/// @return the backing file, or `null` when the resource is not a direct file
|
||||
default @Nullable Path file() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A resource stored as a regular filesystem file.
|
||||
///
|
||||
/// @param path the resource file path
|
||||
/// @param name the stable resource name
|
||||
@NotNullByDefault
|
||||
record File(Path path, String name) implements ThemePackResource {
|
||||
/// Creates a file-backed theme-pack resource named after its file name.
|
||||
///
|
||||
/// @param path the resource file path
|
||||
public File(Path path) {
|
||||
this(path, Objects.requireNonNull(path).getFileName().toString());
|
||||
}
|
||||
|
||||
/// Creates a file-backed theme-pack resource.
|
||||
///
|
||||
/// @param path the resource file path
|
||||
/// @param name the stable resource name
|
||||
public File {
|
||||
path = Objects.requireNonNull(path).toAbsolutePath().normalize();
|
||||
name = Objects.requireNonNull(name).trim();
|
||||
if (name.isEmpty()) {
|
||||
throw new IllegalArgumentException("Theme-pack resource name is blank");
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the file.
|
||||
@Override
|
||||
public InputStream openStream() throws IOException {
|
||||
return Files.newInputStream(path);
|
||||
}
|
||||
|
||||
/// Returns the backing file.
|
||||
@Override
|
||||
public Path file() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource stored as an entry inside a zip theme-pack file.
|
||||
///
|
||||
/// @param zipFile the zip theme-pack file
|
||||
/// @param entryName the normalized zip entry name
|
||||
@NotNullByDefault
|
||||
record Zip(Path zipFile, String entryName) implements ThemePackResource {
|
||||
/// Creates a zip-entry theme-pack resource.
|
||||
///
|
||||
/// @param zipFile the zip theme-pack file
|
||||
/// @param entryName the normalized zip entry name
|
||||
public Zip {
|
||||
zipFile = Objects.requireNonNull(zipFile).toAbsolutePath().normalize();
|
||||
entryName = ThemePackAsset.normalizeEntryName(entryName);
|
||||
}
|
||||
|
||||
/// Returns the zip entry name.
|
||||
@Override
|
||||
public String name() {
|
||||
return entryName;
|
||||
}
|
||||
|
||||
/// Opens the zip entry and closes the zip file when the returned stream is closed.
|
||||
@Override
|
||||
public InputStream openStream() throws IOException {
|
||||
ZipArchiveReader zip = new ZipArchiveReader(zipFile, StandardCharsets.UTF_8);
|
||||
boolean success = false;
|
||||
try {
|
||||
ZipArchiveEntry entry = zip.getEntry(entryName);
|
||||
if (entry == null || entry.isDirectory()) {
|
||||
throw new IOException("Theme-pack asset is missing: " + entryName);
|
||||
}
|
||||
InputStream input = zip.getInputStream(entry);
|
||||
success = true;
|
||||
return new ZipEntryInputStream(input, zip);
|
||||
} finally {
|
||||
if (!success) {
|
||||
zip.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource stored in launcher-bundled classpath resources.
|
||||
///
|
||||
/// @param resourcePath the classpath resource path
|
||||
/// @param entryName the theme-pack entry name
|
||||
@NotNullByDefault
|
||||
record Builtin(String resourcePath, String entryName) implements ThemePackResource {
|
||||
/// Creates a classpath-backed theme-pack resource.
|
||||
///
|
||||
/// @param resourcePath the classpath resource path
|
||||
/// @param entryName the theme-pack entry name
|
||||
public Builtin {
|
||||
resourcePath = Objects.requireNonNull(resourcePath);
|
||||
entryName = ThemePackAsset.normalizeEntryName(entryName);
|
||||
}
|
||||
|
||||
/// Returns the theme-pack entry name.
|
||||
@Override
|
||||
public String name() {
|
||||
return entryName;
|
||||
}
|
||||
|
||||
/// Opens the classpath resource.
|
||||
@Override
|
||||
public InputStream openStream() throws IOException {
|
||||
@Nullable InputStream input = ThemePackResource.class.getResourceAsStream(resourcePath);
|
||||
if (input == null) {
|
||||
throw new IOException("Built-in theme-pack asset is missing: " + entryName);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
/// An input stream that owns a zip file handle.
|
||||
@NotNullByDefault
|
||||
final class ZipEntryInputStream extends FilterInputStream {
|
||||
/// The zip file that must stay open while this stream is read.
|
||||
private final ZipArchiveReader zipFile;
|
||||
|
||||
/// Whether this stream has already been closed.
|
||||
private boolean closed;
|
||||
|
||||
/// Creates a zip-entry stream.
|
||||
///
|
||||
/// @param input the zip entry input stream
|
||||
/// @param zipFile the owning zip file
|
||||
private ZipEntryInputStream(InputStream input, ZipArchiveReader zipFile) {
|
||||
super(input);
|
||||
this.zipFile = Objects.requireNonNull(zipFile);
|
||||
}
|
||||
|
||||
/// Closes both the entry stream and the owning zip file.
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
|
||||
@Nullable IOException exception = null;
|
||||
try {
|
||||
super.close();
|
||||
} catch (IOException e) {
|
||||
exception = e;
|
||||
}
|
||||
|
||||
try {
|
||||
zipFile.close();
|
||||
} catch (IOException e) {
|
||||
if (exception != null) {
|
||||
exception.addSuppressed(e);
|
||||
} else {
|
||||
exception = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (exception != null) {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +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.annotations.SerializedName;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Identifies one theme declared by an installed theme pack.
|
||||
///
|
||||
/// @param packId the theme-pack identifier
|
||||
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public record ThemeReference(
|
||||
@SerializedName("packId") String packId,
|
||||
@SerializedName("themeId") @Nullable String themeId) {
|
||||
|
||||
/// Creates a theme reference.
|
||||
///
|
||||
/// @param packId the theme-pack identifier
|
||||
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
|
||||
public ThemeReference {
|
||||
packId = ThemePackManifest.requirePackageId(packId);
|
||||
if (themeId != null) {
|
||||
themeId = ThemePackManifest.requireThemeId(themeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +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.glavo.monetfx.Brightness;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Environment values used to resolve conditional theme-pack overrides.
|
||||
///
|
||||
/// @param brightness the effective light or dark mode after resolving launcher and system settings
|
||||
/// @param os the normalized operating system name used by theme conditions
|
||||
/// @param language the normalized UI language subtag used by theme conditions
|
||||
@NotNullByDefault
|
||||
public record ThemeResolveContext(
|
||||
Brightness brightness,
|
||||
String os,
|
||||
String language) {
|
||||
|
||||
/// Creates a theme resolution context.
|
||||
///
|
||||
/// @param brightness the effective light or dark mode after resolving launcher and system settings
|
||||
/// @param os the normalized operating system name used by theme conditions
|
||||
/// @param language the normalized UI language subtag used by theme conditions
|
||||
public ThemeResolveContext {
|
||||
Objects.requireNonNull(brightness);
|
||||
os = normalizeToken(os, "os");
|
||||
language = normalizeToken(language, "language");
|
||||
}
|
||||
|
||||
/// Creates a context for the current process platform.
|
||||
///
|
||||
/// @param brightness the effective light or dark mode after resolving launcher and system settings
|
||||
/// @return a context containing the current OS and default language
|
||||
public static ThemeResolveContext current(Brightness brightness) {
|
||||
return new ThemeResolveContext(
|
||||
brightness,
|
||||
normalizeOperatingSystem(OperatingSystem.CURRENT_OS),
|
||||
Locale.getDefault().getLanguage());
|
||||
}
|
||||
|
||||
/// Returns the normalized value for one supported condition key.
|
||||
///
|
||||
/// @param key the condition key
|
||||
/// @return the normalized context value, or `null` when the key is unsupported
|
||||
@Nullable String conditionValue(String key) {
|
||||
return switch (key) {
|
||||
case ThemeCondition.KEY_BRIGHTNESS -> brightness.name().toLowerCase(Locale.ROOT);
|
||||
case ThemeCondition.KEY_OS -> os;
|
||||
case ThemeCondition.KEY_LANGUAGE -> language;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the normalized name for an operating system.
|
||||
///
|
||||
/// @param operatingSystem the operating system to normalize
|
||||
/// @return the normalized operating system name
|
||||
static String normalizeOperatingSystem(OperatingSystem operatingSystem) {
|
||||
Objects.requireNonNull(operatingSystem);
|
||||
|
||||
return operatingSystem == OperatingSystem.UNKNOWN ? "unknown" : operatingSystem.getCheckedName();
|
||||
}
|
||||
|
||||
/// Normalizes one condition context token.
|
||||
private static String normalizeToken(String value, String name) {
|
||||
Objects.requireNonNull(value, name);
|
||||
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("Empty theme condition context value: " + name);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +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.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Title-bar settings contributed by a theme-pack appearance.
|
||||
///
|
||||
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
|
||||
@NotNullByDefault
|
||||
public record ThemeTitleBar(@Nullable Boolean transparent) {
|
||||
/// JSON member name for title-bar transparency.
|
||||
private static final String FIELD_TRANSPARENT = "transparent";
|
||||
|
||||
/// Creates a title-bar patch.
|
||||
///
|
||||
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
|
||||
public ThemeTitleBar {
|
||||
}
|
||||
|
||||
/// Parses title-bar settings from a JSON object.
|
||||
///
|
||||
/// @param object the title-bar JSON object
|
||||
/// @return the parsed title-bar patch
|
||||
static ThemeTitleBar fromJson(JsonObject object) throws JsonParseException {
|
||||
Objects.requireNonNull(object);
|
||||
|
||||
return new ThemeTitleBar(readTransparent(object));
|
||||
}
|
||||
|
||||
/// Converts this title-bar patch to its JSON representation.
|
||||
///
|
||||
/// @return the JSON object representing this title-bar patch
|
||||
public JsonObject toJsonObject() {
|
||||
JsonObject object = new JsonObject();
|
||||
if (transparent != null) {
|
||||
object.addProperty(FIELD_TRANSPARENT, transparent);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
/// Returns whether this title-bar object contains no concrete fields.
|
||||
///
|
||||
/// @return `true` when every field is inherited
|
||||
public boolean isEmpty() {
|
||||
return transparent == null;
|
||||
}
|
||||
|
||||
/// Returns a title-bar patch that applies the given patch over this title-bar patch.
|
||||
///
|
||||
/// @param patch the patch to apply
|
||||
/// @return the merged title-bar patch
|
||||
public ThemeTitleBar merge(ThemeTitleBar patch) {
|
||||
Objects.requireNonNull(patch);
|
||||
|
||||
return new ThemeTitleBar(patch.transparent != null ? patch.transparent : transparent);
|
||||
}
|
||||
|
||||
/// Reads the optional transparent field.
|
||||
private static @Nullable Boolean readTransparent(JsonObject object) {
|
||||
JsonElement element = object.get(FIELD_TRANSPARENT);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
|
||||
LOG.warning("Ignored invalid theme titleBar.transparent: expected a boolean, got " + element);
|
||||
return null;
|
||||
}
|
||||
return primitive.getAsBoolean();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 489 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user