Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5803235407 | ||
|
|
afbf567fef | ||
|
|
72b3c1981a | ||
|
|
72ee82543b | ||
|
|
996c9f4c86 | ||
|
|
f8c1e97376 | ||
|
|
f146040ff3 | ||
|
|
0da28173ab | ||
|
|
be822666a7 | ||
|
|
8c99ee3b0d | ||
|
|
0e3b4a7853 | ||
|
|
615b1dbaae | ||
|
|
0fa51f830d | ||
|
|
007bb345d0 | ||
|
|
46536f11d3 | ||
|
|
b1232b5dc8 | ||
|
|
f8f047d67e | ||
|
|
86529c6f15 | ||
|
|
d63829c344 | ||
|
|
34359a7e86 | ||
|
|
aa335d8365 | ||
|
|
a565d3b7ce | ||
|
|
3bff3c7f23 | ||
|
|
4e6e832e9c | ||
|
|
d6648a840e | ||
|
|
f8c2af716f | ||
|
|
bd812ccb67 | ||
|
|
dad190f7e6 | ||
|
|
1b1ba64a38 | ||
|
|
e31268c39d | ||
|
|
cb9d32dc91 | ||
|
|
36568df5b1 | ||
|
|
b4ac42d789 | ||
|
|
32aafb11a3 | ||
|
|
36dab70b7a | ||
|
|
ebf253b1a7 | ||
|
|
539dcca5ca | ||
|
|
659b6c2a70 | ||
|
|
3dcc7ab311 | ||
|
|
e0ccb58272 | ||
|
|
d4ec8ebec8 | ||
|
|
f3b21e5099 | ||
|
|
202fd38180 | ||
|
|
f5768f2f95 | ||
|
|
2d377d06de | ||
|
|
42d1581ba0 | ||
|
|
995a23c2bc | ||
|
|
8969f55a1f | ||
|
|
312a285792 | ||
|
|
d85b052195 | ||
|
|
cd0d3d1b97 | ||
|
|
6ccc7df998 | ||
|
|
73e5909a69 | ||
|
|
ade487277d | ||
|
|
e755d17977 | ||
|
|
292288de9e | ||
|
|
57f6dec462 | ||
|
|
8e1a549c9d | ||
|
|
1d18eae377 | ||
|
|
6ef04d2fa5 | ||
|
|
1778e9d916 | ||
|
|
dbb63e9faa |
No files matched your search
@@ -48,3 +48,9 @@ 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,16 +46,20 @@ 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: |
|
||||
@@ -67,6 +71,7 @@ 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
|
||||
@@ -76,6 +81,8 @@ 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
|
||||
@@ -86,6 +93,7 @@ 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 \
|
||||
@@ -120,6 +128,9 @@ 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
|
||||
@@ -69,3 +69,5 @@ language-subtag-registry
|
||||
# AI
|
||||
/.gemini/
|
||||
!/.gemini/config.yaml
|
||||
.codex
|
||||
/.gradle-*/
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
|
||||
## Gradle
|
||||
|
||||
- When invoking Gradle in this repository, always set `GRADLE_USER_HOME` to the workspace-local `.gradle-user-home` directory.
|
||||
- Prefer commands such as `./gradlew -g .gradle-user-home ...` or the equivalent environment-variable-based configuration.
|
||||
- When running Gradle `test` tasks, use a higher timeout of ten minutes.
|
||||
+39
-10
@@ -6,6 +6,8 @@ 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
|
||||
@@ -56,7 +58,7 @@ dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation(project(":HMCLBoot"))
|
||||
implementation("libs:JFoenix")
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.jwebp)
|
||||
implementation(libs.fxsvgimage)
|
||||
implementation(libs.java.info)
|
||||
implementation(libs.monet.fx)
|
||||
@@ -195,7 +197,7 @@ tasks.shadowJar {
|
||||
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
|
||||
|
||||
listOf(
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*",
|
||||
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
|
||||
).forEach { exclude("com/sun/jna/$it/**") }
|
||||
|
||||
@@ -251,20 +253,22 @@ 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 { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
|
||||
outputs.files(extensions.map { artifactFile(it) })
|
||||
|
||||
doLast {
|
||||
val jarContent = jarPath.readBytes()
|
||||
|
||||
ZipFile(jarPath).use { zipFile ->
|
||||
for (extension in extensions) {
|
||||
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
|
||||
val output = artifactFile(extension)
|
||||
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
|
||||
?: throw GradleException("HMCLauncher.$extension not found")
|
||||
|
||||
@@ -279,8 +283,31 @@ 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> {
|
||||
@@ -377,12 +404,14 @@ 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.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,277 @@
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,10 @@ import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
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.*;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
|
||||
@@ -138,6 +136,9 @@ public final class Launcher extends Application {
|
||||
Platform.setImplicitExit(false);
|
||||
Controllers.initialize(primaryStage);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
Themes.applyNativeDarkMode(primaryStage);
|
||||
|
||||
UpdateChecker.init();
|
||||
|
||||
primaryStage.show();
|
||||
@@ -343,6 +344,8 @@ public final class Launcher extends Application {
|
||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
|
||||
launch(Launcher.class, args);
|
||||
|
||||
@@ -24,7 +24,6 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -46,7 +45,6 @@ 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";
|
||||
@@ -60,34 +58,38 @@ 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 = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
|
||||
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");
|
||||
if (hmclHome == null) {
|
||||
String hmclHome = System.getProperty("hmcl.home", System.getenv("HMCL_USER_HOME"));
|
||||
if (StringUtils.isBlank(hmclHome)) {
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgData = System.getenv("XDG_DATA_HOME");
|
||||
if (StringUtils.isNotBlank(xdgData)) {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_GLOBAL_DIRECTORY = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_GLOBAL_DIRECTORY = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
|
||||
HMCL_GLOBAL_DIRECTORY = Path.of(hmclHome).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir");
|
||||
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
|
||||
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
|
||||
HMCL_CURRENT_DIRECTORY = StringUtils.isNotBlank(hmclCurrentDir)
|
||||
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
: CURRENT_DIRECTORY.resolve(".hmcl");
|
||||
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
|
||||
|
||||
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
|
||||
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
|
||||
? Path.of(hmclDependencies).toAbsolutePath().normalize()
|
||||
: HMCL_CURRENT_DIRECTORY.resolve("dependencies");
|
||||
}
|
||||
|
||||
public static boolean isStable() {
|
||||
|
||||
@@ -515,7 +515,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
|
||||
return false;
|
||||
|
||||
return FileUtils.isNameValid(id);
|
||||
return FileUtils.isNameValidForJar(id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,7 +72,6 @@ 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.platform.Platform.SYSTEM_PLATFORM;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
|
||||
|
||||
public final class LauncherHelper {
|
||||
|
||||
@@ -625,7 +624,7 @@ public final class LauncherHelper {
|
||||
break;
|
||||
case VANILLA_X86:
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
|
||||
&& isCompatibleWithX86Java()) {
|
||||
&& Platform.isSupportedTranslationX86_64()) {
|
||||
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -31,7 +31,6 @@ public final class Log {
|
||||
|
||||
private final String log;
|
||||
private Log4jLevel level;
|
||||
private boolean selected = false;
|
||||
|
||||
public Log(String log) {
|
||||
this.log = log;
|
||||
@@ -57,14 +56,6 @@ 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;
|
||||
|
||||
@@ -122,16 +122,27 @@ 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)
|
||||
return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || 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;
|
||||
}
|
||||
break;
|
||||
case LINUX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
return architecture == Architecture.X86;
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
// macOS Arm64 compatible with x86-64 programs via Rosetta 2.
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
return architecture == Architecture.X86_64;
|
||||
break;
|
||||
}
|
||||
@@ -364,13 +375,13 @@ public final class JavaManager {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
|
||||
if (Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
}
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
|
||||
break;
|
||||
}
|
||||
@@ -784,13 +795,15 @@ 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 (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
|
||||
if (Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
|
||||
}
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +468,9 @@ public final class Accounts {
|
||||
}
|
||||
} else if (exception instanceof MicrosoftService.XBox400Exception) {
|
||||
return i18n("account.methods.microsoft.error.wrong_verify_method");
|
||||
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionLicenseNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_license");
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionProfileNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_character");
|
||||
} else if (exception instanceof MicrosoftService.NoXuiException) {
|
||||
return i18n("account.methods.microsoft.error.add_family");
|
||||
|
||||
@@ -45,7 +45,7 @@ public enum VersionIconType {
|
||||
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
|
||||
return switch (modLoaderType) {
|
||||
case FORGE -> VersionIconType.FORGE;
|
||||
case NEO_FORGED -> VersionIconType.NEO_FORGE;
|
||||
case NEO_FORGE -> VersionIconType.NEO_FORGE;
|
||||
case FABRIC -> VersionIconType.FABRIC;
|
||||
case QUILT -> VersionIconType.QUILT;
|
||||
case LITE_LOADER -> VersionIconType.CHICKEN;
|
||||
|
||||
@@ -71,9 +71,15 @@ public final class TerracottaBundle {
|
||||
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();
|
||||
@@ -113,7 +119,7 @@ public final class TerracottaBundle {
|
||||
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
|
||||
}
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
|
||||
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
|
||||
try (
|
||||
InputStream is = tree.getInputStream(archive);
|
||||
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
|
||||
@@ -122,8 +128,8 @@ public final class TerracottaBundle {
|
||||
}
|
||||
|
||||
String hash = HexFormat.of().formatHex(digest.digest());
|
||||
if (!check.getChecksum().equalsIgnoreCase(hash)) {
|
||||
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
|
||||
if (!check.checksum().equalsIgnoreCase(hash)) {
|
||||
throw new ChecksumMismatchException(check.algorithm(), check.checksum(), hash);
|
||||
}
|
||||
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
@@ -172,7 +178,7 @@ public final class TerracottaBundle {
|
||||
return false;
|
||||
}
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
|
||||
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
|
||||
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
|
||||
int n;
|
||||
while ((n = is.read(buffer)) >= 0) {
|
||||
@@ -182,7 +188,7 @@ public final class TerracottaBundle {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
|
||||
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.checksum())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ import org.glavo.monetfx.beans.property.ColorSchemeProperty;
|
||||
import org.glavo.monetfx.beans.property.ReadOnlyColorSchemeProperty;
|
||||
import org.glavo.monetfx.beans.property.SimpleColorSchemeProperty;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.ui.MacOSNativeUtils;
|
||||
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
@@ -233,6 +234,11 @@ public final class Themes {
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && MacOSNativeUtils.isSupported()) {
|
||||
MacOSNativeUtils.setAppearance(darkModeProperty().get());
|
||||
|
||||
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance);
|
||||
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -213,6 +214,21 @@ public final class Controllers {
|
||||
return decorator;
|
||||
}
|
||||
|
||||
public static void saveWindowStates() {
|
||||
if (stageX != null) {
|
||||
config().setX(stageX.get() / SCREEN.getBounds().getWidth());
|
||||
}
|
||||
if (stageY != null) {
|
||||
config().setY(stageY.get() / SCREEN.getBounds().getHeight());
|
||||
}
|
||||
if (stageHeight != null) {
|
||||
config().setHeight(stageHeight.get());
|
||||
}
|
||||
if (stageWidth != null) {
|
||||
config().setWidth(stageWidth.get());
|
||||
}
|
||||
}
|
||||
|
||||
public static void onApplicationStop() {
|
||||
stageSizeChangeListener = null;
|
||||
if (stageX != null) {
|
||||
@@ -375,7 +391,7 @@ public final class Controllers {
|
||||
Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
Controllers.dialog(i18n("fatal.unsupported_platform.macos_arm64"), null, MessageType.INFO, continueAction);
|
||||
continueAction.run();
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
Controllers.dialog(i18n("fatal.unsupported_platform.windows_arm64"), null, MessageType.INFO, continueAction);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX &&
|
||||
@@ -579,7 +595,7 @@ public final class Controllers {
|
||||
return pane.getCompletableFuture();
|
||||
}
|
||||
|
||||
public static TaskExecutorDialogPane taskDialog(TaskExecutor executor, String title, TaskCancellationAction onCancel) {
|
||||
public static TaskExecutorDialogPane taskDialog(TaskExecutor executor, String title, @NotNull TaskCancellationAction onCancel) {
|
||||
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(onCancel);
|
||||
pane.setTitle(title);
|
||||
pane.setExecutor(executor);
|
||||
@@ -587,7 +603,7 @@ public final class Controllers {
|
||||
return pane;
|
||||
}
|
||||
|
||||
public static TaskExecutorDialogPane taskDialog(Task<?> task, String title, TaskCancellationAction onCancel) {
|
||||
public static TaskExecutorDialogPane taskDialog(Task<?> task, String title, @NotNull TaskCancellationAction onCancel) {
|
||||
TaskExecutor executor = task.executor();
|
||||
TaskExecutorDialogPane pane = taskDialog(executor, title, onCancel);
|
||||
executor.start();
|
||||
|
||||
@@ -62,6 +62,7 @@ import javafx.stage.Stage;
|
||||
import javafx.util.Callback;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.task.CacheFileTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
@@ -1166,12 +1167,10 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static Image loadImage(String url) throws Exception {
|
||||
URI uri = NetworkUtils.toURI(url);
|
||||
|
||||
URLConnection connection = NetworkUtils.createConnection(uri);
|
||||
if (connection instanceof HttpURLConnection)
|
||||
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
|
||||
public static Image loadImage(WebURL url) throws Exception {
|
||||
URLConnection connection = NetworkUtils.createConnection(url);
|
||||
if (connection instanceof HttpURLConnection httpConnection)
|
||||
connection = NetworkUtils.resolveConnection(httpConnection);
|
||||
|
||||
try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) {
|
||||
String contentType = Objects.requireNonNull(connection.getContentType(), "");
|
||||
|
||||
@@ -37,6 +37,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class HTMLRenderer {
|
||||
private static final String INDENT = " ";
|
||||
private static URI resolveLink(Node linkNode) {
|
||||
String href = linkNode.absUrl("href");
|
||||
if (href.isEmpty())
|
||||
@@ -57,6 +58,7 @@ public final class HTMLRenderer {
|
||||
private boolean underline;
|
||||
private boolean strike;
|
||||
private boolean highlight;
|
||||
private int indentLevel;
|
||||
private String headerLevel;
|
||||
private Node hyperlink;
|
||||
|
||||
@@ -218,7 +220,7 @@ public final class HTMLRenderer {
|
||||
appendImage(node);
|
||||
break;
|
||||
case "li":
|
||||
appendText("\n \u2022 ");
|
||||
appendText("\n" + INDENT.repeat(indentLevel) + " \u2022 ");
|
||||
break;
|
||||
case "dt":
|
||||
appendText(" ");
|
||||
@@ -237,11 +239,14 @@ public final class HTMLRenderer {
|
||||
}
|
||||
|
||||
if (node.childNodeSize() > 0) {
|
||||
boolean isLiNode = "li".equals(name);
|
||||
if (isLiNode) indentLevel++;
|
||||
pushNode(node);
|
||||
for (Node childNode : node.childNodes()) {
|
||||
appendNode(childNode);
|
||||
}
|
||||
popNode();
|
||||
if (isLiNode) indentLevel--;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.game.GameDumpGenerator;
|
||||
@@ -39,7 +38,6 @@ import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.util.CircularArrayList;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
@@ -261,9 +259,6 @@ public final class LogWindow extends Stage {
|
||||
private static final PseudoClass INFO = PseudoClass.getPseudoClass("info");
|
||||
private static final PseudoClass DEBUG = PseudoClass.getPseudoClass("debug");
|
||||
private static final PseudoClass TRACE = PseudoClass.getPseudoClass("trace");
|
||||
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
|
||||
private final Set<ListCell<Log>> selected = new HashSet<>();
|
||||
private final JFXSnackbar snackbar = new JFXSnackbar();
|
||||
|
||||
LogWindowSkin(LogWindowImpl control) {
|
||||
@@ -309,6 +304,7 @@ public final class LogWindow extends Stage {
|
||||
|
||||
{
|
||||
ListView<Log> listView = control.listView;
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
listView.getItems().addListener((InvalidationListener) observable -> {
|
||||
if (!listView.getItems().isEmpty() && control.autoScroll.get())
|
||||
listView.scrollTo(listView.getItems().size() - 1);
|
||||
@@ -318,7 +314,6 @@ public final class LogWindow extends Stage {
|
||||
+ "\"; -fx-font-size: " + config().getFontSize() + "px;");
|
||||
listView.setCellFactory(x -> new ListCell<>() {
|
||||
{
|
||||
x.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
getStyleClass().add("log-window-list-cell");
|
||||
Region clippedContainer = (Region) listView.lookup(".clipped-container");
|
||||
if (clippedContainer != null) {
|
||||
@@ -328,32 +323,6 @@ public final class LogWindow extends Stage {
|
||||
setPadding(new Insets(2));
|
||||
setWrapText(true);
|
||||
setGraphic(null);
|
||||
|
||||
setOnMouseClicked(event -> {
|
||||
if (event.getButton() != MouseButton.PRIMARY)
|
||||
return;
|
||||
|
||||
if (!event.isControlDown()) {
|
||||
for (ListCell<Log> logListCell : selected) {
|
||||
if (logListCell != this) {
|
||||
logListCell.pseudoClassStateChanged(SELECTED, false);
|
||||
if (logListCell.getItem() != null) {
|
||||
logListCell.getItem().setSelected(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selected.clear();
|
||||
}
|
||||
|
||||
selected.add(this);
|
||||
pseudoClassStateChanged(SELECTED, true);
|
||||
if (getItem() != null) {
|
||||
getItem().setSelected(true);
|
||||
}
|
||||
|
||||
event.consume();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -367,7 +336,6 @@ public final class LogWindow extends Stage {
|
||||
pseudoClassStateChanged(INFO, !empty && item.getLevel() == Log4jLevel.INFO);
|
||||
pseudoClassStateChanged(DEBUG, !empty && item.getLevel() == Log4jLevel.DEBUG);
|
||||
pseudoClassStateChanged(TRACE, !empty && item.getLevel() == Log4jLevel.TRACE);
|
||||
pseudoClassStateChanged(SELECTED, !empty && item.isSelected());
|
||||
|
||||
if (empty) {
|
||||
setText(null);
|
||||
@@ -379,10 +347,13 @@ public final class LogWindow extends Stage {
|
||||
|
||||
listView.setOnKeyPressed(event -> {
|
||||
if (event.isControlDown() && event.getCode() == KeyCode.C) {
|
||||
if (listView.getSelectionModel().isEmpty())
|
||||
return;
|
||||
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
for (Log item : listView.getItems()) {
|
||||
if (item != null && item.isSelected()) {
|
||||
for (Log item : listView.getSelectionModel().getSelectedItems()) {
|
||||
if (item != null) {
|
||||
if (item.getLog() != null)
|
||||
stringBuilder.append(item.getLog());
|
||||
stringBuilder.append('\n');
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui;
|
||||
|
||||
import com.sun.jna.Pointer;
|
||||
import org.jackhuang.hmcl.util.platform.macos.ObjectiveCRuntime;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class MacOSNativeUtils {
|
||||
|
||||
private static final Pointer nsApp = init();
|
||||
|
||||
private static @Nullable Pointer init() {
|
||||
if (ObjectiveCRuntime.INSTANCE == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
var objc = ObjectiveCRuntime.INSTANCE;
|
||||
|
||||
Pointer nsApplication = objc.objc_getClass("NSApplication");
|
||||
if (!isNull(nsApplication)) {
|
||||
Pointer sharedSel = objc.sel_registerName("sharedApplication");
|
||||
if (!isNull(sharedSel))
|
||||
return objc.objc_msgSend(nsApplication, sharedSel);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to initialize macOS appearance support", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isSupported() {
|
||||
return nsApp != null;
|
||||
}
|
||||
|
||||
private static boolean isNull(Pointer pointer) {
|
||||
return pointer == null || Pointer.nativeValue(pointer) == 0;
|
||||
}
|
||||
|
||||
public static void setAppearance(boolean dark) {
|
||||
setAppearance(dark, false);
|
||||
}
|
||||
|
||||
public static void setAppearance(boolean dark, boolean highContrast) {
|
||||
if (nsApp == null) return;
|
||||
|
||||
try {
|
||||
var objc = ObjectiveCRuntime.INSTANCE;
|
||||
|
||||
Pointer nsAppearance = objc.objc_getClass("NSAppearance");
|
||||
if (isNull(nsAppearance))
|
||||
return;
|
||||
|
||||
Pointer namedSel = objc.sel_registerName("appearanceNamed:");
|
||||
Pointer nsString = objc.objc_getClass("NSString");
|
||||
if (isNull(nsString)) return;
|
||||
|
||||
Pointer sel = objc.sel_registerName("stringWithUTF8String:");
|
||||
|
||||
String appearanceName;
|
||||
if (highContrast) {
|
||||
appearanceName = dark ? "NSAppearanceNameAccessibilityHighContrastDarkAqua" : "NSAppearanceNameAccessibilityHighContrastAqua";
|
||||
} else {
|
||||
appearanceName = dark ? "NSAppearanceNameDarkAqua" : "NSAppearanceNameAqua";
|
||||
}
|
||||
|
||||
Pointer appearanceNamePtr = objc.objc_msgSend(nsString, sel, appearanceName);
|
||||
if (isNull(appearanceNamePtr)) return;
|
||||
|
||||
Pointer appearance = objc.objc_msgSend(nsAppearance, namedSel, appearanceNamePtr);
|
||||
if (isNull(appearance)) return;
|
||||
|
||||
Pointer setSel = objc.sel_registerName("setAppearance:");
|
||||
objc.objc_msgSend(nsApp, setSel, appearance);
|
||||
} catch (Throwable t) {
|
||||
LOG.warning("Failed to set macOS appearance", t);
|
||||
}
|
||||
}
|
||||
|
||||
private MacOSNativeUtils() {
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ public enum SVG {
|
||||
EXTENSION_FILL("M8.8 21H5q-.825 0-1.4125-.5875T3 19V15.2q1.2 0 2.1-.7625T6 12.5q0-1.175-.9-1.9375T3 9.8V6q0-.825.5875-1.4125T5 4H9q0-1.05.725-1.775T11.5 1.5q1.05 0 1.775.725T14 4h4q.825 0 1.4125.5875T20 6v4q1.05 0 1.775.725T22.5 12.5q0 1.05-.725 1.775T20 15v4q0 .825-.5875 1.4125T18 21H14.2q0-1.25-.7875-2.125T11.5 18q-1.125 0-1.9125.875T8.8 21Z"),
|
||||
FEEDBACK("M12 15Q12.425 15 12.7125 14.7125T13 14Q13 13.575 12.7125 13.2875T12 13Q11.575 13 11.2875 13.2875T11 14Q11 14.425 11.2875 14.7125T12 15ZM11 11H13V5H11V11ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"),
|
||||
FEEDBACK_FILL("M2 22V4q0-.825.5875-1.4125T4 2H20q.825 0 1.4125.5875T22 4V16q0 .825-.5875 1.4125T20 18H6L2 22Zm10-7q.425 0 .7125-.2875T13 14t-.2875-.7125T12 13t-.7125.2875T11 14t.2875.7125T12 15Zm-1-4h2V5H11v6Z"),
|
||||
FILE_OPEN("M6 22q-0.825 0-1.4125-0.5875T4 20v-16q0-0.825 0.5875-1.4125T6 2h8l6 6v6h-2v-5H13v-5H6v16h9v2H6Zm15.95 0.375L19 19.425v2.225h-2v-5.65h5.65v2h-2.25l2.95 2.95-1.4 1.425Zm-15.95-2.375v-16 16Z"),
|
||||
FOLDER("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8V18Q22 18.825 21.4125 19.4125T20 20H4ZM4 18H20V8H11.175L9.175 6H4V18ZM4 18V6 18Z"),
|
||||
FOLDER_COPY("M3 21Q2.175 21 1.5875 20.4125T1 19V6H3V19H20V21H3ZM7 17Q6.175 17 5.5875 16.4125T5 15V4Q5 3.175 5.5875 2.5875T7 2H12L14 4H21Q21.825 4 22.4125 4.5875T23 6V15Q23 15.825 22.4125 16.4125T21 17H7ZM7 15H21V6H13.175L11.175 4H7V15ZM7 15V4 15Z"),
|
||||
FOLDER_OPEN("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8H11.175L9.175 6H4V18L6.4 10H23.5L20.925 18.575Q20.725 19.225 20.1875 19.6125T19 20H4ZM6.1 18H19L20.8 12H7.9L6.1 18ZM6.1 18 7.9 12 6.1 18ZM4 8V6 8Z"),
|
||||
|
||||
@@ -50,10 +50,10 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
setHeading(new Label(i18n("update.changelog")));
|
||||
setBody(new JFXSpinner());
|
||||
|
||||
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
|
||||
String url = CHANGELOG_URL + remoteVersion.channel().channelName + ".html";
|
||||
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.getVersion());
|
||||
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.version());
|
||||
VersionNumber currentVersion = VersionNumber.asVersion(Metadata.VERSION);
|
||||
if (targetVersion.compareTo(currentVersion) <= 0)
|
||||
// Downgrade update, no need to display changelog
|
||||
|
||||
@@ -303,7 +303,7 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
for (String key : ALLOWED_LINKS) {
|
||||
String value = links.get(key);
|
||||
if (value != null) {
|
||||
Hyperlink link = new Hyperlink(i18n("account.injector.link." + key));
|
||||
JFXHyperlink link = new JFXHyperlink(i18n("account.injector.link." + key));
|
||||
FXUtils.installSlowTooltip(link, value);
|
||||
link.setOnAction(e -> FXUtils.openLink(value));
|
||||
result.add(link);
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.geometry.Insets;
|
||||
@@ -29,6 +30,7 @@ import javafx.scene.control.Label;
|
||||
import javafx.scene.input.DragEvent;
|
||||
import javafx.scene.input.TransferMode;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||
import org.jackhuang.hmcl.auth.offline.Skin;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.TextureModel;
|
||||
@@ -135,7 +137,9 @@ public class OfflineAccountSkinPane extends StackPane {
|
||||
capeSelector.setValue(account.getSkin().getLocalCapePath());
|
||||
}
|
||||
|
||||
skinBinding = FXUtils.observeWeak(() -> {
|
||||
PauseTransition pauseTransition = new PauseTransition(Duration.seconds(1));
|
||||
|
||||
Runnable loadSkin = () -> {
|
||||
getSkin().load(account.getUsername())
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null) {
|
||||
@@ -157,6 +161,23 @@ public class OfflineAccountSkinPane extends StackPane {
|
||||
result.getCape() != null ? result.getCape().getImage() : null);
|
||||
}
|
||||
}).start();
|
||||
};
|
||||
|
||||
pauseTransition.setOnFinished(e -> loadSkin.run());
|
||||
|
||||
skinBinding = FXUtils.observeWeak(() -> {
|
||||
Skin.Type selectedType = skinItem.getSelectedData();
|
||||
|
||||
if (selectedType == Skin.Type.CUSTOM_SKIN_LOADER_API) {
|
||||
if (!cslApiField.validate()) {
|
||||
pauseTransition.stop();
|
||||
return;
|
||||
}
|
||||
pauseTransition.playFromStart();
|
||||
} else {
|
||||
pauseTransition.stop();
|
||||
loadSkin.run();
|
||||
}
|
||||
}, skinItem.selectedDataProperty(), cslApiField.textProperty(), modelCombobox.valueProperty(), skinSelector.valueProperty(), capeSelector.valueProperty());
|
||||
|
||||
FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> {
|
||||
|
||||
@@ -19,6 +19,8 @@ package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
@@ -28,6 +30,7 @@ import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -43,8 +46,6 @@ final class ComponentSublistWrapper extends VBox implements NoPaddingComponent {
|
||||
private boolean expanded = false;
|
||||
|
||||
ComponentSublistWrapper(ComponentSublist sublist) {
|
||||
boolean noPadding = !sublist.hasComponentPadding();
|
||||
|
||||
this.getStyleClass().add("options-sublist-wrapper");
|
||||
|
||||
Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20);
|
||||
@@ -104,12 +105,26 @@ final class ComponentSublistWrapper extends VBox implements NoPaddingComponent {
|
||||
|
||||
if (container == null) {
|
||||
this.container = new VBox();
|
||||
|
||||
if (!noPadding) {
|
||||
container.setPadding(new Insets(8, 16, 10, 16));
|
||||
}
|
||||
this.container.getStyleClass().add("container");
|
||||
FXUtils.setLimitHeight(container, 0);
|
||||
FXUtils.setOverflowHidden(container);
|
||||
|
||||
Rectangle rectangle = FXUtils.setOverflowHidden(container);
|
||||
rectangle.getStyleClass().add("overflow-hidden");
|
||||
|
||||
var last = PseudoClass.getPseudoClass("last");
|
||||
|
||||
InvalidationListener updateArc = o -> {
|
||||
if (ComponentSublistWrapper.this.getPseudoClassStates().contains(last)) {
|
||||
rectangle.setArcHeight(4);
|
||||
rectangle.setArcWidth(4);
|
||||
} else {
|
||||
rectangle.setArcHeight(0);
|
||||
rectangle.setArcWidth(0);
|
||||
}
|
||||
};
|
||||
updateArc.invalidated(null);
|
||||
ComponentSublistWrapper.this.getPseudoClassStates().addListener(updateArc);
|
||||
|
||||
container.getChildren().setAll(sublist);
|
||||
ComponentSublistWrapper.this.getChildren().add(container);
|
||||
|
||||
@@ -120,8 +135,7 @@ final class ComponentSublistWrapper extends VBox implements NoPaddingComponent {
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
|
||||
double contentHeight = expanded ? (sublist.prefHeight(sublist.getWidth()) + (noPadding ? 4 : 8 + 10)) : 0;
|
||||
double contentHeight = expanded ? sublist.prefHeight(sublist.getWidth()) : 0;
|
||||
double targetRotate = expanded ? -180 : 0;
|
||||
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
@@ -30,6 +31,7 @@ import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import java.nio.file.Path;
|
||||
@@ -38,10 +40,18 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class FileSelector extends HBox {
|
||||
private final StringProperty value = new SimpleStringProperty();
|
||||
private String chooserTitle = i18n("selector.choose_file");
|
||||
private boolean directory = false;
|
||||
private String chooserTitle = "";
|
||||
private SelectionMode selectionMode = SelectionMode.FILE;
|
||||
private final ObservableList<FileChooser.ExtensionFilter> extensionFilters = FXCollections.observableArrayList();
|
||||
|
||||
JFXButton selectButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN, 15);
|
||||
|
||||
public enum SelectionMode {
|
||||
FILE,
|
||||
DIRECTORY,
|
||||
FILE_OR_DIRECTORY
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value.get();
|
||||
}
|
||||
@@ -63,12 +73,12 @@ public class FileSelector extends HBox {
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isDirectory() {
|
||||
return directory;
|
||||
public SelectionMode getSelectionMode() {
|
||||
return selectionMode;
|
||||
}
|
||||
|
||||
public FileSelector setDirectory(boolean directory) {
|
||||
this.directory = directory;
|
||||
public FileSelector setSelectionMode(SelectionMode selectionMode) {
|
||||
this.selectionMode = selectionMode;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -80,26 +90,20 @@ public class FileSelector extends HBox {
|
||||
JFXTextField customField = new JFXTextField();
|
||||
FXUtils.bindString(customField, valueProperty());
|
||||
|
||||
JFXButton selectButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN, 15);
|
||||
selectButton.setOnAction(e -> {
|
||||
if (directory) {
|
||||
DirectoryChooser chooser = new DirectoryChooser();
|
||||
chooser.setTitle(chooserTitle);
|
||||
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
|
||||
if (dir != null) {
|
||||
String path = FileUtils.getAbsolutePath(dir);
|
||||
customField.setText(path);
|
||||
value.setValue(path);
|
||||
}
|
||||
} else {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.getExtensionFilters().addAll(getExtensionFilters());
|
||||
chooser.setTitle(chooserTitle);
|
||||
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
|
||||
if (file != null) {
|
||||
String path = FileUtils.getAbsolutePath(file);
|
||||
customField.setText(path);
|
||||
value.setValue(path);
|
||||
switch (selectionMode) {
|
||||
case FILE -> openFileChooser(customField);
|
||||
case DIRECTORY -> openDirectoryChooser(customField);
|
||||
case FILE_OR_DIRECTORY -> {
|
||||
PopupMenu selectPopupMenu = new PopupMenu();
|
||||
JFXPopup selectModePopup = new JFXPopup(selectPopupMenu);
|
||||
|
||||
selectPopupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.FILE_OPEN, i18n("selector.choose_file"), () -> openFileChooser(customField), selectModePopup),
|
||||
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("selector.choose_directory"), () -> openDirectoryChooser(customField), selectModePopup)
|
||||
);
|
||||
|
||||
selectModePopup.show(selectButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, -selectButton.getWidth(), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -108,4 +112,27 @@ public class FileSelector extends HBox {
|
||||
setSpacing(3);
|
||||
getChildren().addAll(customField, selectButton);
|
||||
}
|
||||
|
||||
private void openFileChooser(JFXTextField customField) {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.getExtensionFilters().addAll(getExtensionFilters());
|
||||
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_file") : chooserTitle);
|
||||
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
|
||||
if (file != null) {
|
||||
String path = FileUtils.getAbsolutePath(file);
|
||||
customField.setText(path);
|
||||
value.setValue(path);
|
||||
}
|
||||
}
|
||||
|
||||
private void openDirectoryChooser(JFXTextField customField) {
|
||||
DirectoryChooser chooser = new DirectoryChooser();
|
||||
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_directory") : chooserTitle);
|
||||
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
|
||||
if (dir != null) {
|
||||
String path = FileUtils.getAbsolutePath(dir);
|
||||
customField.setText(path);
|
||||
value.setValue(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,14 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import com.jfoenix.utils.JFXNodeUtils;
|
||||
import javafx.animation.Animation;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.NumberBinding;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.Node;
|
||||
@@ -26,6 +32,10 @@ import javafx.scene.control.ScrollBar;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
import org.jackhuang.hmcl.util.MathUtils;
|
||||
|
||||
// Referenced in root.css
|
||||
@@ -36,6 +46,10 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
|
||||
private Rectangle track = new Rectangle();
|
||||
private Rectangle thumb = new Rectangle();
|
||||
|
||||
@SuppressWarnings({"FieldCanBeLocal", "FieldMayBeFinal"})
|
||||
private ChangeListener<Boolean> thumbHoverListener;
|
||||
private Animation thumbHoverAnimation;
|
||||
|
||||
public FloatScrollBarSkin(final ScrollBar scrollBar) {
|
||||
this.scrollBar = scrollBar;
|
||||
scrollBar.setPrefHeight(1e-18);
|
||||
@@ -45,8 +59,8 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
|
||||
Point2D dragStart;
|
||||
double preDragThumbPos;
|
||||
|
||||
NumberBinding range = Bindings.subtract(scrollBar.maxProperty(), scrollBar.minProperty());
|
||||
NumberBinding position = Bindings.divide(Bindings.subtract(scrollBar.valueProperty(), scrollBar.minProperty()), range);
|
||||
final NumberBinding range = Bindings.subtract(scrollBar.maxProperty(), scrollBar.minProperty());
|
||||
final NumberBinding position = Bindings.divide(Bindings.subtract(scrollBar.valueProperty(), scrollBar.minProperty()), range);
|
||||
|
||||
{
|
||||
// Children are added unmanaged because for some reason the height of the bar keeps changing
|
||||
@@ -63,7 +77,6 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
|
||||
|
||||
setup();
|
||||
|
||||
|
||||
thumb.setOnMousePressed(me -> {
|
||||
if (me.isSynthesized()) {
|
||||
// touch-screen events handled by Scroll handler
|
||||
@@ -128,14 +141,18 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
|
||||
track.widthProperty().unbind();
|
||||
track.heightProperty().unbind();
|
||||
|
||||
double offset = 4;
|
||||
|
||||
if (scrollBar.getOrientation() == Orientation.HORIZONTAL) {
|
||||
track.relocate(0, -8);
|
||||
track.widthProperty().bind(scrollBar.widthProperty());
|
||||
track.setHeight(8);
|
||||
track.relocate(offset, -6.0);
|
||||
NumberBinding trackWidth = Bindings.max(0, Bindings.subtract(scrollBar.widthProperty(), offset * 2));
|
||||
track.widthProperty().bind(trackWidth);
|
||||
track.setHeight(6.0);
|
||||
} else {
|
||||
track.relocate(-8, 0);
|
||||
track.setWidth(8);
|
||||
track.heightProperty().bind(scrollBar.heightProperty());
|
||||
track.relocate(-6.0, offset);
|
||||
track.setWidth(6.0);
|
||||
NumberBinding trackHeight = Bindings.max(0, Bindings.subtract(scrollBar.heightProperty(), offset * 2));
|
||||
track.heightProperty().bind(trackHeight);
|
||||
}
|
||||
|
||||
thumb.xProperty().unbind();
|
||||
@@ -144,15 +161,37 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
|
||||
thumb.heightProperty().unbind();
|
||||
|
||||
if (scrollBar.getOrientation() == Orientation.HORIZONTAL) {
|
||||
thumb.relocate(0, -8);
|
||||
thumb.widthProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.widthProperty())));
|
||||
thumb.setHeight(8);
|
||||
thumb.xProperty().bind(Bindings.subtract(scrollBar.widthProperty(), thumb.widthProperty()).multiply(position));
|
||||
thumb.relocate(0, -6.0);
|
||||
thumb.setHeight(6.0);
|
||||
|
||||
NumberBinding trackWidth = Bindings.max(0, Bindings.subtract(scrollBar.widthProperty(), offset * 2));
|
||||
|
||||
thumb.widthProperty().bind(Bindings.min(trackWidth,
|
||||
Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(trackWidth))));
|
||||
|
||||
thumb.xProperty().bind(
|
||||
Bindings.add(offset,
|
||||
Bindings.multiply(
|
||||
Bindings.max(0, Bindings.subtract(trackWidth, thumb.widthProperty())),
|
||||
position)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
thumb.relocate(-8, 0);
|
||||
thumb.setWidth(8);
|
||||
thumb.heightProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.heightProperty())));
|
||||
thumb.yProperty().bind(Bindings.subtract(scrollBar.heightProperty(), thumb.heightProperty()).multiply(position));
|
||||
thumb.relocate(-6.0, 0);
|
||||
thumb.setWidth(6.0);
|
||||
|
||||
NumberBinding trackHeight = Bindings.max(0, Bindings.subtract(scrollBar.heightProperty(), offset * 2));
|
||||
|
||||
thumb.heightProperty().bind(Bindings.min(trackHeight,
|
||||
Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(trackHeight))));
|
||||
|
||||
thumb.yProperty().bind(
|
||||
Bindings.add(offset,
|
||||
Bindings.multiply(
|
||||
Bindings.max(0, Bindings.subtract(trackHeight, thumb.heightProperty())),
|
||||
position)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +201,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
|
||||
return Double.MAX_VALUE;
|
||||
}
|
||||
|
||||
return 8;
|
||||
return 6.0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -171,13 +210,46 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
|
||||
return Double.MAX_VALUE;
|
||||
}
|
||||
|
||||
return 8;
|
||||
return 6.0;
|
||||
}
|
||||
};
|
||||
|
||||
this.thumbHoverListener = FXUtils.onWeakChangeAndOperate(thumb.hoverProperty(), newValue -> {
|
||||
if (thumbHoverAnimation != null) {
|
||||
thumbHoverAnimation.stop();
|
||||
thumbHoverAnimation = null;
|
||||
}
|
||||
|
||||
double targetOpacity = newValue ? 1.0 : 0.5;
|
||||
double currentOpacity = thumb.getOpacity();
|
||||
|
||||
double opacityAdjustment = targetOpacity - currentOpacity;
|
||||
if (Math.abs(opacityAdjustment) < 0.001) {
|
||||
if (opacityAdjustment != 0)
|
||||
thumb.setOpacity(targetOpacity);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (AnimationUtils.isAnimationEnabled() && JFXNodeUtils.isTreeVisible(thumb)) {
|
||||
thumbHoverAnimation = new Timeline(
|
||||
new KeyFrame(Duration.ZERO, new KeyValue(thumb.opacityProperty(), currentOpacity)),
|
||||
new KeyFrame(Motion.SHORT2, new KeyValue(thumb.opacityProperty(), targetOpacity))
|
||||
);
|
||||
thumbHoverAnimation.play();
|
||||
} else {
|
||||
thumb.setOpacity(targetOpacity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
if (thumbHoverAnimation != null) {
|
||||
thumbHoverAnimation.stop();
|
||||
thumbHoverAnimation = null;
|
||||
}
|
||||
thumbHoverListener = null;
|
||||
scrollBar = null;
|
||||
group = null;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -56,6 +57,7 @@ public class InputDialogPane extends JFXDialogLayout implements DialogAware {
|
||||
this.setBody(new VBox(textField));
|
||||
|
||||
lblCreationWarning = new Label();
|
||||
lblCreationWarning.setPadding(new Insets(0, 5, 0, 0));
|
||||
|
||||
acceptPane = new SpinnerPane();
|
||||
acceptPane.getStyleClass().add("small-spinner-pane");
|
||||
|
||||
@@ -20,10 +20,7 @@ package org.jackhuang.hmcl.ui.construct;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ListProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
@@ -36,6 +33,8 @@ import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
@@ -44,7 +43,7 @@ import java.util.function.Function;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
|
||||
/// @author Glavo
|
||||
public class LineSelectButton<T> extends LineButton {
|
||||
public class LineSelectButton<T extends @UnknownNullability Object> extends LineButton {
|
||||
|
||||
private static final String DEFAULT_STYLE_CLASS = "line-select-button";
|
||||
private static final PseudoClass SELECTED_PSEUDO_CLASS = PseudoClass.getPseudoClass("selected");
|
||||
@@ -57,13 +56,9 @@ public class LineSelectButton<T> extends LineButton {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
|
||||
InvalidationListener updateTrailingText = observable -> {
|
||||
Function<T, String> converter = getConverter();
|
||||
T value = getValue();
|
||||
if (value != null) {
|
||||
Function<T, String> converter = getConverter();
|
||||
setTrailingText(converter != null ? converter.apply(value) : value.toString());
|
||||
} else {
|
||||
setTrailingText(null);
|
||||
}
|
||||
setTrailingText(converter != null ? converter.apply(value) : Objects.toString(value, ""));
|
||||
};
|
||||
converterProperty().addListener(updateTrailingText);
|
||||
valueProperty().addListener(updateTrailingText);
|
||||
@@ -96,9 +91,6 @@ public class LineSelectButton<T> extends LineButton {
|
||||
var itemTitleLabel = new Label();
|
||||
itemTitleLabel.getStyleClass().add("title-label");
|
||||
itemTitleLabel.textProperty().bind(Bindings.createStringBinding(() -> {
|
||||
if (item == null)
|
||||
return "";
|
||||
|
||||
Function<T, String> converter = getConverter();
|
||||
return converter != null ? converter.apply(item) : Objects.toString(item, "");
|
||||
}, converterProperty()));
|
||||
@@ -173,10 +165,14 @@ public class LineSelectButton<T> extends LineButton {
|
||||
return converterProperty().get();
|
||||
}
|
||||
|
||||
public void setConverter(Function<T, String> value) {
|
||||
public void setConverter(Function<T, String> value) { // TODO: rename
|
||||
converterProperty().set(value);
|
||||
}
|
||||
|
||||
public void setNullSafeConverter(Function<@NotNull T, String> value) {
|
||||
converterProperty().set(it -> it != null ? value.apply(it) : "");
|
||||
}
|
||||
|
||||
private ObjectProperty<Function<T, String>> descriptionConverter;
|
||||
|
||||
public ObjectProperty<Function<T, String>> descriptionConverterProperty() {
|
||||
|
||||
@@ -55,7 +55,8 @@ public final class MultiFileItem<T> extends VBox {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public MultiFileItem() {
|
||||
setPadding(new Insets(0, 0, 10, 0));
|
||||
this.getStyleClass().add("multi-file-item");
|
||||
|
||||
setSpacing(8);
|
||||
|
||||
group.selectedToggleProperty().addListener((a, b, newValue) -> {
|
||||
@@ -266,8 +267,8 @@ public final class MultiFileItem<T> extends VBox {
|
||||
selector.setValue(value);
|
||||
}
|
||||
|
||||
public FileOption<T> setDirectory(boolean directory) {
|
||||
selector.setDirectory(directory);
|
||||
public FileOption<T> setSelectionMode(FileSelector.SelectionMode selectionMode) {
|
||||
selector.setSelectionMode(selectionMode);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,12 +81,11 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
|
||||
setCancel(cancel);
|
||||
|
||||
btnCancel.setDisable(onCancel.getCancellationAction() == null);
|
||||
btnCancel.setOnAction(e -> {
|
||||
if (onCancel.getCancellationAction() != null) {
|
||||
if (executor != null)
|
||||
executor.cancel();
|
||||
onCancel.getCancellationAction().accept(this);
|
||||
}
|
||||
if (executor != null)
|
||||
executor.cancel();
|
||||
onCancel.getCancellationAction().accept(this);
|
||||
});
|
||||
|
||||
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
|
||||
|
||||
@@ -30,7 +30,6 @@ import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
public class TwoLineListItem extends VBox {
|
||||
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
|
||||
@@ -177,7 +176,18 @@ public class TwoLineListItem extends VBox {
|
||||
lblTitle.setMinWidth(Label.USE_PREF_SIZE);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
FXUtils.onChangeAndOperate(tagsBox.heightProperty(), height -> FXUtils.setLimitHeight(scrollPane, height.doubleValue()));
|
||||
var expectedHeight = Bindings.createDoubleBinding(
|
||||
() -> {
|
||||
if (tags.isEmpty()) return 0.0;
|
||||
double h = tagsBox.prefHeight(-1);
|
||||
return h > 0 ? h : Double.NaN;
|
||||
},
|
||||
tags, tagsBox.heightProperty()
|
||||
);
|
||||
|
||||
scrollPane.minHeightProperty().bind(expectedHeight);
|
||||
scrollPane.prefHeightProperty().bind(expectedHeight);
|
||||
scrollPane.maxHeightProperty().bind(expectedHeight);
|
||||
firstLine.getChildren().setAll(lblTitle, scrollPane);
|
||||
|
||||
tags.addListener((InvalidationListener) ignored -> scrollPane.requestLayout());
|
||||
|
||||
@@ -20,8 +20,8 @@ package org.jackhuang.hmcl.ui.construct;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
@@ -54,12 +54,7 @@ public class URLValidator extends ValidatorBase {
|
||||
if (StringUtils.isBlank(textField.getText()))
|
||||
hasErrors.set(!nullable);
|
||||
else {
|
||||
try {
|
||||
NetworkUtils.toURI(textField.getText());
|
||||
hasErrors.set(false);
|
||||
} catch (IllegalArgumentException e) {
|
||||
hasErrors.set(true);
|
||||
}
|
||||
hasErrors.set(WebURL.tryParseBrowserInput(textField.getText()) == null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Duration;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
|
||||
import org.jackhuang.hmcl.setting.EnumBackgroundImage;
|
||||
@@ -205,7 +206,7 @@ public class DecoratorController {
|
||||
String backgroundImageUrl = config().getBackgroundImageUrl();
|
||||
if (backgroundImageUrl != null) {
|
||||
try {
|
||||
image = FXUtils.loadImage(backgroundImageUrl);
|
||||
image = FXUtils.loadImage(WebURL.parseBrowserInput(backgroundImageUrl));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Couldn't load background image", e);
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
}
|
||||
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
|
||||
handler.resolve();
|
||||
}, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("profile.already_exists"), (it) -> !finalExistingFiles.contains(it)));
|
||||
}, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValidForJar), new Validator(i18n("profile.already_exists"), (it) -> !finalExistingFiles.contains(it)));
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -423,7 +423,11 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
if (status == Status.LOADING)
|
||||
transitionPane.setContent(spinner, ContainerAnimations.FADE);
|
||||
else if (status == Status.SUCCESS)
|
||||
transitionPane.setContent(centerWrapper, ContainerAnimations.FADE);
|
||||
if (control.versions.isEmpty()) {
|
||||
transitionPane.setContent(emptyPane, ContainerAnimations.FADE);
|
||||
} else {
|
||||
transitionPane.setContent(centerWrapper, ContainerAnimations.FADE);
|
||||
}
|
||||
else // if (status == Status.FAILED)
|
||||
transitionPane.setContent(failedPane, ContainerAnimations.FADE);
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
vs.isFullscreen(),
|
||||
vs.getWidth(),
|
||||
vs.getHeight(),
|
||||
vs.getMaxMemory(),
|
||||
null,
|
||||
exportInfo.getMinMemory(),
|
||||
vs.isShowLogs(),
|
||||
/* showConsoleOnError */ true,
|
||||
|
||||
@@ -316,7 +316,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
if (skinnable.options.isRequireAuthlibInjectorServer()) {
|
||||
var serversSelectButton = new LineSelectButton<AuthlibInjectorServer>();
|
||||
serversSelectButton.setTitle(i18n("account.injector.server"));
|
||||
serversSelectButton.setConverter(AuthlibInjectorServer::getName);
|
||||
serversSelectButton.setNullSafeConverter(AuthlibInjectorServer::getName);
|
||||
serversSelectButton.setDescriptionConverter(AuthlibInjectorServer::getUrl);
|
||||
serversSelectButton.itemsProperty().set(config().getAuthlibInjectorServers());
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.image;
|
||||
|
||||
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.SnapshotParameters;
|
||||
@@ -29,6 +28,9 @@ import org.girod.javafx.svgimage.LoaderParameters;
|
||||
import org.girod.javafx.svgimage.SVGImage;
|
||||
import org.girod.javafx.svgimage.SVGLoader;
|
||||
import org.girod.javafx.svgimage.ScaleQuality;
|
||||
import org.glavo.webp.WebPImage;
|
||||
import org.glavo.webp.WebPImageLoadOptions;
|
||||
import org.glavo.webp.javafx.WebPFXImage;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.image.apng.Png;
|
||||
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Bitmap;
|
||||
@@ -38,13 +40,8 @@ import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
|
||||
import org.jackhuang.hmcl.util.SwingFXUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -71,16 +68,8 @@ public final class ImageUtils {
|
||||
};
|
||||
|
||||
public static final ImageLoader WEBP = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
|
||||
WebPImageReaderSpi spi = new WebPImageReaderSpi();
|
||||
ImageReader reader = spi.createReaderInstance(null);
|
||||
BufferedImage bufferedImage;
|
||||
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
|
||||
reader.setInput(imageInput, true, true);
|
||||
bufferedImage = reader.read(0, reader.getDefaultReadParam());
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
|
||||
var options = new WebPImageLoadOptions(requestedWidth, requestedHeight, preserveRatio, smooth);
|
||||
return new WebPFXImage(WebPImage.read(input, options));
|
||||
};
|
||||
|
||||
public static final ImageLoader SVG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
|
||||
@@ -468,6 +457,19 @@ public final class ImageUtils {
|
||||
return new AnimationImageImpl(width, height, framePixels, durations, cycleCount);
|
||||
}
|
||||
|
||||
private static int[] rgbaToArgb(ByteBuffer rgba) {
|
||||
int pixelCount = rgba.remaining() / 4;
|
||||
int[] argb = new int[pixelCount];
|
||||
for (int i = 0; i < pixelCount; i++) {
|
||||
int r = rgba.get() & 0xFF;
|
||||
int g = rgba.get() & 0xFF;
|
||||
int b = rgba.get() & 0xFF;
|
||||
int a = rgba.get() & 0xFF;
|
||||
argb[i] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
return argb;
|
||||
}
|
||||
|
||||
private ImageUtils() {
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ public final class AboutPage extends SpinnerPane {
|
||||
|
||||
ComponentList legal = new ComponentList();
|
||||
{
|
||||
var copyright = LineButton.createExternalLinkButton(Metadata.ABOUT_URL);
|
||||
var copyright = new LineButton();
|
||||
copyright.setLargeTitle(true);
|
||||
copyright.setTitle(i18n("about.copyright"));
|
||||
copyright.setSubtitle(i18n("about.copyright.statement"));
|
||||
|
||||
@@ -27,14 +27,20 @@ import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
|
||||
import org.jackhuang.hmcl.setting.Settings;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
|
||||
|
||||
import java.net.Proxy;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -72,7 +78,7 @@ public class DownloadSettingsPage extends StackPane {
|
||||
var versionListSourcePane = new LineSelectButton<String>();
|
||||
versionListSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty().not());
|
||||
versionListSourcePane.setTitle(i18n("settings.launcher.version_list_source"));
|
||||
versionListSourcePane.setConverter(converter);
|
||||
versionListSourcePane.setNullSafeConverter(converter);
|
||||
versionListSourcePane.setDescriptionConverter(descriptionConverter);
|
||||
versionListSourcePane.setItems(DownloadProviders.AUTO_PROVIDERS.keySet());
|
||||
versionListSourcePane.valueProperty().bindBidirectional(config().versionListSourceProperty());
|
||||
@@ -80,14 +86,14 @@ public class DownloadSettingsPage extends StackPane {
|
||||
var downloadSourcePane = new LineSelectButton<String>();
|
||||
downloadSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty());
|
||||
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
|
||||
downloadSourcePane.setConverter(converter);
|
||||
downloadSourcePane.setNullSafeConverter(converter);
|
||||
downloadSourcePane.setDescriptionConverter(descriptionConverter);
|
||||
downloadSourcePane.setItems(DownloadProviders.DIRECT_PROVIDERS.keySet());
|
||||
downloadSourcePane.valueProperty().bindBidirectional(config().downloadTypeProperty());
|
||||
|
||||
var defaultAddonSourcePane = new LineSelectButton<String>();
|
||||
defaultAddonSourcePane.setTitle(i18n("settings.launcher.default_addon_source"));
|
||||
defaultAddonSourcePane.setConverter(key -> I18n.i18n("mods." + key));
|
||||
defaultAddonSourcePane.setNullSafeConverter(key -> I18n.i18n("mods." + key));
|
||||
defaultAddonSourcePane.setItems("modrinth", "curseforge");
|
||||
defaultAddonSourcePane.valueProperty().bindBidirectional(config().defaultAddonSourceProperty());
|
||||
|
||||
@@ -98,9 +104,37 @@ public class DownloadSettingsPage extends StackPane {
|
||||
}
|
||||
|
||||
{
|
||||
var downloadList = new ComponentList();
|
||||
|
||||
VBox downloadThreads = new VBox(16);
|
||||
downloadThreads.getStyleClass().add("card-non-transparent");
|
||||
|
||||
ComponentSublist fileCommonLocationSublist = new ComponentSublist();
|
||||
|
||||
{
|
||||
{
|
||||
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
|
||||
fileCommonLocation.loadChildren(Arrays.asList(
|
||||
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
|
||||
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
|
||||
.setChooserTitle(i18n("launcher.cache_directory.choose"))
|
||||
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY)
|
||||
.bindBidirectional(config().commonDirectoryProperty())
|
||||
));
|
||||
fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty());
|
||||
|
||||
fileCommonLocationSublist.getContent().add(fileCommonLocation);
|
||||
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
|
||||
fileCommonLocationSublist.setHasSubtitle(true);
|
||||
fileCommonLocationSublist.subtitleProperty().bind(
|
||||
Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory())
|
||||
.orElse(i18n("launcher.cache_directory.disabled")),
|
||||
config().commonDirectoryProperty(), config().commonDirTypeProperty()));
|
||||
|
||||
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
|
||||
cleanButton.setOnAction(e -> clearCacheDirectory());
|
||||
fileCommonLocationSublist.setHeaderRight(cleanButton);
|
||||
}
|
||||
|
||||
{
|
||||
JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto"));
|
||||
VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0));
|
||||
@@ -153,7 +187,8 @@ public class DownloadSettingsPage extends StackPane {
|
||||
}
|
||||
}
|
||||
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadThreads);
|
||||
downloadList.getContent().addAll(fileCommonLocationSublist, downloadThreads);
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadList);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -330,4 +365,11 @@ public class DownloadSettingsPage extends StackPane {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void clearCacheDirectory() {
|
||||
String commonDirectory = Settings.instance().getCommonDirectory();
|
||||
if (commonDirectory != null) {
|
||||
FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import javafx.collections.ObservableList;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.image.ImageView;
|
||||
@@ -170,6 +171,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
FXUtils.setLimitHeight(updatePane, 55);
|
||||
StackPane.setAlignment(updatePane, Pos.TOP_RIGHT);
|
||||
FXUtils.onClicked(updatePane, this::onUpgrade);
|
||||
updatePane.setCursor(Cursor.HAND);
|
||||
FXUtils.onChange(showUpdateProperty(), this::showUpdate);
|
||||
|
||||
{
|
||||
@@ -183,7 +185,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
prompt.setSubtitle(i18n("update.bubble.subtitle"));
|
||||
prompt.setPickOnBounds(false);
|
||||
prompt.titleProperty().bind(BindingMapping.of(latestVersionProperty()).map(latestVersion ->
|
||||
latestVersion == null ? "" : i18n("update.bubble.title", latestVersion.getVersion())));
|
||||
latestVersion == null ? "" : i18n("update.bubble.title", latestVersion.version())));
|
||||
|
||||
hBox.getChildren().setAll(SVG.UPDATE.createIcon(20), prompt);
|
||||
}
|
||||
@@ -279,10 +281,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
if (show && !config().isDisableAutoShowUpdateDialog()
|
||||
&& getLatestVersion() != null
|
||||
&& !Objects.equals(config().getPromptedVersion(), getLatestVersion().getVersion())) {
|
||||
Controllers.dialog(new MessageDialogPane.Builder("", i18n("update.bubble.title", getLatestVersion().getVersion()), MessageDialogPane.MessageType.INFO)
|
||||
&& !Objects.equals(config().getPromptedVersion(), getLatestVersion().version())) {
|
||||
Controllers.dialog(new MessageDialogPane.Builder("", i18n("update.bubble.title", getLatestVersion().version()), MessageDialogPane.MessageType.INFO)
|
||||
.addAction(i18n("button.view"), () -> {
|
||||
config().setPromptedVersion(getLatestVersion().getVersion());
|
||||
config().setPromptedVersion(getLatestVersion().version());
|
||||
onUpgrade();
|
||||
})
|
||||
.addCancel(null)
|
||||
|
||||
@@ -79,7 +79,7 @@ public class PersonalizationPage extends StackPane {
|
||||
{
|
||||
var brightnessPane = new LineSelectButton<String>();
|
||||
brightnessPane.setTitle(i18n("settings.launcher.brightness"));
|
||||
brightnessPane.setConverter(name -> i18n("settings.launcher.brightness." + name));
|
||||
brightnessPane.setNullSafeConverter(name -> i18n("settings.launcher.brightness." + name));
|
||||
brightnessPane.setItems("auto", "light", "dark");
|
||||
brightnessPane.valueProperty().bindBidirectional(config().themeBrightnessProperty());
|
||||
|
||||
@@ -135,6 +135,7 @@ public class PersonalizationPage extends StackPane {
|
||||
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumBackgroundImage.CUSTOM)
|
||||
.setChooserTitle(i18n("launcher.background.choose"))
|
||||
.addExtensionFilter(FXUtils.getImageExtensionFilter())
|
||||
.setSelectionMode(FileSelector.SelectionMode.FILE_OR_DIRECTORY)
|
||||
.bindBidirectional(config().backgroundImageProperty()),
|
||||
new MultiFileItem.StringOption<>(i18n("launcher.background.network"), EnumBackgroundImage.NETWORK)
|
||||
.setValidators(new URLValidator(true))
|
||||
@@ -294,7 +295,7 @@ public class PersonalizationPage extends StackPane {
|
||||
var fontAntiAliasingPane = new LineSelectButton<Optional<FontSmoothingType>>();
|
||||
fontAntiAliasingPane.setTitle(i18n("settings.launcher.font.anti_aliasing"));
|
||||
fontAntiAliasingPane.setSubtitle(i18n("settings.take_effect_after_restart"));
|
||||
fontAntiAliasingPane.setConverter(value ->
|
||||
fontAntiAliasingPane.setNullSafeConverter(value ->
|
||||
value.isPresent()
|
||||
? i18n("settings.launcher.font.anti_aliasing." + value.get().name().toLowerCase(Locale.ROOT))
|
||||
: i18n("settings.launcher.font.anti_aliasing.auto")
|
||||
|
||||
@@ -20,21 +20,17 @@ package org.jackhuang.hmcl.ui.main;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.text.TextAlignment;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
|
||||
import org.jackhuang.hmcl.setting.Settings;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -61,7 +57,9 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -78,242 +76,193 @@ public final class SettingsPage extends ScrollPane {
|
||||
public SettingsPage() {
|
||||
this.setFitToWidth(true);
|
||||
|
||||
VBox rootPane = new VBox();
|
||||
VBox rootPane = new VBox(10);
|
||||
rootPane.setPadding(new Insets(10));
|
||||
this.setContent(rootPane);
|
||||
FXUtils.smoothScrolling(this);
|
||||
|
||||
ComponentList settingsPane = new ComponentList();
|
||||
{
|
||||
ComponentList updatePaneList = new ComponentList();
|
||||
{
|
||||
StackPane sponsorPane = new StackPane();
|
||||
sponsorPane.setCursor(Cursor.HAND);
|
||||
FXUtils.onClicked(sponsorPane, this::onSponsor);
|
||||
sponsorPane.setPadding(new Insets(8, 0, 8, 0));
|
||||
|
||||
GridPane gridPane = new GridPane();
|
||||
|
||||
ColumnConstraints col = new ColumnConstraints();
|
||||
col.setHgrow(Priority.SOMETIMES);
|
||||
col.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||
|
||||
gridPane.getColumnConstraints().setAll(col);
|
||||
|
||||
RowConstraints row = new RowConstraints();
|
||||
row.setMinHeight(Double.NEGATIVE_INFINITY);
|
||||
row.setValignment(VPos.TOP);
|
||||
row.setVgrow(Priority.SOMETIMES);
|
||||
gridPane.getRowConstraints().setAll(row);
|
||||
|
||||
ObjectProperty<UpdateChannel> updateChannel;
|
||||
{
|
||||
Label label = new Label(i18n("sponsor.hmcl"));
|
||||
label.setWrapText(true);
|
||||
label.setTextAlignment(TextAlignment.JUSTIFY);
|
||||
GridPane.setRowIndex(label, 0);
|
||||
GridPane.setColumnIndex(label, 0);
|
||||
gridPane.getChildren().add(label);
|
||||
}
|
||||
|
||||
sponsorPane.getChildren().setAll(gridPane);
|
||||
settingsPane.getContent().add(sponsorPane);
|
||||
}
|
||||
JFXButton updateButton = FXUtils.newToggleButton4(SVG.UPDATE, 20);
|
||||
updateButton.setOnAction(e -> onUpdate());
|
||||
updateButton.setPadding(Insets.EMPTY);
|
||||
FXUtils.installFastTooltip(updateButton, i18n("update.tooltip"));
|
||||
|
||||
ObjectProperty<UpdateChannel> updateChannel;
|
||||
{
|
||||
var updatePane = new LineSelectButton<UpdateChannel>() {
|
||||
|
||||
JFXButton updateButton = new JFXButton();
|
||||
updateButton.setGraphic(SVG.UPDATE.createIcon(20));
|
||||
updateButton.setOnAction(e -> onUpdate());
|
||||
updateButton.setPadding(Insets.EMPTY);
|
||||
FXUtils.installFastTooltip(updateButton, i18n("update.tooltip"));
|
||||
{
|
||||
getStyleClass().add("update-pane");
|
||||
setNode(IDX_TRAILING, updateButton);
|
||||
}
|
||||
|
||||
var updatePane = new LineSelectButton<UpdateChannel>() {
|
||||
|
||||
{
|
||||
getStyleClass().add("update-pane");
|
||||
setNode(IDX_TRAILING, updateButton);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getTrailingTextIndex() {
|
||||
return LineComponent.IDX_TRAILING + 1;
|
||||
}
|
||||
};
|
||||
updateChannel = updatePane.valueProperty();
|
||||
updatePane.setTitle(i18n("update"));
|
||||
updatePane.setValue(UpdateChannel.getChannel());
|
||||
|
||||
updatePane.setConverter(channel -> i18n("update.channel." + channel.channelName));
|
||||
updatePane.setItems(List.of(UpdateChannel.STABLE, UpdateChannel.DEVELOPMENT));
|
||||
updatePane.setDescriptionConverter(channel -> i18n("update.note." + channel.channelName));
|
||||
|
||||
final StringProperty lblUpdateSubProperty = updatePane.subtitleProperty();
|
||||
|
||||
{
|
||||
updateListener = any -> {
|
||||
boolean outdated = UpdateChecker.isOutdated();
|
||||
|
||||
updateButton.setVisible(outdated);
|
||||
updateButton.setManaged(outdated);
|
||||
updatePane.pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), outdated);
|
||||
|
||||
if (UpdateChecker.isOutdated()) {
|
||||
lblUpdateSubProperty.set(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion()));
|
||||
} else if (UpdateChecker.isCheckingUpdate()) {
|
||||
lblUpdateSubProperty.set(i18n("update.checking"));
|
||||
} else {
|
||||
lblUpdateSubProperty.set(i18n("update.latest"));
|
||||
@Override
|
||||
protected int getTrailingTextIndex() {
|
||||
return LineComponent.IDX_TRAILING + 1;
|
||||
}
|
||||
};
|
||||
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
|
||||
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
|
||||
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
|
||||
updateListener.invalidated(null);
|
||||
updateChannel = updatePane.valueProperty();
|
||||
updatePane.setTitle(i18n("update"));
|
||||
updatePane.setValue(UpdateChannel.getChannel());
|
||||
|
||||
updatePane.setNullSafeConverter(channel -> i18n("update.channel." + channel.channelName));
|
||||
updatePane.setItems(List.of(UpdateChannel.STABLE, UpdateChannel.DEVELOPMENT));
|
||||
updatePane.setDescriptionConverter(channel -> i18n("update.note." + channel.channelName));
|
||||
|
||||
final StringProperty lblUpdateSubProperty = updatePane.subtitleProperty();
|
||||
|
||||
{
|
||||
updateListener = any -> {
|
||||
boolean outdated = UpdateChecker.isOutdated();
|
||||
|
||||
updateButton.setVisible(outdated);
|
||||
updateButton.setManaged(outdated);
|
||||
updatePane.pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), outdated);
|
||||
|
||||
if (UpdateChecker.isOutdated()) {
|
||||
lblUpdateSubProperty.set(i18n("update.newest_version", UpdateChecker.getLatestVersion().version()));
|
||||
} else if (UpdateChecker.isCheckingUpdate()) {
|
||||
lblUpdateSubProperty.set(i18n("update.checking"));
|
||||
} else {
|
||||
lblUpdateSubProperty.set(i18n("update.latest"));
|
||||
}
|
||||
};
|
||||
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
|
||||
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
|
||||
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
|
||||
updateListener.invalidated(null);
|
||||
}
|
||||
|
||||
updatePaneList.getContent().add(updatePane);
|
||||
}
|
||||
|
||||
settingsPane.getContent().add(updatePane);
|
||||
{
|
||||
LineToggleButton previewPane = new LineToggleButton();
|
||||
previewPane.setTitle(i18n("update.preview"));
|
||||
previewPane.setSubtitle(i18n("update.preview.subtitle"));
|
||||
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
|
||||
|
||||
InvalidationListener checkUpdateListener = e -> {
|
||||
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
|
||||
};
|
||||
updateChannel.addListener(checkUpdateListener);
|
||||
previewPane.selectedProperty().addListener(checkUpdateListener);
|
||||
|
||||
updatePaneList.getContent().add(previewPane);
|
||||
}
|
||||
|
||||
{
|
||||
LineToggleButton disableAutoShowUpdateDialogPane = new LineToggleButton();
|
||||
disableAutoShowUpdateDialogPane.setTitle(i18n("update.disable_auto_show_update_dialog"));
|
||||
disableAutoShowUpdateDialogPane.setSubtitle(i18n("update.disable_auto_show_update_dialog.subtitle"));
|
||||
disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(config().disableAutoShowUpdateDialogProperty());
|
||||
updatePaneList.getContent().add(disableAutoShowUpdateDialogPane);
|
||||
}
|
||||
|
||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("update")), updatePaneList);
|
||||
}
|
||||
|
||||
{
|
||||
LineToggleButton previewPane = new LineToggleButton();
|
||||
previewPane.setTitle(i18n("update.preview"));
|
||||
previewPane.setSubtitle(i18n("update.preview.subtitle"));
|
||||
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
|
||||
ComponentList languagePaneList = new ComponentList();
|
||||
|
||||
InvalidationListener checkUpdateListener = e -> {
|
||||
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
|
||||
};
|
||||
updateChannel.addListener(checkUpdateListener);
|
||||
previewPane.selectedProperty().addListener(checkUpdateListener);
|
||||
{
|
||||
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
|
||||
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
|
||||
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
|
||||
|
||||
settingsPane.getContent().add(previewPane);
|
||||
SupportedLocale currentLocale = I18n.getLocale();
|
||||
chooseLanguagePane.setNullSafeConverter(locale -> {
|
||||
if (locale.isDefault())
|
||||
return locale.getDisplayName(currentLocale);
|
||||
else if (locale.isSameLanguage(currentLocale))
|
||||
return locale.getDisplayName(locale);
|
||||
else
|
||||
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
|
||||
});
|
||||
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
|
||||
chooseLanguagePane.valueProperty().bindBidirectional(config().localizationProperty());
|
||||
|
||||
languagePaneList.getContent().add(chooseLanguagePane);
|
||||
|
||||
LineToggleButton disableAutoGameOptionsPane = new LineToggleButton();
|
||||
disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options"));
|
||||
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
|
||||
|
||||
languagePaneList.getContent().add(disableAutoGameOptionsPane);
|
||||
}
|
||||
|
||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList);
|
||||
}
|
||||
|
||||
{
|
||||
LineToggleButton disableAutoShowUpdateDialogPane = new LineToggleButton();
|
||||
disableAutoShowUpdateDialogPane.setTitle(i18n("update.disable_auto_show_update_dialog"));
|
||||
disableAutoShowUpdateDialogPane.setSubtitle(i18n("update.disable_auto_show_update_dialog.subtitle"));
|
||||
disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(config().disableAutoShowUpdateDialogProperty());
|
||||
settingsPane.getContent().add(disableAutoShowUpdateDialogPane);
|
||||
ComponentList miscPaneList = new ComponentList();
|
||||
|
||||
if (AprilFools.isShowAprilFoolsSettings()) {
|
||||
LineToggleButton disableAprilFools = new LineToggleButton();
|
||||
disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools"));
|
||||
disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart"));
|
||||
disableAprilFools.selectedProperty().bindBidirectional(config().disableAprilFoolsProperty());
|
||||
miscPaneList.getContent().add(disableAprilFools);
|
||||
}
|
||||
|
||||
{
|
||||
LineToggleButton allowAutoAgentPane = new LineToggleButton();
|
||||
allowAutoAgentPane.setTitle(i18n("settings.launcher.allow_auto_agent"));
|
||||
allowAutoAgentPane.setSubtitle(i18n("settings.launcher.allow_auto_agent.subtitle"));
|
||||
allowAutoAgentPane.selectedProperty().bindBidirectional(config().allowAutoAgentProperty());
|
||||
|
||||
miscPaneList.getContent().add(allowAutoAgentPane);
|
||||
}
|
||||
|
||||
{
|
||||
BorderPane debugPane = new BorderPane();
|
||||
|
||||
Label left = new Label(i18n("settings.launcher.debug"));
|
||||
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
|
||||
debugPane.setLeft(left);
|
||||
|
||||
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
|
||||
openLogFolderButton.setOnAction(e -> openLogFolder());
|
||||
openLogFolderButton.getStyleClass().add("jfx-button-border");
|
||||
if (LOG.getLogFile() == null)
|
||||
openLogFolderButton.setDisable(true);
|
||||
|
||||
SpinnerPane exportLogPane = new SpinnerPane();
|
||||
|
||||
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
|
||||
exportLogPane.setContent(logButton);
|
||||
logButton.setOnAction(e -> {
|
||||
exportLogPane.showSpinner();
|
||||
onExportLogs().whenCompleteAsync((result, exception) -> {
|
||||
exportLogPane.hideSpinner();
|
||||
if (exception == null) {
|
||||
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
|
||||
FXUtils.showFileInExplorer(result);
|
||||
} else {
|
||||
LOG.warning("Failed to export logs", exception);
|
||||
Controllers.dialog(
|
||||
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
|
||||
null,
|
||||
MessageType.ERROR
|
||||
);
|
||||
}
|
||||
}, Schedulers.javafx());
|
||||
});
|
||||
|
||||
HBox buttonBox = new HBox();
|
||||
buttonBox.setSpacing(10);
|
||||
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
|
||||
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
|
||||
debugPane.setRight(buttonBox);
|
||||
|
||||
miscPaneList.getContent().add(debugPane);
|
||||
}
|
||||
|
||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
|
||||
}
|
||||
|
||||
if (AprilFools.isShowAprilFoolsSettings()) {
|
||||
LineToggleButton disableAprilFools = new LineToggleButton();
|
||||
disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools"));
|
||||
disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart"));
|
||||
disableAprilFools.selectedProperty().bindBidirectional(config().disableAprilFoolsProperty());
|
||||
settingsPane.getContent().add(disableAprilFools);
|
||||
}
|
||||
|
||||
{
|
||||
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
|
||||
fileCommonLocation.loadChildren(Arrays.asList(
|
||||
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
|
||||
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
|
||||
.setChooserTitle(i18n("launcher.cache_directory.choose"))
|
||||
.setDirectory(true)
|
||||
.bindBidirectional(config().commonDirectoryProperty())
|
||||
));
|
||||
fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty());
|
||||
|
||||
ComponentSublist fileCommonLocationSublist = new ComponentSublist();
|
||||
fileCommonLocationSublist.getContent().add(fileCommonLocation);
|
||||
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
|
||||
fileCommonLocationSublist.setHasSubtitle(true);
|
||||
fileCommonLocationSublist.subtitleProperty().bind(
|
||||
Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory())
|
||||
.orElse(i18n("launcher.cache_directory.disabled")),
|
||||
config().commonDirectoryProperty(), config().commonDirTypeProperty()));
|
||||
|
||||
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
|
||||
cleanButton.setOnAction(e -> clearCacheDirectory());
|
||||
fileCommonLocationSublist.setHeaderRight(cleanButton);
|
||||
|
||||
settingsPane.getContent().add(fileCommonLocationSublist);
|
||||
}
|
||||
|
||||
{
|
||||
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
|
||||
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
|
||||
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
|
||||
|
||||
SupportedLocale currentLocale = I18n.getLocale();
|
||||
chooseLanguagePane.setConverter(locale -> {
|
||||
if (locale.isDefault())
|
||||
return locale.getDisplayName(currentLocale);
|
||||
else if (locale.isSameLanguage(currentLocale))
|
||||
return locale.getDisplayName(locale);
|
||||
else
|
||||
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
|
||||
});
|
||||
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
|
||||
chooseLanguagePane.valueProperty().bindBidirectional(config().localizationProperty());
|
||||
|
||||
settingsPane.getContent().add(chooseLanguagePane);
|
||||
}
|
||||
|
||||
{
|
||||
LineToggleButton disableAutoGameOptionsPane = new LineToggleButton();
|
||||
disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options"));
|
||||
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
|
||||
|
||||
settingsPane.getContent().add(disableAutoGameOptionsPane);
|
||||
}
|
||||
|
||||
{
|
||||
LineToggleButton allowAutoAgentPane = new LineToggleButton();
|
||||
allowAutoAgentPane.setTitle(i18n("settings.launcher.allow_auto_agent"));
|
||||
allowAutoAgentPane.setSubtitle(i18n("settings.launcher.allow_auto_agent.subtitle"));
|
||||
allowAutoAgentPane.selectedProperty().bindBidirectional(config().allowAutoAgentProperty());
|
||||
|
||||
settingsPane.getContent().add(allowAutoAgentPane);
|
||||
}
|
||||
|
||||
{
|
||||
BorderPane debugPane = new BorderPane();
|
||||
|
||||
Label left = new Label(i18n("settings.launcher.debug"));
|
||||
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
|
||||
debugPane.setLeft(left);
|
||||
|
||||
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
|
||||
openLogFolderButton.setOnAction(e -> openLogFolder());
|
||||
openLogFolderButton.getStyleClass().add("jfx-button-border");
|
||||
if (LOG.getLogFile() == null)
|
||||
openLogFolderButton.setDisable(true);
|
||||
|
||||
SpinnerPane exportLogPane = new SpinnerPane();
|
||||
|
||||
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
|
||||
exportLogPane.setContent(logButton);
|
||||
logButton.setOnAction(e -> {
|
||||
exportLogPane.showSpinner();
|
||||
onExportLogs().whenCompleteAsync((result, exception) -> {
|
||||
exportLogPane.hideSpinner();
|
||||
if (exception == null) {
|
||||
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
|
||||
FXUtils.showFileInExplorer(result);
|
||||
} else {
|
||||
LOG.warning("Failed to export logs", exception);
|
||||
Controllers.dialog(
|
||||
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
|
||||
null,
|
||||
MessageType.ERROR
|
||||
);
|
||||
}
|
||||
}, Schedulers.javafx());
|
||||
});
|
||||
|
||||
HBox buttonBox = new HBox();
|
||||
buttonBox.setSpacing(10);
|
||||
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
|
||||
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
|
||||
debugPane.setRight(buttonBox);
|
||||
|
||||
settingsPane.getContent().add(debugPane);
|
||||
}
|
||||
|
||||
rootPane.getChildren().add(settingsPane);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,15 +401,4 @@ public final class SettingsPage extends ScrollPane {
|
||||
return outputFile;
|
||||
}), Schedulers.io());
|
||||
}
|
||||
|
||||
private void onSponsor() {
|
||||
FXUtils.openLink("https://github.com/HMCL-dev/HMCL");
|
||||
}
|
||||
|
||||
private void clearCacheDirectory() {
|
||||
String commonDirectory = Settings.instance().getCommonDirectory();
|
||||
if (commonDirectory != null) {
|
||||
FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,14 +58,14 @@ public class SkinCube extends MeshView {
|
||||
5, 0, 4, 1, 0, 5, //P5,T0, P4,T1, P0,T5
|
||||
5, 0, 0, 5, 1, 4, //P5,T0, P0,T5, P1,T4
|
||||
// RIGHT
|
||||
0, 5, 4, 6, 6, 11, //P0,T4 ,P4,T3, P6,T8
|
||||
0, 5, 6, 11, 2, 10, //P0,T4 ,P6,T8, P2,T9
|
||||
5, 3, 1, 4, 3, 9, //P5,T3, P1,T4, P3,T9
|
||||
5, 3, 3, 9, 7, 8, //P5,T3, P3,T9, P7,T8
|
||||
// FRONT
|
||||
1, 4, 0, 5, 2, 10, //P1,T5, P0,T4, P2,T9
|
||||
1, 4, 2, 10, 3, 9, //P1,T5, P2,T9, P3,T10
|
||||
// LEFT
|
||||
5, 3, 1, 4, 3, 9, //P5,T6, P1,T5, P3,T10
|
||||
5, 3, 3, 9, 7, 8, //P5,T6, P3,T10,P7,T11
|
||||
0, 5, 4, 6, 6, 11, //P0,T4 ,P4,T3, P6,T8
|
||||
0, 5, 6, 11, 2, 10, //P0,T4 ,P6,T8, P2,T9
|
||||
// BACK
|
||||
4, 6, 5, 7, 7, 12, //P4,T6, P5,T7, P7,T12
|
||||
4, 6, 7, 12, 6, 11, //P4,T6, P7,T12,P6,T11
|
||||
|
||||
@@ -131,14 +131,15 @@ public class SkinMultipleCubes extends Group {
|
||||
// LEFT
|
||||
getChildren().add(new Face(skin, start_x + width_interval + depth_interval, start_y + depth_interval, depth, height, interval, false, false,
|
||||
() -> new Box(thick, length, length), (b, p) -> {
|
||||
b.setTranslateX((width * length + thick) / 2.0);
|
||||
b.setTranslateX(-(width * length + thick) / 2.0);
|
||||
b.setTranslateY(-((height - 1) / 2.0 - p.getY()) * b.getHeight());
|
||||
b.setTranslateZ(((depth - 1) / 2.0 - p.getX()) * b.getDepth());
|
||||
}));
|
||||
|
||||
// RIGHT
|
||||
getChildren().add(new Face(skin, start_x, start_y + depth_interval, depth, height, interval, true, false,
|
||||
() -> new Box(thick, length, length), (b, p) -> {
|
||||
b.setTranslateX(-(width * length + thick) / 2.0);
|
||||
b.setTranslateX((width * length + thick) / 2.0);
|
||||
b.setTranslateY(-((height - 1) / 2.0 - p.getY()) * b.getHeight());
|
||||
b.setTranslateZ(((depth - 1) / 2.0 - p.getX()) * b.getDepth());
|
||||
}));
|
||||
|
||||
@@ -246,7 +246,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
} else {
|
||||
handler.resolve();
|
||||
}
|
||||
});
|
||||
}, "", new RequiredValidator(i18n("input.not_empty")));
|
||||
});
|
||||
|
||||
if (ThreadLocalRandom.current().nextDouble() < 0.02D) {
|
||||
|
||||
@@ -183,18 +183,15 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
|
||||
nativesDirSublist.setHasSubtitle(true);
|
||||
nativesDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.advanced.natives_directory.custom"), NativesDirectoryType.CUSTOM)
|
||||
.setChooserTitle(i18n("settings.advanced.natives_directory.choose"))
|
||||
.setDirectory(true);
|
||||
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
|
||||
nativesDirItem.loadChildren(Arrays.asList(
|
||||
new MultiFileItem.Option<>(i18n("settings.advanced.natives_directory.default"), NativesDirectoryType.VERSION_FOLDER),
|
||||
nativesDirCustomOption
|
||||
));
|
||||
HintPane nativesDirHint = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
nativesDirHint.setText(i18n("settings.advanced.natives_directory.hint"));
|
||||
nativesDirItem.getChildren().add(nativesDirHint);
|
||||
|
||||
graphicsBackendPane = new LineSelectButton<>();
|
||||
graphicsBackendPane.setTitle(i18n("settings.advanced.graphics_backend"));
|
||||
graphicsBackendPane.setConverter(backend -> i18n("settings.advanced.graphics_backend." + backend.name().toLowerCase(Locale.ROOT)));
|
||||
graphicsBackendPane.setNullSafeConverter(backend -> i18n("settings.advanced.graphics_backend." + backend.name().toLowerCase(Locale.ROOT)));
|
||||
graphicsBackendPane.setDescriptionConverter(backend -> switch (backend) {
|
||||
case DEFAULT -> i18n("settings.advanced.graphics_backend.default.desc");
|
||||
case OPENGL -> i18n("settings.advanced.graphics_backend.opengl.desc");
|
||||
@@ -213,7 +210,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
|
||||
|
||||
rendererPane = new LineSelectButton<>();
|
||||
rendererPane.setTitle(i18n("settings.advanced.renderer"));
|
||||
rendererPane.setConverter(e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT)));
|
||||
rendererPane.setNullSafeConverter(e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT)));
|
||||
rendererPane.setDescriptionConverter(e -> {
|
||||
String bundleKey = "settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT) + ".desc";
|
||||
return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null;
|
||||
|
||||
@@ -28,6 +28,7 @@ import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -97,6 +98,14 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
listView = new JFXListView<>();
|
||||
filteredList = new FilteredList<>(skinnable.getItems());
|
||||
|
||||
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
var selectAllButton = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
|
||||
|
||||
ListChangeListener<Object> listener = change -> {
|
||||
selectAllButton.setDisable(!listView.getItems().isEmpty()
|
||||
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
|
||||
};
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
searchBar = new HBox();
|
||||
@@ -123,12 +132,13 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
enableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
disableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
|
||||
listView.getSelectionModel().getSelectedItems().addListener(listener);
|
||||
|
||||
selectingToolbar.getChildren().addAll(
|
||||
removeButton,
|
||||
enableButton,
|
||||
disableButton,
|
||||
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
|
||||
listView.getSelectionModel().selectRange(0, listView.getItems().size())),//reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
selectAllButton,
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
@@ -184,6 +194,7 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnly));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
this.listView.setItems(filteredList);
|
||||
listView.getItems().addListener(listener);
|
||||
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
|
||||
FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
@@ -531,7 +531,6 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
|
||||
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
listView.setCellFactory(x -> new ListCell<>() {
|
||||
private static final Insets PADDING = new Insets(9, 9, 0, 9);
|
||||
|
||||
@@ -578,7 +577,11 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
} else {
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(getSkinnable().repository.getType()).getModByCurseForgeId(item.getSlug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.getTitle());
|
||||
content.setSubtitle(item.getDescription());
|
||||
String description = item.getDescription();
|
||||
if (description != null) {
|
||||
description = description.replaceAll("\\R", " ");
|
||||
}
|
||||
content.setSubtitle(description);
|
||||
content.getTags().clear();
|
||||
for (String category : item.getCategories()) {
|
||||
if (getSkinnable().shouldDisplayCategory(category))
|
||||
|
||||
@@ -441,7 +441,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
case CLEANROOM:
|
||||
content.addTag(i18n("install.installer.cleanroom"));
|
||||
break;
|
||||
case NEO_FORGED:
|
||||
case NEO_FORGE:
|
||||
content.addTag(i18n("install.installer.neoforge"));
|
||||
break;
|
||||
case FABRIC:
|
||||
|
||||
@@ -160,11 +160,11 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
supportedLoaders.add(ModLoaderType.NEO_FORGED);
|
||||
supportedLoaders.add(ModLoaderType.NEO_FORGE);
|
||||
}
|
||||
|
||||
// Sinytra Connector
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && modManager.hasMod("connectormod", ModLoaderType.NEO_FORGED)
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE)
|
||||
|| "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,10 @@ import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
@@ -154,6 +157,18 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
);
|
||||
|
||||
// Toolbar Selecting
|
||||
|
||||
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
|
||||
|
||||
ListChangeListener<Object> listener = change -> {
|
||||
selectAll.setDisable(!listView.getItems().isEmpty()
|
||||
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
|
||||
};
|
||||
|
||||
listView.getSelectionModel().getSelectedItems().addListener(listener);
|
||||
listView.getItems().addListener(listener);
|
||||
|
||||
toolbarSelecting.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
|
||||
@@ -171,8 +186,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
|
||||
listView.getSelectionModel().selectAll()),
|
||||
selectAll,
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
@@ -473,7 +487,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
case FORGE -> i18n("install.installer.forge");
|
||||
case CLEANROOM -> i18n("install.installer.cleanroom");
|
||||
case LEGACY_FABRIC -> i18n("install.installer.legacyfabric");
|
||||
case NEO_FORGED -> i18n("install.installer.neoforge");
|
||||
case NEO_FORGE -> i18n("install.installer.neoforge");
|
||||
case FABRIC -> i18n("install.installer.fabric");
|
||||
case LITE_LOADER -> i18n("install.installer.liteloader");
|
||||
case QUILT -> i18n("install.installer.quilt");
|
||||
@@ -643,7 +657,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
case FORGE -> content.addTagWarning(i18n("install.installer.forge"));
|
||||
case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric"));
|
||||
case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom"));
|
||||
case NEO_FORGED -> content.addTagWarning(i18n("install.installer.neoforge"));
|
||||
case NEO_FORGE -> content.addTagWarning(i18n("install.installer.neoforge"));
|
||||
case FABRIC -> content.addTagWarning(i18n("install.installer.fabric"));
|
||||
case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader"));
|
||||
case QUILT -> content.addTagWarning(i18n("install.installer.quilt"));
|
||||
|
||||
@@ -239,7 +239,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
gameDirItem.disableProperty().bind(modpack);
|
||||
gameDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.custom"), GameDirectoryType.CUSTOM)
|
||||
.setChooserTitle(i18n("settings.game.working_directory.choose"))
|
||||
.setDirectory(true);
|
||||
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
|
||||
|
||||
gameDirItem.loadChildren(Arrays.asList(
|
||||
new MultiFileItem.Option<>(i18n("settings.advanced.game_dir.default"), GameDirectoryType.ROOT_FOLDER),
|
||||
@@ -336,7 +336,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
launcherVisibilityPane = new LineSelectButton<>();
|
||||
launcherVisibilityPane.setTitle(i18n("settings.advanced.launcher_visible"));
|
||||
launcherVisibilityPane.setItems(LauncherVisibility.values());
|
||||
launcherVisibilityPane.setConverter(e -> i18n("settings.advanced.launcher_visibility." + e.name().toLowerCase(Locale.ROOT)));
|
||||
launcherVisibilityPane.setNullSafeConverter(e -> i18n("settings.advanced.launcher_visibility." + e.name().toLowerCase(Locale.ROOT)));
|
||||
|
||||
BorderPane dimensionPane = new BorderPane();
|
||||
{
|
||||
@@ -351,7 +351,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
cboWindowsSize.setPrefWidth(150);
|
||||
right.setLeft(cboWindowsSize);
|
||||
cboWindowsSize.setEditable(true);
|
||||
cboWindowsSize.setStyle("-fx-padding: 4 4 4 16");
|
||||
cboWindowsSize.setPromptText("854x480");
|
||||
cboWindowsSize.getItems().setAll(getSupportedResolutions());
|
||||
|
||||
@@ -373,7 +372,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
enableDebugLogOutputPane.setTitle(i18n("settings.enable_debug_log_output"));
|
||||
processPriorityPane = new LineSelectButton<>();
|
||||
processPriorityPane.setTitle(i18n("settings.advanced.process_priority"));
|
||||
processPriorityPane.setConverter(e -> i18n("settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT)));
|
||||
processPriorityPane.setNullSafeConverter(e -> i18n("settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT)));
|
||||
processPriorityPane.setDescriptionConverter(e -> {
|
||||
String bundleKey = "settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT) + ".desc";
|
||||
return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null;
|
||||
|
||||
@@ -264,6 +264,11 @@ public final class Versions {
|
||||
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.ps1"), "*.ps1"));
|
||||
Path file = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage()));
|
||||
if (file != null) {
|
||||
if (!isValidScriptExtension(FileUtils.getExtension(file))) {
|
||||
String defaultExt = getDefaultScriptExtension();
|
||||
file = file.resolveSibling(file.getFileName().toString() + "." + defaultExt);
|
||||
}
|
||||
|
||||
LauncherHelper launcherHelper = new LauncherHelper(profile, account, id);
|
||||
for (Consumer<LauncherHelper> injecter : injecters) {
|
||||
injecter.accept(launcherHelper);
|
||||
@@ -273,6 +278,21 @@ public final class Versions {
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isValidScriptExtension(String ext) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
return ext.equalsIgnoreCase("bat") || ext.equalsIgnoreCase("ps1");
|
||||
}
|
||||
return ext.equalsIgnoreCase("sh") || ext.equalsIgnoreCase("bash") || ext.equalsIgnoreCase("command") || ext.equalsIgnoreCase("ps1");
|
||||
}
|
||||
|
||||
private static String getDefaultScriptExtension() {
|
||||
return switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS -> "bat";
|
||||
case MACOS -> "command";
|
||||
default -> "sh";
|
||||
};
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static void launch(Profile profile, String id, Consumer<LauncherHelper>... injecters) {
|
||||
if (!checkVersionForLaunching(profile, id))
|
||||
|
||||
@@ -40,10 +40,12 @@ import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@@ -153,7 +155,7 @@ public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.Backup
|
||||
LOG.warning("Failed to create backup", exception);
|
||||
Controllers.dialog(i18n("world.backup.create.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
|
||||
}
|
||||
}), i18n("world.backup"), null);
|
||||
}), i18n("world.backup"), TaskCancellationAction.NO_CANCEL);
|
||||
}
|
||||
|
||||
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
|
||||
|
||||
@@ -24,13 +24,11 @@ import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.effect.BoxBlur;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.glavo.nbt.tag.*;
|
||||
@@ -162,11 +160,10 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
|
||||
seedPane.setTitle(i18n("world.info.random_seed"));
|
||||
|
||||
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
|
||||
StackPane visibilityButton = new StackPane();
|
||||
JFXButton visibilityButton = FXUtils.newToggleButton4(SVG.VISIBILITY_OFF);
|
||||
{
|
||||
visibilityButton.setCursor(Cursor.HAND);
|
||||
visibilityButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get()));
|
||||
visibilityButton.setAlignment(Pos.CENTER);
|
||||
visibilityButton.setOnAction((event) -> visibility.set(!visibility.get()));
|
||||
}
|
||||
|
||||
Label seedLabel = new Label();
|
||||
@@ -180,7 +177,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
|
||||
blur.setIterations(3);
|
||||
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
|
||||
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
|
||||
visibilityButton.getChildren().setAll(icon.createIcon(12));
|
||||
visibilityButton.setGraphic(icon.createIcon(16));
|
||||
seedLabel.setEffect(isVisibility ? null : blur);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ final class HMCLDownloadTask extends FileDownloadTask {
|
||||
private final RemoteVersion.Type archiveFormat;
|
||||
|
||||
public HMCLDownloadTask(RemoteVersion version, Path target) {
|
||||
super(version.getUrl(), target, version.getIntegrityCheck());
|
||||
archiveFormat = version.getType();
|
||||
super(version.url(), target, version.integrityCheck());
|
||||
archiveFormat = version.type();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -23,11 +23,13 @@ import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class RemoteVersion {
|
||||
public record RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck,
|
||||
boolean preview, boolean force) {
|
||||
|
||||
public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String url) throws IOException {
|
||||
try {
|
||||
@@ -46,54 +48,8 @@ public final class RemoteVersion {
|
||||
}
|
||||
}
|
||||
|
||||
private final UpdateChannel channel;
|
||||
private final String version;
|
||||
private final String url;
|
||||
private final Type type;
|
||||
private final IntegrityCheck integrityCheck;
|
||||
private final boolean preview;
|
||||
private final boolean force;
|
||||
|
||||
public RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck, boolean preview, boolean force) {
|
||||
this.channel = channel;
|
||||
this.version = version;
|
||||
this.url = url;
|
||||
this.type = type;
|
||||
this.integrityCheck = integrityCheck;
|
||||
this.preview = preview;
|
||||
this.force = force;
|
||||
}
|
||||
|
||||
public UpdateChannel getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public IntegrityCheck getIntegrityCheck() {
|
||||
return integrityCheck;
|
||||
}
|
||||
|
||||
public boolean isPreview() {
|
||||
return preview;
|
||||
}
|
||||
|
||||
public boolean isForce() {
|
||||
return force;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
public @NotNull String toString() {
|
||||
return "[" + version + " from " + url + "]";
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +43,13 @@ public final class UpdateChecker {
|
||||
RemoteVersion latest = latestVersion.get();
|
||||
if (latest == null || isDevelopmentVersion(Metadata.VERSION)) {
|
||||
return false;
|
||||
} else if (latest.isForce()
|
||||
} else if (latest.force()
|
||||
|| Metadata.isNightly()
|
||||
|| latest.getChannel() == UpdateChannel.NIGHTLY
|
||||
|| latest.getChannel() != UpdateChannel.getChannel()) {
|
||||
return !latest.getVersion().equals(Metadata.VERSION);
|
||||
|| latest.channel() == UpdateChannel.NIGHTLY
|
||||
|| latest.channel() != UpdateChannel.getChannel()) {
|
||||
return !latest.version().equals(Metadata.VERSION);
|
||||
} else {
|
||||
return VersionNumber.compare(Metadata.VERSION, latest.getVersion()) < 0;
|
||||
return VersionNumber.compare(Metadata.VERSION, latest.version()) < 0;
|
||||
}
|
||||
},
|
||||
latestVersion);
|
||||
|
||||
@@ -20,20 +20,20 @@ package org.jackhuang.hmcl.upgrade;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonParseException;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.jackhuang.hmcl.EntryPoint;
|
||||
import org.jackhuang.hmcl.Main;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.UpgradeDialog;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.SwingUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -43,13 +43,15 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.checkFxUserThread;
|
||||
import static org.jackhuang.hmcl.util.Lang.thread;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class UpdateHandler {
|
||||
private UpdateHandler() {
|
||||
@@ -125,6 +127,29 @@ public final class UpdateHandler {
|
||||
throw new IOException("Current JAR is not verified");
|
||||
}
|
||||
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
Controllers.saveWindowStates();
|
||||
} finally {
|
||||
future.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
future.get();
|
||||
} catch (ExecutionException | InterruptedException ignored) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
FileSaver.waitForAllSaves();
|
||||
} catch (InterruptedException ignored) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
requestUpdate(downloaded, getCurrentLocation());
|
||||
EntryPoint.exit(0);
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
// Copy from javafx.swing
|
||||
/*
|
||||
* Copyright (c) 2012, 2018, Oracle and/or its affiliates. All rights reserved.
|
||||
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||
*
|
||||
* This code is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License version 2 only, as
|
||||
* published by the Free Software Foundation. Oracle designates this
|
||||
* particular file as subject to the "Classpath" exception as provided
|
||||
* by Oracle in the LICENSE file that accompanied this code.
|
||||
*
|
||||
* This code 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
|
||||
* version 2 for more details (a copy is included in the LICENSE file that
|
||||
* accompanied this code).
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License version
|
||||
* 2 along with this work; if not, write to the Free Software Foundation,
|
||||
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
*
|
||||
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
|
||||
* or visit www.oracle.com if you need additional information or have any
|
||||
* questions.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import javafx.scene.image.*;
|
||||
import javafx.scene.image.Image;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.awt.image.SampleModel;
|
||||
import java.awt.image.SinglePixelPackedSampleModel;
|
||||
import java.nio.IntBuffer;
|
||||
|
||||
/**
|
||||
* This class provides utility methods for converting data types between
|
||||
* Swing/AWT and JavaFX formats.
|
||||
*
|
||||
* @since JavaFX 2.2
|
||||
*/
|
||||
public final class SwingFXUtils {
|
||||
private SwingFXUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots the specified {@link BufferedImage} and stores a copy of
|
||||
* its pixels into a JavaFX {@link Image} object, creating a new
|
||||
* object if needed.
|
||||
* The returned {@code Image} will be a static snapshot of the state
|
||||
* of the pixels in the {@code BufferedImage} at the time the method
|
||||
* completes. Further changes to the {@code BufferedImage} will not
|
||||
* be reflected in the {@code Image}.
|
||||
* <p>
|
||||
* The optional JavaFX {@link WritableImage} parameter may be reused
|
||||
* to store the copy of the pixels.
|
||||
* A new {@code Image} will be created if the supplied object is null,
|
||||
* is too small or of a type which the image pixels cannot be easily
|
||||
* converted into.
|
||||
*
|
||||
* @param bimg the {@code BufferedImage} object to be converted
|
||||
* @param wimg an optional {@code WritableImage} object that can be
|
||||
* used to store the returned pixel data
|
||||
* @return an {@code Image} object representing a snapshot of the
|
||||
* current pixels in the {@code BufferedImage}.
|
||||
* @since JavaFX 2.2
|
||||
*/
|
||||
public static WritableImage toFXImage(BufferedImage bimg, WritableImage wimg) {
|
||||
int bw = bimg.getWidth();
|
||||
int bh = bimg.getHeight();
|
||||
switch (bimg.getType()) {
|
||||
case BufferedImage.TYPE_INT_ARGB:
|
||||
case BufferedImage.TYPE_INT_ARGB_PRE:
|
||||
break;
|
||||
default:
|
||||
BufferedImage converted =
|
||||
new BufferedImage(bw, bh, BufferedImage.TYPE_INT_ARGB_PRE);
|
||||
Graphics2D g2d = converted.createGraphics();
|
||||
g2d.drawImage(bimg, 0, 0, null);
|
||||
g2d.dispose();
|
||||
bimg = converted;
|
||||
break;
|
||||
}
|
||||
// assert(bimg.getType == TYPE_INT_ARGB[_PRE]);
|
||||
if (wimg != null) {
|
||||
int iw = (int) wimg.getWidth();
|
||||
int ih = (int) wimg.getHeight();
|
||||
if (iw < bw || ih < bh) {
|
||||
wimg = null;
|
||||
} else if (bw < iw || bh < ih) {
|
||||
int[] empty = new int[iw];
|
||||
PixelWriter pw = wimg.getPixelWriter();
|
||||
PixelFormat<IntBuffer> pf = PixelFormat.getIntArgbPreInstance();
|
||||
if (bw < iw) {
|
||||
pw.setPixels(bw, 0, iw - bw, bh, pf, empty, 0, 0);
|
||||
}
|
||||
if (bh < ih) {
|
||||
pw.setPixels(0, bh, iw, ih - bh, pf, empty, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wimg == null) {
|
||||
wimg = new WritableImage(bw, bh);
|
||||
}
|
||||
PixelWriter pw = wimg.getPixelWriter();
|
||||
DataBufferInt db = (DataBufferInt) bimg.getRaster().getDataBuffer();
|
||||
int[] data = db.getData();
|
||||
int offset = bimg.getRaster().getDataBuffer().getOffset();
|
||||
int scan = 0;
|
||||
SampleModel sm = bimg.getRaster().getSampleModel();
|
||||
if (sm instanceof SinglePixelPackedSampleModel) {
|
||||
scan = ((SinglePixelPackedSampleModel) sm).getScanlineStride();
|
||||
}
|
||||
|
||||
PixelFormat<IntBuffer> pf = (bimg.isAlphaPremultiplied() ?
|
||||
PixelFormat.getIntArgbPreInstance() :
|
||||
PixelFormat.getIntArgbInstance());
|
||||
pw.setPixels(0, 0, bw, bh, pf, data, offset, scan);
|
||||
return wimg;
|
||||
}
|
||||
|
||||
public static WritableImage toFXImage(BufferedImage bimg, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
if (requestedWidth <= 0. || requestedHeight <= 0.) {
|
||||
return toFXImage(bimg, null);
|
||||
}
|
||||
|
||||
int width = (int) requestedWidth;
|
||||
int height = (int) requestedHeight;
|
||||
|
||||
// Calculate actual dimensions if preserveRatio is true
|
||||
if (preserveRatio) {
|
||||
double originalWidth = bimg.getWidth();
|
||||
double originalHeight = bimg.getHeight();
|
||||
double scaleX = requestedWidth / originalWidth;
|
||||
double scaleY = requestedHeight / originalHeight;
|
||||
double scale = Math.min(scaleX, scaleY);
|
||||
|
||||
width = (int) (originalWidth * scale);
|
||||
height = (int) (originalHeight * scale);
|
||||
}
|
||||
|
||||
// Create scaled BufferedImage
|
||||
BufferedImage scaledImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE);
|
||||
Graphics2D g2d = scaledImage.createGraphics();
|
||||
try {
|
||||
if (smooth) {
|
||||
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
}
|
||||
|
||||
g2d.drawImage(bimg, 0, 0, width, height, null);
|
||||
} finally {
|
||||
g2d.dispose();
|
||||
}
|
||||
|
||||
return toFXImage(scaledImage, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,15 @@
|
||||
"title": "LWJGL Unsafe Agent",
|
||||
"subtitle": "Copyright © 2026 Glavo.\nLicensed under the Apache 2.0 License.",
|
||||
"externalLink": "https://github.com/HMCL-dev/lwjgl-unsafe-agent"
|
||||
},
|
||||
{
|
||||
"title": "TomlJ",
|
||||
"subtitle": "Licensed under the Apache 2.0 License.",
|
||||
"externalLink": "https://github.com/tomlj/tomlj"
|
||||
},
|
||||
{
|
||||
"title": "WebURL for Java",
|
||||
"subtitle": "Licensed under the Apache 2.0 License.",
|
||||
"externalLink": "https://github.com/Glavo/weburl-java"
|
||||
}
|
||||
]
|
||||
@@ -70,6 +70,12 @@
|
||||
"subtitleLocalized" : "about.thanks_to.mcim.statement",
|
||||
"externalLink" : "https://github.com/mcmod-info-mirror"
|
||||
},
|
||||
{
|
||||
"image" : "/assets/img/8mi-tech.png",
|
||||
"titleLocalized" : "about.thanks_to.8mi-tech",
|
||||
"subtitleLocalized" : "about.thanks_to.8mi-tech.statement",
|
||||
"externalLink" : "https://8mi.tech"
|
||||
},
|
||||
{
|
||||
"image" : {
|
||||
"light" : "/assets/img/github.png",
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
|
||||
.scroll-bar .thumb {
|
||||
-fx-fill: -monet-surface-tint;
|
||||
-fx-arc-width: 8;
|
||||
-fx-arc-height: 8;
|
||||
-fx-arc-width: 6;
|
||||
-fx-arc-height: 6;
|
||||
}
|
||||
|
||||
.title-label {
|
||||
@@ -1093,8 +1093,12 @@
|
||||
-fx-background-color: -monet-error-container;
|
||||
}
|
||||
|
||||
.multi-file-item {
|
||||
-fx-padding: 10 0 10 0;
|
||||
}
|
||||
|
||||
.options-sublist {
|
||||
-fx-background-color: -monet-surface;
|
||||
-fx-background-color: -monet-surface-container;
|
||||
}
|
||||
|
||||
.options-list-item {
|
||||
@@ -1105,6 +1109,14 @@
|
||||
-fx-font-size: 12;
|
||||
}
|
||||
|
||||
.options-sublist .options-list-item {
|
||||
-fx-background-color: -monet-surface-container;
|
||||
}
|
||||
|
||||
.options-sublist .multi-file-item {
|
||||
-fx-padding: 10 10 10 10;
|
||||
}
|
||||
|
||||
.options-list-item .svg {
|
||||
-fx-fill: -monet-on-surface;
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
@@ -1140,6 +1152,10 @@
|
||||
-fx-border-width: 0;
|
||||
}
|
||||
|
||||
.options-sublist-wrapper > .container {
|
||||
-fx-background-color: -monet-surface-container;
|
||||
}
|
||||
|
||||
.options-sublist-wrapper .expand-icon .svg {
|
||||
-fx-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -48,6 +48,8 @@ about.thanks_to.shulkersakura.statement=For providing the logo for HMCL.
|
||||
about.thanks_to.users=HMCL User Group Members
|
||||
about.thanks_to.users.statement=Thanks for donations, bug reports, and so on.
|
||||
about.thanks_to.yushijinhun.statement=For providing the authlib-injector related support.
|
||||
about.thanks_to.8mi-tech=8Mi-Tech (Quanzhou) Co., Ltd.
|
||||
about.thanks_to.8mi-tech.statement=Provides an accelerated download service for some of the game's static resources.
|
||||
about.open_source=Open Source
|
||||
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL)
|
||||
|
||||
@@ -108,9 +110,9 @@ account.methods.microsoft.close_page=Microsoft account authorization is now comp
|
||||
account.methods.microsoft.deauthorize=Deauthorize
|
||||
account.methods.microsoft.error.add_family=Please click <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">here</a> to change your account birth date to be over 18 years old, or add your account to a family.
|
||||
account.methods.microsoft.error.country_unavailable=Xbox Live is not available in your current country/region.
|
||||
account.methods.microsoft.error.missing_xbox_account=Your Microsoft account does not have a linked Xbox account yet. Please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to link one.
|
||||
account.methods.microsoft.error.no_character=Please confirm that you have purchased Minecraft: Java Edition.\n\
|
||||
If you have already purchased it, a game profile may not have been created. Please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create a game profile.
|
||||
account.methods.microsoft.error.missing_xbox_account=Please confirm that you have purchased Minecraft: Java Edition. If you have, please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create your game profile.
|
||||
account.methods.microsoft.error.no_license=No Minecraft purchase record found. Please click <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">here</a> to get the game.\nIf you are using Game Pass, please create your game profile <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a>. If you have already purchased the game, please check if you are logged into the correct account.
|
||||
account.methods.microsoft.error.no_character=You have not created a game profile yet. Please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create one.
|
||||
account.methods.microsoft.error.banned=Your account may have been banned by Xbox Live.\n\
|
||||
You can click <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">here</a> to check the ban status of your account.
|
||||
account.methods.microsoft.error.unknown=Failed to log in, error code: %d.
|
||||
@@ -363,16 +365,16 @@ download.failed.empty=No versions are available. Please click here to go back.
|
||||
download.failed.no_code=Failed to download
|
||||
download.failed.refresh=Failed to fetch version list. Please click here to retry.
|
||||
download.game=New Game
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93, https://bmclapi2.bangbang93.com/
|
||||
download.provider.bmclapi=BMCLAPI & MCIM
|
||||
download.provider.bmclapi.desc=This option is provided exclusively for users in Chinese Mainland.
|
||||
download.provider.mojang=Official
|
||||
download.provider.mojang.desc=OptiFine is provided by BMCLAPI
|
||||
download.provider.mojang.desc=Note that OptiFine is provided by BMCLAPI.
|
||||
download.provider.official=From Official Sources
|
||||
download.provider.official.desc=Latest, but may load slowly
|
||||
download.provider.official.desc=Always obtain from official sources.
|
||||
download.provider.balanced=From Fastest Available
|
||||
download.provider.balanced.desc=Balanced, but may not be the latest
|
||||
download.provider.balanced.desc=Best choice for ALL users.
|
||||
download.provider.mirror=From Mirror
|
||||
download.provider.mirror.desc=Fast, but may not be the latest
|
||||
download.provider.mirror.desc=Recommended option for users in Chinese Mainland.
|
||||
download.java=Downloading Java
|
||||
download.java.process=Java Download Process
|
||||
download.javafx=Downloading dependencies for the launcher...
|
||||
@@ -452,8 +454,6 @@ fatal.unsupported_platform=Minecraft is not fully supported on your platform yet
|
||||
If you cannot launch Minecraft 1.17 and later, you can try switching the "Renderer" to "Mesa LLVMpipe" in "Global/Instance-specific Settings → Advanced Settings" to use CPU rendering for better compatibility.
|
||||
fatal.unsupported_platform.loongarch=Hello Minecraft! Launcher has provided support for the Loongson platform.\n\
|
||||
If you encounter problems when playing a game, you can visit https://docs.hmcl.net/groups.html for help.
|
||||
fatal.unsupported_platform.macos_arm64=Hello Minecraft! Launcher has provided support for the Apple silicon platform, using native ARM Java to launch games to get a smoother gaming experience.\n\
|
||||
If you encounter problems when playing a game, launching the game with Java based on x86-64 architecture may offer better compatibility.
|
||||
fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher has provided native support for the Windows on Arm platform. If you encounter problems when playing a game, please try launching the game with Java based on x86 architecture.\n\
|
||||
\n\
|
||||
If you are using the <b>Qualcomm</b> platform, you may need to install the <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL Compatibility Pack</a> before playing games.\n\
|
||||
@@ -1309,6 +1309,7 @@ search.page_n=%1$d / %2$s
|
||||
|
||||
selector.choose=Choose
|
||||
selector.choose_file=Choose file
|
||||
selector.choose_directory=Choose directory
|
||||
selector.custom=Custom
|
||||
|
||||
settings=Settings
|
||||
@@ -1365,10 +1366,6 @@ settings.advanced.natives_directory.choose=Choose the location of the desired na
|
||||
settings.advanced.natives_directory.custom=Custom
|
||||
settings.advanced.natives_directory.default=Default
|
||||
settings.advanced.natives_directory.default.version_id=<Instance Name>
|
||||
settings.advanced.natives_directory.hint=This option is intended only for users of Apple silicon or other not officially supported platforms. Please do not edit this option unless you know what you are doing.\n\
|
||||
\n\
|
||||
Before proceeding, please make sure all libraries (e.g. lwjgl.dll, libopenal.so) are provided in your desired directory.\n\
|
||||
Note: It is recommended to use a fully English-letters path for the specified local library file. Otherwise it may lead to game launch failure.
|
||||
settings.advanced.no_jvm_args=Do not add default JVM arguments
|
||||
settings.advanced.no_optimizing_jvm_args=Do not add default JVM optimization arguments
|
||||
settings.advanced.precall_command=Pre-launch Command
|
||||
@@ -1487,6 +1484,7 @@ settings.launcher.download_source=Download Source
|
||||
settings.launcher.download_source.auto=Automatically Choose Download Sources
|
||||
settings.launcher.default_addon_source=Default Addon Source
|
||||
settings.launcher.enable_game_list=Show instance list in homepage
|
||||
settings.launcher.misc=Misc
|
||||
settings.launcher.font=Font
|
||||
settings.launcher.font.anti_aliasing=Anti-aliasing
|
||||
settings.launcher.font.anti_aliasing.auto=Auto
|
||||
|
||||
@@ -354,18 +354,11 @@ download.external_link=زيارة موقع التنزيل
|
||||
download.failed=فشل تنزيل "%1$s"، رمز الاستجابة: %2$d.
|
||||
download.failed.empty=لا توجد إصدارات متاحة. انقر هنا للرجوع.
|
||||
download.failed.no_code=فشل التنزيل
|
||||
download.failed.refresh=فشل جلب قائمة الإصدارات. انقر هنا لإعادة المحاولة.
|
||||
download.game=لعبة جديدة
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93, https://bmclapi2.bangbang93.com/
|
||||
download.provider.mojang=رسمي
|
||||
download.provider.mojang.desc=يتم توفير OptiFine عبر BMCLAPI
|
||||
download.failed.refresh=فشل في جلب قائمة الإصدارات. يرجى النقر هنا لإعادة المحاولة.
|
||||
download.game=New Game
|
||||
download.provider.official=من المصادر الرسمية
|
||||
download.provider.official.desc=الأحدث، لكن قد يكون التحميل بطيئاً
|
||||
download.provider.balanced=من أسرع المصادر المتاحة
|
||||
download.provider.balanced.desc=متوازن، لكن قد لا يكون الأحدث
|
||||
download.provider.mirror=من مرآة
|
||||
download.provider.mirror.desc=سريع، لكن قد لا يكون الأحدث
|
||||
download.java=جارٍ تنزيل Java
|
||||
download.java.process=عملية تنزيل Java
|
||||
download.javafx=جارٍ تنزيل مكتبات المشغّل...
|
||||
@@ -445,8 +438,6 @@ fatal.unsupported_platform=لا يدعم Minecraft منصتك الحالية ب
|
||||
إذا لم تتمكن من تشغيل Minecraft 1.17 أو أحدث، يمكنك محاولة تغيير "المُصيِّر" إلى "Mesa LLVMpipe" في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة" لاستخدام معالجة المعالج المركزي لتوافق أفضل.
|
||||
fatal.unsupported_platform.loongarch=وفّر Hello Minecraft! Launcher دعماً لمنصة Loongson.\n\
|
||||
إذا واجهت مشاكل أثناء اللعب، يمكنك زيارة https://docs.hmcl.net/groups.html للحصول على مساعدة.
|
||||
fatal.unsupported_platform.macos_arm64=وفّر Hello Minecraft! Launcher دعماً لمنصة Apple silicon، باستخدام Java ARM الأصلي لتشغيل الألعاب للحصول على تجربة لعب أكثر سلاسة.\n\
|
||||
إذا واجهت مشاكل أثناء اللعب، قد يوفر تشغيل اللعبة بـ Java المبني على معمارية x86-64 توافقاً أفضل.
|
||||
fatal.unsupported_platform.windows_arm64=وفّر Hello Minecraft! Launcher دعماً أصلياً لمنصة Windows on Arm. إذا واجهت مشاكل أثناء اللعب، يُرجى محاولة تشغيل اللعبة بـ Java المبني على معمارية x86.\n\
|
||||
\n\
|
||||
إذا كنت تستخدم منصة <b>Qualcomm</b>، قد تحتاج إلى تثبيت <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">حزمة توافق OpenGL</a> قبل اللعب.\n\
|
||||
@@ -1341,10 +1332,6 @@ settings.advanced.natives_directory.choose=اختر موقع المكتبة ال
|
||||
settings.advanced.natives_directory.custom=مخصص
|
||||
settings.advanced.natives_directory.default=افتراضي
|
||||
settings.advanced.natives_directory.default.version_id=<اسم النسخة>
|
||||
settings.advanced.natives_directory.hint=هذا الخيار مخصص فقط لمستخدمي Apple silicon والمنصات غير المدعومة رسمياً. يُرجى عدم تعديل هذا الخيار إلا إذا كنت تعرف ما تفعله.\n\
|
||||
\n\
|
||||
قبل المتابعة، يُرجى التأكد من توفر جميع المكتبات (مثل lwjgl.dll وlibopenal.so) في المجلد المحدد.\n\
|
||||
ملاحظة: يُنصح باستخدام مسار يحتوي على حروف إنجليزية فقط لملف المكتبة المحلية المحدد، وإلا قد يتسبب ذلك في فشل تشغيل اللعبة.
|
||||
settings.advanced.no_jvm_args=عدم إضافة وسيطات JVM الافتراضية
|
||||
settings.advanced.no_optimizing_jvm_args=عدم إضافة وسيطات تحسين JVM الافتراضية
|
||||
settings.advanced.precall_command=أمر ما قبل التشغيل
|
||||
|
||||
@@ -336,10 +336,6 @@ download.failed.empty=No hay versiones disponibles, por favor haga clic aquí pa
|
||||
download.failed.no_code=No se ha podido descargar
|
||||
download.failed.refresh=No se ha podido obtener la lista de versiones. Por favor, haga clic aquí para volver a intentarlo.
|
||||
download.game=Nuevo juego
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93, https://bmclapi2.bangbang93.com/
|
||||
download.provider.mojang=Oficial
|
||||
download.provider.mojang.desc=OptiFine es proporcionado por BMCLAPI
|
||||
download.provider.official=De fuentes oficiales
|
||||
download.provider.balanced=De la fuente más rápida disponible
|
||||
download.provider.mirror=Desde espejo
|
||||
@@ -418,8 +414,6 @@ fatal.unsupported_platform=Minecraft aún no es totalmente compatible con tu pla
|
||||
Si no puedes iniciar Minecraft 1.17 y versiones posteriores, puedes intentar cambiar el «Renderizador» a «Mesa LLVMpipe» en «Configuración global/específica de la instancia → Configuración avanzada» para utilizar el renderizado por CPU y mejorar la compatibilidad.
|
||||
fatal.unsupported_platform.loongarch=Hello Minecraft! Launcher ha prestado apoyo a la plataforma Loongson.\n\
|
||||
Si tienes problemas al jugar, puedes visitar https://docs.hmcl.net/groups.html para obtener ayuda.
|
||||
fatal.unsupported_platform.macos_arm64=Hello Minecraft! Launcher ha proporcionado soporte para la plataforma de chips de Apple, utilizando Java nativo de ARM para ejecutar juegos y conseguir una experiencia de juego más fluida.\n\
|
||||
Si tienes problemas al jugar a un juego, ejecutarlo con Java de arquitectura x86-64 puede tener mejor compatibilidad.
|
||||
fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher ha proporcionado soporte nativo para la plataforma Windows en Arm. Si tiene problemas al jugar a un juego, intente iniciarlo con Java de arquitectura x86.\n\
|
||||
\n\
|
||||
Si utilizas la plataforma <b>Qualcomm</b>, es posible que tengas que instalar el <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">paquete de compatibilidad OpenGL</a> antes de jugar.\n\
|
||||
@@ -1251,9 +1245,6 @@ settings.advanced.natives_directory.choose=Elija la ubicación de la biblioteca
|
||||
settings.advanced.natives_directory.custom=Personalizado
|
||||
settings.advanced.natives_directory.default=Por defecto
|
||||
settings.advanced.natives_directory.default.version_id=<Nombre de la instancia>
|
||||
settings.advanced.natives_directory.hint=Esta opción está pensada sólo para usuarios de chips de Apple u otras plataformas no soportadas oficialmente. Por favor, no modifique esta opción a menos que sepa lo que está haciendo.\n\
|
||||
\n\
|
||||
Antes de proceder, por favor, asegúrese de que todas las bibliotecas (por ejemplo, lwjgl.dll, libopenal.so) se proporcionan en su directorio deseado.
|
||||
settings.advanced.no_jvm_args=No añadir argumentos JVM por defecto
|
||||
settings.advanced.no_optimizing_jvm_args=No añadir argumentos de optimización de JVM
|
||||
settings.advanced.precall_command=Comando pre-lanzamiento:
|
||||
|
||||
@@ -293,10 +293,6 @@ download.failed=%1$s のダウンロードに失敗しました、応答コー
|
||||
download.failed.empty=候補者なし。戻るにはここをクリックしてください。
|
||||
download.failed.refresh=バージョンリストをダウンロードできません。ここをクリックして再試行してください。
|
||||
download.game=ゲームのダウンロード
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=ミラーソース
|
||||
download.provider.mojang=Mojang
|
||||
download.provider.mojang.desc=OptiFineはBMCLAPIにより提供されます
|
||||
download.provider.official=公式ソースからロード
|
||||
download.provider.balanced=mcbbsのソースからロード
|
||||
download.provider.mirror=ミラーソースからロード
|
||||
@@ -778,7 +774,6 @@ settings.advanced.natives_directory=ネイティブライブラリパス(例
|
||||
settings.advanced.natives_directory.choose=ネイティブライブラリパスを選択してください
|
||||
settings.advanced.natives_directory.custom=Custom(ゲームが実行時に必要とするネイティブライブラリを提供する必要があります)
|
||||
settings.advanced.natives_directory.default=Standard(ネイティブライブラリは当社が提供します)
|
||||
settings.advanced.natives_directory.hint=このオプションは、Appleシリコンまたはその他の公式にサポートされていないプラットフォーム/アーキテクチャでゲームをプレイする場合にのみ使用されます。\nこのオプションを変更する意味がわからない場合は、このオプションを変更しないことをお勧めします。、またはゲームが起動しません。\nこのオプションを変更する必要がある場合は、ゲームを実行するために必要なすべてのネイティブライブラリをカスタムネイティブディレクトリの下に配置する必要があります。lwjgl.dll(liblwjgl.so)、openal.dll(libopenal.so)。HMCLは、不足しているネイティブライブラリを提供しません。
|
||||
settings.advanced.no_jvm_args=デフォルトのJVM引数なし
|
||||
settings.advanced.precall_command=起動前コマンド
|
||||
settings.advanced.precall_command.prompt=ゲーム開始前に実行されます
|
||||
|
||||
@@ -46,6 +46,8 @@ about.thanks_to.shulkersakura.statement=鑄其徽
|
||||
about.thanks_to.users=HMCL 群組諸員
|
||||
about.thanks_to.users.statement=敬謝諸君之貲勉、報謬、謀案
|
||||
about.thanks_to.yushijinhun.statement=有助於 authlib-injector
|
||||
about.thanks_to.8mi-tech=八藍米資訊科技 (泉州) 有限公所
|
||||
about.thanks_to.8mi-tech.statement=供遊戲部分靜態資源之加速下載役
|
||||
about.open_source=開源
|
||||
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL)
|
||||
|
||||
@@ -332,10 +334,6 @@ download.failed.empty=[無可裝之版,點此返]\n(君可求助於右上之
|
||||
download.failed.no_code=引之未成
|
||||
download.failed.refresh=[載版列未成,點此重試]\n(君可求助於右上之鈕)
|
||||
download.game=新戲
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93,https://bmclapi2.bangbang93.com
|
||||
download.provider.mojang=官版
|
||||
download.provider.mojang.desc=Optifine 猶取諸 BMCLAPI 也
|
||||
download.provider.official=先官版
|
||||
download.provider.official.desc=至新,容有艱
|
||||
download.provider.balanced=先其快者
|
||||
@@ -385,7 +383,6 @@ fatal.illegal_char=君之案夾名有諱文「=」,是以外載之戶簿、離
|
||||
fatal.unsupported_platform=礦藝未盡適君之算機,是以戲事或損,至於不能啟。\n\
|
||||
礦藝一點一七以晉之不能啟,或於「(全例/例殊) 戲設 → 進階置設 → 勘誤置設」改「繪製之器」以「Mesa LLVMpipe」,或能兼之。\n君可求助於右上之鈕。
|
||||
fatal.unsupported_platform.loongarch=HMCL 既適龍芯。\n凡有謬,遽求助於右上之鈕。
|
||||
fatal.unsupported_platform.macos_arm64=HMCL 既適蘋矽。宜啟以 ARM 之爪哇,以益君之戲事。\n誠有謬,宜啟以 x86-64 之爪哇,以益其兼。\n凡有謬,遽求助於右上之鈕。
|
||||
fatal.unsupported_platform.windows_arm64=HMCL 既適 ARM 之視窗。誠有謬,宜啟以 x86 之爪哇。\n誠用<b>栝柑</b>,或須置<a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">開圖庫兼囊</a>,而後可戯。擊鏈以置之於微軟貨舍。\n君可求助於右上之鈕。
|
||||
|
||||
file=案
|
||||
@@ -1066,7 +1063,6 @@ settings.advanced.natives_directory.choose=擇府庫之徑
|
||||
settings.advanced.natives_directory.custom=自定 (賴君所供之府庫也)
|
||||
settings.advanced.natives_directory.default=本 (賴啟者所供之府庫)
|
||||
settings.advanced.natives_directory.default.version_id=<例名>
|
||||
settings.advanced.natives_directory.hint=供於械網之弗見適者,有如蘋矽,以自定府庫也。微其之知,毋改是,改則或弗啟。\n\n將改,而宜錄府庫於是,蓋 lwjgl.dll (liblwjgl.so)、openal.dll (libopenal.so) 之類。啟者弗補是府庫也!\n\n誡:府庫之徑,但宜書以英文、數、下綫,否則或弗啟。
|
||||
settings.advanced.no_jvm_args=無添爪哇虛機之本通弦
|
||||
settings.advanced.precall_command=令於戲前
|
||||
settings.advanced.precall_command.prompt=將前戲啟而行
|
||||
|
||||
@@ -333,10 +333,6 @@ download.failed.empty=Версий нет. Нажмите здесь, чтобы
|
||||
download.failed.no_code=Не удалось скачать
|
||||
download.failed.refresh=Не удалось получить список версий. Нажмите здесь, чтобы повторить попытку.
|
||||
download.game=Новая игра
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93, https://bmclapi2.bangbang93.com/
|
||||
download.provider.mojang=Официальный
|
||||
download.provider.mojang.desc=OptiFine предоставляется BMCLAPI
|
||||
download.provider.official=Из официальных источников
|
||||
download.provider.balanced=Из самых быстрых доступных
|
||||
download.provider.mirror=Из зеркала
|
||||
@@ -412,8 +408,6 @@ fatal.unsupported_platform=Minecraft еще не полностью поддер
|
||||
Если вы не можете запустить Minecraft версии 1.17 и новее, попробуйте переключить «Рендерер» на «Mesa LLVMpipe» в разделе «Глобальные/настройки экземпляра → Расширенные настройки», чтобы использовать рендеринг через CPU для лучшей совместимости.
|
||||
fatal.unsupported_platform.loongarch=Лаунчер обеспечил поддержку платформы Loongson.\n\
|
||||
Если у вас возникнут проблемы во время игры, вы можете обратиться за помощью на сайт https://docs.hmcl.net/groups.html.
|
||||
fatal.unsupported_platform.macos_arm64=Лаунчер обеспечил поддержку платформы Apple silicon, используя родную ARM Java для запуска игр, чтобы получить более плавный игровой опыт.\n\
|
||||
Если вы столкнулись с проблемами во время игры, запуск игры с Java на базе архитектуры x86-64 может обеспечить лучшую совместимость.
|
||||
fatal.unsupported_platform.windows_arm64=Лаунчер обеспечил нативную поддержку платформы Windows на архитектуре Arm. Если у вам возникли проблемы во время игры, попробуйте запустить игру с Java на базе архитектуры x86.\n\
|
||||
\n\
|
||||
Если вы используете платформу <b>Qualcomm</b>, вам может потребоваться установить <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">пакет совместимости OpenGL</a> перед началом игры.\n\
|
||||
@@ -1242,10 +1236,6 @@ settings.advanced.natives_directory.choose=Выберите расоложени
|
||||
settings.advanced.natives_directory.custom=Пользовательское
|
||||
settings.advanced.natives_directory.default=По умолчанию
|
||||
settings.advanced.natives_directory.default.version_id=<Название сборки>
|
||||
settings.advanced.natives_directory.hint=Параметр предназначен только для пользователей Apple silicon или других официально не поддерживаемых платформ. Не изменяйте, если не знаете что делаете.\n\
|
||||
\n\
|
||||
Прежде чем продолжить, убедитесь что все библиотеки (например, lwjgl.dll, libopenal.so) находятся в нужной папке.\n\
|
||||
Примечание: Рекомендуется использовать полностью английские пути для указанных файлов локальных библиотек, иначе игра может не запуститься.
|
||||
settings.advanced.no_jvm_args=Не добавлять аргументы JVM по умолчанию
|
||||
settings.advanced.precall_command=Команда перед запуском
|
||||
settings.advanced.precall_command.prompt=Команды, которые необходимо выполнить перед запуском игры
|
||||
|
||||
@@ -332,10 +332,6 @@ download.failed.empty=Немає доступних версій. Натисні
|
||||
download.failed.no_code=Не вдалося завантажити
|
||||
download.failed.refresh=Не вдалося отримати список версій. Натисніть тут, щоб повторити спробу.
|
||||
download.game=Нова гра
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93, https://bmclapi2.bangbang93.com/
|
||||
download.provider.mojang=Офіційне
|
||||
download.provider.mojang.desc=OptiFine надається BMCLAPI
|
||||
download.provider.official=З офіційних джерел
|
||||
download.provider.balanced=З найшвидшого доступного
|
||||
download.provider.mirror=З дзеркала
|
||||
@@ -398,8 +394,6 @@ fatal.unsupported_platform=Minecraft ще не повністю підтриму
|
||||
Якщо ви не можете запустити Minecraft версії 1.17 і новіші, спробуйте змінити «Рендерер» на «Mesa LLVMpipe» у розділі «Глобальні/налаштування екземпляра → Розширені налаштування», щоб використовувати рендеринг через CPU для кращої сумісності.
|
||||
fatal.unsupported_platform.loongarch=Hello Minecraft! Лаунчер надав підтримку платформи Loongson.\n\
|
||||
Якщо у вас виникли проблеми під час гри, ви можете відвідати https://docs.hmcl.net/groups.html для отримання допомоги.
|
||||
fatal.unsupported_platform.macos_arm64=Hello Minecraft! Лаунчер надав підтримку платформи Apple silicon, використовуючи нативну ARM Java для запуску ігор для отримання плавнішого ігрового досвіду.\n\
|
||||
Якщо у вас виникли проблеми під час гри, запуск гри з Java на основі архітектури x86-64 може забезпечити кращу сумісність.
|
||||
fatal.unsupported_platform.windows_arm64=Hello Minecraft! Лаунчер надав нативну підтримку платформи Windows on Arm. Якщо у вас виникли проблеми під час гри, спробуйте запустити гру з Java на основі архітектури x86.\n\
|
||||
Якщо ви використовуєте платформу <b>Qualcomm</b>, вам може знадобитися встановити <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">Пакет сумісності OpenGL</a> перед грою.\n\
|
||||
Натисніть посилання, щоб перейти до Microsoft Store та встановити пакет сумісності.
|
||||
@@ -1190,9 +1184,6 @@ settings.advanced.natives_directory.choose=Виберіть місце розт
|
||||
settings.advanced.natives_directory.custom=Власний
|
||||
settings.advanced.natives_directory.default=Типово
|
||||
settings.advanced.natives_directory.default.version_id=<Назва екземпляра>
|
||||
settings.advanced.natives_directory.hint=Ця опція призначена лише для користувачів Apple silicon або інших неофіційно підтримуваних платформ. Не редагуйте цю опцію, якщо ви не знаєте, що робите. \n\
|
||||
Перед продовженням переконайтеся, що всі бібліотеки (наприклад, lwjgl.dll, libopenal.so) надано у вашому бажаному каталозі. \n\
|
||||
Примітка: Рекомендується використовувати повністю англійські літери для вказаного локального файлу бібліотеки. Інакше це може призвести до невдалого запуску гри.
|
||||
settings.advanced.no_jvm_args=Не додавати типові аргументи JVM
|
||||
settings.advanced.precall_command=Команда перед запуском
|
||||
settings.advanced.precall_command.prompt=Команди для виконання перед запуском гри
|
||||
|
||||
@@ -47,6 +47,8 @@ about.thanks_to.shulkersakura.statement=提供 HMCL 的標誌
|
||||
about.thanks_to.users=HMCL 使用者群組成員
|
||||
about.thanks_to.users.statement=感謝使用者群組成員贊助充電、積極催更、回報問題、出謀劃策
|
||||
about.thanks_to.yushijinhun.statement=提供 authlib-injector 相關支援
|
||||
about.thanks_to.8mi-tech=八藍米信息科技 (泉州) 有限公司
|
||||
about.thanks_to.8mi-tech.statement=提供了遊戲部分靜態資源的加速下載服務
|
||||
about.open_source=開放原始碼
|
||||
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL/)
|
||||
|
||||
@@ -109,8 +111,9 @@ account.methods.microsoft.close_page=已完成 Microsoft 帳戶授權。啟動
|
||||
account.methods.microsoft.deauthorize=移除應用存取權
|
||||
account.methods.microsoft.error.add_family=請點擊 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此處</a> 更改你的帳戶出生日期,使年齡滿 18 歲以上,或將帳戶加入到家庭中。
|
||||
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 Xbox Live 的支援。
|
||||
account.methods.microsoft.error.missing_xbox_account=請點擊下方 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 關聯 Xbox 帳戶。
|
||||
account.methods.microsoft.error.no_character=請確認你已經購買了 Minecraft: Java 版。\n若已購買,則可能未建立遊戲檔案。請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
|
||||
account.methods.microsoft.error.missing_xbox_account=請確認你已經購買了 Minecraft: Java 版。若已購買,請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
|
||||
account.methods.microsoft.error.no_license=未找到 Minecraft 購買記錄。請點擊 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此處</a> 購買遊戲。\n若你使用 Game Pass,請在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。若已購買,請檢查登入的帳戶是否正確。
|
||||
account.methods.microsoft.error.no_character=你可能未建立遊戲檔案。請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
|
||||
account.methods.microsoft.error.banned=你的帳戶可能被 Xbox Live 封禁。\n你可以點擊 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此處</a> 按鈕查詢帳戶封禁狀態。
|
||||
account.methods.microsoft.error.unknown=登入失敗。錯誤碼:%d。
|
||||
account.methods.microsoft.error.wrong_verify_method=登入失敗。請在 Microsoft 帳戶登入頁面嘗試使用密碼登入,不要使用其他登入方式。
|
||||
@@ -359,16 +362,16 @@ download.failed.empty=沒有可供安裝的版本,按一下此處返回。
|
||||
download.failed.no_code=下載失敗
|
||||
download.failed.refresh=載入版本清單失敗,按一下此處重試。
|
||||
download.game=新遊戲
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93, https://bmclapi2.bangbang93.com/
|
||||
download.provider.bmclapi=BMCLAPI & MCIM
|
||||
download.provider.bmclapi.desc=適用於中國大陸的鏡像源
|
||||
download.provider.mojang=官方伺服器
|
||||
download.provider.mojang.desc=OptiFine 由 BMCLAPI 提供
|
||||
download.provider.official=盡量使用官方源
|
||||
download.provider.official.desc=最新,但可能載入慢
|
||||
download.provider.balanced=選取載入速度快的下載源
|
||||
download.provider.balanced.desc=平衡,但可能不是最新
|
||||
download.provider.mojang.desc=注意:OptiFine 的下載由 BMCLAPI 提供
|
||||
download.provider.official=盡量使用官方來源
|
||||
download.provider.official.desc=優先從官方來源獲取資訊
|
||||
download.provider.balanced=選取載入速度快的下載來源
|
||||
download.provider.balanced.desc=根據你的網路連線狀況選用最佳來源
|
||||
download.provider.mirror=盡量使用鏡像源
|
||||
download.provider.mirror.desc=載入快,但可能不是最新
|
||||
download.provider.mirror.desc=推薦中國大陸使用者使用
|
||||
download.java=下載 Java
|
||||
download.java.process=下載 Java
|
||||
download.javafx=正在下載必要的執行時元件
|
||||
@@ -419,7 +422,6 @@ fatal.illegal_char=由於您的執行路徑中存在無效字元『=』,您將
|
||||
fatal.unsupported_platform=Minecraft 尚未對您的平臺提供完善支援,所以可能影響遊戲體驗或無法啟動遊戲。\n\
|
||||
若無法啟動 Minecraft 1.17 及更高版本,可以嘗試在「(全域/實例特定) 遊戲設定 → 進階設定 → 除錯選項」中將「繪製器」切換為「Mesa LLVMpipe」,以獲得更好的相容性。
|
||||
fatal.unsupported_platform.loongarch=Hello Minecraft! Launcher 已為龍芯提供支援。\n如果遇到問題,你可以點擊右上角幫助按鈕進行求助。
|
||||
fatal.unsupported_platform.macos_arm64=Hello Minecraft! Launcher 已為 Apple Silicon 平臺提供支援。使用 ARM 原生 Java 啟動遊戲以獲得更流暢的遊戲體驗。\n如果你在遊戲中遭遇問題,使用 x86-64 架構的 Java 啟動遊戲可能有更好的相容性。
|
||||
fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher 已為 Windows on Arm 平臺提供原生支援。如果你在遊戲中遭遇問題,請嘗試使用 x86 架構的 Java 啟動遊戲。\n\n如果你正在使用<b>高通</b>平臺,你可能需要安裝 <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL 相容包</a>後才能進行遊戲。點擊連結前往 Microsoft Store 安裝相容包。
|
||||
# When translating the following string into non-Latin languages, please retain the English translation
|
||||
fatal.wine_warning=HMCL is running under Wine or one of its distributions.\nAs a cross-platform application, HMCL is best experienced natively on your host OS.\nPress "OK" to continue, but please note that some issues may arise.\n\n您目前正在使用 Wine 或其衍生版本執行 HMCL。\nHMCL 是跨平臺的,我們建議您直接在主機系統上執行。\n按下「確定」以繼續,但請注意,此操作可能會引發一些問題。
|
||||
@@ -1099,6 +1101,7 @@ search.page_n=%d / %s
|
||||
|
||||
selector.choose=選取
|
||||
selector.choose_file=選取檔案
|
||||
selector.choose_directory=選取目錄
|
||||
selector.custom=自訂
|
||||
|
||||
settings=設定
|
||||
@@ -1155,7 +1158,6 @@ settings.advanced.natives_directory.choose=選取本機庫路徑
|
||||
settings.advanced.natives_directory.custom=自訂 (由你提供遊戲需要的本機庫)
|
||||
settings.advanced.natives_directory.default=預設 (由啟動器提供遊戲本機庫)
|
||||
settings.advanced.natives_directory.default.version_id=<實例名稱>
|
||||
settings.advanced.natives_directory.hint=本選項提供給 Apple Silicon 等未受遊戲官方支援的平台來自訂遊戲本機庫。如果你不知道本選項的含義,請你不要修改本選項,否則會導致遊戲無法啟動。\n\n如果你要修改本選項,你需要保證自訂目錄下有遊戲所需的本機庫檔案,如 lwjgl.dll (liblwjgl.so), openal.dll (libopenal.so) 等檔案。啟動器不會幫你補全缺少的本機庫檔案。\n\n注意:建議指定的本機庫檔案路徑使用全英文字元,否則可能導致遊戲啟動失敗。
|
||||
settings.advanced.no_jvm_args=不新增預設的 Java 虛擬機參數
|
||||
settings.advanced.no_optimizing_jvm_args=不自動新增 Java 虛擬機最佳化參數
|
||||
settings.advanced.precall_command=遊戲啟動前執行指令
|
||||
@@ -1272,6 +1274,7 @@ settings.launcher.download_source=下載來源
|
||||
settings.launcher.download_source.auto=自動選取下載來源
|
||||
settings.launcher.default_addon_source=遊戲內容預設下載來源
|
||||
settings.launcher.enable_game_list=在首頁內顯示遊戲清單
|
||||
settings.launcher.misc=雜項
|
||||
settings.launcher.font=字體
|
||||
settings.launcher.font.anti_aliasing=反鋸齒
|
||||
settings.launcher.font.anti_aliasing.auto=自動
|
||||
|
||||
@@ -47,6 +47,8 @@ about.thanks_to.shulkersakura.statement=提供 HMCL 的徽标
|
||||
about.thanks_to.users=HMCL 用户群成员
|
||||
about.thanks_to.users.statement=感谢用户群成员赞助充电、积极催更、反馈问题、出谋划策
|
||||
about.thanks_to.yushijinhun.statement=提供 authlib-injector 相关支持
|
||||
about.thanks_to.8mi-tech=八蓝米信息科技 (泉州) 有限公司
|
||||
about.thanks_to.8mi-tech.statement=提供了游戏部分静态资源的加速下载服务
|
||||
about.open_source=开源
|
||||
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL)
|
||||
|
||||
@@ -110,8 +112,9 @@ account.methods.microsoft.close_page=已完成微软账户授权。其余登录
|
||||
account.methods.microsoft.deauthorize=解除账户授权
|
||||
account.methods.microsoft.error.add_family=请点击 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此处</a> 更改你的账户出生日期,使年龄满 18 岁以上,或将账户加入到家庭中。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 Xbox Live 的支持。
|
||||
account.methods.microsoft.error.missing_xbox_account=请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 关联 Xbox 账户。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.no_character=请确认你已经购买了 Minecraft: Java 版。\n若已购买,则可能未创建游戏档案。请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.missing_xbox_account=请确认你已经购买了 Minecraft: Java 版。若已购买,请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.no_license=未找到 Minecraft 购买记录。请点击 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此处</a> 购买游戏。\n若你使用 Game Pass,请在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。若已购买,请检查登录的账户是否正确。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.no_character=你尚未创建游戏档案。请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.banned=你的账户可能被 Xbox Live 封禁。\n你可以点击 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此处</a> 按钮查询账户封禁状态。
|
||||
account.methods.microsoft.error.unknown=未知问题。错误码:%d。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.wrong_verify_method=登录失败。请在微软账户登录页面使用密码登录,不要使用其他登录方式。\n你可以点击右上角帮助按钮进行求助。
|
||||
@@ -361,16 +364,16 @@ download.failed.empty=[没有可供安装的版本,点击此处返回]\n(你
|
||||
download.failed.no_code=下载失败
|
||||
download.failed.refresh=[加载版本列表失败,点击此处重试]\n(你可以点击右上角帮助按钮进行求助)
|
||||
download.game=新游戏
|
||||
download.provider.bmclapi=BMCLAPI
|
||||
download.provider.bmclapi.desc=bangbang93, https://bmclapi2.bangbang93.com
|
||||
download.provider.bmclapi=BMCLAPI & MCIM
|
||||
download.provider.bmclapi.desc=适用于中国大陆的镜像源
|
||||
download.provider.mojang=官方
|
||||
download.provider.mojang.desc=OptiFine 自动安装使用 BMCLAPI 下载源
|
||||
download.provider.mojang.desc=注:OptiFine 自动安装使用 BMCLAPI 下载源
|
||||
download.provider.official=尽量使用官方源
|
||||
download.provider.official.desc=最新,但可能加载慢
|
||||
download.provider.official.desc=优先从官方源获取信息
|
||||
download.provider.balanced=选择加载速度快的下载源
|
||||
download.provider.balanced.desc=平衡,但可能不是最新
|
||||
download.provider.balanced.desc=根据你的网络连接情况选择最佳源
|
||||
download.provider.mirror=尽量使用镜像源
|
||||
download.provider.mirror.desc=加载快,但可能不是最新
|
||||
download.provider.mirror.desc=推荐中国大陆用户使用
|
||||
download.java=下载 Java
|
||||
download.java.process=下载 Java
|
||||
download.javafx=正在下载必要的运行时组件……
|
||||
@@ -423,7 +426,6 @@ fatal.unsupported_platform=Minecraft 尚未对你的平台提供完善支持,
|
||||
若无法启动 Minecraft 1.17 及更高版本,可以尝试在“(全局/实例特定) 游戏设置 → 高级设置 → 调试选项”中将“渲染器”切换为“Mesa LLVMpipe”,以获得更好的兼容性。\n\
|
||||
如遇到问题,你可以点击右上角帮助按钮进行求助。
|
||||
fatal.unsupported_platform.loongarch=Hello Minecraft! Launcher 已为龙芯提供支持。\n如果遇到问题,你可以点击右上角帮助按钮进行求助。
|
||||
fatal.unsupported_platform.macos_arm64=Hello Minecraft! Launcher 已为 Apple Silicon 平台提供支持。使用 ARM 原生 Java 启动游戏以获得更流畅的游戏体验。\n如果你在游戏中遇到问题,使用 x86-64 架构的 Java 启动游戏可能有更好的兼容性。\n如遇到问题,你可以点击右上角帮助按钮进行求助。
|
||||
fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher 已为 Windows on Arm 平台提供原生支持。如果你在游戏中遇到问题,请尝试使用 x86 架构的 Java 启动游戏。\n如果你正在使用 <b>高通</b> 平台,你可能需要安装 <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL 兼容包</a> 后才能进行游戏。点击链接前往 Microsoft Store 安装兼容包。\n如遇到问题,你可以点击右上角帮助按钮进行求助。
|
||||
# When translating the following string into non-Latin languages, please retain the English translation
|
||||
fatal.wine_warning=HMCL is running under Wine or one of its distributions.\nAs a cross-platform application, HMCL is best experienced natively on your host OS.\nPress "OK" to continue, but please note that some issues may arise.\n\n您目前正在使用 Wine 或其发行版运行 HMCL。\nHMCL 是跨平台的,我们建议您直接在宿主系统上运行。\n按下“确定”以继续,但请注意,此操作可能会引发一些问题。
|
||||
@@ -1016,7 +1018,7 @@ world.info.player.game_type=游戏模式
|
||||
world.info.player.game_type.adventure=冒险
|
||||
world.info.player.game_type.creative=创造
|
||||
world.info.player.game_type.hardcore=极限
|
||||
world.info.player.game_type.spectator=旁观者
|
||||
world.info.player.game_type.spectator=旁观
|
||||
world.info.player.game_type.survival=生存
|
||||
world.info.player.health=生命值
|
||||
world.info.player.last_death_location=上次死亡位置
|
||||
@@ -1104,6 +1106,7 @@ search.page_n=%d / %s
|
||||
|
||||
selector.choose=选择
|
||||
selector.choose_file=选择文件
|
||||
selector.choose_directory=选择文件夹
|
||||
selector.custom=自定义
|
||||
|
||||
settings=设置
|
||||
@@ -1160,7 +1163,6 @@ settings.advanced.natives_directory.choose=选择本地库路径
|
||||
settings.advanced.natives_directory.custom=自定义 (由你提供游戏需要的本地库)
|
||||
settings.advanced.natives_directory.default=默认 (由启动器提供游戏本地库)
|
||||
settings.advanced.natives_directory.default.version_id=<实例名称>
|
||||
settings.advanced.natives_directory.hint=本选项提供给 Apple Silicon 等未受游戏官方支持的平台来自定义游戏本地库。如果你不知道本选项的含义,请不要修改本选项,否则会导致游戏无法启动!\n\n如果你要修改本选项,你需要保证自定义文件夹下有游戏所需的本地库文件,如 lwjgl.dll (liblwjgl.so)、openal.dll (libopenal.so) 等文件。启动器不会帮你补全缺少的本地库文件!\n\n注意:指定的本地库文件路径建议只包含英文大小写字母、数字和下划线,否则可能会导致启动游戏失败。
|
||||
settings.advanced.no_jvm_args=不添加默认的 Java 虚拟机参数
|
||||
settings.advanced.no_optimizing_jvm_args=不自动添加 Java 虚拟机优化参数
|
||||
settings.advanced.precall_command=游戏启动前执行命令
|
||||
@@ -1277,6 +1279,7 @@ settings.launcher.download_source=下载源
|
||||
settings.launcher.download_source.auto=自动选择下载源
|
||||
settings.launcher.default_addon_source=游戏内容默认下载源
|
||||
settings.launcher.enable_game_list=在主页内显示版本列表
|
||||
settings.launcher.misc=杂项
|
||||
settings.launcher.font=字体
|
||||
settings.launcher.font.anti_aliasing=抗锯齿
|
||||
settings.launcher.font.anti_aliasing.auto=自动
|
||||
|
||||
@@ -44,6 +44,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
border-radius: 4px;
|
||||
background-color: var(--monet-surface-container-high);
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||
|
||||
@@ -16,7 +16,7 @@ dependencies {
|
||||
api(libs.kala.compress.tar)
|
||||
api(libs.simple.png.javafx)
|
||||
api(libs.gson)
|
||||
api(libs.toml)
|
||||
api(libs.tomlj)
|
||||
api(libs.xz)
|
||||
api(libs.lz4)
|
||||
api(libs.fx.gson)
|
||||
@@ -27,6 +27,7 @@ dependencies {
|
||||
api(libs.jna)
|
||||
api(libs.pci.ids)
|
||||
api(libs.hello.nbt)
|
||||
api(libs.weburl)
|
||||
|
||||
compileOnlyApi(libs.jetbrains.annotations)
|
||||
|
||||
|
||||
+9
-6
@@ -24,12 +24,11 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
@@ -58,13 +57,17 @@ public class AuthlibInjectorServer implements Observable {
|
||||
public static AuthlibInjectorServer locateServer(String url) throws IOException {
|
||||
try {
|
||||
url = NetworkUtils.addHttpsIfMissing(url);
|
||||
HttpURLConnection conn = NetworkUtils.createHttpConnection(url);
|
||||
|
||||
WebURL webURL = WebURL.parseBrowserInput(url);
|
||||
url = webURL.toString();
|
||||
|
||||
HttpURLConnection conn = NetworkUtils.createHttpConnection(webURL);
|
||||
conn = NetworkUtils.resolveConnection(conn);
|
||||
|
||||
String ali = conn.getHeaderField("x-authlib-injector-api-location");
|
||||
if (ali != null) {
|
||||
URI absoluteAli = conn.getURL().toURI().resolve(NetworkUtils.toURI(ali));
|
||||
if (!urlEqualsIgnoreSlash(url, absoluteAli.toString())) {
|
||||
WebURL absoluteAli = WebURL.parse(ali, WebURL.of(conn.getURL()));
|
||||
if (!urlEqualsIgnoreSlash(webURL.toString(), absoluteAli.toString())) {
|
||||
conn.disconnect();
|
||||
url = absoluteAli.toString();
|
||||
conn = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(absoluteAli));
|
||||
@@ -81,7 +84,7 @@ public class AuthlibInjectorServer implements Observable {
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
} catch (IllegalArgumentException | URISyntaxException e) {
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||
import org.jackhuang.hmcl.auth.OAuth;
|
||||
import org.jackhuang.hmcl.auth.ServerDisconnectException;
|
||||
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.*;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.io.*;
|
||||
@@ -245,7 +248,16 @@ public class MicrosoftService {
|
||||
.createConnection();
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode == HTTP_NOT_FOUND) {
|
||||
throw new NoMinecraftJavaEditionProfileException();
|
||||
MinecraftLicense license = HttpRequest.GET("https://api.minecraftservices.com/entitlements/license")
|
||||
.authorization(tokenType, accessToken)
|
||||
.getJson(MinecraftLicense.class);
|
||||
boolean hasMinecraftLicense = license != null && license.items() != null && license.items().stream()
|
||||
.anyMatch(item -> "game_minecraft".equals(item.name()));
|
||||
if (!hasMinecraftLicense) {
|
||||
throw new MinecraftJavaEditionLicenseNotFoundException();
|
||||
} else {
|
||||
throw new MinecraftJavaEditionProfileNotFoundException();
|
||||
}
|
||||
} else if (responseCode != 200) {
|
||||
throw new ResponseCodeException("https://api.minecraftservices.com/minecraft/profile", responseCode);
|
||||
}
|
||||
@@ -324,7 +336,10 @@ public class MicrosoftService {
|
||||
public final static class XBox400Exception extends AuthenticationException {
|
||||
}
|
||||
|
||||
public final static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
|
||||
public final static class MinecraftJavaEditionProfileNotFoundException extends AuthenticationException {
|
||||
}
|
||||
|
||||
public final static class MinecraftJavaEditionLicenseNotFoundException extends AuthenticationException {
|
||||
}
|
||||
|
||||
public final static class NoXuiException extends AuthenticationException {
|
||||
@@ -425,6 +440,21 @@ public class MicrosoftService {
|
||||
|
||||
}
|
||||
|
||||
@JsonSerializable
|
||||
public record MinecraftLicense(
|
||||
@SerializedName("items") List<MinecraftLicenseItem> items,
|
||||
@SerializedName("signature") String signature,
|
||||
@SerializedName("keyId") String keyId
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonSerializable
|
||||
public record MinecraftLicenseItem(
|
||||
@SerializedName("name") String name,
|
||||
@SerializedName("signature") String signature
|
||||
) {
|
||||
}
|
||||
|
||||
public static class MinecraftProfileResponse extends MinecraftErrorResponse implements Validation {
|
||||
@SerializedName("id")
|
||||
UUID id;
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
@@ -244,10 +245,15 @@ public class Skin {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
return new Context() {
|
||||
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
@Override
|
||||
public void reset() throws IOException {
|
||||
baos.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) {
|
||||
baos.write(buffer, offset, len);
|
||||
@@ -260,7 +266,7 @@ public class Skin {
|
||||
setResult(new ByteArrayInputStream(baos.toByteArray()));
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheBytes(response, baos.toByteArray());
|
||||
repository.cacheBytes(UrlResponseInfo.of(response), baos.toByteArray());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,7 +242,7 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
|
||||
}
|
||||
},
|
||||
CLEANROOM(true, "cleanroom", "com\\.cleanroommc", "cleanroom", ModLoaderType.CLEANROOM),
|
||||
NEO_FORGE(true, "neoforge", "net\\.neoforged\\.fancymodloader", "(core|loader)", ModLoaderType.NEO_FORGED) {
|
||||
NEO_FORGE(true, "neoforge", "net\\.neoforged\\.fancymodloader", "(core|loader)", ModLoaderType.NEO_FORGE) {
|
||||
private final Pattern NEO_FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?<forge>[0-9.]+)(-([0-9.]+))?$");
|
||||
|
||||
@Override
|
||||
@@ -390,7 +390,7 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
|
||||
public static final String MOD_LAUNCHER_MAIN = "cpw.mods.modlauncher.Launcher";
|
||||
public static final String BOOTSTRAP_LAUNCHER_MAIN = "cpw.mods.bootstraplauncher.BootstrapLauncher";
|
||||
public static final String FORGE_BOOTSTRAP_MAIN = "net.minecraftforge.bootstrap.ForgeBootstrap";
|
||||
public static final String NEO_FORGED_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client";
|
||||
public static final String NEO_FORGE_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client";
|
||||
|
||||
public static final Set<String> FORGE_OPTIFINE_MAIN = Set.of(
|
||||
LibraryAnalyzer.VANILLA_MAIN,
|
||||
@@ -398,7 +398,7 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
|
||||
LibraryAnalyzer.MOD_LAUNCHER_MAIN,
|
||||
LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN,
|
||||
LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN,
|
||||
LibraryAnalyzer.NEO_FORGED_BOOTSTRAP_MAIN
|
||||
LibraryAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN
|
||||
);
|
||||
|
||||
public static final VersionRange<VersionNumber> FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50");
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.ZlibUtils;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
@@ -168,6 +169,22 @@ public class ForgeNewInstallTask extends Task<Version> {
|
||||
}
|
||||
|
||||
if (!Objects.equals(code, entry.getValue())) {
|
||||
if (!ZlibUtils.IS_ZLIB_COMPATIBLE && FileUtils.getExtension(artifact).equals("jar")) {
|
||||
// Forge/NeoForge generates JARs dynamically during installation.
|
||||
// When native compression libraries such as zlib-ng are in use,
|
||||
// the resulting JAR may be compressed differently, causing its
|
||||
// SHA-1 hash to differ from the expected value recorded in the
|
||||
// install profile. In this case, fall back to verifying that the
|
||||
// file is at least a structurally valid ZIP/JAR archive.
|
||||
try {
|
||||
FileDownloadTask.ZIP_INTEGRITY_CHECK_HANDLER.checkIntegrity(artifact, artifact);
|
||||
LOG.info("Ignoring SHA-1 mismatch for " + artifact + " due to non-standard zlib compression output");
|
||||
continue;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Files.delete(artifact);
|
||||
throw new ChecksumMismatchException("SHA-1", entry.getValue(), code);
|
||||
}
|
||||
|
||||
+12
-3
@@ -52,10 +52,13 @@ public final class LegacyFabricVersionList extends VersionList<LegacyFabricRemot
|
||||
lock.writeLock().lock();
|
||||
|
||||
try {
|
||||
for (String gameVersion : gameVersions)
|
||||
for (String loaderVersion : loaderVersions)
|
||||
for (String metaGameVersion : gameVersions) {
|
||||
String gameVersion = normalizeVersion(metaGameVersion);
|
||||
for (String loaderVersion : loaderVersions) {
|
||||
versions.put(gameVersion, new LegacyFabricRemoteVersion(gameVersion, loaderVersion,
|
||||
Collections.singletonList(getLaunchMetaUrl(gameVersion, loaderVersion))));
|
||||
Collections.singletonList(getLaunchMetaUrl(metaGameVersion, loaderVersion))));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
@@ -71,6 +74,12 @@ public final class LegacyFabricVersionList extends VersionList<LegacyFabricRemot
|
||||
.stream().map(GameVersion::getVersion).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static String normalizeVersion(String version) {
|
||||
return version.startsWith("2point0_")
|
||||
? "2.0_" + version.substring("2point0_".length())
|
||||
: version;
|
||||
}
|
||||
|
||||
private static String getLaunchMetaUrl(String gameVersion, String loaderVersion) {
|
||||
return String.format("https://meta.legacyfabric.net/v2/versions/loader/%s/%s", gameVersion, loaderVersion);
|
||||
}
|
||||
|
||||
@@ -88,13 +88,15 @@ public final class World {
|
||||
root = fs.getPath("/");
|
||||
fileName = FileUtils.getName(this.file);
|
||||
} else {
|
||||
List<Path> files = Files.list(fs.getPath("/")).toList();
|
||||
if (files.size() != 1 || !Files.isDirectory(files.get(0))) {
|
||||
throw new IOException("Not a valid world zip file");
|
||||
}
|
||||
try (Stream<Path> filesStream = Files.list(fs.getPath("/"))) {
|
||||
List<Path> files = filesStream.toList();
|
||||
if (files.size() != 1 || !Files.isDirectory(files.get(0))) {
|
||||
throw new IOException("Not a valid world zip file");
|
||||
}
|
||||
|
||||
root = files.get(0);
|
||||
fileName = FileUtils.getName(root);
|
||||
root = files.get(0);
|
||||
fileName = FileUtils.getName(root);
|
||||
}
|
||||
}
|
||||
|
||||
Path levelDat = root.resolve("level.dat");
|
||||
|
||||
@@ -109,7 +109,7 @@ public class DefaultLauncher extends Launcher {
|
||||
|
||||
res.add(options.getJava().getBinary().toString());
|
||||
|
||||
res.addAllWithoutParsing(options.getOverrideJavaArguments());
|
||||
res.addAllWithoutParsingAndReadExternal(options.getOverrideJavaArguments());
|
||||
|
||||
if (options.getMaxMemory() != null && options.getMaxMemory() > 0)
|
||||
res.addDefault("-Xmx", options.getMaxMemory() + "m");
|
||||
@@ -722,10 +722,10 @@ public class DefaultLauncher extends Launcher {
|
||||
boolean usePowerShell = "ps1".equals(scriptExtension);
|
||||
|
||||
if (!usePowerShell) {
|
||||
if (isWindows && !scriptExtension.equals("bat"))
|
||||
if (isWindows && !scriptExtension.equalsIgnoreCase("bat"))
|
||||
throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'bat' or 'ps1' in Windows");
|
||||
else if (!isWindows && !(scriptExtension.equals("sh") || scriptExtension.equals("command")))
|
||||
throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'sh', 'ps1' or 'command' in macOS/Linux");
|
||||
else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtension.equalsIgnoreCase("command") || scriptExtension.equalsIgnoreCase("bash")))
|
||||
throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'sh', 'bash', 'ps1' or 'command' in macOS/Linux");
|
||||
}
|
||||
|
||||
final Command commandLine = generateCommandLine(nativeFolder);
|
||||
|
||||
@@ -21,7 +21,7 @@ public enum ModLoaderType {
|
||||
UNKNOWN,
|
||||
FORGE,
|
||||
CLEANROOM,
|
||||
NEO_FORGED,
|
||||
NEO_FORGE,
|
||||
FABRIC,
|
||||
QUILT,
|
||||
LITE_LOADER,
|
||||
|
||||
@@ -47,7 +47,7 @@ public final class ModManager {
|
||||
var map = new HashMap<String, List<Pair<ModMetadataReader, ModLoaderType>>>();
|
||||
var zipReaders = List.<Pair<ModMetadataReader, ModLoaderType>>of(
|
||||
pair(ForgeNewModMetadata::fromForgeFile, ModLoaderType.FORGE),
|
||||
pair(ForgeNewModMetadata::fromNeoForgeFile, ModLoaderType.NEO_FORGED),
|
||||
pair(ForgeNewModMetadata::fromNeoForgeFile, ModLoaderType.NEO_FORGE),
|
||||
pair(ForgeOldModMetadata::fromFile, ModLoaderType.FORGE),
|
||||
pair(FabricModMetadata::fromFile, ModLoaderType.FABRIC),
|
||||
pair(QuiltModMetadata::fromFile, ModLoaderType.QUILT),
|
||||
@@ -365,14 +365,16 @@ public final class ModManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if "mods" directory has mod file named "fileName" no matter the mod is disabled or not
|
||||
* Check if "mods" directory has mod file named "fileName" no matter the mod is disabled,upgraded or not
|
||||
*
|
||||
* @param fileName name of the file whose existence is being checked
|
||||
* @return true if the file exists
|
||||
*/
|
||||
public boolean hasSimpleMod(String fileName) {
|
||||
return Files.exists(getModsDirectory().resolve(StringUtils.removeSuffix(fileName, DISABLED_EXTENSION)))
|
||||
|| Files.exists(getModsDirectory().resolve(StringUtils.addSuffix(fileName, DISABLED_EXTENSION)));
|
||||
|| Files.exists(getModsDirectory().resolve(StringUtils.addSuffix(fileName, DISABLED_EXTENSION)))
|
||||
|| Files.exists(getModsDirectory().resolve(StringUtils.removeSuffix(fileName, OLD_EXTENSION)))
|
||||
|| Files.exists(getModsDirectory().resolve(StringUtils.addSuffix(fileName, OLD_EXTENSION)));
|
||||
}
|
||||
|
||||
public Path getSimpleModPath(String fileName) {
|
||||
|
||||
@@ -24,6 +24,8 @@ import org.jackhuang.hmcl.mod.RemoteModRepository;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -211,7 +213,13 @@ public class CurseAddon implements RemoteMod.IMod {
|
||||
}
|
||||
|
||||
public RemoteMod toMod() {
|
||||
String iconUrl = Optional.ofNullable(logo).map(Logo::getThumbnailUrl).orElse("");
|
||||
String iconUrl = "";
|
||||
if (logo != null) {
|
||||
if (StringUtils.isNotBlank(logo.getThumbnailUrl()))
|
||||
iconUrl = logo.getThumbnailUrl();
|
||||
else if (StringUtils.isNotBlank(logo.getUrl()))
|
||||
iconUrl = logo.getUrl();
|
||||
}
|
||||
|
||||
return new RemoteMod(
|
||||
slug,
|
||||
@@ -585,12 +593,12 @@ public class CurseAddon implements RemoteMod.IMod {
|
||||
}
|
||||
return RemoteMod.Dependency.ofGeneral(RELATION_TYPE.get(dependency.getRelationType()), CurseForgeRemoteModRepository.MODS, Integer.toString(dependency.getModId()));
|
||||
}).distinct().filter(Objects::nonNull).collect(Collectors.toList()),
|
||||
gameVersions.stream().filter(ver -> ver.startsWith("1.") || ver.contains("w")).collect(Collectors.toList()),
|
||||
gameVersions.stream().filter(GameVersionNumber::isKnown).toList(),
|
||||
gameVersions.stream().flatMap(version -> {
|
||||
if ("fabric".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.FABRIC);
|
||||
else if ("forge".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.FORGE);
|
||||
else if ("quilt".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.QUILT);
|
||||
else if ("neoforge".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.NEO_FORGED);
|
||||
else if ("neoforge".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.NEO_FORGE);
|
||||
else return Stream.empty();
|
||||
}).collect(Collectors.toList())
|
||||
);
|
||||
|
||||
@@ -188,9 +188,13 @@ public final class CurseCompletionTask extends Task<Void> {
|
||||
String fileName = file.fileName();
|
||||
return switch (classID) {
|
||||
case 12, // Resource pack
|
||||
6552 -> { // Shader pack
|
||||
Path res = (classID == 12 ? resourcePacksRoot : shaderPacksRoot).resolve(fileName);
|
||||
yield Files.exists(res) ? null : res;
|
||||
6945 -> { // Data pack
|
||||
Path path = resourcePacksRoot.resolve(fileName);
|
||||
yield Files.exists(path) ? null : path;
|
||||
}
|
||||
case 6552 -> { // Shader pack
|
||||
Path path = shaderPacksRoot.resolve(fileName);
|
||||
yield Files.exists(path) ? null : path;
|
||||
}
|
||||
default -> {
|
||||
if (modManager.hasSimpleMod(fileName)) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.moandjiezana.toml.Toml;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
@@ -35,6 +34,10 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
import org.tomlj.Toml;
|
||||
import org.tomlj.TomlArray;
|
||||
import org.tomlj.TomlParseResult;
|
||||
import org.tomlj.TomlTable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -42,8 +45,8 @@ import java.lang.reflect.Type;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.Manifest;
|
||||
@@ -171,15 +174,15 @@ public final class ForgeNewModMetadata {
|
||||
}
|
||||
|
||||
public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException {
|
||||
return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGED);
|
||||
return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGE);
|
||||
}
|
||||
|
||||
private static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException {
|
||||
if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGED) {
|
||||
if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGE) {
|
||||
throw new IOException("Invalid mod loader: " + modLoaderType);
|
||||
}
|
||||
|
||||
if (modLoaderType == ModLoaderType.NEO_FORGED) {
|
||||
if (modLoaderType == ModLoaderType.NEO_FORGE) {
|
||||
try {
|
||||
return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, tree);
|
||||
} catch (Exception ignored) {
|
||||
@@ -208,10 +211,15 @@ public final class ForgeNewModMetadata {
|
||||
ZipArchiveEntry modToml = tree.getEntry(tomlPath);
|
||||
if (modToml == null)
|
||||
throw new IOException("File " + modFile + " is not a Forge 1.13+ or NeoForge mod.");
|
||||
Toml toml = new Toml().read(tree.readTextEntry(modToml));
|
||||
ForgeNewModMetadata metadata = toml.to(ForgeNewModMetadata.class);
|
||||
TomlParseResult tomlParseResult = Toml.parse(tree.readTextEntry(modToml));
|
||||
if (tomlParseResult.hasErrors()) {
|
||||
var ioException = new IOException("Mod " + modFile + " `%s` is malformed..".formatted(modToml.getName()));
|
||||
tomlParseResult.errors().forEach(ioException::addSuppressed);
|
||||
throw ioException;
|
||||
}
|
||||
ForgeNewModMetadata metadata = JsonUtils.GSON.fromJson(tomlParseResult.toJson(), ForgeNewModMetadata.class);
|
||||
if (metadata == null || metadata.getMods().isEmpty())
|
||||
throw new IOException("Mod " + modFile + " `mods.toml` is malformed..");
|
||||
throw new IOException("Mod " + modFile + " `%s` is malformed..".formatted(modToml.getName()));
|
||||
Mod mod = metadata.getMods().get(0);
|
||||
ZipArchiveEntry manifestMF = tree.getEntry("META-INF/MANIFEST.MF");
|
||||
String jarVersion = "";
|
||||
@@ -224,7 +232,7 @@ public final class ForgeNewModMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
ModLoaderType type = analyzeLoader(toml, mod.getModId(), modLoaderType);
|
||||
ModLoaderType type = analyzeLoader(tomlParseResult, mod.getModId(), modLoaderType);
|
||||
|
||||
return new LocalModFile(modManager, modManager.getLocalMod(mod.getModId(), type), modFile, mod.getDisplayName(), new LocalModFile.Description(mod.getDescription()),
|
||||
mod.getAuthors(), jarVersion == null ? mod.getVersion() : mod.getVersion().replace("${file.jarVersion}", jarVersion), "",
|
||||
@@ -293,23 +301,32 @@ public final class ForgeNewModMetadata {
|
||||
throw new IOException();
|
||||
}
|
||||
|
||||
private static ModLoaderType analyzeLoader(Toml toml, String modID, ModLoaderType loader) {
|
||||
List<HashMap<String, Object>> dependencies = null;
|
||||
private static ModLoaderType analyzeLoader(TomlParseResult toml, String modID, ModLoaderType loader) {
|
||||
List<Map<String, Object>> dependencies = null;
|
||||
try {
|
||||
dependencies = toml.getList("dependencies." + modID);
|
||||
TomlArray tomlArray = toml.getArray("dependencies." + modID);
|
||||
if (tomlArray != null) {
|
||||
dependencies = tomlArray.toList().stream().map( o -> ((TomlTable) o).toMap()).toList();
|
||||
}
|
||||
} catch (ClassCastException ignored) { // https://github.com/HMCL-dev/HMCL/issues/5068
|
||||
}
|
||||
|
||||
if (dependencies == null) {
|
||||
try {
|
||||
dependencies = toml.getList("dependencies"); // ??? I have no idea why some of the Forge mods use [[dependencies]]
|
||||
TomlArray tomlArray = toml.getArray("dependencies"); // ??? I have no idea why some of the Forge mods use [[dependencies]]
|
||||
if (tomlArray != null) {
|
||||
dependencies = tomlArray.toList().stream().map( o -> ((TomlTable) o).toMap()).toList();
|
||||
}
|
||||
} catch (ClassCastException e) {
|
||||
try {
|
||||
Toml table = toml.getTable("dependencies");
|
||||
TomlTable table = toml.getTable("dependencies");
|
||||
if (table == null)
|
||||
return loader;
|
||||
|
||||
dependencies = table.getList(modID);
|
||||
TomlArray tomlArray = table.getArray(modID);
|
||||
if (tomlArray != null) {
|
||||
dependencies = tomlArray.toList().stream().map( o -> ((TomlTable) o).toMap()).toList();
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
@@ -321,13 +338,13 @@ public final class ForgeNewModMetadata {
|
||||
|
||||
ModLoaderType result = null;
|
||||
loop:
|
||||
for (HashMap<String, Object> dependency : dependencies) {
|
||||
for (Map<String, Object> dependency : dependencies) {
|
||||
switch ((String) dependency.get("modId")) {
|
||||
case "forge":
|
||||
result = ModLoaderType.FORGE;
|
||||
break loop;
|
||||
case "neoforge":
|
||||
result = ModLoaderType.NEO_FORGED;
|
||||
result = ModLoaderType.NEO_FORGE;
|
||||
break loop;
|
||||
}
|
||||
}
|
||||
|
||||
+45
-7
@@ -24,11 +24,7 @@ import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.RemoteMod;
|
||||
import org.jackhuang.hmcl.mod.RemoteModRepository;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
@@ -42,6 +38,7 @@ import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -63,6 +60,41 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
public static final ModrinthRemoteModRepository RESOURCE_PACKS = new ModrinthRemoteModRepository("resourcepack");
|
||||
public static final ModrinthRemoteModRepository SHADER_PACKS = new ModrinthRemoteModRepository("shader");
|
||||
|
||||
private static final Comparator<String> TAG_COMPARATOR = PriorityComparator.of(
|
||||
List.of("babric",
|
||||
"bta-babric",
|
||||
"bukkit",
|
||||
"bungeecord",
|
||||
"canvas",
|
||||
"datapack",
|
||||
"fabric",
|
||||
"folia",
|
||||
"forge",
|
||||
"geyser",
|
||||
"iris",
|
||||
"java-agent",
|
||||
"legacy-fabric",
|
||||
"liteloader",
|
||||
"minecraft",
|
||||
"modloader",
|
||||
"mrpack",
|
||||
"neoforge",
|
||||
"nilloader",
|
||||
"optifine",
|
||||
"ornith",
|
||||
"paper",
|
||||
"purpur",
|
||||
"quilt",
|
||||
"rift",
|
||||
"spigot",
|
||||
"sponge",
|
||||
"vanilla",
|
||||
"velocity",
|
||||
"waterfall"),
|
||||
Comparator.naturalOrder(),
|
||||
false
|
||||
);
|
||||
|
||||
private static final Semaphore SEMAPHORE = new Semaphore(16);
|
||||
|
||||
private static final String PREFIX = "https://api.modrinth.com";
|
||||
@@ -95,6 +127,12 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> sortDisplayCategories(List<String> displayCategories) {
|
||||
return displayCategories != null && !displayCategories.isEmpty()
|
||||
? displayCategories.stream().sorted(TAG_COMPARATOR).toList()
|
||||
: List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
@@ -630,7 +668,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
loaders.stream().flatMap(loader -> {
|
||||
if ("fabric".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.FABRIC);
|
||||
else if ("forge".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.FORGE);
|
||||
else if ("neoforge".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.NEO_FORGED);
|
||||
else if ("neoforge".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.NEO_FORGE);
|
||||
else if ("quilt".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.QUILT);
|
||||
else if ("liteloader".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.LITE_LOADER);
|
||||
else return Stream.empty();
|
||||
@@ -811,7 +849,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
author,
|
||||
title,
|
||||
description,
|
||||
displayCategories,
|
||||
sortDisplayCategories(displayCategories),
|
||||
String.format("https://modrinth.com/%s/%s", projectType, projectId),
|
||||
iconUrl,
|
||||
this
|
||||
|
||||
@@ -19,14 +19,18 @@ package org.jackhuang.hmcl.task;
|
||||
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -80,17 +84,29 @@ public final class CacheFileTask extends FetchTask<Path> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
assert checkETag;
|
||||
assert response != null;
|
||||
|
||||
Path temp = Files.createTempFile("hmcl-download-", null);
|
||||
OutputStream fileOutput = Files.newOutputStream(temp);
|
||||
|
||||
return new Context() {
|
||||
private final Path temp = Files.createTempFile("hmcl-download-", null);
|
||||
private final FileChannel fileOutput = FileChannel.open(temp,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.CREATE);
|
||||
|
||||
@Override
|
||||
public void reset() throws IOException {
|
||||
fileOutput.truncate(0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||
fileOutput.write(buffer, offset, len);
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, offset, len);
|
||||
while (byteBuffer.hasRemaining()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
fileOutput.write(byteBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -99,25 +115,27 @@ public final class CacheFileTask extends FetchTask<Path> {
|
||||
fileOutput.close();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to close file: " + temp, e);
|
||||
deleteTempFile();
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!isSuccess()) {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete file: " + temp, e);
|
||||
}
|
||||
deleteTempFile();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setResult(repository.cacheRemoteFile(response, temp));
|
||||
setResult(repository.cacheRemoteFile(UrlResponseInfo.of(response), temp));
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete file: " + temp, e);
|
||||
}
|
||||
deleteTempFile();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteTempFile() {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete file: " + temp, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,10 +21,7 @@ import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.ContentEncoding;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.util.io.*;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
@@ -38,8 +35,13 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.threadPool;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -91,14 +93,11 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
public void execute() throws Exception {
|
||||
boolean checkETag;
|
||||
switch (shouldCheckETag()) {
|
||||
case CHECK_E_TAG:
|
||||
checkETag = true;
|
||||
break;
|
||||
case NOT_CHECK_E_TAG:
|
||||
checkETag = false;
|
||||
break;
|
||||
default:
|
||||
case CHECK_E_TAG -> checkETag = true;
|
||||
case NOT_CHECK_E_TAG -> checkETag = false;
|
||||
default -> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ArrayList<DownloadException> exceptions = null;
|
||||
@@ -135,12 +134,111 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class HttpResumeContext {
|
||||
private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("bytes ([0-9]+)-([0-9]+)/([0-9]+)");
|
||||
|
||||
static @Nullable FetchTask.HttpResumeContext of(HttpResponse<?> response) throws IOException {
|
||||
if (response.statusCode() != HttpURLConnection.HTTP_OK)
|
||||
return null;
|
||||
|
||||
boolean acceptRanges = response.headers().firstValue("accept-ranges").orElse("").equalsIgnoreCase("bytes");
|
||||
if (!acceptRanges)
|
||||
return null;
|
||||
|
||||
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
|
||||
if (contentEncoding != ContentEncoding.IDENTITY)
|
||||
return null;
|
||||
|
||||
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
if (contentLength < 0)
|
||||
return null;
|
||||
|
||||
String eTag = response.headers().firstValue("etag").orElse(null);
|
||||
String strongETag = isStrongETag(eTag) ? eTag : null;
|
||||
String lastModified = response.headers().firstValue("last-modified").orElse(null);
|
||||
if (strongETag == null && StringUtils.isBlank(lastModified))
|
||||
return null;
|
||||
|
||||
return new HttpResumeContext(response.uri(), contentLength, strongETag, lastModified);
|
||||
}
|
||||
|
||||
private final URI uri;
|
||||
private final long contentLength;
|
||||
private final @Nullable String strongETag;
|
||||
private final @Nullable String lastModified;
|
||||
|
||||
long countUncompressed;
|
||||
|
||||
private HttpResumeContext(URI uri, long contentLength, @Nullable String strongETag, @Nullable String lastModified) {
|
||||
this.uri = uri;
|
||||
this.contentLength = contentLength;
|
||||
this.strongETag = strongETag;
|
||||
this.lastModified = lastModified;
|
||||
}
|
||||
|
||||
private static boolean isStrongETag(@Nullable String eTag) {
|
||||
return StringUtils.isNotBlank(eTag) && !eTag.regionMatches(true, 0, "W/", 0, 2);
|
||||
}
|
||||
|
||||
String ifRange() {
|
||||
return strongETag != null ? strongETag : Objects.requireNonNull(lastModified);
|
||||
}
|
||||
|
||||
boolean canResume(HttpResponse<?> response) throws IOException {
|
||||
if (response.statusCode() != HttpURLConnection.HTTP_PARTIAL)
|
||||
return false;
|
||||
|
||||
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
|
||||
if (contentEncoding != ContentEncoding.IDENTITY)
|
||||
return false;
|
||||
|
||||
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
if (this.contentLength != contentLength + this.countUncompressed)
|
||||
return false;
|
||||
|
||||
if (strongETag != null) {
|
||||
String eTag = response.headers().firstValue("etag").orElse(null);
|
||||
if (!strongETag.equals(eTag))
|
||||
return false;
|
||||
} else {
|
||||
if (!uri.equals(response.uri()))
|
||||
return false;
|
||||
|
||||
String lastModified = response.headers().firstValue("last-modified").orElse("");
|
||||
if (!Objects.requireNonNull(this.lastModified).equals(lastModified))
|
||||
return false;
|
||||
}
|
||||
|
||||
String contentRange = response.headers().firstValue("content-range").orElse("");
|
||||
Matcher matcher = CONTENT_RANGE_PATTERN.matcher(contentRange);
|
||||
if (!matcher.matches())
|
||||
return false;
|
||||
|
||||
try {
|
||||
long start = Long.parseLong(matcher.group(1));
|
||||
long end = Long.parseLong(matcher.group(2));
|
||||
long total = Long.parseLong(matcher.group(3));
|
||||
|
||||
if (start != countUncompressed || end < start || total != this.contentLength)
|
||||
return false;
|
||||
|
||||
return end - start + 1 == contentLength;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasPartialContent() {
|
||||
return countUncompressed > 0 && countUncompressed < contentLength;
|
||||
}
|
||||
}
|
||||
|
||||
private void download(Context context,
|
||||
InputStream inputStream,
|
||||
@Nullable FetchTask.HttpResumeContext resume, InputStream inputStream,
|
||||
long contentLength,
|
||||
ContentEncoding contentEncoding) throws IOException, InterruptedException {
|
||||
try (var ignored = context;
|
||||
var counter = new CounterInputStream(inputStream);
|
||||
boolean success = false;
|
||||
try (var counter = new CounterInputStream(inputStream);
|
||||
var input = contentEncoding.wrap(counter)) {
|
||||
long lastDownloaded = 0L;
|
||||
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
|
||||
@@ -150,7 +248,15 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
int len = input.read(buffer);
|
||||
if (len == -1) break;
|
||||
|
||||
context.write(buffer, 0, len);
|
||||
try {
|
||||
context.write(buffer, 0, len);
|
||||
} catch (Throwable e) {
|
||||
context.broken = true;
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (resume != null)
|
||||
resume.countUncompressed += len;
|
||||
|
||||
if (contentLength >= 0) {
|
||||
// Update progress information per second
|
||||
@@ -169,6 +275,10 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
if (contentLength >= 0 && counter.downloaded != contentLength)
|
||||
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
|
||||
|
||||
success = true;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
context.withResult(true);
|
||||
}
|
||||
}
|
||||
@@ -185,121 +295,197 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
}
|
||||
}
|
||||
|
||||
Context context = null;
|
||||
HttpResumeContext resumeContext = null;
|
||||
|
||||
ArrayList<Exception> exceptions = null;
|
||||
|
||||
// If loading the cache fails, the cache should not be loaded again.
|
||||
boolean useCachedResult = true;
|
||||
for (int retryTime = 0, retryLimit = retry; retryTime < retryLimit; retryTime++) {
|
||||
if (isCancelled()) {
|
||||
throw new InterruptedException();
|
||||
}
|
||||
|
||||
List<URI> redirects = null;
|
||||
try {
|
||||
beforeDownload(uri);
|
||||
updateProgress(0);
|
||||
|
||||
HttpResponse<InputStream> response;
|
||||
String bmclapiHash;
|
||||
|
||||
URI currentURI = uri;
|
||||
|
||||
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("accept-encoding", "gzip");
|
||||
if (useCachedResult && checkETag)
|
||||
headers.putAll(repository.injectConnection(uri));
|
||||
|
||||
do {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
|
||||
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
|
||||
.header("User-Agent", NetworkUtils.USER_AGENT);
|
||||
|
||||
headers.forEach(requestBuilder::header);
|
||||
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
|
||||
|
||||
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
|
||||
if (DigestUtils.isSha1Digest(bmclapiHash)) {
|
||||
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
|
||||
if (cache.isPresent()) {
|
||||
useCachedResult(cache.get());
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int code = response.statusCode();
|
||||
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
|
||||
if (redirects == null) {
|
||||
redirects = new ArrayList<>();
|
||||
} else if (redirects.size() >= 20) {
|
||||
throw new IOException("Too much redirects");
|
||||
}
|
||||
|
||||
String location = response.headers().firstValue("Location").orElse(null);
|
||||
if (StringUtils.isBlank(location))
|
||||
throw new IOException("Redirected to an empty location");
|
||||
|
||||
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
|
||||
redirects.add(target);
|
||||
|
||||
if (!NetworkUtils.isHttpUri(target))
|
||||
throw new IOException("Redirected to not http URI: " + target);
|
||||
|
||||
currentURI = target;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
int responseCode = response.statusCode();
|
||||
if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
|
||||
// Handle cache
|
||||
try {
|
||||
Path cache = repository.getCachedRemoteFile(currentURI, false);
|
||||
useCachedResult(cache);
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
} catch (CacheRepository.CacheExpiredException e) {
|
||||
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
|
||||
repository.removeRemoteEntry(currentURI);
|
||||
useCachedResult = false;
|
||||
// Now we must reconnect the server since 304 may result in empty content,
|
||||
// if we want to redownload the file, we must reconnect the server without etag settings.
|
||||
retryLimit++;
|
||||
continue;
|
||||
}
|
||||
} else if (responseCode / 100 == 4) {
|
||||
throw new FileNotFoundException(uri.toString());
|
||||
} else if (responseCode / 100 != 2) {
|
||||
throw new ResponseCodeException(uri, responseCode);
|
||||
try {
|
||||
for (int retryTime = 0, retryLimit = retry; retryTime < retryLimit; retryTime++) {
|
||||
if (isCancelled()) {
|
||||
throw new InterruptedException();
|
||||
}
|
||||
|
||||
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
var contentEncoding = ContentEncoding.fromResponse(response);
|
||||
List<URI> redirects = null;
|
||||
try {
|
||||
beforeDownload(uri);
|
||||
updateProgress(0);
|
||||
|
||||
download(getContext(response, checkETag, bmclapiHash),
|
||||
response.body(),
|
||||
contentLength,
|
||||
contentEncoding);
|
||||
return;
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
} catch (FileNotFoundException ex) {
|
||||
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
|
||||
} catch (Exception ex) {
|
||||
if (exceptions == null)
|
||||
exceptions = new ArrayList<>();
|
||||
HttpResponse<InputStream> response;
|
||||
String bmclapiHash;
|
||||
|
||||
exceptions.add(ex);
|
||||
URI currentURI = uri;
|
||||
|
||||
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("accept-encoding", "gzip");
|
||||
|
||||
if (retryTime < retryLimit - 1) {
|
||||
// Wait for a while before retrying
|
||||
Thread.sleep(200);
|
||||
boolean resumeRequested = resumeContext != null && resumeContext.hasPartialContent();
|
||||
if (useCachedResult && checkETag && !resumeRequested)
|
||||
headers.putAll(repository.injectConnection(uri));
|
||||
if (resumeRequested) {
|
||||
headers.put("range", "bytes=" + resumeContext.countUncompressed + "-");
|
||||
headers.put("if-range", resumeContext.ifRange());
|
||||
}
|
||||
|
||||
do {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
|
||||
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
|
||||
.header("User-Agent", NetworkUtils.USER_AGENT);
|
||||
headers.forEach(requestBuilder::header);
|
||||
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
|
||||
|
||||
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
|
||||
if (DigestUtils.isSha1Digest(bmclapiHash)) {
|
||||
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
|
||||
if (cache.isPresent()) {
|
||||
closeResponseBody(response);
|
||||
useCachedResult(cache.get());
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int code = response.statusCode();
|
||||
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
|
||||
if (redirects == null) {
|
||||
redirects = new ArrayList<>();
|
||||
} else if (redirects.size() >= 20) {
|
||||
throw new IOException("Too much redirects");
|
||||
}
|
||||
|
||||
String location = response.headers().firstValue("Location").orElse(null);
|
||||
if (StringUtils.isBlank(location))
|
||||
throw new IOException("Redirected to an empty location");
|
||||
|
||||
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
|
||||
redirects.add(target);
|
||||
|
||||
if (!NetworkUtils.isHttpUri(target))
|
||||
throw new IOException("Redirected to not http URI: " + target);
|
||||
|
||||
closeResponseBody(response);
|
||||
currentURI = target;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
boolean closeResponseBody = true;
|
||||
try {
|
||||
int responseCode = response.statusCode();
|
||||
if (resumeRequested && responseCode == 416) {
|
||||
resumeContext = null;
|
||||
discardContext(context);
|
||||
context = null;
|
||||
retryLimit++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
|
||||
// Handle cache
|
||||
try {
|
||||
Path cache = repository.getCachedRemoteFile(currentURI, false);
|
||||
useCachedResult(cache);
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
} catch (CacheRepository.CacheExpiredException e) {
|
||||
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
|
||||
repository.removeRemoteEntry(currentURI);
|
||||
useCachedResult = false;
|
||||
// Now we must reconnect the server since 304 may result in empty content,
|
||||
// if we want to redownload the file, we must reconnect the server without etag settings.
|
||||
retryLimit++;
|
||||
continue;
|
||||
}
|
||||
} else if (responseCode / 100 == 4) {
|
||||
throw new FileNotFoundException(uri.toString());
|
||||
} else if (responseCode / 100 != 2) {
|
||||
throw new ResponseCodeException(uri, responseCode);
|
||||
}
|
||||
|
||||
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
|
||||
|
||||
if (context == null) {
|
||||
context = getContext(response, checkETag, bmclapiHash);
|
||||
resumeContext = HttpResumeContext.of(response);
|
||||
} else if (resumeRequested) {
|
||||
if (resumeContext.canResume(response)) {
|
||||
// Resume download
|
||||
LOG.info("Resuming " + resumeContext.uri + " from " + resumeContext.countUncompressed);
|
||||
} else {
|
||||
// Failed to resume download, so we will retry from the beginning
|
||||
resumeContext = null;
|
||||
discardContext(context);
|
||||
context = null;
|
||||
retryLimit++;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
discardContext(context);
|
||||
context = getContext(response, checkETag, bmclapiHash);
|
||||
resumeContext = HttpResumeContext.of(response);
|
||||
}
|
||||
|
||||
try {
|
||||
closeResponseBody = false;
|
||||
download(context,
|
||||
resumeContext, response.body(),
|
||||
contentLength,
|
||||
contentEncoding);
|
||||
} catch (IOException | InterruptedException | RuntimeException | Error e) {
|
||||
if (context.broken) {
|
||||
IOUtils.closeQuietly(context, e);
|
||||
context = null;
|
||||
resumeContext = null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
context.close();
|
||||
} catch (IOException | RuntimeException | Error e) {
|
||||
context.withResult(false);
|
||||
IOUtils.closeQuietly(context, e);
|
||||
context = null;
|
||||
resumeContext = null;
|
||||
throw e;
|
||||
}
|
||||
context = null;
|
||||
return;
|
||||
} finally {
|
||||
if (closeResponseBody) {
|
||||
closeResponseBody(response);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
} catch (FileNotFoundException ex) {
|
||||
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
|
||||
} catch (Exception ex) {
|
||||
if (exceptions == null)
|
||||
exceptions = new ArrayList<>();
|
||||
|
||||
exceptions.add(ex);
|
||||
|
||||
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
|
||||
if (retryTime < retryLimit - 1) {
|
||||
// Wait for a while before retrying
|
||||
Thread.sleep(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (context != null) {
|
||||
try {
|
||||
context.close();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to close context for " + NetworkUtils.dropQuery(uri), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +493,19 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
throw toDownloadException(uri, null, exceptions);
|
||||
}
|
||||
|
||||
private static void closeResponseBody(HttpResponse<InputStream> response) {
|
||||
InputStream body = response.body();
|
||||
if (body != null)
|
||||
IOUtils.closeQuietly(body);
|
||||
}
|
||||
|
||||
private static void discardContext(@Nullable Context context) {
|
||||
if (context != null) {
|
||||
context.withResult(false);
|
||||
IOUtils.closeQuietly(context);
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException {
|
||||
ArrayList<Exception> exceptions = null;
|
||||
for (int retryTime = 0; retryTime < retry; retryTime++) {
|
||||
@@ -319,10 +518,12 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
updateProgress(0);
|
||||
|
||||
URLConnection conn = NetworkUtils.createConnection(uri);
|
||||
download(getContext(),
|
||||
conn.getInputStream(),
|
||||
conn.getContentLengthLong(),
|
||||
ContentEncoding.fromConnection(conn));
|
||||
try (Context context = getContext()) {
|
||||
download(context,
|
||||
null, conn.getInputStream(),
|
||||
conn.getContentLengthLong(),
|
||||
ContentEncoding.fromConnection(conn));
|
||||
}
|
||||
return;
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
@@ -425,16 +626,22 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
|
||||
protected static abstract class Context implements Closeable {
|
||||
private boolean success;
|
||||
private boolean broken;
|
||||
|
||||
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
|
||||
protected final boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public void withResult(boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
protected boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
public abstract void reset() throws IOException;
|
||||
|
||||
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
|
||||
|
||||
@Override
|
||||
public abstract void close() throws IOException;
|
||||
}
|
||||
|
||||
protected enum EnumCheckETag {
|
||||
|
||||
@@ -22,15 +22,20 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
@@ -44,10 +49,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
*/
|
||||
public class FileDownloadTask extends FetchTask<Void> {
|
||||
|
||||
public static class IntegrityCheck {
|
||||
private final String algorithm;
|
||||
private final String checksum;
|
||||
|
||||
public record IntegrityCheck(String algorithm, String checksum) {
|
||||
public IntegrityCheck(String algorithm, String checksum) {
|
||||
this.algorithm = requireNonNull(algorithm);
|
||||
this.checksum = requireNonNull(checksum);
|
||||
@@ -58,16 +60,8 @@ public class FileDownloadTask extends FetchTask<Void> {
|
||||
else return new IntegrityCheck(algorithm, checksum);
|
||||
}
|
||||
|
||||
public String getAlgorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public String getChecksum() {
|
||||
return checksum;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
public @NotNull String toString() {
|
||||
return String.format("IntegrityCheck[algorithm='%s', checksum='%s']", algorithm, checksum);
|
||||
}
|
||||
}
|
||||
@@ -158,7 +152,7 @@ public class FileDownloadTask extends FetchTask<Void> {
|
||||
protected EnumCheckETag shouldCheckETag() {
|
||||
// Check cache
|
||||
if (integrityCheck != null && caching) {
|
||||
Optional<Path> cache = repository.checkExistentFile(candidate, integrityCheck.getAlgorithm(), integrityCheck.getChecksum());
|
||||
Optional<Path> cache = repository.checkExistentFile(candidate, integrityCheck.algorithm(), integrityCheck.checksum());
|
||||
if (cache.isPresent()) {
|
||||
try {
|
||||
FileUtils.copyFile(cache.get(), file);
|
||||
@@ -185,14 +179,14 @@ public class FileDownloadTask extends FetchTask<Void> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
Path temp = Files.createTempFile(null, null);
|
||||
|
||||
String algorithm;
|
||||
String checksum;
|
||||
if (integrityCheck != null) {
|
||||
algorithm = integrityCheck.getAlgorithm();
|
||||
checksum = integrityCheck.getChecksum();
|
||||
algorithm = integrityCheck.algorithm();
|
||||
checksum = integrityCheck.checksum();
|
||||
} else if (bmclapiHash != null && DigestUtils.isSha1Digest(bmclapiHash)) {
|
||||
algorithm = "SHA-1";
|
||||
checksum = bmclapiHash;
|
||||
@@ -203,15 +197,32 @@ public class FileDownloadTask extends FetchTask<Void> {
|
||||
|
||||
MessageDigest digest = algorithm != null ? DigestUtils.getDigest(algorithm) : null;
|
||||
|
||||
OutputStream fileOutput = Files.newOutputStream(temp);
|
||||
FileChannel fileOutput = FileChannel.open(temp,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.CREATE);
|
||||
return new Context() {
|
||||
@Override
|
||||
public void reset() throws IOException {
|
||||
if (digest != null) {
|
||||
digest.reset();
|
||||
}
|
||||
|
||||
fileOutput.truncate(0L);
|
||||
fileOutput.position(0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||
if (digest != null) {
|
||||
digest.update(buffer, offset, len);
|
||||
}
|
||||
|
||||
fileOutput.write(buffer, offset, len);
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, offset, len);
|
||||
while (byteBuffer.hasRemaining()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
fileOutput.write(byteBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -220,47 +231,60 @@ public class FileDownloadTask extends FetchTask<Void> {
|
||||
fileOutput.close();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to close file: " + temp, e);
|
||||
deleteTempFile();
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!isSuccess()) {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete file: " + temp, e);
|
||||
}
|
||||
deleteTempFile();
|
||||
return;
|
||||
}
|
||||
|
||||
for (IntegrityCheckHandler handler : integrityCheckHandlers) {
|
||||
handler.checkIntegrity(temp, file);
|
||||
}
|
||||
|
||||
Files.createDirectories(file.toAbsolutePath().getParent());
|
||||
|
||||
boolean moved = false;
|
||||
try {
|
||||
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Unable to move temp file from " + temp + " to " + file, e);
|
||||
}
|
||||
|
||||
// Integrity check
|
||||
if (checksum != null) {
|
||||
String actualChecksum = HexFormat.of().formatHex(digest.digest());
|
||||
if (!checksum.equalsIgnoreCase(actualChecksum)) {
|
||||
throw new ChecksumMismatchException(algorithm, checksum, actualChecksum);
|
||||
for (IntegrityCheckHandler handler : integrityCheckHandlers) {
|
||||
handler.checkIntegrity(temp, file);
|
||||
}
|
||||
}
|
||||
|
||||
if (caching && algorithm != null) {
|
||||
if (checksum != null) {
|
||||
String actualChecksum = HexFormat.of().formatHex(digest.digest());
|
||||
if (!checksum.equalsIgnoreCase(actualChecksum)) {
|
||||
throw new ChecksumMismatchException(algorithm, checksum, actualChecksum);
|
||||
}
|
||||
}
|
||||
|
||||
Files.createDirectories(file.toAbsolutePath().getParent());
|
||||
|
||||
try {
|
||||
repository.cacheFile(file, algorithm, checksum);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to cache file", e);
|
||||
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
moved = true;
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Unable to move temp file from " + temp + " to " + file, e);
|
||||
}
|
||||
|
||||
if (caching && algorithm != null) {
|
||||
try {
|
||||
repository.cacheFile(file, algorithm, checksum);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to cache file", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheRemoteFile(UrlResponseInfo.of(response), file);
|
||||
}
|
||||
} finally {
|
||||
if (!moved) {
|
||||
deleteTempFile();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheRemoteFile(response, file);
|
||||
private void deleteTempFile() {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete file: " + temp, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.task;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -61,13 +63,18 @@ public final class GetTask extends FetchTask<String> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
|
||||
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
|
||||
long length = -1;
|
||||
if (response != null)
|
||||
length = response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : (int) length);
|
||||
|
||||
return new Context() {
|
||||
@Override
|
||||
public void reset() throws IOException {
|
||||
baos.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) {
|
||||
baos.write(buffer, offset, len);
|
||||
@@ -85,7 +92,7 @@ public final class GetTask extends FetchTask<String> {
|
||||
setResult(result);
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheText(response, result);
|
||||
repository.cacheText(UrlResponseInfo.of(response), result);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,13 +23,13 @@ import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
@@ -228,20 +228,20 @@ public class CacheRepository {
|
||||
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
|
||||
}
|
||||
|
||||
public Path cacheRemoteFile(HttpResponse<?> response, Path downloaded) throws IOException {
|
||||
return cacheData(response, () -> {
|
||||
public Path cacheRemoteFile(UrlResponseInfo info, Path downloaded) throws IOException {
|
||||
return cacheData(info, () -> {
|
||||
String hash = DigestUtils.digestToString(SHA1, downloaded);
|
||||
Path cached = cacheFile(downloaded, SHA1, hash);
|
||||
return new CacheResult(hash, cached);
|
||||
});
|
||||
}
|
||||
|
||||
public Path cacheText(HttpResponse<?> response, String text) throws IOException {
|
||||
return cacheBytes(response, text.getBytes(UTF_8));
|
||||
public Path cacheText(UrlResponseInfo info, String text) throws IOException {
|
||||
return cacheBytes(info, text.getBytes(UTF_8));
|
||||
}
|
||||
|
||||
public Path cacheBytes(HttpResponse<?> response, byte[] bytes) throws IOException {
|
||||
return cacheData(response, () -> {
|
||||
public Path cacheBytes(UrlResponseInfo info, byte[] bytes) throws IOException {
|
||||
return cacheData(info, () -> {
|
||||
String hash = DigestUtils.digestToString(SHA1, bytes);
|
||||
Path cached = getFile(SHA1, hash);
|
||||
Files.createDirectories(cached.getParent());
|
||||
@@ -252,15 +252,15 @@ public class CacheRepository {
|
||||
|
||||
private static final Pattern MAX_AGE = Pattern.compile("(s-maxage|max-age)=(?<time>[0-9]+)");
|
||||
|
||||
private Path cacheData(HttpResponse<?> response, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
|
||||
String eTag = response.headers().firstValue("etag").orElse(null);
|
||||
private Path cacheData(UrlResponseInfo info, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
|
||||
String eTag = info.headers().firstValue("etag").orElse(null);
|
||||
if (StringUtils.isBlank(eTag)) return null;
|
||||
URI uri = NetworkUtils.dropQuery(response.uri());
|
||||
URI uri = NetworkUtils.dropQuery(info.uri());
|
||||
long expires = 0L;
|
||||
|
||||
expires:
|
||||
try {
|
||||
String cacheControl = response.headers().firstValue("cache-control").orElse(null);
|
||||
String cacheControl = info.headers().firstValue("cache-control").orElse(null);
|
||||
if (StringUtils.isNotBlank(cacheControl)) {
|
||||
if (cacheControl.contains("no-store"))
|
||||
return null;
|
||||
@@ -273,7 +273,7 @@ public class CacheRepository {
|
||||
}
|
||||
}
|
||||
|
||||
String expiresHeader = response.headers().firstValue("expires").orElse(null);
|
||||
String expiresHeader = info.headers().firstValue("expires").orElse(null);
|
||||
if (StringUtils.isNotBlank(expiresHeader)) {
|
||||
expires = ZonedDateTime.parse(expiresHeader.trim(), DateTimeFormatter.RFC_1123_DATE_TIME)
|
||||
.toInstant().toEpochMilli();
|
||||
@@ -282,7 +282,7 @@ public class CacheRepository {
|
||||
LOG.warning("Failed to parse expires time", e);
|
||||
}
|
||||
|
||||
String lastModified = response.headers().firstValue("last-modified").orElse(null);
|
||||
String lastModified = info.headers().firstValue("last-modified").orElse(null);
|
||||
|
||||
CacheResult cacheResult = cacheSupplier.get();
|
||||
ETagItem eTagItem = new ETagItem(uri.toString(),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.util;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/// A comparator that orders elements based on a predefined priority map,
|
||||
/// falling back to another comparator for elements not present in the map.
|
||||
///
|
||||
/// Elements found in the priority map are compared by their associated priority values.
|
||||
/// When only one of the two elements has a priority, the `prioritizedFirst` flag
|
||||
/// determines whether the prioritized element is ordered before or after the other.
|
||||
///
|
||||
/// @param <T> the type of elements to be compared
|
||||
public final class PriorityComparator<T> implements Comparator<T> {
|
||||
|
||||
/// Creates a new `PriorityComparator` with a priority map and a fallback comparator.
|
||||
/// Elements with a defined priority are ordered before those without.
|
||||
public static <T extends Comparable<T>> PriorityComparator<T> of(T... values) {
|
||||
Map<T, Integer> priorities = new HashMap<>();
|
||||
for (int i = 0; i < values.length; i++)
|
||||
priorities.put(values[i], i);
|
||||
return new PriorityComparator<>(priorities, Comparator.naturalOrder(), true);
|
||||
}
|
||||
|
||||
/// Creates a new `PriorityComparator` with a priority map, a fallback comparator, and a flag to determine the order of prioritized elements.
|
||||
/// Elements with a defined priority are ordered before those without if `prioritizedFirst` is `true`, otherwise they are ordered after.
|
||||
public static <T> PriorityComparator<T> of(List<T> values, Comparator<? super T> fallback, boolean prioritizedFirst) {
|
||||
Objects.requireNonNull(fallback);
|
||||
|
||||
Map<T, Integer> priorities = new HashMap<>();
|
||||
for (int i = 0; i < values.size(); i++)
|
||||
priorities.put(values.get(i), i);
|
||||
return new PriorityComparator<>(priorities, fallback, prioritizedFirst);
|
||||
}
|
||||
|
||||
/// A mapping from values to their priority. Lower values indicate higher priority.
|
||||
private final @NotNull Map<T, Integer> priorities;
|
||||
|
||||
/// The fallback comparator used when neither element has a defined priority.
|
||||
private final @NotNull Comparator<? super T> fallback;
|
||||
|
||||
/// If `true`, elements with a defined priority are ordered before those without;
|
||||
/// if `false`, they are ordered after.
|
||||
private final boolean prioritizedFirst;
|
||||
|
||||
/// Creates a new `PriorityComparator`.
|
||||
///
|
||||
/// @param priorities a map from values to their priority (lower value = higher priority)
|
||||
/// @param fallback the comparator to use when neither element has a defined priority
|
||||
/// @param prioritizedFirst if `true`, prioritized elements come before non-prioritized ones
|
||||
private PriorityComparator(
|
||||
Map<T, Integer> priorities,
|
||||
Comparator<? super T> fallback,
|
||||
boolean prioritizedFirst) {
|
||||
this.priorities = priorities;
|
||||
this.fallback = fallback;
|
||||
this.prioritizedFirst = prioritizedFirst;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(T value1, T value2) {
|
||||
Integer p1 = priorities.get(value1);
|
||||
Integer p2 = priorities.get(value2);
|
||||
|
||||
if (p1 != null) {
|
||||
if (p2 != null) {
|
||||
return p1.compareTo(p2);
|
||||
}
|
||||
|
||||
return prioritizedFirst ? -1 : 1;
|
||||
} else {
|
||||
if (p2 != null) {
|
||||
return prioritizedFirst ? 1 : -1;
|
||||
} else {
|
||||
return fallback.compare(value1, value2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user