Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
583e004d71 | ||
|
|
63207affc6 | ||
|
|
8b147bf20a | ||
|
|
15754119d5 | ||
|
|
8119324a92 | ||
|
|
c0ad7764b0 | ||
|
|
3ea054ae31 | ||
|
|
d03ad38e24 | ||
|
|
93eaee317c | ||
|
|
68b3a9f076 | ||
|
|
c943e4c74f | ||
|
|
4dba96e635 | ||
|
|
e521a1b91a | ||
|
|
a7e0fe7499 | ||
|
|
4e2d5cac9e | ||
|
|
452d030817 | ||
|
|
da9ec2c1e1 | ||
|
|
4ebbb400ab | ||
|
|
0cfc2471a0 | ||
|
|
413a7291ec | ||
|
|
e4893a6df9 | ||
|
|
bb16a0b6c0 | ||
|
|
5768cd446e | ||
|
|
6f406cede8 | ||
|
|
0597282463 | ||
|
|
f91fbe29b4 | ||
|
|
cccff7effd | ||
|
|
101257cae8 | ||
|
|
a6e74e7c19 | ||
|
|
615ebe0311 | ||
|
|
8b3468e2d9 | ||
|
|
7f1a812373 | ||
|
|
22823151c3 | ||
|
|
c0844834cb | ||
|
|
7921c7abb5 | ||
|
|
c610519f87 | ||
|
|
bb1949f919 | ||
|
|
a1e946bba6 | ||
|
|
c1bf66b972 | ||
|
|
25952aa514 | ||
|
|
54d8d877be | ||
|
|
662a82f21f | ||
|
|
3f0fcc1382 | ||
|
|
258556b22c | ||
|
|
d7e3cc4473 | ||
|
|
93d4489b94 | ||
|
|
77eee17d36 | ||
|
|
4118fa9729 | ||
|
|
83ffc91a88 | ||
|
|
fdf883d09d | ||
|
|
342bcc5438 | ||
|
|
59bcc7fe62 | ||
|
|
77b42b6f99 |
No files matched your search
@@ -6,3 +6,6 @@ contact_links:
|
||||
- name: Discord 服务器
|
||||
url: https://discord.gg/jVvC7HfM6U
|
||||
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
|
||||
- name: GitHub Issue
|
||||
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: QQ 群
|
||||
url: https://docs.hmcl.net/groups.html
|
||||
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
|
||||
- name: GitHub Issue
|
||||
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
distribution: 'liberica'
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Setup Gradle
|
||||
|
||||
+25
-12
@@ -1,4 +1,6 @@
|
||||
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
|
||||
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
|
||||
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
|
||||
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
||||
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
@@ -6,7 +8,6 @@ import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
||||
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
|
||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
||||
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
||||
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
||||
@@ -145,6 +146,7 @@ val addOpens = listOf(
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.graphics/com.sun.javafx.tk.quantum",
|
||||
"javafx.controls/javafx.scene.control",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
@@ -461,21 +463,32 @@ val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList"
|
||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||
}
|
||||
|
||||
val createLocaleNamesResourceBundle = tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
dependsOn(createLanguageList)
|
||||
val createLocaleNamesResourceBundle =
|
||||
tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
|
||||
// mcmod data
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModData") {
|
||||
inputFile.set(layout.projectDirectory.file("mod.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt"))
|
||||
tasks.register<FormatJsonFile>("formatModJson") {
|
||||
file.set(layout.projectDirectory.file("mod.json"))
|
||||
}
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModPackData") {
|
||||
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt"))
|
||||
tasks.register<FormatJsonFile>("formatModpackJson") {
|
||||
file.set(layout.projectDirectory.file("modpack.json"))
|
||||
}
|
||||
|
||||
tasks.register<UpdateAddonProjectInfos>("updateModProjectInfos") {
|
||||
inputFile.set(layout.projectDirectory.file("mod.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/mod.csv"))
|
||||
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MOD)
|
||||
}
|
||||
|
||||
tasks.register<UpdateAddonProjectInfos>("updateModpackProjectInfos") {
|
||||
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/modpack.csv"))
|
||||
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MODPACK)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final StackPane box = new StackPane();
|
||||
private final StackPane mark = new StackPane();
|
||||
private final SVGPath markShape = new SVGPath();
|
||||
private final double lineThick = 2.0;
|
||||
private final double padding = 10.0;
|
||||
private final JFXRippler rippler;
|
||||
@@ -37,7 +38,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final double labelOffset = -8.0;
|
||||
private final Transition transition;
|
||||
private boolean invalid = true;
|
||||
private JFXFillTransition select;
|
||||
private boolean selected;
|
||||
private boolean indeterminate;
|
||||
|
||||
/// The checkbox fill animation, initialized with the configured checked color.
|
||||
private JFXFillTransition fillTransition;
|
||||
|
||||
public JFXCheckBoxSkin(JFXCheckBox control) {
|
||||
super(control);
|
||||
@@ -50,22 +55,17 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
boxContainer.getChildren().add(this.box);
|
||||
boxContainer.setPadding(new Insets(this.padding));
|
||||
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
|
||||
this.updateRippleColor();
|
||||
SVGPath shape = new SVGPath();
|
||||
shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
||||
this.mark.setShape(shape);
|
||||
this.mark.setMaxSize(15.0, 12.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
||||
this.updateRippleColor(control.isSelected(), control.isIndeterminate());
|
||||
this.mark.setShape(this.markShape);
|
||||
this.updateMarkShape(true, false);
|
||||
this.mark.setVisible(false);
|
||||
this.mark.setScaleX(0.0);
|
||||
this.mark.setScaleY(0.0);
|
||||
boxContainer.getChildren().add(this.mark);
|
||||
this.container.getChildren().add(this.rippler);
|
||||
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
|
||||
control.selectedProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.updateRippleColor();
|
||||
this.playSelectAnimation(newVal);
|
||||
});
|
||||
this.registerChangeListener(control.selectedProperty(), ignored -> requestSelectionUpdate());
|
||||
this.registerChangeListener(control.indeterminateProperty(), ignored -> requestSelectionUpdate());
|
||||
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||
if (focusVisibleProperty == null)
|
||||
@@ -82,37 +82,63 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
|
||||
this.updateChildren();
|
||||
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
|
||||
if (select != null) {
|
||||
select.stop();
|
||||
if (fillTransition != null) {
|
||||
fillTransition.stop();
|
||||
}
|
||||
this.createFillTransition();
|
||||
updateColors();
|
||||
boolean selected = control.isSelected();
|
||||
boolean indeterminate = control.isIndeterminate();
|
||||
JFXFillTransition fillTransition = this.createFillTransition();
|
||||
this.fillTransition = fillTransition;
|
||||
fillTransition.jumpTo(selected || indeterminate ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||
updateColors(selected, indeterminate);
|
||||
});
|
||||
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors());
|
||||
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors(control.isSelected(), control.isIndeterminate()));
|
||||
this.transition = new CheckBoxTransition();
|
||||
this.createFillTransition();
|
||||
this.fillTransition = this.createFillTransition();
|
||||
}
|
||||
|
||||
private void updateRippleColor() {
|
||||
var control = (JFXCheckBox) this.getSkinnable();
|
||||
this.rippler.setRipplerFill(control.isSelected()
|
||||
private void requestSelectionUpdate() {
|
||||
this.invalid = true;
|
||||
this.getSkinnable().requestLayout();
|
||||
}
|
||||
|
||||
private void updateRippleColor(boolean selected, boolean indeterminate) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
this.rippler.setRipplerFill(selected || indeterminate
|
||||
? control.getCheckedColor()
|
||||
: control.getUnCheckedColor());
|
||||
}
|
||||
|
||||
private void updateColors() {
|
||||
private void updateColors(boolean selected, boolean indeterminate) {
|
||||
var control = (JFXCheckBox) getSkinnable();
|
||||
boolean isSelected = control.isSelected();
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT);
|
||||
rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor());
|
||||
boolean marked = selected || indeterminate;
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, marked ? control.getCheckedColor() : Color.TRANSPARENT);
|
||||
rippler.setRipplerFill(marked ? control.getCheckedColor() : control.getUnCheckedColor());
|
||||
this.updateBorder(selected, indeterminate);
|
||||
}
|
||||
|
||||
private void updateBorder(boolean selected, boolean indeterminate) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
|
||||
box.setBorder(new Border(new BorderStroke(
|
||||
isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
selected || indeterminate ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
borderStroke.getTopStyle(),
|
||||
borderStroke.getRadii(),
|
||||
borderStroke.getWidths())));
|
||||
}
|
||||
|
||||
private void updateMarkShape(boolean selected, boolean indeterminate) {
|
||||
if (indeterminate) {
|
||||
this.markShape.setContent("M1 0 H9 A1 1 0 0 1 10 1 A1 1 0 0 1 9 2 H1 A1 1 0 0 1 0 1 A1 1 0 0 1 1 0 Z");
|
||||
this.mark.setMaxSize(10.0, 2.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary;");
|
||||
} else if (selected) {
|
||||
this.markShape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
||||
this.mark.setMaxSize(15.0, 12.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
||||
}
|
||||
}
|
||||
|
||||
protected void updateChildren() {
|
||||
super.updateChildren();
|
||||
if (this.container != null) {
|
||||
@@ -140,9 +166,10 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
|
||||
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
|
||||
if (this.invalid) {
|
||||
if (this.getSkinnable().isSelected()) {
|
||||
this.playSelectAnimation(true);
|
||||
}
|
||||
boolean selected = checkBox.isSelected();
|
||||
boolean indeterminate = checkBox.isIndeterminate();
|
||||
this.updateRippleColor(selected, indeterminate);
|
||||
this.playSelectAnimation(selected, indeterminate);
|
||||
|
||||
this.invalid = false;
|
||||
}
|
||||
@@ -169,47 +196,85 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
};
|
||||
}
|
||||
|
||||
/// Animates a selection change, or applies it immediately when animations are disabled.
|
||||
private void playSelectAnimation(boolean selection) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
private void playSelectAnimation(boolean selected, boolean indeterminate) {
|
||||
boolean previousSelected = this.selected;
|
||||
boolean previousIndeterminate = this.indeterminate;
|
||||
this.selected = selected;
|
||||
this.indeterminate = indeterminate;
|
||||
boolean previousMarked = previousSelected || previousIndeterminate;
|
||||
boolean marked = selected || indeterminate;
|
||||
boolean sameVisualState = previousIndeterminate == indeterminate
|
||||
&& (indeterminate || previousSelected == selected);
|
||||
this.updateBorder(selected, indeterminate);
|
||||
|
||||
this.box.setBorder(new Border(new BorderStroke(
|
||||
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
BorderStrokeStyle.SOLID,
|
||||
new CornerRadii(2.0),
|
||||
new BorderWidths(this.lineThick))));
|
||||
if (sameVisualState) {
|
||||
return;
|
||||
}
|
||||
this.invalid = false;
|
||||
|
||||
if (!AnimationUtils.isAnimationEnabled()) {
|
||||
finishSelectionAnimation();
|
||||
finishSelectionAnimation(selected, indeterminate);
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
if (previousMarked && marked) {
|
||||
this.transition.stop();
|
||||
this.transition.setOnFinished(null);
|
||||
this.transition.jumpTo(this.transition.getCycleDuration());
|
||||
this.transition.setRate(-1.0);
|
||||
this.transition.setOnFinished(event -> {
|
||||
this.transition.setOnFinished(null);
|
||||
this.updateMarkShape(selected, indeterminate);
|
||||
this.transition.jumpTo(Duration.ZERO);
|
||||
this.transition.setRate(1.0);
|
||||
this.transition.play();
|
||||
});
|
||||
this.transition.play();
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setOnFinished(null);
|
||||
if (marked) {
|
||||
this.updateMarkShape(selected, indeterminate);
|
||||
}
|
||||
this.transition.setRate(marked ? 1.0 : -1.0);
|
||||
this.fillTransition.setRate(marked ? 1.0 : -1.0);
|
||||
this.fillTransition.play();
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
}
|
||||
|
||||
/// Stops selection animations and immediately displays the checkbox's current selected state.
|
||||
/// Stops selection animations and immediately displays the checkbox's current state.
|
||||
///
|
||||
/// The check mark, fill, and border are updated even if no animation is running.
|
||||
/// This also suppresses the initial selection animation on the first layout.
|
||||
/// The state indicator, fill, and border are updated even if no animation is running.
|
||||
/// This also suppresses pending selection updates and the initial selection animation.
|
||||
/// The skin must not have been disposed.
|
||||
public void finishSelectionAnimation() {
|
||||
transition.stop();
|
||||
select.stop();
|
||||
invalid = false;
|
||||
boolean selected = getSkinnable().isSelected();
|
||||
mark.setVisible(selected);
|
||||
mark.setScaleX(selected ? 1.0 : 0.0);
|
||||
mark.setScaleY(selected ? 1.0 : 0.0);
|
||||
updateColors();
|
||||
CheckBox checkBox = this.getSkinnable();
|
||||
this.finishSelectionAnimation(checkBox.isSelected(), checkBox.isIndeterminate());
|
||||
}
|
||||
|
||||
private void createFillTransition() {
|
||||
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
||||
private void finishSelectionAnimation(boolean selected, boolean indeterminate) {
|
||||
this.selected = selected;
|
||||
this.indeterminate = indeterminate;
|
||||
boolean marked = selected || indeterminate;
|
||||
transition.stop();
|
||||
transition.setOnFinished(null);
|
||||
transition.jumpTo(marked ? transition.getCycleDuration() : Duration.ZERO);
|
||||
fillTransition.stop();
|
||||
fillTransition.jumpTo(marked ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||
invalid = false;
|
||||
updateMarkShape(selected, indeterminate);
|
||||
mark.setVisible(marked);
|
||||
mark.setScaleX(marked ? 1.0 : 0.0);
|
||||
mark.setScaleY(marked ? 1.0 : 0.0);
|
||||
updateColors(selected, indeterminate);
|
||||
}
|
||||
|
||||
private JFXFillTransition createFillTransition() {
|
||||
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
||||
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
|
||||
this.select.setInterpolator(Interpolator.EASE_OUT);
|
||||
fillTransition.setInterpolator(Interpolator.EASE_OUT);
|
||||
return fillTransition;
|
||||
}
|
||||
|
||||
private final class CheckBoxTransition extends CachedTransition {
|
||||
|
||||
@@ -21,6 +21,7 @@ import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBoxBase;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
@@ -28,6 +29,7 @@ import javafx.scene.paint.Paint;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -178,6 +180,11 @@ public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
||||
|
||||
comboBox.hide();
|
||||
});
|
||||
Platform.runLater(() -> {
|
||||
if (listView.lookup(".virtual-flow") instanceof VirtualFlow<?> virtualFlow) {
|
||||
FXUtils.smoothScrolling(virtualFlow);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDatePicker;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.svg.SVGGlyph;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.*;
|
||||
import javafx.animation.Animation.Status;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.SnapshotParameters;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontWeight;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.SVGContainer;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Year;
|
||||
import java.time.YearMonth;
|
||||
import java.time.chrono.ChronoLocalDate;
|
||||
import java.time.chrono.Chronology;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DecimalStyle;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.temporal.WeekFields;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class JFXDatePickerContent extends VBox {
|
||||
private static final String SPINNER_LABEL = "spinner-label";
|
||||
private static final String ROBOTO = "Roboto";
|
||||
protected JFXDatePicker datePicker;
|
||||
private JFXButton backMonthButton;
|
||||
private JFXButton forwardMonthButton;
|
||||
private Label selectedDateLabel;
|
||||
private Label selectedYearLabel;
|
||||
private Label monthYearLabel;
|
||||
protected GridPane contentGrid;
|
||||
private final StackPane calendarPlaceHolder = new StackPane();
|
||||
private final CachedTransition showTransition;
|
||||
private final CachedTransition hideTransition;
|
||||
private ParallelTransition tempImageTransition;
|
||||
private final int daysPerWeek = 7;
|
||||
private final List<DateCell> weekDaysCells = new ArrayList<>();
|
||||
private final List<DateCell> weekNumberCells = new ArrayList<>();
|
||||
protected List<DateCell> dayCells = new ArrayList<>();
|
||||
private LocalDate[] dayCellDates;
|
||||
private DateCell currentFocusedDayCell = null;
|
||||
private final ListView<String> yearsListView = new JFXListView<>() {
|
||||
{
|
||||
this.getStyleClass().addAll("date-picker-list-view", "no-padding");
|
||||
this.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||
this.setCellFactory((listView) -> new ListCell<>() {
|
||||
static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
|
||||
final Label label = new Label();
|
||||
final StackPane root;
|
||||
final RipplerContainer ripplerContainer;
|
||||
|
||||
final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
{
|
||||
label.getStyleClass().add(SPINNER_LABEL);
|
||||
label.setMaxWidth(Double.MAX_VALUE);
|
||||
label.textProperty().bind(itemProperty());
|
||||
StackPane pane = new StackPane(label);
|
||||
pane.setPadding(new Insets(7));
|
||||
ripplerContainer = new RipplerContainer(pane);
|
||||
root = new StackPane(ripplerContainer);
|
||||
root.getStyleClass().add("data-picker-list-cell");
|
||||
this.setGraphic(root);
|
||||
|
||||
var selectedListenerWeak = holder.weak((InvalidationListener) observable -> {
|
||||
String newVal = JFXDatePickerContent.this.selectedYearLabel.getText();
|
||||
if (newVal != null && getItem() != null && getItem().equals(newVal))
|
||||
updateSelected(true);
|
||||
});
|
||||
JFXDatePickerContent.this.selectedYearLabel.textProperty().addListener(selectedListenerWeak);
|
||||
itemProperty().addListener(selectedListenerWeak);
|
||||
|
||||
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
|
||||
if (getListView() != null) {
|
||||
if (selected) {
|
||||
getListView().getSelectionModel().select(getIndex());
|
||||
} else {
|
||||
getListView().getSelectionModel().clearSelection(getIndex());
|
||||
}
|
||||
}
|
||||
root.pseudoClassStateChanged(SELECTED, selected);
|
||||
if (selected) {
|
||||
int offset = Integer.parseInt(getItem()) - Integer.parseInt(JFXDatePickerContent.this.selectedYearLabel.getText());
|
||||
JFXDatePickerContent.this.forward(offset, ChronoUnit.YEARS, false, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void updateItem(String item, boolean empty) {
|
||||
String oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
setText(null);
|
||||
|
||||
if (Objects.equals(oldItem, item) && oldEmpty == empty) return;
|
||||
ripplerContainer.releaseRippleImmediately();
|
||||
|
||||
if (!empty && item != null) {
|
||||
setGraphic(root);
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
final DateTimeFormatter fullFormatter = DateTimeFormatter.ofPattern("EEE, MMM yyyy");
|
||||
final DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMMM");
|
||||
final DateTimeFormatter yearFormatter = DateTimeFormatter.ofPattern("y");
|
||||
final DateTimeFormatter weekNumberFormatter = DateTimeFormatter.ofPattern("w");
|
||||
final DateTimeFormatter weekDayNameFormatter = DateTimeFormatter.ofPattern("ccc");
|
||||
final DateTimeFormatter dayCellFormatter = DateTimeFormatter.ofPattern("d");
|
||||
private final ObjectProperty<YearMonth> selectedYearMonth = new SimpleObjectProperty<>(this, "selectedYearMonth");
|
||||
|
||||
final ObjectProperty<LocalDate> valueProperty = new SimpleObjectProperty<>(this, "value");
|
||||
|
||||
JFXDatePickerContent(final DatePicker datePicker) {
|
||||
this.datePicker = (JFXDatePicker) datePicker;
|
||||
this.getStyleClass().add("date-picker-popup");
|
||||
LocalDate date = datePicker.getValue();
|
||||
this.valueProperty.set(date);
|
||||
this.valueProperty.addListener((o, old, n) -> {
|
||||
this.displayedYearMonthProperty().set(n != null ? YearMonth.from(n) : YearMonth.now());
|
||||
this.updateValues();
|
||||
});
|
||||
this.selectedYearMonth.set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||
this.selectedYearMonth.addListener((observable, oldValue, newValue) -> this.updateValues());
|
||||
this.getChildren().add(this.createHeaderPane());
|
||||
this.getChildren().add(new Separator());
|
||||
this.contentGrid = new GridPane() {
|
||||
protected double computePrefWidth(double height) {
|
||||
int nCols = JFXDatePickerContent.this.daysPerWeek + (datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||
double leftSpace = this.snapSpaceX(this.getInsets().getLeft());
|
||||
double rightSpace = this.snapSpaceX(this.getInsets().getRight());
|
||||
double hgaps = this.snapSpaceX(this.getHgap()) * (double) (nCols - 1);
|
||||
double contentWidth = super.computePrefWidth(height) - leftSpace - rightSpace - hgaps;
|
||||
return this.snapSizeX(contentWidth / (double) nCols) * (double) nCols + leftSpace + rightSpace + hgaps;
|
||||
}
|
||||
|
||||
protected void layoutChildren() {
|
||||
if (this.getWidth() > (double) 0.0F && this.getHeight() > (double) 0.0F) {
|
||||
super.layoutChildren();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
this.contentGrid.setFocusTraversable(true);
|
||||
this.contentGrid.getStyleClass().add("calendar-grid");
|
||||
this.contentGrid.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.contentGrid.setPadding(new Insets(0.0F, 12.0F, 12.0F, 12.0F));
|
||||
this.contentGrid.setVgap(4);
|
||||
this.contentGrid.setHgap(4);
|
||||
this.createWeekDaysCells();
|
||||
this.createDayCells();
|
||||
|
||||
this.yearsListView.getItems().addAll(IntStream.rangeClosed(1900, Year.now().getValue() + 100).mapToObj(Integer::toString).toList());
|
||||
|
||||
StackPane main = new StackPane();
|
||||
this.yearsListView.setVisible(false);
|
||||
this.yearsListView.setOpacity(0.0F);
|
||||
this.yearsListView.maxWidthProperty().bind(main.widthProperty());
|
||||
this.yearsListView.maxHeightProperty().bind(main.heightProperty());
|
||||
main.getChildren().setAll(this.contentGrid, this.yearsListView);
|
||||
|
||||
VBox contentHolder = new VBox();
|
||||
contentHolder.getChildren().setAll(this.createCalendarMonthLabelPane(), main);
|
||||
this.calendarPlaceHolder.getStyleClass().add("content");
|
||||
this.calendarPlaceHolder.getChildren().setAll(contentHolder, this.createCalendarArrowsPane());
|
||||
FXUtils.setOverflowHidden(this.calendarPlaceHolder);
|
||||
|
||||
this.getChildren().add(this.calendarPlaceHolder);
|
||||
|
||||
this.getChildren().add(this.createActions());
|
||||
|
||||
this.refresh();
|
||||
this.addEventHandler(KeyEvent.ANY, (event) -> {
|
||||
Node node = this.getScene().getFocusOwner();
|
||||
if (node instanceof DateCell) {
|
||||
this.currentFocusedDayCell = (DateCell) node;
|
||||
}
|
||||
|
||||
switch (event.getCode()) {
|
||||
case HOME -> {
|
||||
this.init();
|
||||
this.goToDate(LocalDate.now(), true);
|
||||
event.consume();
|
||||
}
|
||||
case PAGE_UP -> {
|
||||
if (!this.backMonthButton.isDisabled()) {
|
||||
this.forward(-1, ChronoUnit.MONTHS, true, true);
|
||||
}
|
||||
|
||||
event.consume();
|
||||
}
|
||||
case PAGE_DOWN -> {
|
||||
if (!this.forwardMonthButton.isDisabled()) {
|
||||
this.forward(1, ChronoUnit.MONTHS, true, true);
|
||||
}
|
||||
|
||||
event.consume();
|
||||
}
|
||||
case ESCAPE -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
event.consume();
|
||||
}
|
||||
default -> event.consume();
|
||||
}
|
||||
|
||||
});
|
||||
this.showTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setCycleDuration(Duration.millis(320.0F));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXDatePickerContent.this.yearsListView.setVisible(true);
|
||||
}
|
||||
};
|
||||
this.hideTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setCycleDuration(Duration.millis(320.0F));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
|
||||
protected void stopping() {
|
||||
super.stopping();
|
||||
JFXDatePickerContent.this.yearsListView.setVisible(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ObjectProperty<YearMonth> displayedYearMonthProperty() {
|
||||
return this.selectedYearMonth;
|
||||
}
|
||||
|
||||
private void createWeekDaysCells() {
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
DateCell cell = new DateCell();
|
||||
cell.getStyleClass().add("day-name-cell");
|
||||
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
cell.setAlignment(Pos.BASELINE_CENTER);
|
||||
this.weekDaysCells.add(cell);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
DateCell cell = new DateCell();
|
||||
cell.getStyleClass().add("week-number-cell");
|
||||
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
this.weekNumberCells.add(cell);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected VBox createHeaderPane() {
|
||||
Label title = new Label(I18n.i18n("button.select_date"));
|
||||
title.getStyleClass().add("title");
|
||||
title.setFont(Font.font(ROBOTO, FontWeight.NORMAL, 12.0F));
|
||||
HBox titleContainer = new HBox(title);
|
||||
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
titleContainer.setPadding(new Insets(0, 0, 5, 0));
|
||||
this.selectedYearLabel = new Label();
|
||||
this.selectedYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.selectedYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 14.0F));
|
||||
HBox yearLabelContainer = new HBox();
|
||||
yearLabelContainer.getStyleClass().add("spinner");
|
||||
yearLabelContainer.getChildren().addAll(this.selectedYearLabel);
|
||||
yearLabelContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
yearLabelContainer.setFillHeight(false);
|
||||
yearLabelContainer.setOnMouseClicked((click) -> {
|
||||
if (!this.yearsListView.isVisible()) {
|
||||
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||
this.hideTransition.stop();
|
||||
this.showTransition.play();
|
||||
}
|
||||
|
||||
});
|
||||
this.selectedDateLabel = new Label();
|
||||
this.selectedDateLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.selectedDateLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 32.0F));
|
||||
HBox selectedDateContainer = new HBox();
|
||||
selectedDateContainer.getStyleClass().add("spinner");
|
||||
selectedDateContainer.getChildren().addAll(this.selectedDateLabel);
|
||||
selectedDateContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
selectedDateContainer.setOnMouseClicked((click) -> {
|
||||
if (this.yearsListView.isVisible()) {
|
||||
this.showTransition.stop();
|
||||
this.hideTransition.play();
|
||||
}
|
||||
|
||||
});
|
||||
VBox headerPanel = new VBox();
|
||||
headerPanel.getStyleClass().add("month-year-pane");
|
||||
headerPanel.setPadding(new Insets(12.0F, 24.0F, 12.0F, 24.0F));
|
||||
headerPanel.getChildren().addAll(titleContainer, yearLabelContainer, selectedDateContainer);
|
||||
return headerPanel;
|
||||
}
|
||||
|
||||
protected BorderPane createCalendarArrowsPane() {
|
||||
SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.GRAY);
|
||||
SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.GRAY);
|
||||
leftChevron.setSize(6.0F, 11.0F);
|
||||
rightChevron.setSize(6.0F, 11.0F);
|
||||
this.backMonthButton = new JFXButton();
|
||||
this.backMonthButton.setMinSize(40.0F, 40.0F);
|
||||
this.backMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||
this.backMonthButton.getStyleClass().add("left-button");
|
||||
this.backMonthButton.setGraphic(leftChevron);
|
||||
this.backMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||
this.backMonthButton.setOnAction((t) -> this.forward(-1, ChronoUnit.MONTHS, false, true));
|
||||
this.forwardMonthButton = new JFXButton();
|
||||
this.forwardMonthButton.setMinSize(40.0F, 40.0F);
|
||||
this.forwardMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||
this.forwardMonthButton.getStyleClass().add("right-button");
|
||||
this.forwardMonthButton.setGraphic(rightChevron);
|
||||
this.forwardMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||
this.forwardMonthButton.setOnAction((t) -> this.forward(1, ChronoUnit.MONTHS, false, true));
|
||||
BorderPane arrowsContainer = new BorderPane();
|
||||
arrowsContainer.setLeft(this.backMonthButton);
|
||||
arrowsContainer.setRight(this.forwardMonthButton);
|
||||
arrowsContainer.setPadding(new Insets(4.0F, 12.0F, 2.0F, 12.0F));
|
||||
arrowsContainer.setPickOnBounds(false);
|
||||
return arrowsContainer;
|
||||
}
|
||||
|
||||
protected BorderPane createCalendarMonthLabelPane() {
|
||||
this.monthYearLabel = new Label();
|
||||
this.monthYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.monthYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 13.0F));
|
||||
SVGContainer svgContainer = new SVGContainer(13);
|
||||
HBox box = new HBox(monthYearLabel, svgContainer);
|
||||
box.setSpacing(3);
|
||||
box.setAlignment(Pos.CENTER);
|
||||
FXUtils.onChangeAndOperate(this.yearsListView.visibleProperty(), b -> svgContainer.setIcon(b ? SVG.ARROW_DROP_UP : SVG.ARROW_DROP_DOWN));
|
||||
FXUtils.onClicked(box, () -> {
|
||||
if (!this.yearsListView.isVisible()) {
|
||||
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||
this.hideTransition.stop();
|
||||
this.showTransition.play();
|
||||
} else {
|
||||
this.showTransition.stop();
|
||||
this.hideTransition.play();
|
||||
}
|
||||
});
|
||||
BorderPane monthContainer = new BorderPane();
|
||||
monthContainer.setMinHeight(50.0F);
|
||||
monthContainer.setCenter(box);
|
||||
monthContainer.setPadding(new Insets(2.0F, 12.0F, 2.0F, 12.0F));
|
||||
return monthContainer;
|
||||
}
|
||||
|
||||
protected HBox createActions() {
|
||||
JFXButton cancelButton = new JFXButton(I18n.i18n("button.cancel"));
|
||||
cancelButton.getStyleClass().add("dialog-cancel");
|
||||
FXUtils.onClicked(cancelButton, () -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
JFXButton acceptButton = new JFXButton(I18n.i18n("button.ok"));
|
||||
acceptButton.getStyleClass().add("dialog-accept");
|
||||
FXUtils.onClicked(acceptButton, () -> {
|
||||
datePicker.setValue(valueProperty.get());
|
||||
fireEvent(new DialogCloseEvent());
|
||||
});
|
||||
|
||||
HBox hBox = new HBox(cancelButton, acceptButton);
|
||||
hBox.setPadding(new Insets(0, 5, 3, 0));
|
||||
hBox.setSpacing(5);
|
||||
hBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
return hBox;
|
||||
}
|
||||
|
||||
void updateContentGrid() {
|
||||
this.contentGrid.getColumnConstraints().clear();
|
||||
this.contentGrid.getChildren().clear();
|
||||
int colsNumber = this.daysPerWeek + (this.datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||
ColumnConstraints columnConstraints = new ColumnConstraints();
|
||||
columnConstraints.setPercentWidth(100.0F);
|
||||
|
||||
for (int i = 0; i < colsNumber; ++i) {
|
||||
this.contentGrid.getColumnConstraints().add(columnConstraints);
|
||||
}
|
||||
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
this.contentGrid.add(this.weekDaysCells.get(i), i + colsNumber - this.daysPerWeek, 1);
|
||||
}
|
||||
|
||||
if (this.datePicker.isShowWeekNumbers()) {
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
this.contentGrid.add(this.weekNumberCells.get(i), 0, i + 2);
|
||||
}
|
||||
}
|
||||
|
||||
for (int row = 0; row < 6; ++row) {
|
||||
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||
this.contentGrid.add(this.dayCells.get(row * this.daysPerWeek + col), col + colsNumber - this.daysPerWeek, row + 2);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
this.updateDayNameCells();
|
||||
this.updateValues();
|
||||
}
|
||||
|
||||
private void updateDayNameCells() {
|
||||
int weekFirstDay = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||
LocalDate date = LocalDate.of(2009, 7, 12 + weekFirstDay);
|
||||
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
String name = this.weekDayNameFormatter.withLocale(this.getLocale()).format(date.plusDays(i));
|
||||
if (!LocaleUtils.isChinese(this.weekNumberFormatter.getLocale())) {
|
||||
name = name.substring(0, 1).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
this.weekDaysCells.get(i).setText(name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void updateValues() {
|
||||
this.updateWeekNumberDateCells();
|
||||
this.updateDayCells();
|
||||
this.updateMonthYearPane();
|
||||
}
|
||||
|
||||
void updateWeekNumberDateCells() {
|
||||
if (this.datePicker.isShowWeekNumbers()) {
|
||||
Locale locale = this.getLocale();
|
||||
LocalDate firstDayOfMonth = this.selectedYearMonth.get().atDay(1);
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
LocalDate date = firstDayOfMonth.plusWeeks(i);
|
||||
String weekNumber = this.weekNumberFormatter.withLocale(locale).withDecimalStyle(DecimalStyle.of(locale)).format(date);
|
||||
this.weekNumberCells.get(i).setText(weekNumber);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void updateDayCells() {
|
||||
Locale locale = this.getLocale();
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
int firstDayOfWeek = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||
int firstOfMonthIndex = this.selectedYearMonth.get().atDay(1).getDayOfWeek().getValue() - firstDayOfWeek;
|
||||
firstOfMonthIndex += firstOfMonthIndex < 0 ? this.daysPerWeek : 0;
|
||||
YearMonth currentYearMonth = this.selectedYearMonth.get();
|
||||
int daysInCurMonth = -1;
|
||||
|
||||
for (int i = 0; i < 6 * this.daysPerWeek; ++i) {
|
||||
DateCell dayCell = this.dayCells.get(i);
|
||||
try {
|
||||
if (daysInCurMonth == -1) {
|
||||
daysInCurMonth = currentYearMonth.lengthOfMonth();
|
||||
}
|
||||
|
||||
int dayIndex = i - firstOfMonthIndex + 1;
|
||||
LocalDate date;
|
||||
if (dayIndex <= 0) {
|
||||
YearMonth previous = currentYearMonth.minusMonths(1);
|
||||
date = previous.atDay(previous.lengthOfMonth() + dayIndex);
|
||||
} else if (dayIndex > daysInCurMonth) {
|
||||
YearMonth next = currentYearMonth.plusMonths(1);
|
||||
date = next.atDay(dayIndex - daysInCurMonth);
|
||||
} else {
|
||||
date = currentYearMonth.atDay(dayIndex);
|
||||
}
|
||||
this.dayCellDates[i] = date;
|
||||
if (date.equals(LocalDate.now())) {
|
||||
dayCell.getStyleClass().add("today");
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("today");
|
||||
}
|
||||
|
||||
if (date.equals(valueProperty.get())) {
|
||||
dayCell.getStyleClass().add("selected");
|
||||
this.currentFocusedDayCell = dayCell;
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("selected");
|
||||
}
|
||||
|
||||
ChronoLocalDate cDate = chrono.date(date);
|
||||
String cellText = this.dayCellFormatter.withLocale(locale).withChronology(chrono).withDecimalStyle(DecimalStyle.of(locale)).format(cDate);
|
||||
dayCell.setText(cellText);
|
||||
if (i < firstOfMonthIndex) {
|
||||
dayCell.getStyleClass().add("previous-month");
|
||||
dayCell.getStyleClass().removeAll("next-month");
|
||||
dayCell.setDisable(true);
|
||||
} else if (i >= firstOfMonthIndex + daysInCurMonth) {
|
||||
dayCell.getStyleClass().add("next-month");
|
||||
dayCell.getStyleClass().removeAll("previous-month");
|
||||
dayCell.setDisable(true);
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("previous-month", "next-month");
|
||||
dayCell.setDisable(false);
|
||||
}
|
||||
|
||||
dayCell.updateItem(date, false);
|
||||
} catch (DateTimeException var13) {
|
||||
dayCell.setText("");
|
||||
dayCell.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void updateMonthYearPane() {
|
||||
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||
this.selectedDateLabel.setText(fullFormatter.withLocale(this.getLocale()).format(Objects.requireNonNullElseGet(valueProperty.get(), LocalDate::now)));
|
||||
|
||||
this.selectedYearLabel.setText(this.formatYear(yearMonth));
|
||||
this.monthYearLabel.setText(this.formatMonth(yearMonth) + " " + this.formatYear(yearMonth));
|
||||
Chronology chrono = this.datePicker.getChronology();
|
||||
LocalDate firstDayOfMonth = yearMonth.atDay(1);
|
||||
this.backMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, -1, ChronoUnit.DAYS));
|
||||
this.forwardMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, 1, ChronoUnit.MONTHS));
|
||||
}
|
||||
|
||||
private String formatMonth(YearMonth yearMonth) {
|
||||
try {
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||
return this.monthFormatter.withLocale(this.getLocale()).withChronology(chrono).format(cDate);
|
||||
} catch (DateTimeException var4) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String formatYear(YearMonth yearMonth) {
|
||||
try {
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||
return this.yearFormatter.withLocale(this.getLocale()).withChronology(chrono).withDecimalStyle(DecimalStyle.of(this.getLocale())).format(cDate);
|
||||
} catch (DateTimeException var4) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected LocalDate dayCellDate(DateCell dateCell) {
|
||||
assert this.dayCellDates != null;
|
||||
|
||||
return this.dayCellDates[this.dayCells.indexOf(dateCell)];
|
||||
}
|
||||
|
||||
protected void forward(int offset, ChronoUnit unit, boolean focusDayCell, boolean withAnimation) {
|
||||
if (withAnimation && (this.tempImageTransition == null || this.tempImageTransition.getStatus() == Status.STOPPED)) {
|
||||
Pane monthContent = (Pane) this.calendarPlaceHolder.getChildren().get(0);
|
||||
this.getParent().setManaged(false);
|
||||
SnapshotParameters snapShotparams = new SnapshotParameters();
|
||||
snapShotparams.setFill(Color.TRANSPARENT);
|
||||
WritableImage temp = monthContent.snapshot(snapShotparams, new WritableImage((int) monthContent.getWidth(), (int) monthContent.getHeight()));
|
||||
ImageView tempImage = new ImageView(temp);
|
||||
this.calendarPlaceHolder.getChildren().add(this.calendarPlaceHolder.getChildren().size() - 2, tempImage);
|
||||
TranslateTransition imageTransition = new TranslateTransition(Duration.millis(160.0F), tempImage);
|
||||
imageTransition.setToX((double) (-offset) * this.calendarPlaceHolder.getWidth());
|
||||
imageTransition.setOnFinished((finish) -> this.calendarPlaceHolder.getChildren().remove(tempImage));
|
||||
monthContent.setTranslateX((double) offset * this.calendarPlaceHolder.getWidth());
|
||||
TranslateTransition contentTransition = new TranslateTransition(Duration.millis(160.0F), monthContent);
|
||||
contentTransition.setToX(0.0F);
|
||||
this.tempImageTransition = new ParallelTransition(imageTransition, contentTransition);
|
||||
this.tempImageTransition.setOnFinished((finish) -> {
|
||||
this.calendarPlaceHolder.getChildren().remove(tempImage);
|
||||
this.getParent().setManaged(true);
|
||||
});
|
||||
this.tempImageTransition.play();
|
||||
}
|
||||
|
||||
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||
DateCell dateCell = this.currentFocusedDayCell;
|
||||
if (dateCell == null || this.dayCellDate(dateCell).getMonth() != yearMonth.getMonth()) {
|
||||
dateCell = this.findDayCellOfDate(yearMonth.atDay(1));
|
||||
}
|
||||
|
||||
this.goToDayCell(dateCell, offset, unit, focusDayCell);
|
||||
}
|
||||
|
||||
private void goToDayCell(DateCell dateCell, int offset, ChronoUnit unit, boolean focusDayCell) {
|
||||
this.goToDate(this.dayCellDate(dateCell).plus(offset, unit), focusDayCell);
|
||||
}
|
||||
|
||||
private void goToDate(LocalDate date, boolean focusDayCell) {
|
||||
if (this.isValidDate(this.datePicker.getChronology(), date)) {
|
||||
this.valueProperty.set(date);
|
||||
this.selectedYearMonth.set(YearMonth.from(date));
|
||||
if (focusDayCell) {
|
||||
this.findDayCellOfDate(date).requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void selectDayCell(DateCell dateCell) {
|
||||
this.valueProperty.set(this.dayCellDate(dateCell));
|
||||
}
|
||||
|
||||
private DateCell findDayCellOfDate(LocalDate date) {
|
||||
for (int i = 0; i < this.dayCellDates.length; ++i) {
|
||||
if (date.equals(this.dayCellDates[i])) {
|
||||
return this.dayCells.get(i);
|
||||
}
|
||||
}
|
||||
|
||||
return this.dayCells.get(this.dayCells.size() / 2 + 1);
|
||||
}
|
||||
|
||||
void init() {
|
||||
this.contentGrid.setOpacity(1.0F);
|
||||
this.yearsListView.setOpacity(0.0F);
|
||||
this.yearsListView.setVisible(false);
|
||||
}
|
||||
|
||||
void clearFocus() {
|
||||
LocalDate focusDate = this.valueProperty.get();
|
||||
if (focusDate == null) {
|
||||
focusDate = LocalDate.now();
|
||||
}
|
||||
|
||||
if (YearMonth.from(focusDate).equals(this.selectedYearMonth.get())) {
|
||||
this.goToDate(focusDate, true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void createDayCells() {
|
||||
for (int row = 0; row < 6; ++row) {
|
||||
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||
DateCell dayCell = this.createDayCell();
|
||||
dayCell.addEventHandler(MouseEvent.MOUSE_CLICKED, (click) -> {
|
||||
if (click.getButton() == MouseButton.PRIMARY) {
|
||||
DateCell selectedDayCell = (DateCell) click.getSource();
|
||||
this.selectDayCell(selectedDayCell);
|
||||
}
|
||||
});
|
||||
dayCell.setOnMouseEntered((event) -> dayCell.getStyleClass().add("hovered"));
|
||||
dayCell.setOnMouseExited((event) -> dayCell.getStyleClass().removeAll("hovered"));
|
||||
dayCell.setAlignment(Pos.BASELINE_CENTER);
|
||||
dayCell.setBorder(new Border(new BorderStroke(Color.TRANSPARENT, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(5.0F))));
|
||||
dayCell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
this.dayCells.add(dayCell);
|
||||
}
|
||||
}
|
||||
|
||||
this.dayCellDates = new LocalDate[6 * this.daysPerWeek];
|
||||
this.updateContentGrid();
|
||||
}
|
||||
|
||||
private DateCell createDayCell() {
|
||||
DateCell dayCell = null;
|
||||
if (this.datePicker.getDayCellFactory() != null) {
|
||||
dayCell = this.datePicker.getDayCellFactory().call(this.datePicker);
|
||||
}
|
||||
|
||||
if (dayCell == null) {
|
||||
dayCell = new DateCell();
|
||||
}
|
||||
dayCell.getStyleClass().add("day-cell");
|
||||
dayCell.setPrefSize(36.0F, 36.0F);
|
||||
dayCell.setDisable(false);
|
||||
dayCell.setStyle(null);
|
||||
dayCell.setGraphic(null);
|
||||
dayCell.setTooltip(null);
|
||||
|
||||
return dayCell;
|
||||
}
|
||||
|
||||
protected Chronology getPrimaryChronology() {
|
||||
return this.datePicker.getChronology();
|
||||
}
|
||||
|
||||
protected Locale getLocale() {
|
||||
return SettingsManager.settings().languageProperty().get().getLocale();
|
||||
}
|
||||
|
||||
protected boolean isValidDate(Chronology chrono, LocalDate date, int offset, ChronoUnit unit) {
|
||||
return date != null && this.isValidDate(chrono, date.plus(offset, unit));
|
||||
}
|
||||
|
||||
protected boolean isValidDate(Chronology chrono, LocalDate date) {
|
||||
try {
|
||||
if (date != null) {
|
||||
chrono.date(date);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (DateTimeException var4) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.ReflectionHelper;
|
||||
import com.jfoenix.adapters.skins.DatePickerSkin;
|
||||
import com.jfoenix.controls.JFXDatePicker;
|
||||
import com.jfoenix.controls.JFXDialog;
|
||||
import com.jfoenix.controls.JFXDialog.DialogTransition;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Stack;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class JFXDatePickerSkin extends DatePickerSkin {
|
||||
public static final String PROPERTY_DATE_PICKER_CONTENTS = JFXDatePicker.class.getName() + ".dialog.contents";
|
||||
|
||||
private final JFXDatePicker jfxDatePicker;
|
||||
private TextField displayNode;
|
||||
private JFXDatePickerContent content;
|
||||
private JFXDialog dialog;
|
||||
|
||||
public JFXDatePickerSkin(JFXDatePicker datePicker) {
|
||||
super(datePicker);
|
||||
this.jfxDatePicker = datePicker;
|
||||
|
||||
try {
|
||||
Object expressionHelper = ReflectionHelper.getFieldContent(datePicker.focusedProperty().getClass().getSuperclass(), datePicker.focusedProperty(), "helper");
|
||||
ChangeListener<Boolean>[] changeListeners = ReflectionHelper.getFieldContent(expressionHelper, "changeListeners");
|
||||
|
||||
int i = changeListeners.length - 1;
|
||||
if (i >= 0) {
|
||||
while (i >= 0 && changeListeners[i] == null) {
|
||||
--i;
|
||||
}
|
||||
if (i >= 0) datePicker.focusedProperty().removeListener(changeListeners[i]);
|
||||
}
|
||||
} catch (NullPointerException e) {
|
||||
LOG.warning("Cannot remove focusedProperty listener", e);
|
||||
}
|
||||
|
||||
datePicker.focusedProperty().addListener((obj, oldVal, newVal) -> {
|
||||
if (this.getEditor() != null && !newVal) {
|
||||
this.setTextFromTextFieldIntoComboBoxValue2();
|
||||
}
|
||||
|
||||
});
|
||||
var arrowBox = new HBox(new RipplerContainer(SVG.EDIT_CALENDAR.createIcon(30)));
|
||||
arrowBox.setAlignment(Pos.CENTER);
|
||||
this.setArrow(arrowBox);
|
||||
this.getArrowButton().getChildren().setAll(arrowBox);
|
||||
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.getArrowButton().setPadding(new Insets(1.0F, 8.0F, 1.0F, 8.0F));
|
||||
this.registerChangeListener2(datePicker.converterProperty(), "CONVERTER", this::updateDisplayNode2);
|
||||
this.registerChangeListener2(datePicker.dayCellFactoryProperty(), "DAY_CELL_FACTORY", () -> {
|
||||
this.updateDisplayNode2();
|
||||
this.content = null;
|
||||
this.setPopup2(null);
|
||||
});
|
||||
this.registerChangeListener2(datePicker.editorProperty(), "EDITOR", this::getEditableInputNode2);
|
||||
this.registerChangeListener2(datePicker.showingProperty(), "SHOWING", () -> {
|
||||
if (this.jfxDatePicker.isShowing()) {
|
||||
if (this.content != null) {
|
||||
LocalDate date = this.jfxDatePicker.getValue();
|
||||
this.content.displayedYearMonthProperty().set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||
this.content.updateValues();
|
||||
}
|
||||
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
});
|
||||
this.registerChangeListener2(datePicker.showWeekNumbersProperty(), "SHOW_WEEK_NUMBERS", () -> {
|
||||
if (this.content != null) {
|
||||
this.content.updateContentGrid();
|
||||
this.content.updateWeekNumberDateCells();
|
||||
}
|
||||
|
||||
});
|
||||
this.registerChangeListener2(datePicker.valueProperty(), "VALUE", () -> {
|
||||
this.updateDisplayNode2();
|
||||
this.jfxDatePicker.fireEvent(new ActionEvent());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public JFXDatePickerContent getPopupContent() {
|
||||
if (this.content == null) {
|
||||
this.content = new JFXDatePickerContent(this.jfxDatePicker);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
if (!((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||
super.show();
|
||||
}
|
||||
|
||||
if (this.content != null) {
|
||||
this.content.init();
|
||||
this.content.clearFocus();
|
||||
}
|
||||
|
||||
if (((JFXDatePicker) this.getSkinnable()).isOverLay() && this.dialog == null) {
|
||||
StackPane dialogParent = this.jfxDatePicker.getDialogParent();
|
||||
if (dialogParent == null) {
|
||||
dialogParent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||
}
|
||||
|
||||
this.dialog = new JFXDialog(dialogParent, this.getPopupContent(), DialogTransition.CENTER, true) {
|
||||
@Override
|
||||
public void close() {
|
||||
super.close();
|
||||
Object o = getDialogContainer().getProperties().get(PROPERTY_DATE_PICKER_CONTENTS);
|
||||
if (o instanceof Stack<?> stack) {
|
||||
stack.pop();
|
||||
}
|
||||
jfxDatePicker.hide();
|
||||
}
|
||||
};
|
||||
this.getPopupContent().addEventHandler(DialogCloseEvent.CLOSE, e -> this.dialog.close());
|
||||
getArrowButton().setOnMouseClicked((click) -> {
|
||||
if (((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||
StackPane parent = this.jfxDatePicker.getDialogParent();
|
||||
if (parent == null) {
|
||||
parent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||
}
|
||||
Stack<JFXDatePickerContent> contentStack = (Stack<JFXDatePickerContent>) parent.getProperties().computeIfAbsent(PROPERTY_DATE_PICKER_CONTENTS, k -> new Stack<>());
|
||||
contentStack.push(this.getPopupContent());
|
||||
|
||||
this.getPopupContent().valueProperty.set(this.getSkinnable().getValue());
|
||||
this.dialog.show(parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
JFXDialog getDialog() {
|
||||
return this.dialog;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getDisplayNode() {
|
||||
if (this.displayNode == null) {
|
||||
this.displayNode = this.getEditableInputNode2();
|
||||
this.displayNode.getStyleClass().add("date-picker-display-node");
|
||||
this.updateDisplayNode2();
|
||||
}
|
||||
|
||||
this.displayNode.setEditable(this.jfxDatePicker.isEditable());
|
||||
return this.displayNode;
|
||||
}
|
||||
|
||||
public void syncWithAutoUpdate() {
|
||||
if (!this.getPopup2().isShowing() && this.jfxDatePicker.isShowing()) {
|
||||
this.jfxDatePicker.hide();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
arc = new Arc();
|
||||
arc.setManaged(false);
|
||||
arc.setLength(180);
|
||||
arc.setLength(-180);
|
||||
arc.getStyleClass().setAll("arc");
|
||||
arc.setFill(Color.TRANSPARENT);
|
||||
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||
@@ -192,24 +192,24 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
|
||||
frames.add(new KeyFrame(Duration.seconds(duration),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 45 + startingAngle,
|
||||
-(angle + 45 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 90 + startingAngle,
|
||||
-(angle + 90 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 135 + startingAngle,
|
||||
-(angle + 135 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 435 + startingAngle,
|
||||
-(angle + 435 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
}
|
||||
|
||||
@@ -222,21 +222,21 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
addKeyFrames(keyFrames, 900, 2.8);
|
||||
addKeyFrames(keyFrames, 1350, 4.2);
|
||||
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
1845 + startingAngle,
|
||||
-(1845 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
|
||||
timeline = new Timeline();
|
||||
timeline.getKeyFrames().setAll(keyFrames);
|
||||
} else {
|
||||
final double arcLength = 250;
|
||||
final double arcLength = -250;
|
||||
timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(), -(45 + startingAngle), Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
|
||||
new KeyFrame(Duration.seconds(1.2),
|
||||
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(), -(45 + 360 + startingAngle), Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,8 @@ import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
@@ -49,9 +47,11 @@ import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.macos.LibSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Shell32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.User32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.swing.*;
|
||||
@@ -142,12 +142,9 @@ public final class Launcher extends Application {
|
||||
Platform.setImplicitExit(false);
|
||||
Controllers.initialize(primaryStage);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
Themes.applyNativeDarkMode(primaryStage);
|
||||
|
||||
RegionUtils.init();
|
||||
UpdateChecker.init();
|
||||
|
||||
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage);
|
||||
primaryStage.show();
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
@@ -332,6 +329,9 @@ public final class Launcher extends Application {
|
||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
@Nullable Boolean isRoot = isRootUser();
|
||||
LOG.info("Administrator User: " + (isRoot == null ? "Unknown" : isRoot.toString()));
|
||||
|
||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
@@ -388,6 +388,9 @@ public final class Launcher extends Application {
|
||||
return;
|
||||
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
|
||||
return;
|
||||
@Nullable Path thisJar = JarUtils.thisJarPath();
|
||||
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
|
||||
return;
|
||||
|
||||
try {
|
||||
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
|
||||
@@ -594,5 +597,68 @@ public final class Launcher extends Application {
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
private static @Nullable Boolean isRootUser() {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS -> {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
|
||||
if (reg == null)
|
||||
return null;
|
||||
try {
|
||||
return reg.exists(WinReg.HKEY.HKEY_USERS, "S-1-5-19");
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to check Windows administrator privileges", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case LINUX -> {
|
||||
Path selfStatus = Paths.get("/proc/self/status");
|
||||
if (Files.exists(selfStatus)) {
|
||||
try (var reader = Files.newBufferedReader(selfStatus)) {
|
||||
@Nullable String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("Uid:")) {
|
||||
String[] parts = line.split("\t");
|
||||
if (parts.length >= 3) {
|
||||
String uid = parts[2].trim();
|
||||
return "0".equals(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read " + selfStatus, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
case MACOS -> {
|
||||
if (NativeUtils.USE_JNA) {
|
||||
LibSystem libSystem = LibSystem.INSTANCE;
|
||||
if (libSystem != null) {
|
||||
try {
|
||||
return libSystem.geteuid() == 0;
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to check macOS root privileges", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS.isUnixLike()) {
|
||||
@Nullable Path id = SystemUtils.which("id");
|
||||
if (id != null) {
|
||||
try {
|
||||
return "0".equals(SystemUtils.run(id.toString(), "-u").trim());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to run id -u", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon;
|
||||
|
||||
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProjectInfo;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
|
||||
public static final AddonProjectInfoRegistry<ModProjectInfo> MOD = new AddonProjectInfoRegistry<>(
|
||||
"/assets/addon/mod.csv",
|
||||
32768
|
||||
) {
|
||||
@Override
|
||||
protected int getFieldsCount() {
|
||||
return MOD_IDS_INDEX + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModProjectInfo parseAddonProjectInfo(String[] fields) throws Exception {
|
||||
return new ModProjectInfo(
|
||||
Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||
fields[CURSEFORGE_ID_INDEX],
|
||||
fields[MODRINTH_ID_INDEX],
|
||||
fields[MAIN_NAME_INDEX],
|
||||
fields[SUB_NAME_INDEX],
|
||||
fields[ABBR_NAME_INDEX],
|
||||
fields[MOD_IDS_INDEX]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/// Provides metadata for modpacks.
|
||||
public static final AddonProjectInfoRegistry<ModpackProjectInfo> MODPACK = new AddonProjectInfoRegistry<>(
|
||||
"/assets/addon/modpack.csv", 2048
|
||||
) {
|
||||
@Override
|
||||
protected int getFieldsCount() {
|
||||
return ABBR_NAME_INDEX + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModpackProjectInfo parseAddonProjectInfo(String[] fields) {
|
||||
return new ModpackProjectInfo(Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||
fields[CURSEFORGE_ID_INDEX], fields[MODRINTH_ID_INDEX],
|
||||
fields[MAIN_NAME_INDEX], fields[SUB_NAME_INDEX], fields[ABBR_NAME_INDEX]);
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns the registry for the addon type, or `null` for null or unsupported types.
|
||||
public static @Nullable AddonProjectInfoRegistry<?> getByAddonType(@Nullable RemoteAddon.Type type) {
|
||||
if (type == null) return null;
|
||||
return switch (type) {
|
||||
case MOD -> MOD;
|
||||
case MODPACK -> MODPACK;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
protected static final int MCMOD_ID_INDEX = 0;
|
||||
protected static final int CURSEFORGE_ID_INDEX = 1;
|
||||
protected static final int MODRINTH_ID_INDEX = 2;
|
||||
protected static final int MAIN_NAME_INDEX = 3;
|
||||
protected static final int SUB_NAME_INDEX = 4;
|
||||
protected static final int ABBR_NAME_INDEX = 5;
|
||||
protected static final int MOD_IDS_INDEX = 6;
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final String assetsPath;
|
||||
private final int initialCapacity;
|
||||
|
||||
private volatile @Nullable SoftReference<@Unmodifiable List<I>> infosRef;
|
||||
|
||||
/// Softly caches projects indexed by mod ID.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> modIdMapRef;
|
||||
|
||||
/// Softly caches projects indexed by cleaned secondary name.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> subnameMapRef;
|
||||
|
||||
/// Softly caches projects indexed by CurseForge identifier.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> curseForgeMapRef;
|
||||
|
||||
/// Softly caches searchable names in resource and field order.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> keywordsRef;
|
||||
|
||||
/// Creates a registry reading records from the given classpath resource.
|
||||
AddonProjectInfoRegistry(String assetsPath, int initialCapacity) {
|
||||
this.assetsPath = assetsPath;
|
||||
this.initialCapacity = initialCapacity;
|
||||
}
|
||||
|
||||
protected abstract int getFieldsCount();
|
||||
|
||||
protected abstract I parseAddonProjectInfo(String[] fields) throws Exception;
|
||||
|
||||
private @Unmodifiable List<I> loadInfos() throws IOException {
|
||||
@Nullable InputStream inputStream = AddonProjectInfoRegistry.class.getResourceAsStream(assetsPath);
|
||||
if (inputStream == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||
ArrayList<I> infos = new ArrayList<>(initialCapacity);
|
||||
@Unmodifiable List<I> result = Collections.unmodifiableList(infos);
|
||||
|
||||
int fieldsCount = getFieldsCount();
|
||||
String[] fields = new String[fieldsCount];
|
||||
|
||||
@Nullable StringBuilder fieldBuilder = null;
|
||||
|
||||
@Nullable String line;
|
||||
loop:
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
for (int fieldIndex = 0; fieldIndex < fieldsCount; fieldIndex++) {
|
||||
if (offset > line.length()) {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
|
||||
int end = line.indexOf(',', offset);
|
||||
if (end < 0)
|
||||
end = line.length();
|
||||
|
||||
|
||||
String field;
|
||||
if (offset == end) {
|
||||
field = "";
|
||||
} else {
|
||||
int cursor = offset;
|
||||
while (cursor < end) {
|
||||
if (line.charAt(cursor) == '\\') {
|
||||
break;
|
||||
} else {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cursor == end) {
|
||||
field = line.substring(offset, end);
|
||||
} else {
|
||||
if (fieldBuilder == null) {
|
||||
fieldBuilder = new StringBuilder(32);
|
||||
} else {
|
||||
fieldBuilder.setLength(0);
|
||||
}
|
||||
|
||||
fieldBuilder.append(line, offset, cursor);
|
||||
while (cursor < end) {
|
||||
char c = line.charAt(cursor);
|
||||
if (c == '\\') {
|
||||
if (cursor < end - 1) {
|
||||
char next = line.charAt(cursor + 1);
|
||||
switch (next) {
|
||||
case '0' -> {
|
||||
fieldBuilder.append('\0');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'n' -> {
|
||||
fieldBuilder.append('\n');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'r' -> {
|
||||
fieldBuilder.append('\r');
|
||||
cursor += 2;
|
||||
}
|
||||
case 't' -> {
|
||||
fieldBuilder.append('\t');
|
||||
cursor += 2;
|
||||
}
|
||||
case '\\' -> {
|
||||
fieldBuilder.append('\\');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'c' -> {
|
||||
fieldBuilder.append(',');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'u' -> {
|
||||
if (cursor < end - 5) {
|
||||
try {
|
||||
int codePoint = Integer.parseInt(line, cursor + 2, cursor + 6, 16);
|
||||
fieldBuilder.append((char) codePoint);
|
||||
cursor += 6;
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
field = fieldBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
fields[fieldIndex] = field;
|
||||
offset = end + 1;
|
||||
}
|
||||
|
||||
try {
|
||||
I info = parseAddonProjectInfo(fields);
|
||||
info.setKeepAlive(result);
|
||||
infos.add(info);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to parse line in " + assetsPath + ": " + line, e);
|
||||
}
|
||||
}
|
||||
|
||||
infos.trimToSize();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getModIdMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.modIdMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.modIdMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
if (info instanceof ModProjectInfo mod) {
|
||||
for (String id : mod.getModIds()) {
|
||||
if (!id.isEmpty())
|
||||
map.putIfAbsent(id, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.modIdMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getSubnameMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.subnameMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.subnameMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
String name = cleanSubname(info.subName());
|
||||
if (!name.isEmpty())
|
||||
map.putIfAbsent(name, info);
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.subnameMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getCurseForgeMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.curseForgeMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.curseForgeMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
String id = info.curseforgeId();
|
||||
if (StringUtils.isNotBlank(id))
|
||||
map.putIfAbsent(id, info);
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.curseForgeMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the first project with the exact CurseForge identifier, or `null` if the
|
||||
/// identifier is null, blank or unknown.
|
||||
public @Nullable I getInfoByCurseForgeId(@Nullable String id) {
|
||||
return StringUtils.isBlank(id) ? null : getCurseForgeMap().get(id);
|
||||
}
|
||||
|
||||
/// Finds a project by its cleaned secondary name, falling back to its exact mod ID.
|
||||
/// A name match is accepted only when the ID is blank or belongs to that mod project.
|
||||
/// Null and blank inputs are absent; returns `null` if neither lookup matches.
|
||||
/// Duplicate keys resolve to the first project in resource order.
|
||||
public @Nullable I getInfo(@Nullable String id, @Nullable String subname) {
|
||||
String name = cleanSubname(subname);
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
@Nullable I info = getSubnameMap().get(name);
|
||||
if (info != null && (StringUtils.isBlank(id)
|
||||
|| info instanceof ModProjectInfo mod && mod.getModIds().contains(id)))
|
||||
return info;
|
||||
}
|
||||
return StringUtils.isBlank(id) ? null : getModIdMap().get(id);
|
||||
}
|
||||
|
||||
private static boolean isSupportedSubnameChar(int ch) {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||
|| ".+\\".indexOf(ch) >= 0;
|
||||
}
|
||||
|
||||
/// Removes supported separators and pictographs from a secondary name.
|
||||
/// Returns an empty string for null, blank or unsupported names; matching is case-sensitive.
|
||||
private static String cleanSubname(@Nullable String subname) {
|
||||
if (StringUtils.isBlank(subname))
|
||||
return "";
|
||||
|
||||
int offset = 0;
|
||||
while (offset < subname.length()) {
|
||||
int ch = subname.charAt(offset);
|
||||
if (!isSupportedSubnameChar(ch))
|
||||
break;
|
||||
offset++;
|
||||
}
|
||||
|
||||
if (offset == subname.length())
|
||||
return subname;
|
||||
|
||||
StringBuilder builder = new StringBuilder(subname.length());
|
||||
builder.append(subname, 0, offset);
|
||||
|
||||
while (offset < subname.length()) {
|
||||
int ch = subname.codePointAt(offset);
|
||||
if (isSupportedSubnameChar(ch)) {
|
||||
builder.appendCodePoint(ch);
|
||||
} else if (!(Character.isWhitespace(ch)
|
||||
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0
|
||||
|| ch >= 0x1F300 && ch <= 0x1FAFF)) {
|
||||
return "";
|
||||
}
|
||||
offset += Character.charCount(ch);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/// Returns searchable names, rebuilding the index after its soft reference is cleared.
|
||||
private @Unmodifiable List<Pair<String, I>> getKeywords() {
|
||||
@Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> reference = keywordsRef;
|
||||
@Unmodifiable List<Pair<String, I>> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = keywordsRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
List<Pair<String, I>> keywords = new ArrayList<>();
|
||||
for (I info : getInfos()) {
|
||||
if (!info.mainName().isEmpty())
|
||||
keywords.add(Pair.pair(info.mainName(), info));
|
||||
if (!info.subName().isEmpty())
|
||||
keywords.add(Pair.pair(info.subName(), info));
|
||||
if (!info.abbrName().isEmpty())
|
||||
keywords.add(Pair.pair(info.abbrName(), info));
|
||||
}
|
||||
cached = List.copyOf(keywords);
|
||||
keywordsRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns projects ranked by descending longest common subsequence length after removing
|
||||
/// space characters from the query. Comparison is case-sensitive and uses UTF-16 units.
|
||||
/// Matches require at least one unit and at least query length minus three units.
|
||||
/// Each matching name contributes a result, so projects may occur more than once.
|
||||
/// Ties preserve resource order, then primary name, secondary name and abbreviation order.
|
||||
public @Unmodifiable List<I> search(String query) {
|
||||
query = query.chars().filter(ch -> !Character.isSpaceChar(ch))
|
||||
.collect(StringBuilder::new, (sb, ch) -> sb.append((char) ch), StringBuilder::append).toString();
|
||||
@Unmodifiable List<Pair<String, I>> keywords = getKeywords();
|
||||
int maxLength = keywords.stream().mapToInt(keyword -> keyword.getKey().length()).max().orElse(0);
|
||||
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxLength);
|
||||
List<Pair<Integer, I>> matches = new ArrayList<>();
|
||||
for (Pair<String, I> keyword : keywords) {
|
||||
int score = lcs.calc(query, keyword.getKey());
|
||||
if (score >= Math.max(1, query.length() - 3))
|
||||
matches.add(Pair.pair(score, keyword.getValue()));
|
||||
}
|
||||
return matches.stream().sorted((a, b) -> Integer.compare(b.getKey(), a.getKey()))
|
||||
.map(Pair::getValue).toList();
|
||||
}
|
||||
|
||||
/// Returns cached project metadata, loading it on demand. Missing resources and I/O failures
|
||||
/// produce an empty list; malformed records are skipped. The cache is softly referenced.
|
||||
public @Unmodifiable List<I> getInfos() {
|
||||
@Nullable SoftReference<@Unmodifiable List<I>> infosRef = this.infosRef;
|
||||
@Unmodifiable List<I> infos = infosRef != null ? infosRef.get() : null;
|
||||
if (infos != null)
|
||||
return infos;
|
||||
|
||||
lock.lock();
|
||||
try {
|
||||
infosRef = this.infosRef;
|
||||
infos = infosRef != null ? infosRef.get() : null;
|
||||
if (infos != null)
|
||||
return infos;
|
||||
|
||||
try {
|
||||
infos = loadInfos();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load addon project info from " + assetsPath, e);
|
||||
infos = List.of();
|
||||
}
|
||||
this.infosRef = new SoftReference<>(infos);
|
||||
return infos;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-20
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
* 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
|
||||
@@ -15,47 +15,55 @@
|
||||
* 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.instances;
|
||||
package org.jackhuang.hmcl.addon.update;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonCheckUpdatesTask<T extends LocalAddonFile> extends Task<List<LocalAddonFile.AddonUpdate>> {
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final List<Task<LocalAddonFile.AddonUpdate>> dependents;
|
||||
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
|
||||
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
|
||||
|
||||
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<T> addons) {
|
||||
this.downloadProvider = downloadProvider;
|
||||
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile<?>> addons) {
|
||||
dependents = addons.stream().map(addon ->
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
LocalAddonFile.AddonUpdate candidate = null;
|
||||
AddonUpdate candidate = null;
|
||||
AddonUpdate candidateRelease = null;
|
||||
for (RemoteAddon.Source source : RemoteAddon.Source.values()) {
|
||||
LocalAddonFile.AddonUpdate update = null;
|
||||
AddonUpdate update = null;
|
||||
AddonUpdate updateRelease = null;
|
||||
try {
|
||||
update = addon.checkUpdates(downloadProvider, gameVersion, source);
|
||||
var u = addon.checkUpdates(downloadProvider, gameVersion, source);
|
||||
if (u != null) {
|
||||
update = u.key();
|
||||
updateRelease = u.value();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e);
|
||||
}
|
||||
if (update == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished())) {
|
||||
if (update == null) continue;
|
||||
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished()))
|
||||
candidate = update;
|
||||
}
|
||||
|
||||
if (updateRelease == null) continue;
|
||||
if (candidateRelease == null || candidateRelease.targetVersion().datePublished().isBefore(updateRelease.targetVersion().datePublished()))
|
||||
candidateRelease = updateRelease;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
if (candidate == null) return null; // If there's no candidate for all channels, then no candidate for release channel
|
||||
return Pair.pair(candidate, candidateRelease);
|
||||
}).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
|
||||
).toList();
|
||||
|
||||
@@ -85,8 +93,12 @@ public class AddonCheckUpdatesTask<T extends LocalAddonFile> extends Task<List<L
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
setResult(dependents.stream()
|
||||
.map(Task::getResult)
|
||||
.filter(Objects::nonNull).toList());
|
||||
List<AddonUpdate> commonUpdates = new ArrayList<>(), releaseUpdates = new ArrayList<>();
|
||||
dependents.stream().map(Task::getResult).filter(Objects::nonNull)
|
||||
.forEachOrdered(pair -> {
|
||||
commonUpdates.add(pair.key());
|
||||
if (pair.value() != null) releaseUpdates.add(pair.value());
|
||||
});
|
||||
setResult(new AddonUpdates(commonUpdates, releaseUpdates));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon.update;
|
||||
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonManager;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonUpdateTask extends Task<Void> {
|
||||
private final Collection<Task<?>> dependents = new ArrayList<>();
|
||||
private final List<LocalAddonFile<?>> failedAddons = new ArrayList<>();
|
||||
|
||||
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
|
||||
setStage("addon.check_update.confirm");
|
||||
getProperties().put("total", addons.size());
|
||||
|
||||
for (AddonUpdate addon : addons) {
|
||||
LocalAddonFile<?> local = addon.localAddonFile();
|
||||
RemoteAddon.Version remote = addon.targetVersion();
|
||||
boolean isDisabled = local.isDisabled();
|
||||
String fileName = remote.file().filename();
|
||||
if (isDisabled)
|
||||
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
|
||||
String newFileName = fileName;
|
||||
Path targetFile = addonDirectory.resolve(newFileName);
|
||||
|
||||
dependents.add(Task
|
||||
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
|
||||
.thenComposeAsync(tempFile ->
|
||||
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
|
||||
.setName(remote.name())
|
||||
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
|
||||
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
|
||||
.whenComplete(Schedulers.javafx(), exception -> {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to delete temp file " + tempFile, e);
|
||||
}
|
||||
if (exception != null) {
|
||||
// restore state if failed
|
||||
local.setOld(false);
|
||||
if (isDisabled)
|
||||
local.markDisabled();
|
||||
} else {
|
||||
local.onUpdated(newFileName);
|
||||
if (!local.keepOldFiles()) {
|
||||
try {
|
||||
local.delete();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to update addon", exception);
|
||||
failedAddons.add(local);
|
||||
}
|
||||
}).withCounter("addon.check_update.confirm"));
|
||||
}
|
||||
}
|
||||
|
||||
public List<LocalAddonFile<?>> getFailedAddons() {
|
||||
return failedAddons;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependents() {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPreExecute() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void preExecute() {
|
||||
notifyPropertiesChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (!isDependentsSucceeded())
|
||||
throw getException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.download;
|
||||
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
|
||||
import org.jackhuang.hmcl.game.AssetObject;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadSource;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
private volatile DownloadSource versionListSource = DownloadSource.DEFAULT;
|
||||
private volatile DownloadSource fileSource = DownloadSource.DEFAULT;
|
||||
|
||||
private static DownloadCandidates getCandidates(
|
||||
DownloadSource source,
|
||||
WebURL defaultUrl, WebURL bmclapiUrl
|
||||
) {
|
||||
if (RegionUtils.isIsChinaMainlandMachine()) {
|
||||
return switch (source) {
|
||||
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
|
||||
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
|
||||
};
|
||||
} else {
|
||||
return switch (source) {
|
||||
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl);
|
||||
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
|
||||
if (urls.isEmpty()) {
|
||||
throw new IllegalArgumentException("urls cannot be empty");
|
||||
}
|
||||
|
||||
// true means prefer mirror, false means prefer official, null means do not use mirror
|
||||
@Nullable Boolean mirrorFirst;
|
||||
if (RegionUtils.isIsChinaMainlandMachine()) {
|
||||
mirrorFirst = switch (source) {
|
||||
case DEFAULT, MIRROR -> true;
|
||||
case OFFICIAL -> false;
|
||||
};
|
||||
} else {
|
||||
mirrorFirst = switch (source) {
|
||||
case DEFAULT, OFFICIAL -> null;
|
||||
case MIRROR -> true;
|
||||
};
|
||||
}
|
||||
|
||||
if (mirrorFirst == null) {
|
||||
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
|
||||
}
|
||||
|
||||
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 2);
|
||||
for (String url : urls) {
|
||||
DownloadCandidate candidate = DownloadCandidate.of(url);
|
||||
if (candidate.url() == null) {
|
||||
// Invalid URL, just add it to the list and let the download task handle it.
|
||||
candidates.add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable DownloadCandidate mirrorCandidate = null;
|
||||
boolean fallback = false;
|
||||
for (MirrorRule rule : rules) {
|
||||
if (url.startsWith(rule.source)) {
|
||||
mirrorCandidate = DownloadCandidate.of(rule.target + url.substring(rule.source.length()));
|
||||
fallback = rule.fallback;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mirrorCandidate == null) {
|
||||
candidates.add(candidate);
|
||||
} else if (mirrorFirst && !fallback) {
|
||||
candidates.add(mirrorCandidate);
|
||||
candidates.add(candidate);
|
||||
} else {
|
||||
candidates.add(candidate);
|
||||
candidates.add(mirrorCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadCandidates.of(List.copyOf(candidates));
|
||||
}
|
||||
|
||||
private final String bmclRoot;
|
||||
private final List<MirrorRule> rules;
|
||||
|
||||
public HMCLDownloadProvider(String bmclRoot) {
|
||||
this.bmclRoot = bmclRoot;
|
||||
this.rules = List.of(
|
||||
new MirrorRule("https://bmclapi2.bangbang93.com", bmclRoot),
|
||||
new MirrorRule("https://launchermeta.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://piston-meta.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://piston-data.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://launcher.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://libraries.minecraft.net", bmclRoot + "/libraries"),
|
||||
new MirrorRule("http://files.minecraftforge.net/maven", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://files.minecraftforge.net/maven", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://maven.minecraftforge.net", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://maven.neoforged.net/releases/", bmclRoot + "/maven/"),
|
||||
new MirrorRule("http://dl.liteloader.com/versions", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://dl.liteloader.com/versions", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://meta.fabricmc.net", bmclRoot + "/fabric-meta"),
|
||||
new MirrorRule("https://maven.fabricmc.net", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://authlib-injector.yushi.moe", bmclRoot + "/mirrors/authlib-injector"),
|
||||
new MirrorRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||
new MirrorRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||
new MirrorRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
|
||||
new MirrorRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
|
||||
new MirrorRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
|
||||
|
||||
// https://github.com/mcmod-info-mirror/mcim-rust-api
|
||||
new MirrorRule("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth", true),
|
||||
new MirrorRule("https://cdn.modrinth.com", "https://mod.mcimirror.top", true),
|
||||
new MirrorRule("https://api.curseforge.com", "https://mod.mcimirror.top/curseforge", true),
|
||||
new MirrorRule("https://edge.forgecdn.net", "https://mod.mcimirror.top", true)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
|
||||
assert (gameVersion == null) == (type == GameComponentType.GAME);
|
||||
switch (type) {
|
||||
case FORGE -> {
|
||||
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
|
||||
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||
if (source == DownloadSource.MIRROR) {
|
||||
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||
} else {
|
||||
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||
}
|
||||
}
|
||||
case NEO_FORGE -> {
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
|
||||
if (source == DownloadSource.MIRROR || source == DownloadSource.DEFAULT) {
|
||||
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||
} else {
|
||||
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||
}
|
||||
}
|
||||
case OPTIFINE -> {
|
||||
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return super.fetchVersionsAsync(type, gameVersion);
|
||||
}
|
||||
|
||||
public void setFileSource(DownloadSource fileSource) {
|
||||
this.fileSource = Objects.requireNonNull(fileSource);
|
||||
}
|
||||
|
||||
public void setVersionListSource(DownloadSource versionListSource) {
|
||||
this.versionListSource = Objects.requireNonNull(versionListSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLCacheRepository getCacheRepository() {
|
||||
return HMCLCacheRepository.REPOSITORY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getGameVersionListCandidates() {
|
||||
return getCandidates(
|
||||
versionListSource,
|
||||
GameRemoteVersion.VERSION_MANIFEST_URL,
|
||||
WebURL.parse(bmclRoot + "/mc/game/version_manifest.json")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
|
||||
DownloadSource source = versionListSource;
|
||||
return new FabricLikeVersionListCandidates(
|
||||
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/loader")),
|
||||
getCandidates(source, FabricRemoteVersion.GAME_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/game"))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getCleanroomVersionListCandidates() {
|
||||
return RegionUtils.isIsChinaMainlandMachine()
|
||||
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
|
||||
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getDownloadCandidates(List<String> urls) {
|
||||
return getCandidates(fileSource, rules, urls);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getVersionListCandidates(List<String> urls) {
|
||||
return getCandidates(versionListSource, rules, urls);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
|
||||
return getCandidates(
|
||||
fileSource,
|
||||
WebURL.parse("https://resources.download.minecraft.net/" + assetObject.getLocation()),
|
||||
WebURL.parse(bmclRoot + "/assets/" + assetObject.getLocation())
|
||||
);
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private static final class FallbackTask<T> extends Task<T> {
|
||||
|
||||
private final Task<? extends T> task;
|
||||
private final Task<? extends T> fallback;
|
||||
|
||||
private List<Task<?>> dependencies = List.of();
|
||||
|
||||
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
|
||||
this.task = task;
|
||||
this.fallback = fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends Task<?>> getDependents() {
|
||||
return List.of(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (isDependentsSucceeded()) {
|
||||
setResult(task.getResult());
|
||||
} else {
|
||||
dependencies = List.of(fallback);
|
||||
fallback.storeTo(this::setResult);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Task<?>> getDependencies() {
|
||||
return dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
private record MirrorRule(String source, String target, boolean fallback) {
|
||||
public MirrorRule(String source, String target) {
|
||||
this(source, target, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +565,13 @@ public class HMCLGameInstance extends DefaultGameInstance {
|
||||
return getLayout().getInstanceGameSettingsFile(id);
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
|
||||
public LaunchOptions.Builder getLaunchOptions(
|
||||
JavaRuntime javaRuntime,
|
||||
Path javaExecutable,
|
||||
Path gameDir,
|
||||
List<String> javaAgents,
|
||||
List<String> javaArguments,
|
||||
boolean makeLaunchScript) {
|
||||
GameSettings.Effective vs = getEffectiveSettings();
|
||||
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
|
||||
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
|
||||
@@ -577,7 +583,7 @@ public class HMCLGameInstance extends DefaultGameInstance {
|
||||
? null
|
||||
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
|
||||
SystemInfo.getPhysicalMemoryStatus().available(),
|
||||
javaVersion.getPlatform()) / 1024L / 1024L);
|
||||
javaRuntime.getPlatform()) / 1024L / 1024L);
|
||||
} else {
|
||||
maxMemory = vs.getMaxMemory();
|
||||
}
|
||||
@@ -585,7 +591,8 @@ public class HMCLGameInstance extends DefaultGameInstance {
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setInstanceId(getId())
|
||||
.setGameDir(gameDir)
|
||||
.setJava(javaVersion)
|
||||
.setJava(javaRuntime)
|
||||
.setJavaExecutable(javaExecutable)
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(getId().id())
|
||||
.setProfileName(Metadata.TITLE)
|
||||
|
||||
@@ -129,8 +129,8 @@ public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
}
|
||||
});
|
||||
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), List.of("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, List.of("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -31,7 +31,6 @@ import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -66,9 +65,9 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException {
|
||||
public Modpack readManifest(ZipArchiveReader file, Path path) throws IOException, JsonParseException {
|
||||
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class);
|
||||
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
|
||||
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
|
||||
if (game.jar() == null)
|
||||
|
||||
@@ -32,15 +32,14 @@ import org.jackhuang.hmcl.launch.*;
|
||||
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.JavaVersionType;
|
||||
import org.jackhuang.hmcl.setting.LauncherVisibility;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
@@ -57,13 +56,12 @@ import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -150,29 +148,46 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
/// Builds and executes the launch pipeline for the captured game instance.
|
||||
@SuppressWarnings("NonAtomicOperationOnVolatileField")
|
||||
private void launch0() {
|
||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||
PROCESSES.removeIf(it -> it.get() == null);
|
||||
|
||||
final var context = new Object() {
|
||||
volatile GameInstanceManifest launchManifest;
|
||||
volatile JavaRuntime javaRuntime;
|
||||
volatile Path javaExecutable;
|
||||
};
|
||||
|
||||
HMCLGameRepository repository = repository();
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||
var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest()));
|
||||
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
|
||||
CountDownLatch launchingLatch = new CountDownLatch(1);
|
||||
List<String> javaAgents = new ArrayList<>(0);
|
||||
List<String> javaArguments = new ArrayList<>(0);
|
||||
|
||||
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
|
||||
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||
context.launchManifest = LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest());
|
||||
|
||||
TaskExecutor executor = checkGameState(gameInstance, setting, launchManifest.get())
|
||||
TaskExecutor executor = checkGameState(gameInstance, setting, context.launchManifest)
|
||||
.thenComposeAsync(java -> {
|
||||
javaVersionRef.set(Objects.requireNonNull(java));
|
||||
launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments));
|
||||
context.javaRuntime = Objects.requireNonNull(java);
|
||||
context.javaExecutable = java.getBinary();
|
||||
if (java.getPlatform().os() == OperatingSystem.WINDOWS
|
||||
&& scriptFile == null
|
||||
&& !setting.getInheritable(GameSettings::notUseJavawProperty)
|
||||
&& FileUtils.getName(java.getBinary()).equalsIgnoreCase("java.exe")) {
|
||||
Path javawBinary = java.getBinary().resolveSibling("javaw.exe");
|
||||
if (Files.isRegularFile(javawBinary)) {
|
||||
context.javaExecutable = javawBinary;
|
||||
}
|
||||
}
|
||||
|
||||
context.launchManifest = NativePatcher.patchNative(gameInstance, context.launchManifest, java, setting, javaArguments);
|
||||
if (setting.getInheritable(GameSettings::notCheckGameProperty))
|
||||
return null;
|
||||
return Task.allOf(
|
||||
dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.get(), integrityCheck),
|
||||
dependencyManager.checkGameCompletionAsync(gameInstance, context.launchManifest, integrityCheck),
|
||||
Task.composeAsync(() -> {
|
||||
try {
|
||||
@Nullable ModpackConfiguration<?> configuration =
|
||||
@@ -205,7 +220,7 @@ public final class LauncherHelper {
|
||||
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) {
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, context.launchManifest, lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
@@ -215,13 +230,13 @@ public final class LauncherHelper {
|
||||
})
|
||||
);
|
||||
}).withStage("launch.state.dependencies")
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get()))
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), context.launchManifest))
|
||||
.thenComposeAsync(() -> {
|
||||
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
|| !NativePatcher.needPatchMemoryUtil(context.launchManifest, context.javaRuntime.getParsedVersion())) {
|
||||
return Task.completed(null);
|
||||
} else {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
@@ -241,7 +256,12 @@ public final class LauncherHelper {
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
|
||||
javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
context.javaRuntime,
|
||||
context.javaExecutable,
|
||||
repository.getBaseDirectory(),
|
||||
javaAgents,
|
||||
javaArguments,
|
||||
scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
@@ -250,7 +270,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(javaVersionRef.get().getBinary());
|
||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
|
||||
try {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg != null) {
|
||||
@@ -285,7 +305,7 @@ public final class LauncherHelper {
|
||||
|
||||
return new HMCLGameLauncher(
|
||||
gameInstance,
|
||||
launchManifest.get(),
|
||||
context.launchManifest,
|
||||
authInfo,
|
||||
launchOptions,
|
||||
launcherVisibility == LauncherVisibility.CLOSE
|
||||
@@ -307,10 +327,10 @@ public final class LauncherHelper {
|
||||
if (launcherVisibility == LauncherVisibility.CLOSE)
|
||||
Launcher.stopApplication();
|
||||
else
|
||||
launchingStepsPane.setCancel(new TaskCancellationAction(it -> {
|
||||
launchingStepsPane.setCancel(it -> {
|
||||
process.stop();
|
||||
it.fireEvent(new DialogCloseEvent());
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
runLater(() -> {
|
||||
launchingStepsPane.fireEvent(new DialogCloseEvent());
|
||||
@@ -356,26 +376,26 @@ public final class LauncherHelper {
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message += i18n("download.code.404", uri);
|
||||
message += i18n("download.code.404", url);
|
||||
else
|
||||
message += i18n("download.failed", uri, responseCode);
|
||||
message += i18n("download.failed", url, responseCode);
|
||||
} else {
|
||||
message += StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) ex).getUri();
|
||||
} else if (ex instanceof DownloadException de) {
|
||||
String url = de.getUrl();
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
message = i18n("install.failed.downloading.timeout", uri);
|
||||
message = i18n("install.failed.downloading.timeout", url);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
|
||||
message = i18n("assets.index.malformed");
|
||||
@@ -385,11 +405,11 @@ public final class LauncherHelper {
|
||||
message = i18n("account.failed.character_deleted");
|
||||
} else if (ex instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message = i18n("download.code.404", uri);
|
||||
message = i18n("download.code.404", url);
|
||||
else
|
||||
message = i18n("download.failed", uri, responseCode);
|
||||
message = i18n("download.failed", url, responseCode);
|
||||
} else if (ex instanceof CommandTooLongException) {
|
||||
message = i18n("launch.failed.command_too_long");
|
||||
} else if (ex instanceof ExecutionPolicyLimitException) {
|
||||
@@ -744,11 +764,11 @@ public final class LauncherHelper {
|
||||
Throwable resolvedException = resolveException(exception);
|
||||
LOG.warning("Failed to download java", exception);
|
||||
if (!(resolvedException instanceof CancellationException)) {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
}
|
||||
future.completeExceptionally(new CancellationException());
|
||||
}
|
||||
}), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())));
|
||||
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()));
|
||||
}, () -> future.completeExceptionally(new CancellationException())).build());
|
||||
|
||||
return future;
|
||||
|
||||
@@ -20,10 +20,12 @@ package org.jackhuang.hmcl.game;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.ui.instances.ModTranslations;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
@@ -50,16 +52,17 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
if (!StringUtils.containsChinese(searchFilter)) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
|
||||
if (registry == null || !StringUtils.containsChinese(searchFilter)) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
||||
|
||||
int count = 0;
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(getType()).searchMod(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
||||
for (AddonProjectInfo mod : registry.search(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.subName()) ? mod.subName() : mod.mainName()));
|
||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||
englishSearchFiltersSet.add(englishSearchFilter);
|
||||
}
|
||||
@@ -69,7 +72,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
}
|
||||
|
||||
if (englishSearchFiltersSet.isEmpty()) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
|
||||
@@ -78,7 +81,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
SearchResult searchResult = null;
|
||||
List<RemoteAddon> remoteAddons = List.of();
|
||||
for (String englishSearchFilter : englishSearchFiltersSet) {
|
||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
|
||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
|
||||
remoteAddons = searchResult.results().toList();
|
||||
if (!remoteAddons.isEmpty()) {
|
||||
break;
|
||||
@@ -91,8 +94,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
continue;
|
||||
}
|
||||
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
||||
@Nullable AddonProjectInfo chineseTranslation = registry.getInfoByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.mainName()) && StringUtils.containsChinese(chineseTranslation.mainName())) {
|
||||
searchResultArray[chineseIndex++] = remoteAddon;
|
||||
} else {
|
||||
searchResultArray[englishIndex--] = remoteAddon;
|
||||
@@ -103,11 +106,11 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
||||
@Nullable AddonProjectInfo chineseRemoteMod = registry.getInfoByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.mainName()) || !StringUtils.containsChinese(chineseRemoteMod.mainName())) {
|
||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||
}
|
||||
String chineseRemoteModName = chineseRemoteMod.getName();
|
||||
String chineseRemoteModName = chineseRemoteMod.mainName();
|
||||
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
|
||||
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
|
||||
}
|
||||
@@ -122,8 +125,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<Category> getCategories() throws IOException {
|
||||
return getBackedRemoteModRepository().getCategories();
|
||||
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
|
||||
return getBackedRemoteModRepository().getCategories(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -20,7 +20,6 @@ package org.jackhuang.hmcl.game;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.io.Unzipper;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
@@ -29,12 +28,10 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
|
||||
private final Path zipFile;
|
||||
private final Charset charset;
|
||||
private final String name;
|
||||
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, String name) {
|
||||
this.zipFile = zipFile;
|
||||
this.charset = charset;
|
||||
this.name = name;
|
||||
|
||||
setName(i18n("modpack.installing"));
|
||||
@@ -51,7 +48,6 @@ public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
new Unzipper(zipFile, dest)
|
||||
.setSubDirectory(subdirectory)
|
||||
.setTerminateIfSubDirectoryNotExists()
|
||||
.setEncoding(charset)
|
||||
.unzip();
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -90,8 +89,8 @@ public final class ModpackHelper {
|
||||
return "zip".equals(ext) || "mrpack".equals(ext);
|
||||
}
|
||||
|
||||
public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
||||
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) {
|
||||
public static Modpack readModpackManifest(Path file) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
||||
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file)) {
|
||||
// Order for trying detecting manifest is necessary here.
|
||||
// Do not change to iterating providers.
|
||||
for (ModpackProvider provider : new ModpackProvider[]{
|
||||
@@ -102,7 +101,7 @@ public final class ModpackHelper {
|
||||
MultiMCModpackProvider.INSTANCE,
|
||||
ServerModpackProvider.INSTANCE}) {
|
||||
try {
|
||||
return provider.readManifest(zipFile, file, charset);
|
||||
return provider.readManifest(zipFile, file);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
@@ -173,6 +172,7 @@ public final class ModpackHelper {
|
||||
|
||||
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
@@ -180,12 +180,13 @@ public final class ModpackHelper {
|
||||
return Files.exists(Paths.get("externalgames").resolve(name));
|
||||
}
|
||||
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
|
||||
/// Extracts a manually created modpack and selects its new external game directory.
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name) {
|
||||
if (isExternalGameNameConflicts(name)) {
|
||||
throw new IllegalArgumentException("name existing");
|
||||
}
|
||||
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, name)
|
||||
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
||||
GameDirectory newGameDirectory = new GameDirectory(
|
||||
GameDirectoryManager.newGameDirectoryId(),
|
||||
@@ -193,7 +194,9 @@ public final class ModpackHelper {
|
||||
PortablePath.fromPath(location));
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
});
|
||||
})
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints("hmcl.modpack");
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
|
||||
@@ -233,19 +236,23 @@ public final class ModpackHelper {
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
/// Updates a server modpack with backup, repository refresh, and modpack stage hints.
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
switch (configuration.getType()) {
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
|
||||
HMCLGameInstance instance = repository.getInstance(instanceId);
|
||||
@@ -253,6 +260,7 @@ public final class ModpackHelper {
|
||||
instance,
|
||||
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
default:
|
||||
@@ -260,15 +268,14 @@ public final class ModpackHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
return getUpdateTask(repository, zipFile, charset, instanceId, configuration, null);
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
return getUpdateTask(repository, zipFile, instanceId, configuration, null);
|
||||
}
|
||||
|
||||
/// Creates an update task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param charset the archive encoding
|
||||
/// @param instanceId the instance to update
|
||||
/// @param configuration the existing modpack configuration
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
@@ -277,23 +284,26 @@ public final class ModpackHelper {
|
||||
public static Task<?> getUpdateTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Charset charset,
|
||||
GameInstanceID instanceId,
|
||||
ModpackConfiguration<?> configuration,
|
||||
@Nullable Set<String> excludedFiles)
|
||||
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile);
|
||||
@Nullable ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
if (provider == null) {
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||
import org.jackhuang.hmcl.auth.offline.Skin;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -99,7 +100,7 @@ public final class TexturesLoader {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
// download it
|
||||
try {
|
||||
new FileDownloadTask(texture.url(), file).run();
|
||||
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.url());
|
||||
} catch (Exception e) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
|
||||
@@ -26,15 +26,11 @@ import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.GameJavaVersion;
|
||||
import org.jackhuang.hmcl.game.JavaVersionConstraint;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
@@ -444,7 +440,7 @@ public final class JavaManager {
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||
}
|
||||
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
||||
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
|
||||
|
||||
// Search in PATH.
|
||||
if (System.getenv("PATH") != null) {
|
||||
|
||||
@@ -18,44 +18,47 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.InvalidationListener;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class DownloadProviders {
|
||||
private DownloadProviders() {
|
||||
}
|
||||
|
||||
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
|
||||
|
||||
private static final DownloadProvider MOJANG_PROVIDER;
|
||||
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
|
||||
private static final DownloadProvider DEFAULT_PROVIDER;
|
||||
private static final String DEFAULT_BMCLAPI_ROOT = "https://bmclapi2.bangbang93.com";
|
||||
private static final String BMCLAPI_ROOT;
|
||||
|
||||
static {
|
||||
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
|
||||
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
MOJANG_PROVIDER = new MojangDownloadProvider();
|
||||
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
|
||||
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
|
||||
@Nullable String bmclOverride = System.getProperty("hmcl.bmclapi.override");
|
||||
if (StringUtils.isBlank(bmclOverride)) {
|
||||
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
|
||||
} else {
|
||||
WebURL url;
|
||||
try {
|
||||
url = WebURL.parseBrowserInput(bmclOverride);
|
||||
} catch (Exception e) {
|
||||
url = null;
|
||||
LOG.warning("Invalid BMCLAPI override URL: " + bmclOverride, e);
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
|
||||
} else {
|
||||
BMCLAPI_ROOT = StringUtils.removeSuffix(url.toString(), "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(BMCLAPI_ROOT);
|
||||
|
||||
/// Initializes download provider settings and synchronizes download thread settings.
|
||||
public static void init() {
|
||||
InvalidationListener onChangeDownloadThreads = observable -> {
|
||||
@@ -67,73 +70,15 @@ public final class DownloadProviders {
|
||||
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
onChangeDownloadThreads.invalidated(null);
|
||||
|
||||
InvalidationListener onChangeDownloadSource = observable -> {
|
||||
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
|
||||
settings().versionListSourceProperty().get(),
|
||||
settings().fileDownloadSourceProperty().get()));
|
||||
};
|
||||
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
|
||||
onChangeDownloadSource.invalidated(null);
|
||||
}
|
||||
|
||||
/// Creates a download provider with independent version-list and file download preferences.
|
||||
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
|
||||
return new AutoDownloadProvider(
|
||||
getCandidates(versionListSource),
|
||||
getCandidates(fileDownloadSource));
|
||||
}
|
||||
|
||||
/// Returns provider candidates ordered by the given source preference.
|
||||
private static List<DownloadProvider> getCandidates(DownloadSource source) {
|
||||
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
|
||||
return switch (normalized) {
|
||||
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
|
||||
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
|
||||
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
|
||||
case OFFICIAL -> List.of(MOJANG_PROVIDER);
|
||||
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
|
||||
};
|
||||
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
|
||||
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current primary preferred download provider
|
||||
*/
|
||||
public static DownloadProvider getDownloadProvider() {
|
||||
return PROVIDER_WRAPPER;
|
||||
return PROVIDER;
|
||||
}
|
||||
|
||||
public static String localizeErrorMessage(Throwable exception) {
|
||||
if (exception instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
return i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception.getCause() instanceof FileNotFoundException) {
|
||||
return i18n("download.code.404", uri);
|
||||
} else if (exception.getCause() instanceof AccessDeniedException) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
|
||||
} else if (exception.getCause() instanceof ArtifactMalformedException) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
|
||||
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
|
||||
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
|
||||
}
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception instanceof ArtifactMalformedException) {
|
||||
return i18n("exception.artifact_malformed");
|
||||
} else if (exception instanceof CancellationException) {
|
||||
return i18n("message.cancelled");
|
||||
}
|
||||
return StringUtils.getStackTrace(exception);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
@@ -33,13 +36,7 @@ import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
@@ -333,6 +330,7 @@ public final class GameDirectoryManager {
|
||||
if (source == target) {
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
rebuildGameDirectories();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
|
||||
public static final class Instance extends GameSettings implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by instance-specific game settings.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 0));
|
||||
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 1));
|
||||
|
||||
/// Creates an empty instance setting.
|
||||
public Instance() {
|
||||
@@ -388,6 +388,19 @@ public sealed abstract class GameSettings extends ObservableSetting {
|
||||
return detectedJava;
|
||||
}
|
||||
|
||||
/// Property name for disabling automatic replacement with `javaw.exe` on Windows.
|
||||
public static final String PROPERTY_NOT_USE_JAVAW = "notUseJavaw";
|
||||
|
||||
/// Whether to keep the selected Java executable instead of replacing it with `javaw.exe`; defaults to `false`.
|
||||
@SerializedName(PROPERTY_NOT_USE_JAVAW)
|
||||
private final InheritableProperty<Boolean> notUseJavaw = newInheritableProperty(PROPERTY_NOT_USE_JAVAW, false);
|
||||
|
||||
/// Returns the property disabling automatic replacement of a Windows Java executable with a sibling `javaw.exe` file.
|
||||
/// If `true`, the selected executable is used unchanged. This property has no effect on other runtime platforms.
|
||||
public InheritableProperty<Boolean> notUseJavawProperty() {
|
||||
return notUseJavaw;
|
||||
}
|
||||
|
||||
/// Property name for customized JVM options.
|
||||
public static final String PROPERTY_JVM_OPTIONS = "jvmOptions";
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import java.util.Objects;
|
||||
public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game settings preset store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0));
|
||||
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 1));
|
||||
|
||||
/// Creates an empty game settings preset store.
|
||||
public GameSettingsPresets() {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.ArtifactMalformedException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
@@ -29,13 +30,12 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
@@ -50,13 +50,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
public final class TerracottaBundle {
|
||||
private final Path root;
|
||||
|
||||
private final List<URI> links;
|
||||
/// Candidate bundle URLs in attempt order.
|
||||
private final List<WebURL> links;
|
||||
|
||||
private final FileDownloadTask.IntegrityCheck hash;
|
||||
|
||||
private final Map<String, FileDownloadTask.IntegrityCheck> files;
|
||||
|
||||
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
|
||||
/// Creates a bundle using the supplied download candidates and archive/file integrity metadata.
|
||||
/// The candidate list and file map are retained without copying.
|
||||
public TerracottaBundle(Path root, List<WebURL> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
|
||||
this.root = root;
|
||||
this.links = links;
|
||||
this.hash = hash;
|
||||
|
||||
@@ -22,6 +22,7 @@ import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
@@ -42,7 +43,6 @@ import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
@@ -288,7 +288,7 @@ public final class TerracottaManager {
|
||||
|
||||
public static Task<String> exportLogs() {
|
||||
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
|
||||
return new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
return Task.completed(null);
|
||||
@@ -297,7 +297,7 @@ public final class TerracottaManager {
|
||||
public static TerracottaState.Waiting setWaiting() {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
|
||||
new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.start();
|
||||
return new TerracottaState.Waiting(-1, -1, null);
|
||||
@@ -317,7 +317,7 @@ public final class TerracottaManager {
|
||||
.thenComposeAsync(nodes -> {
|
||||
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
|
||||
query.add(pair("player", getPlayerName()));
|
||||
for (URI node : nodes) {
|
||||
for (WebURL node : nodes) {
|
||||
query.add(pair("public_nodes", node.toString()));
|
||||
}
|
||||
return new GetTask(NetworkUtils.withQuery(
|
||||
@@ -338,7 +338,7 @@ public final class TerracottaManager {
|
||||
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
|
||||
query.add(pair("room", room));
|
||||
query.add(pair("player", getPlayerName()));
|
||||
for (URI node : nodes) {
|
||||
for (WebURL node : nodes) {
|
||||
query.add(pair("public_nodes", node.toString()));
|
||||
}
|
||||
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
|
||||
|
||||
@@ -18,14 +18,15 @@
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
@@ -35,10 +36,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionRange;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -90,8 +91,8 @@ public final class TerracottaMetadata {
|
||||
}
|
||||
|
||||
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
|
||||
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
|
||||
.map(link -> URI.create(options.replace(link)))
|
||||
@Unmodifiable List<WebURL> links = (RegionUtils.isIsChinaMainlandMachine() ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
|
||||
.map(link -> WebURL.parse(options.replace(link)))
|
||||
.toList();
|
||||
|
||||
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
|
||||
|
||||
@@ -18,17 +18,17 @@
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -43,17 +43,19 @@ public final class TerracottaNodeList {
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
|
||||
try {
|
||||
new URI(url);
|
||||
} catch (URISyntaxException e) {
|
||||
WebURL.parse(url);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException("Invalid URL: " + url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static volatile List<URI> list;
|
||||
/// Cached node URLs, or `null` before the first fetch completes.
|
||||
private static volatile @Nullable @Unmodifiable List<WebURL> list;
|
||||
|
||||
public static List<URI> fetch() {
|
||||
List<URI> list = TerracottaNodeList.list;
|
||||
/// Returns the cached unmodifiable node URLs, fetching once and caching an empty list on failure.
|
||||
public static @Unmodifiable List<WebURL> fetch() {
|
||||
@Nullable @Unmodifiable List<WebURL> list = TerracottaNodeList.list;
|
||||
if (list != null)
|
||||
return list;
|
||||
|
||||
@@ -82,9 +84,9 @@ public final class TerracottaNodeList {
|
||||
return false;
|
||||
}
|
||||
|
||||
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
|
||||
return StringUtils.isBlank(node.region) || RegionUtils.isIsChinaMainlandMachine() == "CN".equalsIgnoreCase(node.region);
|
||||
})
|
||||
.map(it -> URI.create(it.url()))
|
||||
.map(it -> WebURL.parse(it.url()))
|
||||
.toList();
|
||||
LOG.info("Terracotta node list: " + list);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.glavo.monetfx.Brightness;
|
||||
import org.glavo.monetfx.ColorRole;
|
||||
import org.glavo.monetfx.ColorStyle;
|
||||
import org.glavo.monetfx.Contrast;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.setting.BackgroundType;
|
||||
import org.jackhuang.hmcl.setting.LauncherSettings;
|
||||
@@ -36,8 +37,8 @@ import org.jackhuang.hmcl.util.MathUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.ContentEncoding;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
@@ -48,7 +49,6 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
@@ -1497,28 +1497,28 @@ public final class ThemePackManager {
|
||||
List<Path> temporaryFiles,
|
||||
ResolvedBackground background) throws IOException {
|
||||
String url = requireNonBlank(background.networkImageUrl(), "background.url");
|
||||
URI uri = NetworkUtils.toURI(url);
|
||||
if (!NetworkUtils.isHttpUri(uri)) {
|
||||
WebURL webUrl = WebURL.parse(url);
|
||||
if (!NetworkUtils.isHttpUri(webUrl)) {
|
||||
throw new IOException("Theme background URL must be HTTP or HTTPS: " + url);
|
||||
}
|
||||
|
||||
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(uri);
|
||||
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(webUrl);
|
||||
if (background.networkImageCachePolicy() == NetworkBackgroundImageCachePolicy.ENABLED) {
|
||||
try {
|
||||
@Nullable Path cachedFile = new CacheFileTask(uri).run();
|
||||
@Nullable Path cachedFile = new CacheFileTask(webUrl).run();
|
||||
if (cachedFile != null && Files.isRegularFile(cachedFile)) {
|
||||
assets.add(new ThemePackAsset(cachedFile, entryName));
|
||||
return new ThemeBackground.Image(entryName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to cache theme background, falling back to direct download: " + uri, e);
|
||||
LOG.warning("Failed to cache theme background, falling back to direct download: " + webUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
Path temporaryFile = Files.createTempFile("hmcl-theme-background-", ".tmp");
|
||||
boolean success = false;
|
||||
try {
|
||||
downloadNetworkBackground(uri, temporaryFile);
|
||||
downloadNetworkBackground(webUrl, temporaryFile);
|
||||
assets.add(new ThemePackAsset(temporaryFile, entryName));
|
||||
temporaryFiles.add(temporaryFile);
|
||||
success = true;
|
||||
@@ -1535,12 +1535,12 @@ public final class ThemePackManager {
|
||||
}
|
||||
|
||||
/// Downloads one network background into a temporary file without installing it into the persistent image cache.
|
||||
private static void downloadNetworkBackground(URI uri, Path outputFile) throws IOException {
|
||||
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(uri));
|
||||
private static void downloadNetworkBackground(WebURL url, Path outputFile) throws IOException {
|
||||
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(url));
|
||||
try {
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode / 100 != 2) {
|
||||
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + uri);
|
||||
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + url);
|
||||
}
|
||||
|
||||
ContentEncoding contentEncoding = ContentEncoding.fromConnection(connection);
|
||||
@@ -1554,8 +1554,8 @@ public final class ThemePackManager {
|
||||
}
|
||||
|
||||
/// Returns a safe theme-pack asset file name for a downloaded network background.
|
||||
private static String networkBackgroundAssetName(URI uri) {
|
||||
String path = Objects.toString(uri.getPath(), "");
|
||||
private static String networkBackgroundAssetName(WebURL url) {
|
||||
String path = Objects.toString(url.getPath(), "");
|
||||
@Nullable Path fileNamePath = path.isBlank() ? null : Path.of(path).getFileName();
|
||||
String fileName = fileNamePath != null ? fileNamePath.toString() : "";
|
||||
String sanitized = sanitizePathSegment(fileName);
|
||||
|
||||
@@ -91,6 +91,9 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
/// Provides the current launcher MonetFX theme and derived color bindings.
|
||||
@NotNullByDefault
|
||||
public final class Themes {
|
||||
/// The stage property retaining the listener for native appearance updates and marking completed registration.
|
||||
private static final String NATIVE_DARK_MODE_LISTENER = "Themes.applyNativeDarkMode.listener";
|
||||
|
||||
/// The seed color extracted from the last loaded wallpaper image.
|
||||
private static final ReadOnlyObjectWrapper<@Nullable ThemeColor> wallpaperThemeColor = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
@@ -1148,8 +1151,17 @@ public final class Themes {
|
||||
return autoBrightness.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Applies native dark-mode integration to a JavaFX stage where the platform supports it.
|
||||
/// Registers native dark-mode integration once per stage where the platform supports it.
|
||||
///
|
||||
/// Windows updates the stage's native frame; macOS updates the application appearance. The stage retains
|
||||
/// its listener, which observes theme changes weakly. Repeated calls for an already registered stage do
|
||||
/// nothing, including after hiding the stage or replacing its scene.
|
||||
///
|
||||
/// @param stage the stage retaining the registration, accessed on the JavaFX application thread
|
||||
public static void applyNativeDarkMode(Stage stage) {
|
||||
if (stage.getProperties().containsKey(NATIVE_DARK_MODE_LISTENER)) {
|
||||
return;
|
||||
}
|
||||
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
|
||||
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> {
|
||||
if (stage.isShowing()) {
|
||||
@@ -1166,7 +1178,7 @@ public final class Themes {
|
||||
});
|
||||
}
|
||||
});
|
||||
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
|
||||
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
|
||||
|
||||
if (stage.isShowing()) {
|
||||
listener.changed(null, false, Themes.darkModeProperty().get());
|
||||
@@ -1183,7 +1195,7 @@ public final class Themes {
|
||||
MacOSNativeUtils.setAppearance(darkModeProperty().get());
|
||||
|
||||
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance);
|
||||
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
|
||||
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
@@ -70,7 +69,6 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
@@ -96,6 +94,7 @@ public final class Controllers {
|
||||
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
|
||||
/// The coordinator for the main window's scene graph and navigation stack.
|
||||
private static @Nullable Decorator decorator;
|
||||
|
||||
private static DownloadPage downloadPage;
|
||||
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
|
||||
AccountListPage accountListPage = new AccountListPage();
|
||||
@@ -210,6 +209,16 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the main stage's application identity, shutdown action, and taskbar relaunch information.
|
||||
///
|
||||
/// @param stage the unshown main stage
|
||||
private static void configureMainStage(Stage stage) {
|
||||
stage.setOnCloseRequest(event -> Launcher.stopApplication());
|
||||
FXUtils.setIcon(stage);
|
||||
stage.setTitle(Metadata.FULL_TITLE);
|
||||
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(stage);
|
||||
}
|
||||
|
||||
/// Initializes the main application stage, scene graph, and background services.
|
||||
///
|
||||
/// @param stage the primary application stage, which must not have been shown
|
||||
@@ -230,9 +239,9 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
stage.setOnCloseRequest(e -> Launcher.stopApplication());
|
||||
configureMainStage(stage);
|
||||
|
||||
decorator = new Decorator(getRootPage());
|
||||
decorator = new Decorator(getRootPage(), Controllers::configureMainStage);
|
||||
Scene mainScene = decorator.attachStage(stage);
|
||||
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
|
||||
getRootPage().getMainPage().showUpdateDialogProperty().bind(
|
||||
@@ -251,9 +260,6 @@ public final class Controllers {
|
||||
|
||||
StyleSheets.init(mainScene);
|
||||
|
||||
FXUtils.setIcon(stage);
|
||||
stage.setTitle(Metadata.FULL_TITLE);
|
||||
|
||||
if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) {
|
||||
Runnable continueAction = () -> {
|
||||
UserState userState = userState();
|
||||
@@ -398,8 +404,7 @@ public final class Controllers {
|
||||
|
||||
Controllers.taskDialog(
|
||||
Task.composeAsync(Schedulers.io(), () -> {
|
||||
Charset encoding = CompressingUtils.findSuitableEncoding(modpackFile);
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile, encoding);
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
|
||||
return ModpackHelper.getInstallTask(
|
||||
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
|
||||
})
|
||||
@@ -606,20 +611,20 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
public static void openUriOrCopy(@Nullable URI uri) {
|
||||
if (uri == null) return;
|
||||
openUriOrCopy(uri.toString());
|
||||
public static void openUriOrCopy(@Nullable URI url) {
|
||||
if (url == null) return;
|
||||
openUriOrCopy(url.toString());
|
||||
}
|
||||
|
||||
public static void openUriOrCopy(@Nullable String uri) {
|
||||
if (uri == null) return;
|
||||
public static void openUriOrCopy(@Nullable String url) {
|
||||
if (url == null) return;
|
||||
var dialog = new MessageDialogPane.Builder(
|
||||
i18n("web.open_in_browser", uri),
|
||||
i18n("web.open_in_browser", url),
|
||||
i18n("message.confirm"),
|
||||
MessageDialogPane.MessageType.QUESTION
|
||||
)
|
||||
.addAction(i18n("button.copy"), () -> FXUtils.copyText(uri))
|
||||
.yesOrNo(() -> FXUtils.openLink(uri), null)
|
||||
.addAction(i18n("button.copy"), () -> FXUtils.copyText(url))
|
||||
.yesOrNo(() -> FXUtils.openLink(url), null)
|
||||
.build();
|
||||
dialog(dialog);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ public final class DialogUtils {
|
||||
private static void showInDecorator(Decorator decorator, StackPane dialogContainer, Node content) {
|
||||
show(dialogContainer, content, dialog -> {
|
||||
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
|
||||
decorator.capableDraggingWindow(dialog);
|
||||
decorator.capableDraggingOverlay(dialog);
|
||||
decorator.forbidDraggingWindow(pane);
|
||||
dialog.setDialogContainer(dialogContainer);
|
||||
});
|
||||
|
||||
@@ -50,9 +50,7 @@ import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.ColumnConstraints;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
@@ -62,12 +60,14 @@ import javafx.stage.*;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.DownloadCandidates;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.task.CacheFileTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
|
||||
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
|
||||
import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
||||
@@ -75,12 +75,15 @@ import org.jackhuang.hmcl.ui.image.ImageLoader;
|
||||
import org.jackhuang.hmcl.ui.image.ImageUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.ResourceNotFoundError;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
|
||||
import org.jackhuang.hmcl.util.javafx.Subscription;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.w3c.dom.Document;
|
||||
@@ -105,6 +108,7 @@ import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.PathMatcher;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.*;
|
||||
@@ -613,21 +617,21 @@ public final class FXUtils {
|
||||
if (link == null)
|
||||
return;
|
||||
|
||||
String uri = NetworkUtils.encodeLocation(link);
|
||||
String url = NetworkUtils.encodeLocation(link);
|
||||
thread(() -> {
|
||||
try {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri});
|
||||
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", url});
|
||||
return;
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Runtime.getRuntime().exec(new String[]{"open", uri});
|
||||
Runtime.getRuntime().exec(new String[]{"open", url});
|
||||
return;
|
||||
} else {
|
||||
for (String browser : linuxBrowsers) {
|
||||
Path path = SystemUtils.which(browser);
|
||||
if (path != null) {
|
||||
try {
|
||||
Runtime.getRuntime().exec(new String[]{path.toString(), uri});
|
||||
Runtime.getRuntime().exec(new String[]{path.toString(), url});
|
||||
return;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
@@ -640,7 +644,7 @@ public final class FXUtils {
|
||||
}
|
||||
|
||||
try {
|
||||
java.awt.Desktop.getDesktop().browse(new URI(uri));
|
||||
java.awt.Desktop.getDesktop().browse(new URI(url));
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to open link: " + link, e);
|
||||
}
|
||||
@@ -822,7 +826,7 @@ public final class FXUtils {
|
||||
property.addListener(binding);
|
||||
}
|
||||
|
||||
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
|
||||
public static List<Subscription> bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
|
||||
int itemCount = children.length;
|
||||
int childSelectedCount = 0;
|
||||
for (BooleanProperty child : children) {
|
||||
@@ -832,49 +836,46 @@ public final class FXUtils {
|
||||
|
||||
allEnabled.set(childSelectedCount == itemCount);
|
||||
|
||||
class Listener implements InvalidationListener {
|
||||
class AllListener {
|
||||
private int childSelectedCount;
|
||||
private boolean updating = false;
|
||||
private final List<Subscription> subscriptions = new ArrayList<>(children.length + 1);
|
||||
|
||||
public Listener(int childSelectedCount) {
|
||||
public AllListener(int childSelectedCount) {
|
||||
this.childSelectedCount = childSelectedCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable observable) {
|
||||
if (updating)
|
||||
return;
|
||||
|
||||
updating = true;
|
||||
try {
|
||||
boolean value = ((BooleanProperty) observable).get();
|
||||
|
||||
if (observable == allEnabled) {
|
||||
{
|
||||
subscriptions.add(Subscription.subscribe(allEnabled, () -> {
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
try {
|
||||
boolean value = allEnabled.get();
|
||||
for (BooleanProperty child : children) {
|
||||
child.setValue(value);
|
||||
child.set(value);
|
||||
}
|
||||
childSelectedCount = value ? itemCount : 0;
|
||||
} else {
|
||||
if (value)
|
||||
childSelectedCount++;
|
||||
else
|
||||
childSelectedCount--;
|
||||
|
||||
allEnabled.set(childSelectedCount == itemCount);
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}));
|
||||
for (var child : children) {
|
||||
subscriptions.add(Subscription.subscribe(child, () -> {
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
try {
|
||||
if (child.get()) childSelectedCount++;
|
||||
else childSelectedCount--;
|
||||
allEnabled.set(childSelectedCount == itemCount);
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InvalidationListener listener = new Listener(childSelectedCount);
|
||||
|
||||
WeakInvalidationListener weakListener = new WeakInvalidationListener(listener);
|
||||
allEnabled.addListener(listener);
|
||||
for (BooleanProperty child : children) {
|
||||
child.addListener(weakListener);
|
||||
}
|
||||
return new AllListener(childSelectedCount).subscriptions;
|
||||
}
|
||||
|
||||
public static void setIcon(Stage stage) {
|
||||
@@ -1001,8 +1002,9 @@ public final class FXUtils {
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
|
||||
public static Task<Image> getRemoteImageTask(List<URI> uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
return new CacheFileTask(uris)
|
||||
/// Creates a task that caches an image from HTTP(S) candidates and loads it with the requested sizing options.
|
||||
public static Task<Image> getRemoteImageTask(DownloadCandidates candidates, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
return new CacheFileTask(candidates)
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
@@ -1195,6 +1197,15 @@ public final class FXUtils {
|
||||
});
|
||||
}
|
||||
|
||||
public static void onDoubleClicked(Node node, Runnable action) {
|
||||
node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
|
||||
action.run();
|
||||
e.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void addMacOSCloseWindowHandler(Window window, @Nullable Supplier<Boolean> restriction) {
|
||||
window.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
@@ -1272,6 +1283,34 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyFiles(List<Path> paths) {
|
||||
copyFiles(paths.stream().map(Path::toFile).toList(), i18n("message.copied"));
|
||||
}
|
||||
|
||||
public static void copyFiles(List<File> files, @Nullable String toastMessage) {
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putFiles(files);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
|
||||
if (toastMessage != null && !Controllers.isStopped()) {
|
||||
Controllers.showToast(toastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyImage(Image image) {
|
||||
copyImage(image, i18n("message.copied"));
|
||||
}
|
||||
|
||||
public static void copyImage(Image image, @Nullable String toastMessage) {
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putImage(image);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
|
||||
if (toastMessage != null && !Controllers.isStopped()) {
|
||||
Controllers.showToast(toastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Node> parseSegment(String segment, Consumer<String> hyperlinkAction) {
|
||||
if (segment.indexOf('<') < 0)
|
||||
return Collections.singletonList(new Text(segment));
|
||||
@@ -1522,4 +1561,54 @@ public final class FXUtils {
|
||||
|
||||
}
|
||||
|
||||
public static void chooseDateRange(Consumer<@NotNull Pair<@Nullable LocalDate, @Nullable LocalDate>> onConfirm) {
|
||||
Controllers.dialog(new DateRangeDialog(onConfirm));
|
||||
}
|
||||
|
||||
private static class DateRangeDialog extends JFXDialogLayout {
|
||||
|
||||
private final JFXDatePicker fromPicker, toPicker;
|
||||
|
||||
public DateRangeDialog(Consumer<Pair<LocalDate, LocalDate>> onConfirm) {
|
||||
super();
|
||||
|
||||
setHeading(new Label(i18n("button.select_date")));
|
||||
|
||||
GridPane body = new GridPane();
|
||||
body.setHgap(4);
|
||||
body.setVgap(8);
|
||||
{
|
||||
Label fromLabel = new Label(i18n("button.select_date.from"));
|
||||
this.fromPicker = new JFXDatePicker(LocalDate.now());
|
||||
fromPicker.setOverLay(true);
|
||||
fromPicker.setDialogParent(Controllers.getDecorator().getDialogContainer());
|
||||
body.add(fromLabel, 0, 0);
|
||||
body.add(fromPicker, 1, 0);
|
||||
}
|
||||
{
|
||||
Label toLabel = new Label(i18n("button.select_date.to"));
|
||||
this.toPicker = new JFXDatePicker(LocalDate.now());
|
||||
toPicker.setOverLay(true);
|
||||
toPicker.setDialogParent(Controllers.getDecorator().getDialogContainer());
|
||||
body.add(toLabel, 0, 1);
|
||||
body.add(toPicker, 1, 1);
|
||||
}
|
||||
setBody(body);
|
||||
|
||||
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
|
||||
cancelButton.getStyleClass().add("dialog-cancel");
|
||||
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
JFXButton okButton = new JFXButton(i18n("button.ok"));
|
||||
okButton.getStyleClass().add("dialog-accept");
|
||||
okButton.setOnAction(e -> {
|
||||
if (onConfirm != null) onConfirm.accept(Pair.pair(fromPicker.getValue(), toPicker.getValue()));
|
||||
fireEvent(new DialogCloseEvent());
|
||||
});
|
||||
|
||||
setActions(cancelButton, okButton);
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import javafx.scene.shape.SVGPath;
|
||||
/// All vector icons used in the launcher.
|
||||
///
|
||||
/// Unless otherwise stated,
|
||||
/// these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.query=list&icon.set=Material+Symbols">Material Symbols</a>,
|
||||
/// these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.set=Material+Symbols">Material Symbols</a>,
|
||||
/// with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px.
|
||||
/// The view boxes of all icons are normalized to `0 0 24 24`.
|
||||
public enum SVG {
|
||||
@@ -49,6 +49,7 @@ public enum SVG {
|
||||
CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"),
|
||||
CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v3H7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"),
|
||||
CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"),
|
||||
DATE_RANGE("M7.2875 13.7125Q7 13.425 7 13t0.2875 -0.7125Q7.575 12 8 12t0.7125 0.2875Q9 12.575 9 13t-0.2875 0.7125Q8.425 14 8 14t-0.7125 -0.2875zm4 0Q11 13.425 11 13t0.2875 -0.7125Q11.575 12 12 12t0.7125 0.2875Q13 12.575 13 13t-0.2875 0.7125Q12.425 14 12 14t-0.7125 -0.2875zm4 0Q15 13.425 15 13t0.2875 -0.7125Q15.575 12 16 12t0.7125 0.2875Q17 12.575 17 13t-0.2875 0.7125Q16.425 14 16 14t-0.7125 -0.2875zM5 22q-0.825 0 -1.4125 -0.5875T3 20l0 -14q0 -0.825 0.5875 -1.4125T5 4l1 0l0 -2l2 0l0 2l8 0l0 -2l2 0l0 2l1 0q0.825 0 1.4125 0.5875T21 6l0 14q0 0.825 -0.5875 1.4125T19 22L5 22zm0 -2l14 0l0 -10L5 10l0 10zm0 -12l14 0l0 -2L5 6l0 2zm0 0l0 -2 0 2z"),
|
||||
DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"),
|
||||
DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"),
|
||||
DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"),
|
||||
@@ -56,6 +57,7 @@ public enum SVG {
|
||||
DOWNLOAD("M12 16 7 11 8.4 9.55 11 12.15V4H13V12.15L15.6 9.55 17 11 12 16ZM6 20Q5.175 20 4.5875 19.4125T4 18V15H6V18H18V15H20V18Q20 18.825 19.4125 19.4125T18 20H6Z"),
|
||||
DRESSER("M4 21V5Q4 4.175 4.5875 3.5875T6 3H18Q18.825 3 19.4125 3.5875T20 5V21H18V19H6V21H4ZM6 11H11V5H6V11ZM13 7H18V5H13V7ZM13 11H18V9H13V11ZM10 16H14V14H10V16ZM6 13V17H18V13H6ZM6 13V17 13Z"),
|
||||
EDIT("M5 19H6.425L16.2 9.225 14.775 7.8 5 17.575V19ZM3 21V16.75L16.2 3.575Q16.5 3.3 16.8625 3.15T17.625 3Q18.025 3 18.4 3.15T19.05 3.6L20.425 5Q20.725 5.275 20.8625 5.65T21 6.4Q21 6.8 20.8625 7.1625T20.425 7.825L7.25 21H3ZM19 6.4 17.6 5 19 6.4ZM15.475 8.525 14.775 7.8 16.2 9.225 15.475 8.525Z"),
|
||||
EDIT_CALENDAR("M5 22q-0.825 0 -1.4125 -0.5875T3 20l0 -14q0 -0.825 0.5875 -1.4125T5 4l1 0l0 -2l2 0l0 2l8 0l0 -2l2 0l0 2l1 0q0.825 0 1.4125 0.5875T21 6l0 5l-2 0l0 -1L5 10l0 10l7 0l0 2L5 22zm0 -14l14 0l0 -2L5 6l0 2zm0 0l0 -2 0 2zM14 22l0 -3.075l5.525 -5.5q0.225 -0.225 0.5 -0.325t0.55 -0.1q0.3 0 0.575 0.1125t0.5 0.3375l0.925 0.925q0.2 0.225 0.3125 0.5t0.1125 0.55q0 0.275 -0.1 0.5625T22.575 16.5L17.075 22L14 22zm7.5 -6.575 -0.925 -0.925 0.925 0.925zM15.5 20.5l0.95 0l3.025 -3.05 -0.45 -0.475 -0.475 -0.45 -3.05 3.025l0 0.95zm3.525 -3.525 -0.475 -0.45 0.925 0.925 -0.45 -0.475z"),
|
||||
ERROR("M12 17Q12.425 17 12.7125 16.7125T13 16Q13 15.575 12.7125 15.2875T12 15Q11.575 15 11.2875 15.2875T11 16Q11 16.425 11.2875 16.7125T12 17ZM11 13H13V7H11V13ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
|
||||
EXPLORE("M12 12Zm0 8q-3.325 0-5.6625-2.3375T4 12Q4 8.675 6.3375 6.3375T12 4q3.325-0 5.6625 2.3375T20 12q0 3.325-2.3375 5.6625T12 20Zm0 2q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22Zm0-8.5q.625 0 1.0625-.4375T13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5Zm-4.5 3 2-7 7-2-2 7-7 2Z"),
|
||||
EXTENSION("M8.8 21H5Q4.175 21 3.5875 20.4125T3 19V15.2Q4.2 15.2 5.1 14.4375T6 12.5Q6 11.325 5.1 10.5625T3 9.8V6Q3 5.175 3.5875 4.5875T5 4H9Q9 2.95 9.725 2.225T11.5 1.5Q12.55 1.5 13.275 2.225T14 4H18Q18.825 4 19.4125 4.5875T20 6V10Q21.05 10 21.775 10.725T22.5 12.5Q22.5 13.55 21.775 14.275T20 15V19Q20 19.825 19.4125 20.4125T18 21H14.2Q14.2 19.75 13.4125 18.875T11.5 18Q10.375 18 9.5875 18.875T8.8 21ZM5 19H7.125Q7.725 17.35 9.05 16.675T11.5 16Q12.625 16 13.95 16.675T15.875 19H18V13H20Q20.2 13 20.35 12.85T20.5 12.5Q20.5 12.3 20.35 12.15T20 12H18V6H12V4Q12 3.8 11.85 3.65T11.5 3.5Q11.3 3.5 11.15 3.65T11 4V6H5V8.2Q6.35 8.7 7.175 9.875T8 12.5Q8 13.925 7.175 15.1T5 16.8V19ZM11.5 12.5Z"),
|
||||
@@ -109,6 +111,7 @@ public enum SVG {
|
||||
SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"),
|
||||
SCHEMA_FILL("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Z"),
|
||||
SCREENSHOT_MONITOR("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8ZM4 17H20V5H4V17ZM4 17V5 17Z"),
|
||||
SCREENSHOT_MONITOR_FILL("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8Z"),
|
||||
SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material
|
||||
SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"),
|
||||
SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"),
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXSpinner;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
|
||||
@@ -34,6 +35,7 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.nodes.Node;
|
||||
|
||||
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
|
||||
@@ -47,7 +49,9 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
|
||||
|
||||
setHeading(new Label(i18n("update.changelog")));
|
||||
Label heading = new Label(i18n("update.changelog"));
|
||||
heading.setPadding(new Insets(0, 0, 8, 0));
|
||||
setHeading(heading);
|
||||
setBody(new JFXSpinner());
|
||||
|
||||
String url = CHANGELOG_URL + remoteVersion.channel().channelName + ".html";
|
||||
@@ -62,16 +66,18 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
|
||||
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName())) {
|
||||
LOG.warning("Changelog not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
HTMLRenderer renderer = new HTMLRenderer(uri -> {
|
||||
LOG.info("Open link: " + uri);
|
||||
FXUtils.openLink(uri.toString());
|
||||
});
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName())) {
|
||||
LOG.warning("Changelog not found");
|
||||
Element list = new Element("ul");
|
||||
list.appendElement("li").text(i18n("update.changelog.empty"));
|
||||
return renderer.appendNode(list).mergeLineBreaks().render();
|
||||
}
|
||||
|
||||
do {
|
||||
if ("h1".equals(node.nodeName())) {
|
||||
String changelogVersion = node.attr("data-version");
|
||||
|
||||
@@ -40,7 +40,7 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.ClassTitle;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
|
||||
@@ -57,7 +57,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
|
||||
String property = System.getProperty("hmcl.offline.auth.restricted", "auto");
|
||||
|
||||
if ("false".equals(property)
|
||||
|| "auto".equals(property) && LocaleUtils.IS_CHINA_MAINLAND
|
||||
|| "auto".equals(property) && RegionUtils.isIsChinaMainlandMachine()
|
||||
|| SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
RESTRICTED.set(false);
|
||||
else
|
||||
|
||||
@@ -64,6 +64,33 @@ public class ImageContainer extends StackPane {
|
||||
this.getChildren().setAll(imageView);
|
||||
}
|
||||
|
||||
public ImageContainer(Image image, double maxWidth, double maxHeight) {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
|
||||
double width = maxWidth;
|
||||
double height = width / image.getWidth() * image.getHeight();
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height / image.getHeight() * image.getWidth();
|
||||
}
|
||||
|
||||
FXUtils.setLimitWidth(this, width);
|
||||
FXUtils.setLimitHeight(this, height);
|
||||
|
||||
imageView.setPreserveRatio(true);
|
||||
FXUtils.limitSize(imageView, width, height);
|
||||
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||
|
||||
clip.setWidth(width);
|
||||
clip.setHeight(height);
|
||||
updateCornerRadius(getCornerRadius());
|
||||
this.setClip(clip);
|
||||
|
||||
setImage(image);
|
||||
|
||||
this.getChildren().setAll(imageView);
|
||||
}
|
||||
|
||||
private void updateCornerRadius(double radius) {
|
||||
clip.setArcWidth(radius);
|
||||
clip.setArcHeight(radius);
|
||||
|
||||
@@ -85,11 +85,11 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
|
||||
setCancel(cancel);
|
||||
|
||||
btnCancel.setDisable(onCancel.getCancellationAction() == null);
|
||||
btnCancel.setDisable(!onCancel.isEnabled());
|
||||
btnCancel.setOnAction(e -> {
|
||||
if (executor != null)
|
||||
executor.cancel();
|
||||
onCancel.getCancellationAction().accept(this);
|
||||
onCancel.accept(this);
|
||||
});
|
||||
|
||||
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
|
||||
|
||||
@@ -19,10 +19,13 @@ package org.jackhuang.hmcl.ui.decorator;
|
||||
|
||||
import com.jfoenix.controls.JFXSnackbar;
|
||||
import com.jfoenix.controls.JFXSnackbarLayout;
|
||||
import com.jfoenix.skins.JFXDatePickerContent;
|
||||
import com.jfoenix.skins.JFXDatePickerSkin;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
@@ -61,6 +64,7 @@ import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Launcher;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.DialogUtils;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -78,6 +82,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Stack;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
|
||||
/// Coordinates the main window's visual tree, navigation, dialogs, and native window behavior.
|
||||
@@ -129,6 +137,29 @@ public final class Decorator {
|
||||
/// The navigation stack rendered in the main window.
|
||||
private final Navigator navigator = new Navigator();
|
||||
|
||||
/// Reflective access to system decoration, or `null` on unsupported runtimes.
|
||||
private final @Nullable NativeWindowDecoration nativeDecoration = NativeWindowDecoration.create();
|
||||
|
||||
/// Whether the attached stage delegates window decoration and gestures to the platform.
|
||||
private boolean nativeDecorationEnabled;
|
||||
|
||||
/// Installs application-specific identity and lifecycle integration on replacement stages before attachment.
|
||||
private final Consumer<Stage> stageInitializer;
|
||||
|
||||
/// Whether an appearance change has already queued a window-style check.
|
||||
private boolean windowStyleUpdateQueued;
|
||||
|
||||
/// Coalesces appearance changes so stage replacement runs after theme bindings have updated.
|
||||
private final ChangeListener<Boolean> windowStyleListener = (observable, oldValue, newValue) -> {
|
||||
if (!windowStyleUpdateQueued) {
|
||||
windowStyleUpdateQueued = true;
|
||||
Platform.runLater(() -> {
|
||||
windowStyleUpdateQueued = false;
|
||||
updateWindowStyle();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/// The clipped main-window content hosted inside [#shadowContainer].
|
||||
private final MainWindowPane mainWindowPane;
|
||||
|
||||
@@ -141,15 +172,18 @@ public final class Decorator {
|
||||
/// Whether the navigation close action should use the home icon.
|
||||
private final BooleanProperty showCloseAsHome = new SimpleBooleanProperty(this, "showCloseAsHome");
|
||||
|
||||
/// The width of the visible window content, excluding custom decoration insets.
|
||||
/// The width of the visible window content, excluding native borders and custom shadow padding.
|
||||
private final DoubleProperty contentWidth = new SimpleDoubleProperty(this, "contentWidth");
|
||||
|
||||
/// The height of the visible window content, excluding custom decoration insets.
|
||||
/// The height of the visible window content, excluding native borders and custom shadow padding.
|
||||
private final DoubleProperty contentHeight = new SimpleDoubleProperty(this, "contentHeight");
|
||||
|
||||
/// The stage currently controlled by this decorator, or `null` while detached.
|
||||
private @Nullable Stage stage;
|
||||
|
||||
/// Tracks the attached stage's content geometry, or `null` while detached.
|
||||
private @Nullable WindowBounds windowBounds;
|
||||
|
||||
/// The title-bar state supplied by the current page, or `null` before navigation is initialized.
|
||||
private final ObjectProperty<DecoratorPage.@Nullable State> state = new SimpleObjectProperty<>(this, "state");
|
||||
|
||||
@@ -198,49 +232,7 @@ public final class Decorator {
|
||||
private final InvalidationListener stageDecorationListener = observable -> {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage != null) {
|
||||
updateWindowDecoration(currentStage.isMaximized() || currentStage.isFullScreen());
|
||||
}
|
||||
};
|
||||
|
||||
/// Updates the changed visible-content bound and persists normal, non-iconified stage bounds.
|
||||
private final InvalidationListener stageBoundsListener = observable -> {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Insets insets = getWindowInsets();
|
||||
boolean saveBounds = !currentStage.isIconified()
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
|| !currentStage.isFullScreen() && !currentStage.isMaximized());
|
||||
|
||||
if (observable == currentStage.xProperty()) {
|
||||
if (saveBounds) {
|
||||
double currentContentX = currentStage.getX() + insets.getLeft();
|
||||
SettingsManager.state().setX(currentContentX / PRIMARY_SCREEN_BOUNDS.getWidth());
|
||||
}
|
||||
} else if (observable == currentStage.yProperty()) {
|
||||
if (saveBounds) {
|
||||
double currentContentY = currentStage.getY() + insets.getTop();
|
||||
SettingsManager.state().setY(currentContentY / PRIMARY_SCREEN_BOUNDS.getHeight());
|
||||
}
|
||||
} else if (observable == currentStage.widthProperty()) {
|
||||
double currentContentWidth = Math.max(
|
||||
MIN_CONTENT_WIDTH,
|
||||
currentStage.getWidth() - insets.getLeft() - insets.getRight());
|
||||
contentWidth.set(currentContentWidth);
|
||||
if (saveBounds) {
|
||||
SettingsManager.state().setWidth(currentContentWidth);
|
||||
}
|
||||
} else if (observable == currentStage.heightProperty()) {
|
||||
double currentContentHeight = Math.max(
|
||||
MIN_CONTENT_HEIGHT,
|
||||
currentStage.getHeight() - insets.getTop() - insets.getBottom());
|
||||
contentHeight.set(currentContentHeight);
|
||||
if (saveBounds) {
|
||||
SettingsManager.state().setHeight(currentContentHeight);
|
||||
}
|
||||
updateWindowDecoration(WindowState.isMaximized(currentStage) || currentStage.isFullScreen());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,9 +243,13 @@ public final class Decorator {
|
||||
///
|
||||
/// The decorator initially enables its custom window shadow. No scene is created until
|
||||
/// [#attachStage(Stage)] is called or [#getRoot()] is otherwise installed in a scene.
|
||||
/// Appearance changes replace the attached stage when required, preserving its retained scene and window state.
|
||||
///
|
||||
/// @param mainPage the permanent root page of the navigation stack
|
||||
public Decorator(Node mainPage) {
|
||||
/// @param stageInitializer configures each replacement stage on the JavaFX application thread before attachment;
|
||||
/// it must not show the stage and is not called for stages supplied to [#attachStage(Stage)]
|
||||
public Decorator(Node mainPage, Consumer<Stage> stageInitializer) {
|
||||
this.stageInitializer = Objects.requireNonNull(stageInitializer);
|
||||
navigator.setOnNavigated(this::onNavigated);
|
||||
|
||||
mainWindowPane = new MainWindowPane(this);
|
||||
@@ -285,21 +281,21 @@ public final class Decorator {
|
||||
return root;
|
||||
}
|
||||
|
||||
/// Returns the insets reserved outside the main-window content.
|
||||
/// Returns the space reserved inside the scene for custom window shadows.
|
||||
///
|
||||
/// @return the root's shadow insets for a normal window, or [Insets#EMPTY] while maximized or full-screen
|
||||
/// @return the custom shadow insets, or [Insets#EMPTY] with native decoration, supported maximization, or full-screen
|
||||
public Insets getWindowInsets() {
|
||||
return root.getPadding();
|
||||
}
|
||||
|
||||
/// Returns the visible window-content width property, excluding custom decoration insets.
|
||||
/// Returns the visible window-content width property, excluding native borders and custom shadow padding.
|
||||
///
|
||||
/// @return the current content-width property
|
||||
public ReadOnlyDoubleProperty contentWidthProperty() {
|
||||
return contentWidth;
|
||||
}
|
||||
|
||||
/// Returns the visible window-content height property, excluding custom decoration insets.
|
||||
/// Returns the visible window-content height property, excluding native borders and custom shadow padding.
|
||||
///
|
||||
/// @return the current content-height property
|
||||
public ReadOnlyDoubleProperty contentHeightProperty() {
|
||||
@@ -316,11 +312,12 @@ public final class Decorator {
|
||||
|
||||
/// Updates the shadow, outer insets, and content corner shape for an edge-to-edge window state.
|
||||
///
|
||||
/// @param edgeToEdge whether the attached stage is maximized or full-screen
|
||||
/// @param edgeToEdge whether the attached stage is maximized under [WindowState]'s policy or full-screen
|
||||
private void updateWindowDecoration(boolean edgeToEdge) {
|
||||
root.setPadding(edgeToEdge ? Insets.EMPTY : SHADOW_INSETS);
|
||||
shadowContainer.setEffect(edgeToEdge ? null : windowShadow);
|
||||
mainWindowPane.setWindowEdgeToEdge(edgeToEdge);
|
||||
boolean customDecoration = !nativeDecorationEnabled && !edgeToEdge;
|
||||
root.setPadding(customDecoration ? SHADOW_INSETS : Insets.EMPTY);
|
||||
shadowContainer.setEffect(customDecoration ? windowShadow : null);
|
||||
mainWindowPane.setWindowCornersRounded(customDecoration);
|
||||
}
|
||||
|
||||
/// Returns the pane on which application dialogs are stacked.
|
||||
@@ -405,7 +402,14 @@ public final class Decorator {
|
||||
///
|
||||
/// @param node the drag-enabled node
|
||||
public void capableDraggingWindow(Node node) {
|
||||
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> allowMove = true);
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setDraggable(node, true);
|
||||
}
|
||||
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
|
||||
if (!nativeDecorationEnabled) {
|
||||
allowMove = true;
|
||||
}
|
||||
});
|
||||
node.addEventHandler(MouseEvent.MOUSE_EXITED, event -> {
|
||||
if (!dragging) {
|
||||
allowMove = false;
|
||||
@@ -413,6 +417,19 @@ public final class Decorator {
|
||||
});
|
||||
}
|
||||
|
||||
/// Configures window dragging through an overlay while retaining ordinary mouse picking.
|
||||
///
|
||||
/// Native decoration permits dragging only over underlying draggable header areas. Custom decoration
|
||||
/// permits dragging from the overlay itself. Interactive content must be excluded with [#forbidDraggingWindow(Node)].
|
||||
///
|
||||
/// @param overlay the overlay above the window content and title bar
|
||||
public void capableDraggingOverlay(Node overlay) {
|
||||
capableDraggingWindow(overlay);
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setDragTransparent(overlay);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers `node` as the title-bar area used to restore and maximize the attached stage.
|
||||
///
|
||||
/// @param node the title-bar node
|
||||
@@ -425,9 +442,14 @@ public final class Decorator {
|
||||
///
|
||||
/// @param node the node that must consume drag eligibility
|
||||
public void forbidDraggingWindow(Node node) {
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setDraggable(node, false);
|
||||
}
|
||||
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
|
||||
allowMove = false;
|
||||
event.consume();
|
||||
if (!nativeDecorationEnabled) {
|
||||
allowMove = false;
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -436,14 +458,15 @@ public final class Decorator {
|
||||
/// @param event the title-bar click event
|
||||
private void onTitleBarDoubleClick(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
if (nativeDecorationEnabled
|
||||
|| currentStage == null
|
||||
|| !WindowState.supportsMaximization(currentStage)
|
||||
|| event.getButton() != MouseButton.PRIMARY
|
||||
|| event.getClickCount() != 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentStage.setMaximized(!currentStage.isMaximized());
|
||||
currentStage.setMaximized(!WindowState.isMaximized(currentStage));
|
||||
event.consume();
|
||||
}
|
||||
|
||||
@@ -452,7 +475,7 @@ public final class Decorator {
|
||||
/// @param event the title-bar drag event
|
||||
private void onTitleBarDragged(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null || dragging || !currentStage.isMaximized()) {
|
||||
if (nativeDecorationEnabled || currentStage == null || dragging || !WindowState.isMaximized(currentStage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -531,10 +554,10 @@ public final class Decorator {
|
||||
/// @param event the pointer movement event
|
||||
private void onMouseMoved(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null
|
||||
if (nativeDecorationEnabled || currentStage == null
|
||||
|| currentStage.isIconified()
|
||||
|| currentStage.isFullScreen()
|
||||
|| currentStage.isMaximized()
|
||||
|| WindowState.isMaximized(currentStage)
|
||||
|| !currentStage.isResizable()) {
|
||||
root.setCursor(Cursor.DEFAULT);
|
||||
return;
|
||||
@@ -597,10 +620,10 @@ public final class Decorator {
|
||||
/// @param event the primary-button drag event
|
||||
private void onMouseDragged(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null
|
||||
if (nativeDecorationEnabled || currentStage == null
|
||||
|| currentStage.isIconified()
|
||||
|| currentStage.isFullScreen()
|
||||
|| currentStage.isMaximized()
|
||||
|| WindowState.isMaximized(currentStage)
|
||||
|| !event.isPrimaryButtonDown()
|
||||
|| event.isStillSincePress()) {
|
||||
return;
|
||||
@@ -673,13 +696,12 @@ public final class Decorator {
|
||||
return root.getScene();
|
||||
}
|
||||
|
||||
/// Applies the persisted content bounds and current decoration insets to `targetStage`.
|
||||
/// Returns the persisted normal content rectangle without native borders or custom shadow padding.
|
||||
///
|
||||
/// An off-screen persisted position is replaced with a position centered on the primary screen.
|
||||
///
|
||||
/// @param targetStage the stage receiving the initial outer bounds and minimum dimensions
|
||||
private void initializeStageBounds(Stage targetStage) {
|
||||
Insets insets = getWindowInsets();
|
||||
/// @return the initial content bounds in JavaFX screen coordinates
|
||||
private Rectangle2D initialContentBounds() {
|
||||
double initialContentWidth = Math.max(MIN_CONTENT_WIDTH, SettingsManager.state().getWidth());
|
||||
double initialContentHeight = Math.max(MIN_CONTENT_HEIGHT, SettingsManager.state().getHeight());
|
||||
double initialContentX = SettingsManager.state().getX() * PRIMARY_SCREEN_BOUNDS.getWidth();
|
||||
@@ -702,14 +724,68 @@ public final class Decorator {
|
||||
initialContentY = (PRIMARY_SCREEN_BOUNDS.getHeight() - initialContentHeight) / 2;
|
||||
}
|
||||
|
||||
targetStage.setX(initialContentX - insets.getLeft());
|
||||
targetStage.setY(initialContentY - insets.getTop());
|
||||
targetStage.setWidth(initialContentWidth + insets.getLeft() + insets.getRight());
|
||||
targetStage.setHeight(initialContentHeight + insets.getTop() + insets.getBottom());
|
||||
targetStage.setMinWidth(MIN_CONTENT_WIDTH + insets.getLeft() + insets.getRight());
|
||||
targetStage.setMinHeight(MIN_CONTENT_HEIGHT + insets.getTop() + insets.getBottom());
|
||||
contentWidth.set(initialContentWidth);
|
||||
contentHeight.set(initialContentHeight);
|
||||
return new Rectangle2D(initialContentX, initialContentY, initialContentWidth, initialContentHeight);
|
||||
}
|
||||
|
||||
/// Persists a normal content rectangle independently of the window's decoration style.
|
||||
///
|
||||
/// @param bounds the content bounds in JavaFX screen coordinates
|
||||
private void saveContentBounds(Rectangle2D bounds) {
|
||||
SettingsManager.state().setX(bounds.getMinX() / PRIMARY_SCREEN_BOUNDS.getWidth());
|
||||
SettingsManager.state().setY(bounds.getMinY() / PRIMARY_SCREEN_BOUNDS.getHeight());
|
||||
SettingsManager.state().setWidth(bounds.getWidth());
|
||||
SettingsManager.state().setHeight(bounds.getHeight());
|
||||
}
|
||||
|
||||
/// Returns the preferred style for the configured native-decoration policy and current theme.
|
||||
///
|
||||
/// @return the system style when available and permitted by the policy; otherwise the custom transparent style
|
||||
private StageStyle preferredStageStyle() {
|
||||
return nativeDecoration != null && nativeDecoration.isPreferred(
|
||||
Themes.windowTransparentProperty().get(), Themes.darkModeProperty().get())
|
||||
? nativeDecoration.style : StageStyle.TRANSPARENT;
|
||||
}
|
||||
|
||||
/// Replaces the attached stage when transparency or theme brightness requires a different decoration style.
|
||||
///
|
||||
/// Preserves the scene, navigation, dialogs, normal bounds, supported window states, and focus owner.
|
||||
/// Hidden windows remain hidden. Does nothing while detached or when the current style is still suitable.
|
||||
/// Must run on the JavaFX application thread after theme bindings have updated.
|
||||
private void updateWindowStyle() {
|
||||
FXUtils.checkFxUserThread();
|
||||
@Nullable Stage previousStage = stage;
|
||||
if (previousStage == null || previousStage.getStyle() == preferredStageStyle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean showing = previousStage.isShowing();
|
||||
boolean maximized = WindowState.isMaximized(previousStage);
|
||||
boolean fullScreen = previousStage.isFullScreen();
|
||||
boolean iconified = previousStage.isIconified();
|
||||
@Nullable Node focusOwner = previousStage.getScene().getFocusOwner();
|
||||
|
||||
Stage replacement = new Stage();
|
||||
stageInitializer.accept(replacement);
|
||||
replacement.setTitle(previousStage.getTitle());
|
||||
replacement.getIcons().setAll(previousStage.getIcons());
|
||||
replacement.setOnCloseRequest(previousStage.getOnCloseRequest());
|
||||
replacement.setResizable(previousStage.isResizable());
|
||||
replacement.setAlwaysOnTop(previousStage.isAlwaysOnTop());
|
||||
replacement.setFullScreenExitHint(previousStage.getFullScreenExitHint());
|
||||
replacement.setFullScreenExitKeyCombination(previousStage.getFullScreenExitKeyCombination());
|
||||
|
||||
detachStage();
|
||||
previousStage.hide();
|
||||
attachStage(replacement);
|
||||
replacement.setMaximized(maximized && WindowState.supportsMaximization(replacement));
|
||||
replacement.setFullScreen(fullScreen);
|
||||
replacement.setIconified(iconified);
|
||||
if (showing) {
|
||||
replacement.show();
|
||||
}
|
||||
if (focusOwner != null) {
|
||||
focusOwner.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches the retained scene and native window behavior to `newStage`.
|
||||
@@ -717,23 +793,40 @@ public final class Decorator {
|
||||
/// If the root has no scene, this method reuses `newStage`'s scene and replaces its root, or creates a
|
||||
/// transparent scene when the stage has none. If the retained scene belongs to another stage, it is detached
|
||||
/// from that stage before being installed on `newStage`. A newly attached stage receives the persisted normal
|
||||
/// content bounds and minimum size adjusted for the normal decoration insets. The stage and retained scene use
|
||||
/// transparent styles required by the custom decoration. Any active window animation is cancelled and reset.
|
||||
/// When window animations are enabled, each subsequent showing starts the opening animation. This method does
|
||||
/// not show the stage.
|
||||
/// content bounds. Native frame offsets and minimum size are resolved when the stage is shown.
|
||||
/// The native-decoration policy selects the system or custom transparent decoration. Automatic mode uses
|
||||
/// system decoration for supported opaque windows, except for dark windows on Windows; explicit enablement
|
||||
/// bypasses the brightness exclusion. Transparent windows always use custom decoration.
|
||||
/// Any active window animation is cancelled and reset. Custom window
|
||||
/// animations run only with custom decoration. Window-style listeners remain active until [#detachStage()].
|
||||
/// On macOS, native application appearance updates are installed once per stage and retained across detachment.
|
||||
/// This method does not show the stage or invoke the replacement-stage initializer.
|
||||
///
|
||||
/// @param newStage the stage to attach, which must be accessed on the JavaFX application thread
|
||||
/// @return the scene installed on `newStage`
|
||||
/// @throws IllegalStateException if `newStage` has already been shown with a non-transparent style, or if the
|
||||
/// @throws IllegalStateException if `newStage` has already been shown with a different style, or if the
|
||||
/// retained root or scene cannot be transferred from its current owner
|
||||
public Scene attachStage(Stage newStage) {
|
||||
FXUtils.checkFxUserThread();
|
||||
stopWindowAnimation();
|
||||
|
||||
if (newStage.getStyle() != StageStyle.TRANSPARENT) {
|
||||
newStage.initStyle(StageStyle.TRANSPARENT);
|
||||
if (stage != null && stage != newStage) {
|
||||
detachStage();
|
||||
}
|
||||
|
||||
StageStyle style = preferredStageStyle();
|
||||
if (newStage.getStyle() != style) {
|
||||
newStage.initStyle(style);
|
||||
}
|
||||
nativeDecorationEnabled = nativeDecoration != null && style == nativeDecoration.style;
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setContent(null);
|
||||
if (nativeDecorationEnabled) {
|
||||
nativeDecoration.configureStage(newStage);
|
||||
}
|
||||
}
|
||||
mainWindowPane.setNativeDecoration(nativeDecorationEnabled ? nativeDecoration : null);
|
||||
|
||||
@Nullable Scene retainedScene = root.getScene();
|
||||
Scene scene;
|
||||
if (retainedScene == null) {
|
||||
@@ -759,37 +852,37 @@ public final class Decorator {
|
||||
}
|
||||
}
|
||||
|
||||
scene.setFill(Color.TRANSPARENT);
|
||||
if (nativeDecorationEnabled) {
|
||||
scene.fillProperty().bind(Themes.colorSchemeProperty().getSurfaceContainer());
|
||||
} else {
|
||||
scene.fillProperty().unbind();
|
||||
scene.setFill(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
if (newStage.getScene() != scene) {
|
||||
newStage.setScene(scene);
|
||||
}
|
||||
|
||||
if (stage != newStage) {
|
||||
playRestoreMinimizeAnimation = false;
|
||||
if (stage != null) {
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
|
||||
stage.iconifiedProperty().removeListener(iconifiedListener);
|
||||
stage.maximizedProperty().removeListener(stageDecorationListener);
|
||||
stage.fullScreenProperty().removeListener(stageDecorationListener);
|
||||
stage.xProperty().removeListener(stageBoundsListener);
|
||||
stage.yProperty().removeListener(stageBoundsListener);
|
||||
stage.widthProperty().removeListener(stageBoundsListener);
|
||||
stage.heightProperty().removeListener(stageBoundsListener);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Themes.applyNativeDarkMode(newStage);
|
||||
}
|
||||
|
||||
playRestoreMinimizeAnimation = false;
|
||||
updateWindowDecoration(false);
|
||||
initializeStageBounds(newStage);
|
||||
stage = newStage;
|
||||
updateWindowDecoration(newStage.isMaximized() || newStage.isFullScreen());
|
||||
newStage.addEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
|
||||
newStage.iconifiedProperty().addListener(iconifiedListener);
|
||||
newStage.maximizedProperty().addListener(stageDecorationListener);
|
||||
newStage.fullScreenProperty().addListener(stageDecorationListener);
|
||||
newStage.xProperty().addListener(stageBoundsListener);
|
||||
newStage.yProperty().addListener(stageBoundsListener);
|
||||
newStage.widthProperty().addListener(stageBoundsListener);
|
||||
newStage.heightProperty().addListener(stageBoundsListener);
|
||||
windowBounds = new WindowBounds(newStage, root, initialContentBounds(),
|
||||
MIN_CONTENT_WIDTH, MIN_CONTENT_HEIGHT, this::saveContentBounds);
|
||||
contentWidth.bind(windowBounds.contentWidthProperty());
|
||||
contentHeight.bind(windowBounds.contentHeightProperty());
|
||||
updateWindowDecoration(WindowState.isMaximized(newStage) || newStage.isFullScreen());
|
||||
Themes.windowTransparentProperty().addListener(windowStyleListener);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Themes.darkModeProperty().addListener(windowStyleListener);
|
||||
}
|
||||
}
|
||||
root.setCursor(Cursor.DEFAULT);
|
||||
dragging = false;
|
||||
@@ -801,20 +894,27 @@ public final class Decorator {
|
||||
///
|
||||
/// The root, scene, navigation state, dialogs, and snackbar remain owned by this decorator and may subsequently
|
||||
/// be attached to another stage. Calling this method while detached has no effect beyond resetting root transforms.
|
||||
/// Removes window-style listeners; a queued style check does nothing unless another stage has been attached.
|
||||
/// Hiding a stage that will later be shown again does not require detachment.
|
||||
public void detachStage() {
|
||||
FXUtils.checkFxUserThread();
|
||||
stopWindowAnimation();
|
||||
|
||||
if (windowBounds != null) {
|
||||
windowBounds.close();
|
||||
contentWidth.unbind();
|
||||
contentHeight.unbind();
|
||||
windowBounds = null;
|
||||
}
|
||||
if (stage != null) {
|
||||
Themes.windowTransparentProperty().removeListener(windowStyleListener);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Themes.darkModeProperty().removeListener(windowStyleListener);
|
||||
}
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
|
||||
stage.iconifiedProperty().removeListener(iconifiedListener);
|
||||
stage.maximizedProperty().removeListener(stageDecorationListener);
|
||||
stage.fullScreenProperty().removeListener(stageDecorationListener);
|
||||
stage.xProperty().removeListener(stageBoundsListener);
|
||||
stage.yProperty().removeListener(stageBoundsListener);
|
||||
stage.widthProperty().removeListener(stageBoundsListener);
|
||||
stage.heightProperty().removeListener(stageBoundsListener);
|
||||
if (stage.getScene() == root.getScene()) {
|
||||
stage.setScene(null);
|
||||
}
|
||||
@@ -880,7 +980,7 @@ public final class Decorator {
|
||||
/// Calling this method replaces any active minimize, restore, close, or earlier opening animation. If window
|
||||
/// animations are disabled, it restores the stable root transform without starting an animation.
|
||||
private void playOpenAnimation() {
|
||||
if (!AnimationUtils.playWindowAnimation()) {
|
||||
if (nativeDecorationEnabled || !AnimationUtils.playWindowAnimation()) {
|
||||
stopWindowAnimation();
|
||||
Controllers.trimHeap();
|
||||
return;
|
||||
@@ -915,7 +1015,7 @@ public final class Decorator {
|
||||
return;
|
||||
}
|
||||
|
||||
if (AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
|
||||
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
|
||||
@@ -946,7 +1046,7 @@ public final class Decorator {
|
||||
|
||||
/// Closes the application, using the configured window animation when enabled.
|
||||
void closeWindow() {
|
||||
if (AnimationUtils.playWindowAnimation()) {
|
||||
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation()) {
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
|
||||
@@ -1033,13 +1133,23 @@ public final class Decorator {
|
||||
}
|
||||
|
||||
Node newTarget;
|
||||
@Nullable JFXDialogPane currentDialogPane =
|
||||
(JFXDialogPane) getDialogContainer().getProperties()
|
||||
.get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
|
||||
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
|
||||
newTarget = currentDialogPane.peek().orElseThrow();
|
||||
} else {
|
||||
newTarget = navigator.getCurrentPage();
|
||||
{
|
||||
//noinspection unchecked
|
||||
@Nullable Stack<JFXDatePickerContent> stack =
|
||||
(Stack<JFXDatePickerContent>) getDialogContainer().getProperties()
|
||||
.get(JFXDatePickerSkin.PROPERTY_DATE_PICKER_CONTENTS);
|
||||
if (stack != null && !stack.isEmpty()) {
|
||||
newTarget = stack.peek();
|
||||
} else {
|
||||
@Nullable JFXDialogPane currentDialogPane =
|
||||
(JFXDialogPane) getDialogContainer().getProperties()
|
||||
.get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
|
||||
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
|
||||
newTarget = currentDialogPane.peek().orElseThrow();
|
||||
} else {
|
||||
newTarget = navigator.getCurrentPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (@Nullable Node node = target; node != null; node = node.getParent()) {
|
||||
@@ -1074,6 +1184,11 @@ public final class Decorator {
|
||||
|
||||
/// Restores the root node's transform after an animated minimization.
|
||||
private void playRestoreAnimation() {
|
||||
if (nativeDecorationEnabled) {
|
||||
stopWindowAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(root.opacityProperty(), 0, Motion.EASE),
|
||||
|
||||
@@ -63,7 +63,7 @@ final class MainWindowPane extends StackPane {
|
||||
/// The decorator whose state and actions are represented by this pane.
|
||||
private final Decorator decorator;
|
||||
|
||||
/// The clip that rounds normal window corners and becomes square while the window fills the screen.
|
||||
/// The content clip, rounded only for normal windows with custom decoration.
|
||||
private final Rectangle clip = new Rectangle();
|
||||
|
||||
/// The frame containing the title bar and current navigation page.
|
||||
@@ -72,6 +72,9 @@ final class MainWindowPane extends StackPane {
|
||||
/// The title bar containing page navigation state.
|
||||
private final BorderPane titleBar;
|
||||
|
||||
/// The custom help, minimize, and close buttons shared by both decoration styles.
|
||||
private final HBox windowButtons;
|
||||
|
||||
/// The transition container used when the title-bar state changes.
|
||||
private final TransitionPane navBarPane;
|
||||
|
||||
@@ -112,14 +115,15 @@ final class MainWindowPane extends StackPane {
|
||||
center.getChildren().setAll(decorator.getNavigator());
|
||||
frame.setCenter(center);
|
||||
|
||||
HBox rightButtonsContainer = createWindowButtons();
|
||||
windowButtons = createWindowButtons();
|
||||
titleBar = new BorderPane();
|
||||
titleBar.setPickOnBounds(false);
|
||||
titleBar.getStyleClass().add("jfx-tool-bar");
|
||||
titleBar.setRight(rightButtonsContainer);
|
||||
titleBar.setRight(windowButtons);
|
||||
|
||||
navBarPane = new TransitionPane();
|
||||
titleBar.setCenter(navBarPane);
|
||||
decorator.capableDraggingWindow(navBarPane);
|
||||
frame.setTop(titleBar);
|
||||
|
||||
updateTitleBarBackground();
|
||||
@@ -136,11 +140,26 @@ final class MainWindowPane extends StackPane {
|
||||
getChildren().setAll(backgroundNode, frame);
|
||||
}
|
||||
|
||||
/// Updates the content-corner shape for an edge-to-edge window state.
|
||||
/// Moves the custom title bar into or out of the native dragging header.
|
||||
///
|
||||
/// @param edgeToEdge whether the attached window is maximized or full-screen
|
||||
void setWindowEdgeToEdge(boolean edgeToEdge) {
|
||||
double arc = edgeToEdge ? 0.0 : ARC;
|
||||
/// @param decoration the native header support, or `null` for custom decoration
|
||||
void setNativeDecoration(@Nullable NativeWindowDecoration decoration) {
|
||||
frame.setTop(null);
|
||||
if (decoration == null) {
|
||||
frame.setTop(titleBar);
|
||||
} else {
|
||||
decoration.setContent(titleBar);
|
||||
frame.setTop(decoration.headerBar);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables or disables custom rounding of the content corners.
|
||||
///
|
||||
/// Native decoration uses square content bounds so the platform can shape the window outline.
|
||||
///
|
||||
/// @param rounded whether to round the content corners
|
||||
void setWindowCornersRounded(boolean rounded) {
|
||||
double arc = rounded ? ARC : 0.0;
|
||||
clip.setArcWidth(arc);
|
||||
clip.setArcHeight(arc);
|
||||
}
|
||||
@@ -171,6 +190,7 @@ final class MainWindowPane extends StackPane {
|
||||
private HBox createWindowButtons() {
|
||||
HBox buttons = new HBox();
|
||||
buttons.setAlignment(Pos.TOP_RIGHT);
|
||||
decorator.capableDraggingWindow(buttons);
|
||||
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
|
||||
|
||||
JFXButton helpButton = new JFXButton();
|
||||
@@ -247,6 +267,7 @@ final class MainWindowPane extends StackPane {
|
||||
private Node createNavBar(DecoratorPage.State state) {
|
||||
HBox navBar = new HBox();
|
||||
navBar.setAlignment(Pos.CENTER_LEFT);
|
||||
decorator.capableDraggingWindow(navBar);
|
||||
|
||||
// Left navigation buttons
|
||||
if (state.backable()) {
|
||||
@@ -275,6 +296,7 @@ final class MainWindowPane extends StackPane {
|
||||
StackPane titleArea = new StackPane();
|
||||
titleArea.setAlignment(Pos.CENTER_LEFT);
|
||||
titleArea.setMinWidth(0);
|
||||
decorator.capableDraggingWindow(titleArea);
|
||||
HBox.setHgrow(titleArea, Priority.ALWAYS);
|
||||
if (state.titleNode() != null) {
|
||||
titleArea.getChildren().setAll(state.titleNode());
|
||||
@@ -284,6 +306,7 @@ final class MainWindowPane extends StackPane {
|
||||
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
|
||||
titleLabel.getStyleClass().add("jfx-decorator-title");
|
||||
titleLabel.setMinWidth(0);
|
||||
titleLabel.setMouseTransparent(true);
|
||||
titleArea.getChildren().setAll(titleLabel);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.decorator;
|
||||
|
||||
import javafx.application.ConditionalFeature;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Accesses the JavaFX 27 extended-window APIs without linking them on older runtimes.
|
||||
@NotNullByDefault
|
||||
final class NativeWindowDecoration {
|
||||
/// Marks nodes whose title-content descendants are already observed for native hit testing.
|
||||
private static final Object DRAG_CONFIGURED = new Object();
|
||||
|
||||
/// Whether the user explicitly enabled native decoration regardless of automatic platform or brightness exclusions.
|
||||
private final boolean forced;
|
||||
|
||||
/// The platform-supported extended stage style.
|
||||
final StageStyle style;
|
||||
|
||||
/// The header bar that supplies native dragging around the custom title content.
|
||||
final Region headerBar;
|
||||
|
||||
/// Assigns the header bar's center content.
|
||||
private final MethodHandle setCenter;
|
||||
|
||||
/// Assigns a node's native header hit-test behavior.
|
||||
private final MethodHandle setDragType;
|
||||
|
||||
/// Reads explicit native drag exclusions on title nodes.
|
||||
private final MethodHandle getDragType;
|
||||
|
||||
/// Sets the system button height to zero to hide the platform-provided buttons.
|
||||
private final MethodHandle setSystemButtonHeight;
|
||||
|
||||
/// Marks only the specified node as draggable, preserving child control interaction.
|
||||
private final Object draggable;
|
||||
|
||||
/// Excludes a node and its descendants from native dragging.
|
||||
private final Object notDraggable;
|
||||
|
||||
/// Ignores an overlay and its descendants when testing underlying native header areas.
|
||||
private final Object transparentSubtree;
|
||||
|
||||
/// Resolves all required public APIs and creates a detached header bar.
|
||||
///
|
||||
/// @param forced whether to bypass the automatic theme brightness exclusion
|
||||
/// @throws Throwable if a required API cannot be resolved or invoked
|
||||
private NativeWindowDecoration(boolean forced) throws Throwable {
|
||||
this.forced = forced;
|
||||
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
|
||||
style = (StageStyle) lookup.findStaticGetter(StageStyle.class, "EXTENDED", StageStyle.class).invokeExact();
|
||||
Class<?> headerClass = Class.forName("javafx.scene.layout.HeaderBar");
|
||||
Class<?> dragClass = Class.forName("javafx.scene.layout.HeaderDragType");
|
||||
// Adapt runtime-only types once so cached handles can be invoked with exact, statically known signatures.
|
||||
setCenter = lookup.findVirtual(headerClass, "setCenter", MethodType.methodType(void.class, Node.class))
|
||||
.asType(MethodType.methodType(void.class, Region.class, Node.class));
|
||||
setDragType = lookup.findStatic(headerClass, "setDragType", MethodType.methodType(void.class, Node.class, dragClass))
|
||||
.asType(MethodType.methodType(void.class, Node.class, Object.class));
|
||||
getDragType = lookup.findStatic(headerClass, "getDragType", MethodType.methodType(dragClass, Node.class))
|
||||
.asType(MethodType.methodType(Object.class, Node.class));
|
||||
setSystemButtonHeight = lookup.findStatic(headerClass, "setSystemButtonHeight",
|
||||
MethodType.methodType(void.class, Stage.class, double.class));
|
||||
draggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "DRAGGABLE", dragClass).invoke());
|
||||
notDraggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "NONE", dragClass).invoke());
|
||||
transparentSubtree = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "TRANSPARENT_SUBTREE", dragClass).invoke());
|
||||
headerBar = (Region) lookup.findConstructor(headerClass, MethodType.methodType(void.class)).invoke();
|
||||
}
|
||||
|
||||
/// Creates native decoration support according to the configured preference and runtime capabilities.
|
||||
///
|
||||
/// The `hmcl.nativeDecoration` system property takes precedence over `HMCL_NATIVE_DECORATION`.
|
||||
/// Values are case-insensitive and trimmed: `false` disables native decoration, `true` enables it on
|
||||
/// any supported platform, and `auto` enables it only on macOS or Windows 11 and later. Missing or
|
||||
/// unrecognized values use `auto`. All modes require JavaFX 27 or later and extended-window support.
|
||||
/// The preference is read when this method is called and retained by the returned instance.
|
||||
///
|
||||
/// @return the resolved support, or `null` when disabled by policy or unavailable on the runtime
|
||||
static @Nullable NativeWindowDecoration create() {
|
||||
@Nullable String preference = System.getProperty("hmcl.nativeDecoration", System.getenv("HMCL_NATIVE_DECORATION"));
|
||||
if (preference != null) {
|
||||
preference = preference.trim();
|
||||
}
|
||||
if ("false".equalsIgnoreCase(preference)) {
|
||||
return null;
|
||||
}
|
||||
boolean forced = "true".equalsIgnoreCase(preference);
|
||||
if (!forced && preference != null && !"auto".equalsIgnoreCase(preference)) {
|
||||
LOG.warning("Invalid native decoration preference: " + preference + "; using auto");
|
||||
}
|
||||
if (!forced && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS
|
||||
&& !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]);
|
||||
if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) {
|
||||
return null;
|
||||
}
|
||||
return new NativeWindowDecoration(forced);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Native window decoration is unavailable; using custom decoration", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether to use native decoration with the current transparency and theme brightness.
|
||||
///
|
||||
/// Transparent windows always use custom decoration. Automatic mode also excludes dark windows on Windows;
|
||||
/// explicitly enabling native decoration bypasses only that brightness exclusion.
|
||||
///
|
||||
/// @param transparent whether the theme requests window transparency
|
||||
/// @param dark whether the current theme is dark
|
||||
/// @return whether this instance permits native decoration for the supplied theme settings
|
||||
boolean isPreferred(boolean transparent, boolean dark) {
|
||||
return !transparent && (forced || !(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && dark));
|
||||
}
|
||||
|
||||
/// Sets or removes the header content; the caller must first detach it from any other parent.
|
||||
///
|
||||
/// Configures native dragging on non-interactive descendants and observes later child additions.
|
||||
/// Controls other than labels and nodes explicitly excluded from dragging retain ordinary input handling.
|
||||
///
|
||||
/// @param content the title-bar content, or `null` to detach it
|
||||
void setContent(@Nullable Node content) {
|
||||
try {
|
||||
if (content != null) {
|
||||
configureTitleDragging(content);
|
||||
}
|
||||
setCenter.invokeExact(headerBar, content);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot update native header content", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a node as draggable or explicitly non-draggable during native header hit testing.
|
||||
///
|
||||
/// Draggability does not extend to descendants, so interactive title content retains its behavior.
|
||||
///
|
||||
/// @param node the node to configure
|
||||
/// @param enabled whether the node itself may move the window
|
||||
void setDraggable(Node node, boolean enabled) {
|
||||
try {
|
||||
setDragType.invokeExact(node, enabled ? draggable : notDraggable);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot configure native header dragging", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets native header hit testing pass through an overlay and its descendants.
|
||||
///
|
||||
/// Ordinary mouse picking is unchanged. This does not create draggable areas outside the underlying
|
||||
/// header. Descendants excluded with [#setDraggable(Node, boolean)] block native dragging instead.
|
||||
///
|
||||
/// @param overlay the overlay above the header
|
||||
void setDragTransparent(Node overlay) {
|
||||
try {
|
||||
setDragType.invokeExact(overlay, transparentSubtree);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot configure native header overlay", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures native hit testing for title content, including subsequently added descendants.
|
||||
///
|
||||
/// Containers and label graphics may move the window. Other controls and nodes explicitly excluded with
|
||||
/// [#setDraggable(Node, boolean)] retain ordinary input handling, including all of their descendants.
|
||||
///
|
||||
/// @param node the title content to configure
|
||||
private void configureTitleDragging(Node node) {
|
||||
if (node.getProperties().putIfAbsent(DRAG_CONFIGURED, Boolean.TRUE) != null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if ((Object) getDragType.invokeExact(node) == notDraggable) {
|
||||
return;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot read native header dragging", e);
|
||||
}
|
||||
if (node instanceof Control && !(node instanceof Label)) {
|
||||
setDraggable(node, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Native hit testing starts at the deepest picked node; marking only its parent is insufficient.
|
||||
setDraggable(node, true);
|
||||
if (node instanceof Parent parent) {
|
||||
parent.getChildrenUnmodifiable().addListener((ListChangeListener<Node>) change -> {
|
||||
while (change.next()) {
|
||||
for (Node child : change.getAddedSubList()) {
|
||||
configureTitleDragging(child);
|
||||
}
|
||||
}
|
||||
});
|
||||
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||
configureTitleDragging(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides platform-provided window buttons.
|
||||
///
|
||||
/// @param stage the extended stage to configure
|
||||
void configureStage(Stage stage) {
|
||||
try {
|
||||
setSystemButtonHeight.invokeExact(stage, 0.0);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot hide native window buttons", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.decorator;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.ReadOnlyDoubleProperty;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import javafx.stage.WindowEvent;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/// Tracks content bounds independently of the stage's native frame and the root's shadow padding.
|
||||
///
|
||||
/// All access must occur on the JavaFX application thread. The scene and root must remain attached to the stage
|
||||
/// until [#close()] is called. Normal bounds are sampled after layout, and once more before hiding or detaching.
|
||||
/// Maximized bounds under [WindowState]'s policy, full-screen bounds, and iconified bounds are not reported
|
||||
/// as normal bounds. Transparent macOS windows retain normal bounds despite native zoom notifications.
|
||||
/// Minimum sizes are configured separately after showing or a display-scale or resizability change;
|
||||
/// ordinary geometry tracking does not write window constraints.
|
||||
@NotNullByDefault
|
||||
final class WindowBounds implements AutoCloseable {
|
||||
/// The stage whose outer geometry is managed.
|
||||
private final Stage stage;
|
||||
|
||||
/// The scene providing client dimensions and offsets within the native window.
|
||||
private final Scene scene;
|
||||
|
||||
/// The root whose padding reserves space for custom shadows.
|
||||
private final Region root;
|
||||
|
||||
/// The padding used in the normal window state, even when maximized padding is empty.
|
||||
private final Insets normalInsets;
|
||||
|
||||
/// The minimum content width in JavaFX screen coordinates.
|
||||
private final double minWidth;
|
||||
|
||||
/// The minimum content height in JavaFX screen coordinates.
|
||||
private final double minHeight;
|
||||
|
||||
/// Receives normal content bounds in JavaFX screen coordinates.
|
||||
private final Consumer<Rectangle2D> saveBounds;
|
||||
|
||||
/// The latest normal content rectangle, retained through hiding and non-normal window states.
|
||||
private Rectangle2D normalBounds;
|
||||
|
||||
/// The content width excluding both native borders and custom shadow padding.
|
||||
private final ReadOnlyDoubleWrapper contentWidth = new ReadOnlyDoubleWrapper();
|
||||
|
||||
/// The content height excluding both native borders and custom shadow padding.
|
||||
private final ReadOnlyDoubleWrapper contentHeight = new ReadOnlyDoubleWrapper();
|
||||
|
||||
/// Whether the current show operation is still resolving native client offsets.
|
||||
private boolean restoringPosition;
|
||||
|
||||
/// Whether native geometry can be sampled, including during WINDOW_HIDING.
|
||||
private boolean shown;
|
||||
|
||||
/// Whether geometry must be sampled after the next layout pulse.
|
||||
private boolean dirty;
|
||||
|
||||
/// Whether the native minimum needs configuration once normal geometry has stopped changing between pulses.
|
||||
private boolean minimumSizePending;
|
||||
|
||||
/// Whether this tracker has released its listeners.
|
||||
private boolean closed;
|
||||
|
||||
/// Marks geometry for sampling after layout without assuming an order for stage and scene notifications.
|
||||
private final ChangeListener<Object> boundsListener = (observable, oldValue, newValue) -> invalidate();
|
||||
|
||||
/// Compensates for native offsets discovered while creating the window peer.
|
||||
private final InvalidationListener offsetListener = observable -> restorePosition();
|
||||
|
||||
/// Requests new native minimum dimensions when the display scale or resizability changes.
|
||||
private final ChangeListener<Object> minimumSizeListener = (observable, oldValue, newValue) -> invalidateMinimumSize();
|
||||
|
||||
/// Establishes the normal restore rectangle before the native peer becomes visible.
|
||||
private final EventHandler<WindowEvent> showingHandler = event -> restoreBounds();
|
||||
|
||||
/// Completes initial positioning and enables geometry sampling.
|
||||
private final EventHandler<WindowEvent> shownHandler = event -> {
|
||||
restorePosition();
|
||||
restoringPosition = false;
|
||||
shown = true;
|
||||
invalidateMinimumSize();
|
||||
};
|
||||
|
||||
/// Samples geometry before JavaFX clears the hidden scene's native offsets.
|
||||
private final EventHandler<WindowEvent> hidingHandler = event -> {
|
||||
updateBounds();
|
||||
shown = false;
|
||||
restoringPosition = false;
|
||||
};
|
||||
|
||||
/// Samples geometry and separately processes pending minimum-size configuration after layout.
|
||||
private final Runnable pulseListener = this::onPulse;
|
||||
|
||||
/// The properties observed for geometry, state, and padding changes.
|
||||
private final ObservableValue<?> @Unmodifiable [] observed;
|
||||
|
||||
/// The properties that can change the normal native frame dimensions.
|
||||
private final ObservableValue<?> @Unmodifiable [] minimumSizeObserved;
|
||||
|
||||
/// Initializes client sizing and installs listeners on an attached stage and scene.
|
||||
///
|
||||
/// The root's current padding must be its normal-state shadow padding. Initial bounds exclude that padding
|
||||
/// and native decoration. [Stage#sizeToScene()] lets JavaFX determine the required native outer size.
|
||||
///
|
||||
/// @param stage the stage with `root` installed as its scene root
|
||||
/// @param root the scene root, whose minimum and preferred sizes are managed by this tracker
|
||||
/// @param initialBounds the normal content rectangle to restore
|
||||
/// @param minWidth the minimum content width
|
||||
/// @param minHeight the minimum content height
|
||||
/// @param saveBounds the callback receiving sampled normal content bounds
|
||||
WindowBounds(Stage stage, Region root, Rectangle2D initialBounds,
|
||||
double minWidth, double minHeight, Consumer<Rectangle2D> saveBounds) {
|
||||
this.stage = stage;
|
||||
this.scene = Objects.requireNonNull(stage.getScene());
|
||||
this.root = root;
|
||||
this.normalInsets = root.getPadding();
|
||||
this.normalBounds = initialBounds;
|
||||
this.minWidth = minWidth;
|
||||
this.minHeight = minHeight;
|
||||
this.saveBounds = saveBounds;
|
||||
// Enforce minimum content size on the stage, independently of the current page's layout minimum.
|
||||
root.setMinSize(0, 0);
|
||||
contentWidth.set(initialBounds.getWidth());
|
||||
contentHeight.set(initialBounds.getHeight());
|
||||
observed = new ObservableValue<?>[] {
|
||||
stage.xProperty(), stage.yProperty(), stage.widthProperty(), stage.heightProperty(),
|
||||
stage.maximizedProperty(), stage.fullScreenProperty(), stage.iconifiedProperty(),
|
||||
scene.xProperty(), scene.yProperty(), scene.widthProperty(), scene.heightProperty(),
|
||||
root.paddingProperty()
|
||||
};
|
||||
for (ObservableValue<?> observable : observed) {
|
||||
observable.addListener(boundsListener);
|
||||
}
|
||||
scene.xProperty().addListener(offsetListener);
|
||||
scene.yProperty().addListener(offsetListener);
|
||||
minimumSizeObserved = new ObservableValue<?>[] {
|
||||
stage.outputScaleXProperty(), stage.outputScaleYProperty(), stage.resizableProperty()
|
||||
};
|
||||
for (ObservableValue<?> observable : minimumSizeObserved) {
|
||||
observable.addListener(minimumSizeListener);
|
||||
}
|
||||
scene.addPostLayoutPulseListener(pulseListener);
|
||||
stage.addEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
|
||||
stage.addEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
|
||||
stage.addEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
|
||||
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight());
|
||||
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom());
|
||||
restoreBounds();
|
||||
if (stage.isShowing()) {
|
||||
restoringPosition = false;
|
||||
shown = true;
|
||||
invalidateMinimumSize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the content width, excluding native borders and the root's current padding.
|
||||
ReadOnlyDoubleProperty contentWidthProperty() {
|
||||
return contentWidth.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Returns the content height, excluding native borders and the root's current padding.
|
||||
ReadOnlyDoubleProperty contentHeightProperty() {
|
||||
return contentHeight.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Requests one layout pulse to coalesce geometry notifications.
|
||||
private void invalidate() {
|
||||
dirty = true;
|
||||
Platform.requestNextPulse();
|
||||
}
|
||||
|
||||
/// Schedules native minimum-size configuration independently of ordinary geometry sampling.
|
||||
private void invalidateMinimumSize() {
|
||||
minimumSizePending = true;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
/// Samples dirty geometry, then configures pending minimums on a subsequent quiet layout pulse.
|
||||
private void onPulse() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (dirty) {
|
||||
updateBounds();
|
||||
if (minimumSizePending && isNormal()) {
|
||||
Platform.requestNextPulse();
|
||||
}
|
||||
} else if (minimumSizePending && isNormal()) {
|
||||
updateMinimumSize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the visible stage can supply normal window geometry.
|
||||
private boolean isNormal() {
|
||||
return shown && !restoringPosition && !stage.isIconified()
|
||||
&& !WindowState.isMaximized(stage) && !stage.isFullScreen();
|
||||
}
|
||||
|
||||
/// Requests the normal client size and starts compensating for native client offsets.
|
||||
private void restoreBounds() {
|
||||
restoringPosition = true;
|
||||
root.setPrefSize(normalBounds.getWidth() + normalInsets.getLeft() + normalInsets.getRight(),
|
||||
normalBounds.getHeight() + normalInsets.getTop() + normalInsets.getBottom());
|
||||
restorePosition();
|
||||
stage.sizeToScene();
|
||||
}
|
||||
|
||||
/// Aligns the content origin while the native peer is being initialized.
|
||||
private void restorePosition() {
|
||||
if (restoringPosition) {
|
||||
stage.setX(normalBounds.getMinX() - scene.getX() - normalInsets.getLeft());
|
||||
stage.setY(normalBounds.getMinY() - scene.getY() - normalInsets.getTop());
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates content dimensions and saves normal bounds when native geometry is available.
|
||||
private void updateBounds() {
|
||||
dirty = false;
|
||||
if (!shown || restoringPosition || stage.isIconified()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Insets insets = root.getPadding();
|
||||
double width = scene.getWidth() - insets.getLeft() - insets.getRight();
|
||||
double height = scene.getHeight() - insets.getTop() - insets.getBottom();
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
contentWidth.set(width);
|
||||
contentHeight.set(height);
|
||||
if (WindowState.isMaximized(stage) || stage.isFullScreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalBounds = new Rectangle2D(
|
||||
stage.getX() + scene.getX() + insets.getLeft(),
|
||||
stage.getY() + scene.getY() + insets.getTop(),
|
||||
Math.max(minWidth, width), Math.max(minHeight, height));
|
||||
saveBounds.accept(normalBounds);
|
||||
}
|
||||
|
||||
/// Measures the normal native frame and applies content minimums including frame and shadow padding.
|
||||
private void updateMinimumSize() {
|
||||
// Stage and Scene sizes arrive separately. Only measure after pending geometry notifications
|
||||
// have been processed, never from an individual size listener or an ordinary resize pulse.
|
||||
boolean hasFrame = stage.getStyle() != StageStyle.TRANSPARENT && stage.getStyle() != StageStyle.UNDECORATED;
|
||||
double frameWidth = hasFrame ? Math.max(0, stage.getWidth() - scene.getWidth()) : 0;
|
||||
double frameHeight = hasFrame ? Math.max(0, stage.getHeight() - scene.getHeight()) : 0;
|
||||
minimumSizePending = false;
|
||||
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight() + frameWidth);
|
||||
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom() + frameHeight);
|
||||
}
|
||||
|
||||
/// Saves the last available bounds and removes all listeners without hiding or detaching the stage.
|
||||
/// Repeated calls have no effect.
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
updateBounds();
|
||||
closed = true;
|
||||
for (ObservableValue<?> observable : observed) {
|
||||
observable.removeListener(boundsListener);
|
||||
}
|
||||
scene.xProperty().removeListener(offsetListener);
|
||||
scene.yProperty().removeListener(offsetListener);
|
||||
for (ObservableValue<?> observable : minimumSizeObserved) {
|
||||
observable.removeListener(minimumSizeListener);
|
||||
}
|
||||
scene.removePostLayoutPulseListener(pulseListener);
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.decorator;
|
||||
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Applies the main window's platform-specific maximization policy.
|
||||
/// All methods must be called on the JavaFX application thread.
|
||||
@NotNullByDefault
|
||||
public final class WindowState {
|
||||
/// Prevents instantiation of this utility class.
|
||||
private WindowState() {
|
||||
}
|
||||
|
||||
/// Returns whether the launcher honors maximization for the stage's platform and style.
|
||||
///
|
||||
/// Transparent windows on macOS do not support maximization in the launcher: native zoom notifications
|
||||
/// can mark an ordinary borderless window as maximized, including during initialization.
|
||||
/// This policy does not suppress full-screen or iconified states.
|
||||
///
|
||||
/// @param stage the main stage to inspect
|
||||
/// @return `false` for transparent stages on macOS; `true` otherwise
|
||||
public static boolean supportsMaximization(Stage stage) {
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
return OperatingSystem.CURRENT_OS != OperatingSystem.MACOS || stage.getStyle() != StageStyle.TRANSPARENT;
|
||||
}
|
||||
|
||||
/// Returns whether the stage is maximized under the launcher's platform policy.
|
||||
///
|
||||
/// @param stage the main stage to inspect
|
||||
/// @return `true` if maximization is supported and the stage reports that it is maximized
|
||||
public static boolean isMaximized(Stage stage) {
|
||||
return supportsMaximization(stage) && stage.isMaximized();
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,7 @@ import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
|
||||
import org.jackhuang.hmcl.ui.construct.LineToggleButton;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
@@ -248,21 +244,25 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
|
||||
SettingsManager.forceOverwriteUserGameDirectories();
|
||||
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
fireEvent(new PageCloseEvent());
|
||||
});
|
||||
return;
|
||||
}
|
||||
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
} else {
|
||||
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
|
||||
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
|
||||
SettingsManager.forceOverwriteLocalGameDirectories();
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
fireEvent(new PageCloseEvent());
|
||||
});
|
||||
return;
|
||||
}
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
|
||||
|
||||
protected BooleanProperty installable = new SimpleBooleanProperty();
|
||||
|
||||
public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) {
|
||||
public AbstractInstallersPage(WizardController controller, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
|
||||
this.controller = controller;
|
||||
this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(gameVersion), getInstallerItemStyle());
|
||||
this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle());
|
||||
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
GameComponentType type = component.getComponentType();
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.jackhuang.hmcl.ui.InstallerItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -36,11 +37,11 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
class AdditionalInstallersPage extends AbstractInstallersPage {
|
||||
protected final BooleanProperty compatible = new SimpleBooleanProperty();
|
||||
protected final String gameVersion;
|
||||
protected final GameVersionNumber gameVersion;
|
||||
protected final GameInstanceManifest manifest;
|
||||
protected final HMCLGameInstance instance;
|
||||
|
||||
public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, WizardController controller, DownloadProvider downloadProvider) {
|
||||
public AdditionalInstallersPage(HMCLGameInstance instance, GameVersionNumber gameVersion, WizardController controller, DownloadProvider downloadProvider) {
|
||||
super(controller, gameVersion, downloadProvider);
|
||||
this.instance = instance;
|
||||
this.gameVersion = gameVersion;
|
||||
|
||||
@@ -56,7 +56,9 @@ import org.jackhuang.hmcl.ui.wizard.WizardProvider;
|
||||
import org.jackhuang.hmcl.util.FileNameSet;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
@@ -94,7 +96,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
}
|
||||
|
||||
public DownloadPage(GameInstanceID uploadInstance) {
|
||||
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
|
||||
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), GameVersionNumber.unknown(), DownloadProviders.getDownloadProvider(),
|
||||
GameComponentType.GAME, versionPageNavigator::onGameSelected)));
|
||||
modpackTab.setNodeSupplier(loadVersionFor(() -> {
|
||||
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
|
||||
@@ -110,7 +112,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true)));
|
||||
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true)));
|
||||
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)));
|
||||
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteAddonRepository.WORLDS)));
|
||||
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance())));
|
||||
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
|
||||
|
||||
GameDirectoryManager.registerVersionsListener(this::loadVersions);
|
||||
@@ -162,13 +164,13 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
Path dest = targetPath.resolve(result);
|
||||
|
||||
Controllers.taskDialog(Task.composeAsync(() -> {
|
||||
var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.file().url()), dest);
|
||||
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
|
||||
task.setName(file.name());
|
||||
return task;
|
||||
}).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
if (!(exception instanceof CancellationException)) {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
} else {
|
||||
Controllers.showToast(i18n("install.success"));
|
||||
@@ -313,7 +315,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
}
|
||||
|
||||
try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) {
|
||||
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion());
|
||||
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion().toString());
|
||||
|
||||
settings.asStringMap().forEach((key, value) -> {
|
||||
if (!GameComponentType.GAME.getPatchId().equals(key)
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import static javafx.beans.binding.Bindings.createBooleanBinding;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -38,7 +39,7 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
|
||||
private boolean isNameModifiedByUser = false;
|
||||
|
||||
public InstallersPage(WizardController controller, HMCLGameRepository repository, String gameVersion, DownloadProvider downloadProvider) {
|
||||
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
|
||||
super(controller, gameVersion, downloadProvider);
|
||||
|
||||
txtName.getValidators().addAll(
|
||||
@@ -52,7 +53,7 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion();
|
||||
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
|
||||
}
|
||||
|
||||
private String getVersion(String id) {
|
||||
|
||||
@@ -44,26 +44,20 @@ import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
|
||||
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 LocalModpackPage extends ModpackPage {
|
||||
|
||||
private final BooleanProperty installAsVersion = new SimpleBooleanProperty(true);
|
||||
private Modpack manifest = null;
|
||||
private Charset charset;
|
||||
private final ObservableList<ModpackFile> allFiles = FXCollections.observableList(new ArrayList<>());
|
||||
private final ObservableSet<String> excludedFiles = FXCollections.observableSet(new HashSet<>());
|
||||
private final BooleanProperty loadingOptionalFiles = new SimpleBooleanProperty(false);
|
||||
@@ -98,8 +92,8 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
new RequiredValidator(),
|
||||
new Validator(i18n("install.new_game.already_exists"), str -> !ModpackHelper.isExternalGameNameConflicts(str)
|
||||
&& GameDirectoryManager.getGameDirectories().stream()
|
||||
.noneMatch(existingProfile ->
|
||||
str.equals(GameDirectoryManager.getGameDirectoryCustomName(existingProfile)))),
|
||||
.noneMatch(existingProfile ->
|
||||
str.equals(GameDirectoryManager.getGameDirectoryCustomName(existingProfile)))),
|
||||
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
|
||||
}
|
||||
});
|
||||
@@ -125,11 +119,7 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
}
|
||||
|
||||
showSpinner();
|
||||
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile))
|
||||
.thenApplyAsync(encoding -> {
|
||||
charset = encoding;
|
||||
return ModpackHelper.readModpackManifest(selectedFile, encoding);
|
||||
})
|
||||
Task.supplyAsync(() -> ModpackHelper.readModpackManifest(selectedFile))
|
||||
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
|
||||
if (exception instanceof ManuallyCreatedModpackException) {
|
||||
hideSpinner();
|
||||
@@ -142,7 +132,8 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
}
|
||||
|
||||
Controllers.confirm(i18n("modpack.type.manual.warning"), i18n("install.modpack"), MessageDialogPane.MessageType.WARNING,
|
||||
() -> {},
|
||||
() -> {
|
||||
},
|
||||
controller::onEnd);
|
||||
|
||||
controller.getSettings().put(MODPACK_MANUALLY_CREATED, true);
|
||||
@@ -163,7 +154,7 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
|
||||
if (name == null) {
|
||||
// trim: https://github.com/HMCL-dev/HMCL/issues/962
|
||||
txtModpackName.setText(manifest.getName().trim());
|
||||
txtModpackName.setText(processModpackName(manifest.getName()));
|
||||
}
|
||||
|
||||
btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription()));
|
||||
@@ -223,7 +214,6 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
|
||||
private void finishInstall(String name) {
|
||||
controller.getSettings().put(MODPACK_NAME, name);
|
||||
controller.getSettings().put(MODPACK_CHARSET, charset);
|
||||
controller.getSettings().put(MODPACK_EXCLUDED_FILES, getExcludedFiles());
|
||||
controller.onFinish();
|
||||
}
|
||||
@@ -239,10 +229,20 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
|
||||
}
|
||||
|
||||
@Contract("null->null;!null->!null")
|
||||
public static @Nullable String processModpackName(@Nullable String name) {
|
||||
if (name == null)
|
||||
return null;
|
||||
|
||||
return name.trim()
|
||||
.replace(':', '_')
|
||||
.replace('/', '_')
|
||||
.replace('\\', '_');
|
||||
}
|
||||
|
||||
public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("MODPACK_FILE");
|
||||
public static final SettingsMap.Key<String> MODPACK_NAME = new SettingsMap.Key<>("MODPACK_NAME");
|
||||
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
|
||||
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
|
||||
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
|
||||
public static final SettingsMap.Key<String> MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL");
|
||||
public static final SettingsMap.Key<Set<String>> MODPACK_EXCLUDED_FILES = new SettingsMap.Key<>("MODPACK_EXCLUDED_FILES");
|
||||
|
||||
@@ -35,7 +35,6 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -88,12 +87,11 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
Modpack modpack = settings.get(LocalModpackPage.MODPACK_MANIFEST);
|
||||
String name = settings.get(LocalModpackPage.MODPACK_NAME);
|
||||
String iconUrl = settings.get(LocalModpackPage.MODPACK_ICON_URL);
|
||||
Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET);
|
||||
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);
|
||||
var excludedFiles = settings.get(LocalModpackPage.MODPACK_EXCLUDED_FILES);
|
||||
|
||||
if (isManuallyCreated) {
|
||||
return ModpackHelper.getInstallManuallyCreatedModpackTask(selected, name, charset);
|
||||
return ModpackHelper.getInstallManuallyCreatedModpackTask(selected, name);
|
||||
}
|
||||
|
||||
if ((selected == null && serverModpackManifest == null) || modpack == null || name == null) return null;
|
||||
@@ -107,9 +105,9 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
}
|
||||
try {
|
||||
if (serverModpackManifest != null) {
|
||||
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)));
|
||||
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)));
|
||||
} else {
|
||||
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)), excludedFiles);
|
||||
return ModpackHelper.getUpdateTask(repository, selected, instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)), excludedFiles);
|
||||
}
|
||||
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
|
||||
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
|
||||
|
||||
@@ -27,6 +27,7 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
@@ -125,7 +126,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
|
||||
try {
|
||||
if (url.endsWith("server-manifest.json")) {
|
||||
// if urlString ends with .json, we assume that the url is server-manifest.json
|
||||
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
|
||||
Controllers.taskDialog(new GetTask(WebURL.parse(url)).whenComplete(Schedulers.javafx(), (result, e) -> {
|
||||
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
|
||||
if (manifest == null) {
|
||||
handler.reject(i18n("modpack.type.server.malformed"));
|
||||
|
||||
@@ -45,7 +45,7 @@ public final class RemoteModpackPage extends ModpackPage {
|
||||
throw new IllegalStateException("MODPACK_SERVER_MANIFEST should exist");
|
||||
|
||||
try {
|
||||
controller.getSettings().put(MODPACK_MANIFEST, manifest.toModpack(null));
|
||||
controller.getSettings().put(MODPACK_MANIFEST, manifest.toModpack());
|
||||
} catch (IOException e) {
|
||||
Controllers.dialog(i18n("modpack.type.server.malformed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
Platform.runLater(controller::onEnd);
|
||||
@@ -62,8 +62,7 @@ public final class RemoteModpackPage extends ModpackPage {
|
||||
txtModpackName.setText(name);
|
||||
txtModpackName.setDisable(true);
|
||||
} else {
|
||||
// trim: https://github.com/HMCL-dev/HMCL/issues/962
|
||||
txtModpackName.setText(manifest.getName().trim());
|
||||
txtModpackName.setText(LocalModpackPage.processModpackName(manifest.getName()));
|
||||
txtModpackName.getValidators().addAll(
|
||||
new RequiredValidator(),
|
||||
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
|
||||
|
||||
+13
-12
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.download;
|
||||
import javafx.scene.Node;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.LibraryDownloadException;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
@@ -35,11 +36,11 @@ import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.zip.ZipException;
|
||||
@@ -104,11 +105,11 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
public Node createPage(WizardController controller, int step, SettingsMap settings) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + componentType.getPatchId())), gameInstance.getVersion().toString(), downloadProvider, componentType, () -> {
|
||||
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + componentType.getPatchId())), gameInstance.getVersion(), downloadProvider, componentType, () -> {
|
||||
if (oldLibraryVersion == null) {
|
||||
controller.onFinish();
|
||||
} else if (componentType == GameComponentType.GAME) {
|
||||
String newGameVersion = ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion();
|
||||
GameVersionNumber newGameVersion = ((GameRemoteVersion) settings.get(componentType.getPatchId())).getGameVersion();
|
||||
controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider));
|
||||
} else {
|
||||
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType.getPatchId()), oldLibraryVersion, ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()),
|
||||
@@ -137,27 +138,27 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().name()) + "\n";
|
||||
if (exception.getCause() instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message += i18n("download.code.404", uri);
|
||||
message += i18n("download.code.404", url);
|
||||
else
|
||||
message += i18n("download.failed", uri, responseCode);
|
||||
message += i18n("download.failed", url, responseCode);
|
||||
} else {
|
||||
message += StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
Controllers.dialog(message, i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
} else if (exception instanceof DownloadException de) {
|
||||
String url = de.getUrl();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.timeout", url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else {
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
}
|
||||
} else {
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
}
|
||||
} else if (exception instanceof UnsupportedInstallationException unsupportedInstallationException) {
|
||||
switch (unsupportedInstallationException.getReason()) {
|
||||
|
||||
@@ -32,7 +32,6 @@ import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.ComponentVersionList;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||
@@ -53,23 +52,21 @@ import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.ui.wizard.Refreshable;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardPage;
|
||||
import org.jackhuang.hmcl.util.NativePatcher;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.*;
|
||||
@@ -77,31 +74,29 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class VersionsPage extends Control implements WizardPage, Refreshable {
|
||||
private final String gameVersion;
|
||||
private final @Nullable GameVersionNumber gameVersion;
|
||||
private final GameComponentType componentType;
|
||||
private final String title;
|
||||
private final Navigation navigation;
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final ComponentVersionList<?> versionList;
|
||||
private final Runnable callback;
|
||||
|
||||
private final ObservableList<ComponentRemoteVersion> versions = FXCollections.observableArrayList();
|
||||
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
|
||||
|
||||
public VersionsPage(Navigation navigation,
|
||||
String title, String gameVersion,
|
||||
String title, GameVersionNumber gameVersion,
|
||||
DownloadProvider downloadProvider,
|
||||
GameComponentType componentType,
|
||||
Runnable callback) {
|
||||
this.title = title;
|
||||
this.gameVersion = gameVersion;
|
||||
this.gameVersion = componentType == GameComponentType.GAME ? null : gameVersion;
|
||||
this.componentType = componentType;
|
||||
this.navigation = navigation;
|
||||
this.downloadProvider = downloadProvider;
|
||||
this.versionList = downloadProvider.getVersionList(componentType);
|
||||
this.callback = callback;
|
||||
|
||||
refresh();
|
||||
load(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -111,9 +106,12 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
|
||||
@Override
|
||||
public void refresh() {
|
||||
load(true);
|
||||
}
|
||||
|
||||
private void load(boolean refresh) {
|
||||
status.set(Status.LOADING);
|
||||
Task<?> task = versionList.refreshAsync(gameVersion)
|
||||
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
|
||||
Task<?> task = downloadProvider.getVersionsAsync(componentType, gameVersion, refresh)
|
||||
.whenComplete(Schedulers.javafx(), (items, exception) -> {
|
||||
if (exception == null) {
|
||||
versions.setAll(items);
|
||||
@@ -240,7 +238,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
|
||||
if (remoteVersion instanceof GameRemoteVersion) {
|
||||
ComponentRemoteVersion.Type versionType = remoteVersion.getVersionType();
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
|
||||
GameVersionNumber gameVersion = remoteVersion.getGameVersion();
|
||||
|
||||
switch (versionType) {
|
||||
case RELEASE -> {
|
||||
@@ -248,8 +246,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
|
||||
}
|
||||
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
|
||||
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
|
||||
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT && gameVersion.isAprilFools()) {
|
||||
twoLineListItem.addTag(i18n("instance.game.april_fools"));
|
||||
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
|
||||
} else {
|
||||
@@ -289,7 +286,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
iconType = GameInstanceIconType.COMMAND;
|
||||
|
||||
imageView.setImage(iconType.getIcon());
|
||||
String displayGameVersion = I18n.getDisplayVersion(GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()));
|
||||
String displayGameVersion = I18n.getDisplayVersion(remoteVersion.getGameVersion());
|
||||
|
||||
if (twoLineListItem.getSubtitle() == null)
|
||||
twoLineListItem.setSubtitle(displayGameVersion);
|
||||
@@ -326,7 +323,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
column2.setMaxWidth(150);
|
||||
ColumnConstraints column3 = new ColumnConstraints();
|
||||
|
||||
if (control.versionList.hasType())
|
||||
if (control.downloadProvider.hasType(control.componentType))
|
||||
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
|
||||
else
|
||||
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
|
||||
@@ -365,7 +362,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
|
||||
refreshButton.setOnAction(event -> control.onRefresh());
|
||||
|
||||
if (control.versionList.hasType()) {
|
||||
if (control.downloadProvider.hasType(control.componentType)) {
|
||||
searchPane.addRow(rowIndex++,
|
||||
new Label(i18n("instance.search")), nameField,
|
||||
new Label(i18n("instance.game.type")), categoryField,
|
||||
@@ -406,6 +403,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
VBox.setVgrow(list, Priority.ALWAYS);
|
||||
|
||||
control.versions.addListener((InvalidationListener) o -> updateList());
|
||||
if (!control.versions.isEmpty())
|
||||
updateList();
|
||||
|
||||
list.setCellFactory(listView -> new RemoteVersionListCell(control));
|
||||
|
||||
@@ -454,7 +453,23 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
root.setCenter(transitionPane);
|
||||
}
|
||||
|
||||
this.getChildren().setAll(root);
|
||||
if (RegionUtils.isIsChinaMainlandNetwork()) {
|
||||
BorderPane wrapper = new BorderPane();
|
||||
|
||||
HintPane hint = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
hint.setSegment(i18n("download.china"));
|
||||
|
||||
BorderPane.setMargin(hint, new Insets(10, 10, 0, 10));
|
||||
|
||||
wrapper.setTop(hint);
|
||||
|
||||
root.setDisable(true);
|
||||
wrapper.setCenter(root);
|
||||
|
||||
this.getChildren().setAll(wrapper);
|
||||
} else {
|
||||
this.getChildren().setAll(root);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateList() {
|
||||
@@ -470,7 +485,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
|| versionType == ComponentRemoteVersion.Type.PENDING
|
||||
|| versionType == ComponentRemoteVersion.Type.UNOBFUSCATED;
|
||||
case APRIL_FOOLS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
|
||||
&& it.getGameVersion().isAprilFools();
|
||||
case OLD -> versionType == ComponentRemoteVersion.Type.OLD;
|
||||
// case ALL,
|
||||
default -> true;
|
||||
@@ -493,7 +508,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
list.getItems().setAll(versions.collect(Collectors.toList()));
|
||||
list.getItems().setAll(versions.toList());
|
||||
list.scrollTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,6 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
}
|
||||
Path file = Controllers.showSaveDialog(fileChooser);
|
||||
if (file == null) {
|
||||
controller.onEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
@@ -54,8 +54,8 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
|
||||
this.getChildren().setAll(
|
||||
title,
|
||||
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION)
|
||||
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -643,6 +643,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
jvmSettings
|
||||
);
|
||||
{
|
||||
var notUseJavawPane = createInheritableBooleanButton(GameSettings::notUseJavawProperty);
|
||||
jvmSettings.getContent().add(notUseJavawPane);
|
||||
notUseJavawPane.setTitle(i18n("settings.advanced.not_use_javaw"));
|
||||
notUseJavawPane.setSubtitle(i18n("settings.advanced.windows_only"));
|
||||
|
||||
var noJVMArgsPane = createInheritableBooleanButton(GameSettings::noJVMOptionsProperty);
|
||||
jvmSettings.getContent().add(noJVMArgsPane);
|
||||
noJVMArgsPane.setTitle(i18n("settings.advanced.no_jvm_args"));
|
||||
@@ -829,10 +834,14 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
nativeLibrarySettings.getContent().add(nativesDirPane);
|
||||
nativesDirPane.setTitle(i18n("settings.advanced.natives_directory"));
|
||||
{
|
||||
var txtNativesDir = new JFXTextField();
|
||||
txtNativesDir.setPrefWidth(400);
|
||||
nativesDirPane.setRight(txtNativesDir);
|
||||
bindIndependentTextField(nativesDirPane, txtNativesDir, GameSettings::nativesDirectoryProperty);
|
||||
var nativesDirSelector = new FileSelector()
|
||||
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
|
||||
nativesDirSelector.setPrefWidth(400);
|
||||
nativesDirPane.setRight(nativesDirSelector);
|
||||
bindInheritableStringProperty(
|
||||
nativesDirPane,
|
||||
nativesDirSelector.valueProperty(),
|
||||
GameSettings::nativesDirectoryProperty);
|
||||
}
|
||||
|
||||
var noNativesPatchPane = createIndependentBooleanButton(GameSettings::notPatchNativesProperty);
|
||||
|
||||
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
@@ -27,34 +29,48 @@ import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdate;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonManager;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdateTask;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdates;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.CSVTable;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import org.jackhuang.hmcl.util.javafx.Subscription;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -62,86 +78,96 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane implements DecoratorPage {
|
||||
public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update")));
|
||||
|
||||
private final LocalAddonManager<F> localAddonManager;
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final Path localAddonDirectory;
|
||||
private final ObservableList<AddonUpdateObject> objects;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public AddonUpdatesPage(LocalAddonManager<F> localAddonManager, List<LocalAddonFile.AddonUpdate> updates) {
|
||||
this.localAddonManager = localAddonManager;
|
||||
private final JFXListView<AddonUpdateObject> listView;
|
||||
|
||||
public AddonUpdatesPage(DownloadProvider downloadProvider, Path localAddonDirectory, AddonUpdates updates) {
|
||||
this.downloadProvider = downloadProvider;
|
||||
this.localAddonDirectory = localAddonDirectory;
|
||||
|
||||
getStyleClass().add("gray-background");
|
||||
|
||||
TableColumn<AddonUpdateObject, Boolean> enabledColumn = new TableColumn<>();
|
||||
var allEnabledBox = new JFXCheckBox();
|
||||
enabledColumn.setStyle("-fx-alignment: CENTER;");
|
||||
enabledColumn.setGraphic(allEnabledBox);
|
||||
enabledColumn.setCellFactory(JFXCheckBoxTableCell.forTableColumn(enabledColumn));
|
||||
setupCellValueFactory(enabledColumn, AddonUpdateObject::enabledProperty);
|
||||
enabledColumn.setEditable(true);
|
||||
enabledColumn.setMaxWidth(40);
|
||||
enabledColumn.setMinWidth(40);
|
||||
var common = new Lazy<>(() -> updates.commonUpdates().stream().map(AddonUpdateObject::new).toList());
|
||||
var release = new Lazy<>(() -> updates.releaseUpdates().stream().map(AddonUpdateObject::new).toList());
|
||||
|
||||
TableColumn<AddonUpdateObject, String> fileNameColumn = new TableColumn<>(i18n("addon.check_update.file"));
|
||||
fileNameColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(fileNameColumn, AddonUpdateObject::fileNameProperty);
|
||||
objects = FXCollections.observableArrayList();
|
||||
|
||||
TableColumn<AddonUpdateObject, String> currentVersionColumn = new TableColumn<>(i18n("addon.check_update.current_version"));
|
||||
currentVersionColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(currentVersionColumn, AddonUpdateObject::currentVersionProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> targetVersionColumn = new TableColumn<>(i18n("addon.check_update.target_version"));
|
||||
targetVersionColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> sourceColumn = new TableColumn<>(i18n("addon.check_update.source"));
|
||||
setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> changelogColumn = new TableColumn<>(i18n("addon.changelog"));
|
||||
{
|
||||
var oldCellFactory = changelogColumn.getCellFactory();
|
||||
changelogColumn.setCellFactory(param -> {
|
||||
TableCell<AddonUpdateObject, String> cell = oldCellFactory.call(param);
|
||||
cell.getStyleClass().add("addon-changelog-table-cell");
|
||||
cell.setOnMouseClicked(event -> {
|
||||
AddonUpdateObject object;
|
||||
if (cell.isEmpty() || cell.getTableRow() == null || (object = cell.getTableRow().getItem()) == null) return;
|
||||
Controllers.dialog(new AddonChangelog(object));
|
||||
});
|
||||
return cell;
|
||||
});
|
||||
changelogColumn.setCellValueFactory(__ -> new SimpleStringProperty(i18n("button.view")));
|
||||
StackPane centerWrapper = new StackPane();
|
||||
centerWrapper.setStyle("-fx-padding: 10;");
|
||||
|
||||
ComponentList centrePane = new ComponentList();
|
||||
centrePane.getStyleClass().add("no-padding");
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("addon.check_update.only_preview"));
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
|
||||
listView = new JFXListView<>();
|
||||
listView.setItems(objects);
|
||||
listView.getStyleClass().add("jfx-list-view-float");
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
setMargin(listView, new Insets(0, 10, 0, 10));
|
||||
VBox.setVgrow(listView, Priority.ALWAYS);
|
||||
|
||||
listView.setCellFactory(listView -> new AddonUpdateListCell());
|
||||
|
||||
ComponentList.setVgrow(listView, Priority.ALWAYS);
|
||||
|
||||
centrePane.getContent().setAll(listView);
|
||||
|
||||
centerWrapper.getChildren().setAll(centrePane);
|
||||
|
||||
setCenter(centerWrapper);
|
||||
}
|
||||
|
||||
objects = FXCollections.observableList(updates.stream().map(AddonUpdateObject::new).collect(Collectors.toList()));
|
||||
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
|
||||
{
|
||||
HBox actions = new HBox(8);
|
||||
actions.setPadding(new Insets(8));
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
TableView<AddonUpdateObject> table = new TableView<>(objects);
|
||||
table.setEditable(true);
|
||||
table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn, changelogColumn);
|
||||
setMargin(table, new Insets(10, 10, 5, 10));
|
||||
JFXCheckBox allEnabledBox = new JFXCheckBox();
|
||||
allEnabledBox.setText(i18n("button.select_all"));
|
||||
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
|
||||
|
||||
setCenter(table);
|
||||
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
|
||||
{
|
||||
List<Subscription> subscriptions = new ArrayList<>();
|
||||
FXUtils.onChangeAndOperate(showPreviewCheck.selectedProperty(), preview -> {
|
||||
if (preview) objects.setAll(common.get());
|
||||
else objects.setAll(release.get());
|
||||
subscriptions.forEach(Subscription::unsubscribe);
|
||||
subscriptions.clear();
|
||||
subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new)));
|
||||
});
|
||||
}
|
||||
|
||||
HBox actions = new HBox(8);
|
||||
actions.setPadding(new Insets(8));
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
|
||||
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
|
||||
exportListButton.setOnAction(e -> exportList());
|
||||
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
|
||||
exportListButton.setOnAction(e -> exportList());
|
||||
|
||||
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
|
||||
nextButton.setOnAction(e -> updateFiles());
|
||||
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
|
||||
nextButton.setOnAction(e -> updateFiles());
|
||||
|
||||
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
onEscPressed(table, cancelButton::fire);
|
||||
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
onEscPressed(listView, cancelButton::fire);
|
||||
|
||||
actions.getChildren().setAll(exportListButton, nextButton, cancelButton);
|
||||
setBottom(actions);
|
||||
actions.getChildren().setAll(allEnabledBox, showPreviewCheck, spacer, exportListButton, nextButton, cancelButton);
|
||||
|
||||
setBottom(actions);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> void setupCellValueFactory(TableColumn<AddonUpdateObject, T> column, Function<AddonUpdateObject, ObservableValue<T>> mapper) {
|
||||
@@ -150,7 +176,8 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
|
||||
private void updateFiles() {
|
||||
AddonUpdateTask task = new AddonUpdateTask(
|
||||
localAddonManager.getDirectory(),
|
||||
downloadProvider,
|
||||
localAddonDirectory,
|
||||
objects.stream()
|
||||
.filter(AddonUpdateObject::isEnabled)
|
||||
.map(AddonUpdateObject::getData)
|
||||
@@ -210,31 +237,38 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
}
|
||||
|
||||
private static final class AddonUpdateObject {
|
||||
final LocalAddonFile.AddonUpdate data;
|
||||
final AddonUpdate data;
|
||||
final BooleanProperty enabled = new SimpleBooleanProperty();
|
||||
final StringProperty fileName = new SimpleStringProperty();
|
||||
final StringProperty currentVersion = new SimpleStringProperty();
|
||||
final StringProperty targetVersion = new SimpleStringProperty();
|
||||
final StringProperty targetVersionType = new SimpleStringProperty();
|
||||
final StringProperty source = new SimpleStringProperty();
|
||||
String changelog = null;
|
||||
|
||||
public AddonUpdateObject(LocalAddonFile.AddonUpdate data) {
|
||||
final ItemPropertyAsyncCache<Image, AddonUpdateObject> iconCache;
|
||||
|
||||
public AddonUpdateObject(AddonUpdate data) {
|
||||
this.data = data;
|
||||
|
||||
enabled.set(!data.localAddonFile().isDisabled());
|
||||
fileName.set(data.localAddonFile().getFileName());
|
||||
currentVersion.set(data.currentVersion().version());
|
||||
targetVersion.set(data.targetVersion().version());
|
||||
switch (data.currentVersion().self().getSource()) {
|
||||
case CURSEFORGE:
|
||||
source.set(i18n("addon.curseforge"));
|
||||
break;
|
||||
case MODRINTH:
|
||||
source.set(i18n("addon.modrinth"));
|
||||
}
|
||||
targetVersionType.set(data.targetVersion().versionType().name());
|
||||
source.set(switch (data.currentVersion().source()) {
|
||||
case CURSEFORGE -> i18n("addon.curseforge");
|
||||
case MODRINTH -> i18n("addon.modrinth");
|
||||
});
|
||||
|
||||
iconCache = new ItemPropertyAsyncCache.Soft<>(
|
||||
this,
|
||||
this::loadIcon,
|
||||
this::getDefaultIcon
|
||||
);
|
||||
}
|
||||
|
||||
public LocalAddonFile.AddonUpdate getData() {
|
||||
public AddonUpdate getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -246,14 +280,6 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled.set(enabled);
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName.get();
|
||||
}
|
||||
|
||||
public StringProperty fileNameProperty() {
|
||||
return fileName;
|
||||
}
|
||||
@@ -262,6 +288,10 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
this.fileName.set(fileName);
|
||||
}
|
||||
|
||||
public LocalAddonFile getLocalAddonFile() {
|
||||
return data.localAddonFile();
|
||||
}
|
||||
|
||||
public String getCurrentVersion() {
|
||||
return currentVersion.get();
|
||||
}
|
||||
@@ -270,24 +300,12 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
return currentVersion;
|
||||
}
|
||||
|
||||
public void setCurrentVersion(String currentVersion) {
|
||||
this.currentVersion.set(currentVersion);
|
||||
}
|
||||
|
||||
public String getTargetVersion() {
|
||||
return targetVersion.get();
|
||||
}
|
||||
|
||||
public StringProperty targetVersionProperty() {
|
||||
return targetVersion;
|
||||
}
|
||||
|
||||
public void setTargetVersion(String targetVersion) {
|
||||
this.targetVersion.set(targetVersion);
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source.get();
|
||||
public StringProperty targetVersionTypeProperty() {
|
||||
return targetVersionType;
|
||||
}
|
||||
|
||||
public StringProperty sourceProperty() {
|
||||
@@ -297,6 +315,160 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
public void setSource(String source) {
|
||||
this.source.set(source);
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
if (data.localAddonFile() instanceof LocalModFile localModFile) {
|
||||
return GameInstanceIconType
|
||||
.getIconType(localModFile.getModLoaderType())
|
||||
.getIcon();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Image loadIcon() {
|
||||
if (data.localAddonFile() instanceof LocalModFile localModFile) {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(localModFile.getLogoPath())) {
|
||||
iconPaths.add(localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(localModFile.getFile())) {
|
||||
|
||||
for (String path : iconPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(
|
||||
iconPath,
|
||||
80,
|
||||
80,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (!image.isError()
|
||||
&& image.getWidth() > 0
|
||||
&& image.getHeight() > 0
|
||||
&& Math.abs(image.getWidth() - image.getHeight()) < 1
|
||||
) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load addon icons", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.localAddonFile() instanceof ResourcePackFile resourcePackFile) {
|
||||
Image image = resourcePackFile.loadIcon();
|
||||
|
||||
if (image != null
|
||||
&& !image.isError()
|
||||
&& image.getWidth() > 0
|
||||
&& image.getHeight() > 0
|
||||
&& Math.abs(image.getWidth() - image.getHeight()) < 1
|
||||
) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultIcon();
|
||||
}
|
||||
}
|
||||
|
||||
private static class AddonUpdateListCell extends ListCell<AddonUpdateObject> {
|
||||
private final StackPane pane = new StackPane();
|
||||
private final RipplerContainer ripplerContainer;
|
||||
|
||||
private final JFXCheckBox enabledBox = new JFXCheckBox();
|
||||
private final ImageContainer imageContainer = new ImageContainer(40);
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton changelogButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
|
||||
AddonUpdateListCell() {
|
||||
HBox hbox = new HBox(16);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
content.setAlignment(Pos.CENTER);
|
||||
hbox.setAlignment(Pos.CENTER);
|
||||
|
||||
hbox.getChildren().setAll(enabledBox, imageContainer, content, changelogButton);
|
||||
|
||||
pane.getStyleClass().add("md-list-cell");
|
||||
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
|
||||
|
||||
ripplerContainer = new RipplerContainer(hbox);
|
||||
pane.getChildren().setAll(ripplerContainer);
|
||||
|
||||
enabledBox.setMouseTransparent(false);
|
||||
imageContainer.setMouseTransparent(true);
|
||||
|
||||
FXUtils.installFastTooltip(changelogButton, i18n("addon.changelog"));
|
||||
changelogButton.setOnAction(event -> {
|
||||
AddonUpdateObject item = getItem();
|
||||
if (item != null) {
|
||||
Controllers.dialog(new AddonChangelog(item));
|
||||
}
|
||||
});
|
||||
|
||||
FXUtils.onClicked(this, () -> {
|
||||
AddonUpdateObject item = getItem();
|
||||
if (item != null) {
|
||||
item.enabled.set(!item.isEnabled());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateItem(AddonUpdateObject item, boolean empty) {
|
||||
AddonUpdateObject oldItem = getItem();
|
||||
|
||||
if (oldItem != null) {
|
||||
enabledBox.selectedProperty().unbindBidirectional(oldItem.enabledProperty());
|
||||
}
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
|
||||
enabledBox.selectedProperty().bindBidirectional(item.enabledProperty());
|
||||
|
||||
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
|
||||
LocalAddonFile addonFile = item.getLocalAddonFile();
|
||||
|
||||
if (oldItem != item) {
|
||||
content.getTags().clear();
|
||||
|
||||
if (addonFile instanceof LocalModFile modFile) {
|
||||
content.addTag(modFile.getId());
|
||||
content.addTag(item.source.get());
|
||||
} else if (addonFile instanceof ResourcePackFile) {
|
||||
content.addTag(item.source.get());
|
||||
}
|
||||
}
|
||||
|
||||
if (addonFile instanceof LocalModFile modFile) {
|
||||
content.setTitle(modFile.getName());
|
||||
} else if (addonFile instanceof ResourcePackFile resourcePackFile) {
|
||||
content.setTitle(resourcePackFile.getFileName());
|
||||
}
|
||||
|
||||
content.setSubtitle(
|
||||
item.getCurrentVersion()
|
||||
+ " → "
|
||||
+ item.targetVersion.get()
|
||||
+ " | "
|
||||
+ item.targetVersionType.get()
|
||||
);
|
||||
|
||||
setGraphic(pane);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonChangelog extends JFXDialogLayout {
|
||||
@@ -342,13 +514,10 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
|
||||
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
|
||||
spinnerPane.setLoading(true);
|
||||
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
|
||||
RemoteAddonRepository repo = object.data.targetVersion().source().getRepository();
|
||||
Task.supplyAsync(() -> {
|
||||
if (object.changelog != null) {
|
||||
return object.changelog;
|
||||
}
|
||||
if (object.changelog != null) return object.changelog;
|
||||
RemoteAddon.Version version = object.data.targetVersion();
|
||||
if (repo == null) return null;
|
||||
return StringUtils.convertToHtml(
|
||||
repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()),
|
||||
"238222".equals(object.data.targetVersion().projectId())
|
||||
@@ -356,7 +525,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
|
||||
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo == null ? "" : repo.getBaseUrl()));
|
||||
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo.getBaseUrl()));
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
spinnerPane.setFailedReason(null);
|
||||
} else {
|
||||
@@ -367,10 +536,9 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
}
|
||||
|
||||
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
|
||||
Task.supplyAsync(() -> {
|
||||
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
|
||||
return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion());
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
Task.supplyAsync(() ->
|
||||
object.data.targetVersion().source().getRepository().getVersionPageUrl(object.data.targetVersion())
|
||||
).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null && StringUtils.isNotBlank(result)) {
|
||||
button.setExternalLink(result);
|
||||
button.setDisable(false);
|
||||
@@ -380,84 +548,4 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
public static class AddonUpdateTask extends Task<Void> {
|
||||
private final Collection<Task<?>> dependents;
|
||||
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
|
||||
|
||||
AddonUpdateTask(Path addonDirectory, List<LocalAddonFile.AddonUpdate> addons) {
|
||||
setStage("addon.check_update.confirm");
|
||||
getProperties().put("total", addons.size());
|
||||
|
||||
this.dependents = new ArrayList<>();
|
||||
for (LocalAddonFile.AddonUpdate addon : addons) {
|
||||
LocalAddonFile local = addon.localAddonFile();
|
||||
RemoteAddon.Version remote = addon.targetVersion();
|
||||
boolean isDisabled = local.isDisabled();
|
||||
String originalFileName = local.getFile().getFileName().toString();
|
||||
|
||||
dependents.add(Task
|
||||
.runAsync(Schedulers.javafx(), () -> local.setOld(true))
|
||||
.thenComposeAsync(() -> {
|
||||
String fileName = addon.useRemoteFileName() ? remote.file().filename() : originalFileName;
|
||||
if (isDisabled)
|
||||
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
|
||||
|
||||
var task = new FileDownloadTask(
|
||||
remote.file().url(),
|
||||
addonDirectory.resolve(fileName)
|
||||
);
|
||||
|
||||
task.setName(remote.name());
|
||||
return task;
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
// restore state if failed
|
||||
local.setOld(false);
|
||||
if (isDisabled)
|
||||
local.markDisabled();
|
||||
failedAddons.add(local);
|
||||
} else if (!local.keepOldFiles()) {
|
||||
try {
|
||||
local.delete();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.withCounter("addon.check_update.confirm"));
|
||||
}
|
||||
}
|
||||
|
||||
public List<LocalAddonFile> getFailedAddons() {
|
||||
return failedAddons;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependents() {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPreExecute() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void preExecute() {
|
||||
notifyPropertiesChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (!isDependentsSucceeded())
|
||||
throw getException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.ListPageBase;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
@@ -35,6 +36,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -100,28 +102,37 @@ public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.Da
|
||||
}
|
||||
|
||||
void removeSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> {
|
||||
try {
|
||||
dataPack.deletePack(pack);
|
||||
} catch (IOException e) {
|
||||
// Fail to remove mods if the game is running or the datapack is absent.
|
||||
failed.add(pack.getPath().toString());
|
||||
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("datapack.delete.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void enableSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
|
||||
void setSelectedEnabled(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems, boolean enabled) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> pack.setActive(true));
|
||||
}
|
||||
|
||||
void disableSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> pack.setActive(false));
|
||||
.forEach(pack -> {
|
||||
try {
|
||||
pack.setActive(enabled);
|
||||
} catch (IOException e) {
|
||||
failed.add(pack.getPath().toString());
|
||||
LOG.warning("Failed to enable or disable datapack \"" + pack.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("datapack.toggle.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void openDataPackFolder() {
|
||||
|
||||
@@ -25,8 +25,10 @@ import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
@@ -55,7 +57,9 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
@@ -131,9 +135,9 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
}, null);
|
||||
});
|
||||
JFXButton enableButton = createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems()));
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true));
|
||||
JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems()));
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false));
|
||||
removeButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
enableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
disableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
@@ -256,15 +260,11 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
}
|
||||
|
||||
static class DataPackInfoObject extends RecursiveTreeObject<DataPackInfoObject> {
|
||||
private final BooleanProperty activeProperty;
|
||||
private final DataPack.Pack packInfo;
|
||||
|
||||
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
|
||||
|
||||
DataPackInfoObject(DataPack.Pack packInfo) {
|
||||
this.packInfo = packInfo;
|
||||
this.activeProperty = packInfo.activeProperty();
|
||||
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
|
||||
@@ -318,7 +318,10 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
ImageContainer imageContainer = new ImageContainer(32);
|
||||
final TwoLineListItem content = new TwoLineListItem();
|
||||
BooleanProperty booleanProperty;
|
||||
|
||||
private final InvalidationListener activeListener = obs -> checkBox.setSelected(getItem().getPackInfo().isActive());
|
||||
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
|
||||
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
|
||||
|
||||
DataPackInfoListCell(JFXListView<DataPackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
|
||||
super(listView);
|
||||
@@ -343,13 +346,25 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
|
||||
@Override
|
||||
protected void updateControl(DataPackInfoObject dataItem, boolean empty) {
|
||||
if (observedActiveProperty != null)
|
||||
observedActiveProperty.removeListener(weakActiveListener);
|
||||
if (empty) return;
|
||||
content.setTitle(dataItem.getTitle());
|
||||
content.setSubtitle(dataItem.getSubtitle());
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.activeProperty);
|
||||
|
||||
DataPack.Pack packInfo = dataItem.getPackInfo();
|
||||
(observedActiveProperty = packInfo.activeProperty()).addListener(weakActiveListener);
|
||||
checkBox.setSelected(packInfo.isActive());
|
||||
checkBox.setOnAction(event -> {
|
||||
try {
|
||||
packInfo.setActive(checkBox.isSelected());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to change state of datapack " + packInfo.getPath(), e);
|
||||
Controllers.showToast(i18n("datapack.toggle.failed"));
|
||||
} finally {
|
||||
checkBox.setSelected(packInfo.isActive());
|
||||
}
|
||||
});
|
||||
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,14 @@ import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.download.DownloadCandidates;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -57,7 +60,6 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -85,16 +87,18 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
protected final StringProperty downloadSource = new SimpleStringProperty();
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private int searchID = 0;
|
||||
protected RemoteAddonRepository repository;
|
||||
protected final RemoteAddonRepository repository;
|
||||
protected final @NotNull RemoteAddon.Type type;
|
||||
private final DownloadProvider downloadProvider;
|
||||
|
||||
private Runnable retrySearch;
|
||||
|
||||
public DownloadListPage(RemoteAddonRepository repository) {
|
||||
this(repository, null, false);
|
||||
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository) {
|
||||
this(type, repository, null, false);
|
||||
}
|
||||
|
||||
public DownloadListPage(RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
this.type = Objects.requireNonNull(type);
|
||||
this.repository = repository;
|
||||
this.callback = callback;
|
||||
this.instanceSelection = instanceSelection;
|
||||
@@ -178,7 +182,7 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
return version != GameVersionNumber.unknown() ? version.toString() : "";
|
||||
}
|
||||
}).thenApplyAsync(
|
||||
gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
|
||||
gameVersion -> repository.search(downloadProvider, type, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
|
||||
).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (searchID != currentSearchID) {
|
||||
return;
|
||||
@@ -252,8 +256,8 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
|
||||
iconLoader = new RemoteImageLoader(control.downloadProvider) {
|
||||
@Override
|
||||
protected @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris) {
|
||||
return FXUtils.getRemoteImageTask(uris, 80, 80, true, true);
|
||||
protected @NotNull Task<Image> createLoadTask(@NotNull DownloadCandidates urls) {
|
||||
return FXUtils.getRemoteImageTask(urls, 80, 80, true, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -354,7 +358,7 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
|
||||
categoryComboBox.getSelectionModel().select(0);
|
||||
|
||||
Task.supplyAsync(() -> getSkinnable().repository.getCategories())
|
||||
Task.supplyAsync(() -> getSkinnable().repository.getCategories(getSkinnable().type))
|
||||
.thenAcceptAsync(Schedulers.javafx(), categories -> {
|
||||
if (!Objects.equals(getSkinnable().downloadSource.get(), downloadSource)) {
|
||||
return;
|
||||
@@ -604,8 +608,9 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
: PADDING
|
||||
);
|
||||
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.title());
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(getSkinnable().type);
|
||||
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(item.slug()) : null;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : item.title());
|
||||
String description = item.description();
|
||||
if (description != null) {
|
||||
description = description.replaceAll("\\R", " ");
|
||||
@@ -623,7 +628,23 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
});
|
||||
}
|
||||
|
||||
getChildren().setAll(pane);
|
||||
if (RegionUtils.isIsChinaMainlandNetwork()) {
|
||||
BorderPane wrapper = new BorderPane();
|
||||
|
||||
HintPane hint = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
hint.setSegment(i18n("download.china"));
|
||||
|
||||
BorderPane.setMargin(hint, new Insets(10, 10, 0, 10));
|
||||
|
||||
wrapper.setTop(hint);
|
||||
|
||||
pane.setDisable(true);
|
||||
wrapper.setCenter(pane);
|
||||
|
||||
this.getChildren().setAll(wrapper);
|
||||
} else {
|
||||
this.getChildren().setAll(pane);
|
||||
}
|
||||
}
|
||||
|
||||
private record CategoryIndented(int indent, RemoteAddonRepository.Category category) {
|
||||
|
||||
@@ -30,6 +30,8 @@ import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
@@ -65,9 +67,9 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty failed = new SimpleBooleanProperty(false);
|
||||
private final RemoteAddonRepository repository;
|
||||
private final ModTranslations translations;
|
||||
private final RemoteAddon addon;
|
||||
private final ModTranslations.Mod mod;
|
||||
/// Localized project metadata, or null when no registry or matching project is available.
|
||||
private final @Nullable AddonProjectInfo mod;
|
||||
private final HMCLGameInstance.Optional instanceReference;
|
||||
private final DownloadCallback callback;
|
||||
private final DownloadListPage page;
|
||||
@@ -79,9 +81,9 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
this.page = page;
|
||||
this.repository = page.repository;
|
||||
this.addon = addon;
|
||||
this.type = Objects.requireNonNullElse(addon.type(), repository.getType());
|
||||
this.translations = ModTranslations.getTranslationsByAddonType(this.type);
|
||||
this.mod = translations.getModByCurseForgeId(addon.slug());
|
||||
this.type = Objects.requireNonNullElse(addon.type(), page.type);
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(this.type);
|
||||
this.mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
|
||||
this.instanceReference = instanceReference;
|
||||
this.callback = callback;
|
||||
loadAddonVersions();
|
||||
@@ -220,8 +222,8 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
ModTranslations.Mod mod = getSkinnable().translations.getModByCurseForgeId(getSkinnable().addon.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : getSkinnable().addon.title());
|
||||
@Nullable AddonProjectInfo mod = getSkinnable().mod;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : getSkinnable().addon.title());
|
||||
content.setSubtitle(getSkinnable().addon.description());
|
||||
content.getSubtitleLabel().setWrapText(true);
|
||||
getSkinnable().addon.categories().stream()
|
||||
@@ -231,9 +233,10 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
content.getFirstLine().setMinWidth(0);
|
||||
descriptionPane.getChildren().add(content);
|
||||
|
||||
if (getSkinnable().mod != null) {
|
||||
@Nullable String mcmodUrl = mod != null ? mod.mcmodUrl() : null;
|
||||
if (mcmodUrl != null) {
|
||||
JFXHyperlink openMcmodButton = new JFXHyperlink(i18n("mods.mcmod"));
|
||||
openMcmodButton.setExternalLink(getSkinnable().translations.getMcmodUrl(getSkinnable().mod));
|
||||
openMcmodButton.setExternalLink(mcmodUrl);
|
||||
descriptionPane.getChildren().add(openMcmodButton);
|
||||
openMcmodButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||
}
|
||||
@@ -301,6 +304,22 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (getSkinnable().type == RemoteAddon.Type.MODPACK) {
|
||||
control.versions
|
||||
.keys()
|
||||
.stream()
|
||||
.max(Comparator.comparing(GameVersionNumber::asGameVersion))
|
||||
.flatMap(gameVersion ->
|
||||
control.versions.get(gameVersion)
|
||||
.stream()
|
||||
.max(Comparator.comparing(RemoteAddon.Version::datePublished))
|
||||
.map(v -> Pair.pair(gameVersion, v))
|
||||
).ifPresent(pair ->
|
||||
list.getContent().addAll(
|
||||
ComponentList.createComponentListTitle(i18n("addon.download.recommend", pair.key())),
|
||||
new AddonItem(control.addon, pair.value(), control)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
final class Versions {
|
||||
@@ -361,15 +380,14 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
}
|
||||
|
||||
private static final class DependencyAddonItem extends LineButton {
|
||||
public static final EnumMap<RemoteAddon.DependencyType, String> I18N_KEY = new EnumMap<>(Lang.mapOf(
|
||||
Pair.pair(RemoteAddon.DependencyType.EMBEDDED, "addon.dependency.embedded"),
|
||||
Pair.pair(RemoteAddon.DependencyType.OPTIONAL, "addon.dependency.optional"),
|
||||
Pair.pair(RemoteAddon.DependencyType.REQUIRED, "addon.dependency.required"),
|
||||
Pair.pair(RemoteAddon.DependencyType.TOOL, "addon.dependency.tool"),
|
||||
Pair.pair(RemoteAddon.DependencyType.INCLUDE, "addon.dependency.include"),
|
||||
Pair.pair(RemoteAddon.DependencyType.INCOMPATIBLE, "addon.dependency.incompatible"),
|
||||
Pair.pair(RemoteAddon.DependencyType.BROKEN, "addon.dependency.broken")
|
||||
));
|
||||
public static String translateDependencyType(RemoteAddon.DependencyType dependencyType) {
|
||||
return switch (dependencyType) {
|
||||
case EMBEDDED -> i18n("addon.dependency.embedded");
|
||||
case OPTIONAL -> i18n("addon.dependency.optional");
|
||||
case REQUIRED -> i18n("addon.dependency.required");
|
||||
default -> dependencyType.name().toLowerCase(Locale.ROOT);
|
||||
};
|
||||
}
|
||||
|
||||
public final RemoteAddon addon;
|
||||
|
||||
@@ -399,8 +417,9 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
});
|
||||
setNode(IDX_LEADING, pane);
|
||||
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(addon.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title());
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
|
||||
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : addon.title());
|
||||
content.setSubtitle(addon.description());
|
||||
for (String category : addon.categories()) {
|
||||
if (page.shouldDisplayCategory(category))
|
||||
@@ -582,7 +601,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
}
|
||||
|
||||
if (!dependencies.containsKey(dependency.getType())) {
|
||||
Label title = new Label(i18n(DependencyAddonItem.I18N_KEY.get(dependency.getType())));
|
||||
Label title = new Label(DependencyAddonItem.translateDependencyType(dependency.getType()));
|
||||
title.setPadding(new Insets(0, 8, 0, 8));
|
||||
List<DependencyAddonItem> list = new ArrayList<>();
|
||||
dependencies.put(dependency.getType(), Pair.pair(title, list));
|
||||
@@ -605,7 +624,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
return Task.allOf(queue).thenSupplyAsync(() -> {
|
||||
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
|
||||
if (types.value().isEmpty()) return Stream.of();
|
||||
if (types.value().isEmpty()) return Stream.empty();
|
||||
return Stream.concat(
|
||||
Stream.of(types.key()),
|
||||
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
|
||||
|
||||
@@ -60,6 +60,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
private final TabHeader.Tab<WorldListPage> worldListTab = new TabHeader.Tab<>("worldList");
|
||||
private final TabHeader.Tab<SchematicsPage> schematicsTab = new TabHeader.Tab<>("schematicsTab");
|
||||
private final TabHeader.Tab<ResourcePackListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
|
||||
private final TabHeader.Tab<ScreenshotsPage> screenshotsTab = new TabHeader.Tab<>("screenshotsTab");
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty();
|
||||
private final ObjectProperty<HMCLGameInstance.@Nullable Optional> instance =
|
||||
@@ -92,8 +93,9 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
resourcePackTab.setNodeSupplier(() -> new ResourcePackListPage(instance));
|
||||
worldListTab.setNodeSupplier(() -> new WorldListPage(instance));
|
||||
schematicsTab.setNodeSupplier(() -> new SchematicsPage(instance));
|
||||
screenshotsTab.setNodeSupplier(() -> new ScreenshotsPage(instance));
|
||||
|
||||
tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab);
|
||||
tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab, screenshotsTab);
|
||||
tab.select(gameSettingsTab);
|
||||
|
||||
addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
|
||||
@@ -363,13 +365,15 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
JFXPopup browsePopup = new JFXPopup(browseList);
|
||||
browseList.getContent().setAll(
|
||||
new IconedMenuItem(SVG.STADIA_CONTROLLER, i18n("folder.game"), () -> control.onBrowse(""), browsePopup),
|
||||
new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> control.onBrowse("mods"), browsePopup),
|
||||
new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> control.onBrowse("resourcepacks"), browsePopup),
|
||||
new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup),
|
||||
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), control::onBrowseSchematics, browsePopup),
|
||||
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup),
|
||||
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup),
|
||||
new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("folder.logs"), () -> control.onBrowse("logs"), browsePopup),
|
||||
new IconedMenuItem(SVG.FRAME_BUG, i18n("folder.crash-reports"), () -> control.onBrowse("crash-reports"), browsePopup)
|
||||
);
|
||||
@@ -385,6 +389,10 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), control::remove, managementPopup),
|
||||
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), control::export, managementPopup),
|
||||
new MenuSeparator(),
|
||||
// Due to the sidebar height limit, we temporarily place the entries for these tabs
|
||||
// in the management menu; they will be moved back to the sidebar after a future UI refactor.
|
||||
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("screenshots.manage"), () -> control.tab.select(control.screenshotsTab), managementPopup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(null, i18n("instance.manage.redownload_assets_index"), control::redownloadAssetIndex, managementPopup),
|
||||
new IconedMenuItem(null, i18n("instance.manage.remove_assets"), control::clearAssets, managementPopup),
|
||||
new IconedMenuItem(null, i18n("instance.manage.remove_libraries"), control::clearLibraries, managementPopup),
|
||||
|
||||
+19
-26
@@ -17,6 +17,8 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
@@ -32,35 +34,34 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
|
||||
public static DownloadListPage ofMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, true, true);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, false, true);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, true, true);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, true, true);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
|
||||
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, true, true);
|
||||
page.supportChinese.set(false);
|
||||
return page;
|
||||
}
|
||||
|
||||
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
|
||||
super(null, callback, instanceSelection);
|
||||
|
||||
repository = new Repository(type, curseForge, modrinth);
|
||||
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, boolean curseForge, boolean modrinth) {
|
||||
super(type, new Repository(), callback, instanceSelection);
|
||||
((Repository) repository).downloadSource.bind(downloadSource);
|
||||
|
||||
supportChinese.set(true);
|
||||
|
||||
boolean supportedCurseForge = CurseForgeRemoteAddonRepository.isAvailable() && curseForge != null;
|
||||
boolean supportedCurseForge = curseForge && CurseForgeRemoteAddonRepository.isAvailable();
|
||||
|
||||
downloadSources.setAll("addon.modrinth");
|
||||
if (supportedCurseForge) {
|
||||
@@ -70,13 +71,13 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
|
||||
if ("curseforge".equalsIgnoreCase(settings().defaultAddonSourceProperty().get())) {
|
||||
if (supportedCurseForge) {
|
||||
downloadSource.set("addon.curseforge");
|
||||
} else if (modrinth != null) {
|
||||
} else if (modrinth) {
|
||||
downloadSource.set("addon.modrinth");
|
||||
} else {
|
||||
throw new AssertionError("Should not be here.");
|
||||
}
|
||||
} else {
|
||||
if (modrinth != null) {
|
||||
if (modrinth) {
|
||||
downloadSource.set("addon.modrinth");
|
||||
} else if (supportedCurseForge) {
|
||||
downloadSource.set("addon.curseforge");
|
||||
@@ -86,30 +87,22 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
|
||||
}
|
||||
}
|
||||
|
||||
private class Repository extends LocalizedRemoteAddonRepository {
|
||||
private final RemoteAddon.Type type;
|
||||
private final CurseForgeRemoteAddonRepository curseForge;
|
||||
private final ModrinthRemoteAddonRepository modrinth;
|
||||
private static class Repository extends LocalizedRemoteAddonRepository {
|
||||
|
||||
public Repository(RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
|
||||
this.type = type;
|
||||
this.curseForge = curseForge;
|
||||
this.modrinth = modrinth;
|
||||
public Repository() {
|
||||
}
|
||||
|
||||
public final StringProperty downloadSource = new SimpleStringProperty();
|
||||
|
||||
@Override
|
||||
protected RemoteAddonRepository getBackedRemoteModRepository() {
|
||||
if ("addon.modrinth".equals(downloadSource.get())) {
|
||||
return modrinth;
|
||||
return ModrinthRemoteAddonRepository.getInstance();
|
||||
} else {
|
||||
return curseForge;
|
||||
return CurseForgeRemoteAddonRepository.getInstance();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteAddon.Type getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadCandidates;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameDownloadTask;
|
||||
@@ -46,15 +47,14 @@ import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -81,9 +81,9 @@ public final class Instances {
|
||||
|
||||
public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGameRepository repository, GameInstanceID instanceId, RemoteAddon mod, RemoteAddon.Version file) {
|
||||
Path modpack;
|
||||
List<URI> downloadURLs;
|
||||
DownloadCandidates downloadURLs;
|
||||
try {
|
||||
downloadURLs = downloadProvider.injectURLWithCandidates(file.file().url());
|
||||
downloadURLs = downloadProvider.getDownloadCandidates(file.file().url());
|
||||
modpack = Files.createTempFile("modpack", ".zip");
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
Controllers.dialog(
|
||||
@@ -235,7 +235,7 @@ public final class Instances {
|
||||
repository.setSelectedInstance(repository.getInstance(instanceId));
|
||||
} else {
|
||||
Controllers.dialog(
|
||||
DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
|
||||
I18n.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
}), i18n("install.new_game"), TaskCancellationAction.NORMAL);
|
||||
}, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName)));
|
||||
|
||||
@@ -20,9 +20,12 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
@@ -41,10 +44,13 @@ import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
@@ -210,29 +216,39 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
}
|
||||
|
||||
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
try {
|
||||
modManager.removeMods(selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.toArray(LocalModFile[]::new));
|
||||
loadMods(modManager);
|
||||
} catch (IOException ignore) {
|
||||
// Fail to remove mods if the game is running or the mod is absent.
|
||||
}
|
||||
}
|
||||
|
||||
void enableSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> info.setActive(true));
|
||||
.forEach(info -> {
|
||||
try {
|
||||
info.delete();
|
||||
} catch (IOException e) {
|
||||
failed.add(info.getFile().toString());
|
||||
LOG.warning("Failed to delete mod \"" + info.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
loadMods(modManager);
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("mods.delete.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void disableSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
void setSelectedEnabled(ObservableList<ModInfoObject> selectedItems, boolean enabled) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> info.setActive(false));
|
||||
.forEach(info -> {
|
||||
try {
|
||||
info.setActive(enabled);
|
||||
} catch (IOException e) {
|
||||
failed.add(info.getFile().toString());
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("mods.toggle.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
public void openModFolder() {
|
||||
@@ -251,19 +267,22 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
return version != GameVersionNumber.unknown()
|
||||
? new AddonCheckUpdatesTask<>(
|
||||
DownloadProviders.getDownloadProvider(), version.toString(), mods)
|
||||
: null;
|
||||
if (version != GameVersionNumber.unknown()) {
|
||||
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods);
|
||||
} else {
|
||||
LOG.warning("Failed to check for updates, due to unable to get instance game version");
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception instanceof CancellationException) return;
|
||||
if (exception != null || result == null) {
|
||||
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
|
||||
} else if (result.isEmpty()) {
|
||||
if (exception != null) LOG.warning("Failed to check for updates", exception);
|
||||
} else if (result.commonUpdates().isEmpty()) {
|
||||
Controllers.dialog(i18n("addon.check_update.empty"));
|
||||
} else {
|
||||
Controllers.navigateForward(new AddonUpdatesPage<>(modManager, result));
|
||||
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), modManager.getDirectory(), result));
|
||||
}
|
||||
})
|
||||
.withStagesHints("update.checking"),
|
||||
@@ -411,9 +430,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
}, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
|
||||
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getSelectionModel().getSelectedItems().stream()
|
||||
@@ -537,7 +556,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|| predicate.test(modInfo.getGameVersion())
|
||||
|| predicate.test(modInfo.getId())
|
||||
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|
||||
|| predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) {
|
||||
|| predicate.test((item.getProjectInfo() != null ? item.getProjectInfo().displayName() : null))) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
@@ -547,17 +566,15 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
}
|
||||
|
||||
public static final class ModInfoObject {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
private final @Nullable ModTranslations.Mod modTranslations;
|
||||
/// Matching project metadata, or null if the local mod cannot be identified.
|
||||
private final @Nullable ModProjectInfo projectInfo;
|
||||
|
||||
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
|
||||
this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName());
|
||||
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
@@ -566,8 +583,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
public @Nullable ModTranslations.Mod getModTranslations() {
|
||||
return modTranslations;
|
||||
/// Returns matching project metadata, or null if no project was found.
|
||||
public @Nullable ModProjectInfo getProjectInfo() {
|
||||
return projectInfo;
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
@@ -617,8 +635,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.getTitleLabel().setWrapText(true);
|
||||
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getModTranslations().getDisplayName());
|
||||
if (modInfo.getProjectInfo() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getProjectInfo().displayName());
|
||||
else
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
|
||||
@@ -657,8 +675,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
|
||||
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.MODS),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.MODS)
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
|
||||
)) {
|
||||
RemoteAddonRepository repository = item.getValue();
|
||||
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
|
||||
@@ -690,7 +708,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
getActions().add(officialPageButton);
|
||||
}
|
||||
|
||||
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
|
||||
@Nullable String mcmodUrl = modInfo.getProjectInfo() != null ? modInfo.getProjectInfo().mcmodUrl() : null;
|
||||
if (mcmodUrl == null) {
|
||||
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
|
||||
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
|
||||
pair("key", modInfo.getModInfo().getName()),
|
||||
@@ -700,7 +719,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
getActions().add(searchButton);
|
||||
} else {
|
||||
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
|
||||
mcmodButton.setExternalLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
|
||||
mcmodButton.setExternalLink(mcmodUrl);
|
||||
getActions().add(mcmodButton);
|
||||
}
|
||||
|
||||
@@ -729,7 +748,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
|
||||
private BooleanProperty booleanProperty;
|
||||
private final InvalidationListener activeListener = obs -> checkBox.setSelected(getItem().getModInfo().isActive());
|
||||
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
|
||||
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
|
||||
|
||||
private Tooltip warningTooltip;
|
||||
|
||||
@@ -758,6 +779,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|
||||
@Override
|
||||
protected void updateControl(ModInfoObject dataItem, boolean empty) {
|
||||
if (observedActiveProperty != null)
|
||||
observedActiveProperty.removeListener(weakActiveListener);
|
||||
|
||||
pseudoClassStateChanged(WARNING, false);
|
||||
if (warningTooltip != null) {
|
||||
Tooltip.uninstall(this, warningTooltip);
|
||||
@@ -771,15 +795,15 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
content.getTags().clear();
|
||||
|
||||
LocalModFile modInfo = dataItem.getModInfo();
|
||||
ModTranslations.Mod modTranslations = dataItem.getModTranslations();
|
||||
@Nullable ModProjectInfo projectInfo = dataItem.getProjectInfo();
|
||||
|
||||
ModLoaderType modLoaderType = modInfo.getModLoaderType();
|
||||
|
||||
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
|
||||
String displayName = modInfo.getName();
|
||||
if (modTranslations != null && I18n.isUseChinese()) {
|
||||
String chineseName = modTranslations.getName();
|
||||
if (projectInfo != null && I18n.isUseChinese()) {
|
||||
String chineseName = projectInfo.mainName();
|
||||
if (StringUtils.containsChinese(chineseName)) {
|
||||
if (StringUtils.containsEmoji(chineseName)) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
@@ -819,10 +843,18 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
content.addTag(modVersion);
|
||||
}
|
||||
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
|
||||
(observedActiveProperty = modInfo.activeProperty()).addListener(weakActiveListener);
|
||||
checkBox.setSelected(modInfo.isActive());
|
||||
checkBox.setOnAction(event -> {
|
||||
try {
|
||||
modInfo.setActive(checkBox.isSelected());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to change state of mod file " + modInfo.getFile(), e);
|
||||
Controllers.showToast(i18n("mods.toggle.failed"));
|
||||
} finally {
|
||||
checkBox.setSelected(modInfo.isActive());
|
||||
}
|
||||
});
|
||||
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
|
||||
restoreButton.setOnAction(e -> {
|
||||
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
/**
|
||||
* Parser for mod_data.txt
|
||||
*
|
||||
* @see <a href="https://www.mcmod.cn">mcmod.cn</a>
|
||||
*/
|
||||
public enum ModTranslations {
|
||||
MOD("/assets/mod_data.txt") {
|
||||
@Override
|
||||
public String getMcmodUrl(Mod mod) {
|
||||
return String.format("https://www.mcmod.cn/class/%s.html", mod.getMcmod());
|
||||
}
|
||||
},
|
||||
MODPACK("/assets/modpack_data.txt") {
|
||||
@Override
|
||||
public String getMcmodUrl(Mod mod) {
|
||||
return String.format("https://www.mcmod.cn/modpack/%s.html", mod.getMcmod());
|
||||
}
|
||||
},
|
||||
EMPTY("") {
|
||||
@Override
|
||||
public String getMcmodUrl(Mod mod) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
public static ModTranslations getTranslationsByAddonType(@Nullable RemoteAddon.Type type) {
|
||||
if (type == null) return EMPTY;
|
||||
return switch (type) {
|
||||
case MOD -> MOD;
|
||||
case MODPACK -> MODPACK;
|
||||
default -> EMPTY;
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("StatementWithEmptyBody")
|
||||
private static String cleanSubname(String subname) {
|
||||
if (StringUtils.isBlank(subname))
|
||||
return "";
|
||||
|
||||
StringBuilder builder = new StringBuilder(subname.length());
|
||||
for (int i = 0; i < subname.length(); ) {
|
||||
int ch = subname.codePointAt(i);
|
||||
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||
|| ".+\\".indexOf(ch) >= 0) {
|
||||
builder.appendCodePoint(ch);
|
||||
} else if (Character.isWhitespace(ch)
|
||||
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0
|
||||
|| ch >= 0x1F300 && ch <= 0x1FAFF) {
|
||||
// Remove these unnecessary characters from subname
|
||||
} else {
|
||||
// The subname contains unsupported characters, so we do not use this subname to match the mod
|
||||
return "";
|
||||
}
|
||||
i += Character.charCount(ch);
|
||||
}
|
||||
return builder.length() == subname.length() ? subname : builder.toString();
|
||||
}
|
||||
|
||||
private final String resourceName;
|
||||
private volatile List<Mod> mods;
|
||||
private volatile Map<String, Mod> modIdMap; // mod id -> mod
|
||||
private volatile Map<String, Mod> subnameMap;
|
||||
private volatile Map<String, Mod> curseForgeMap; // curseforge id -> mod
|
||||
private volatile List<Pair<String, Mod>> keywords;
|
||||
private volatile int maxKeywordLength = -1;
|
||||
|
||||
ModTranslations(String resourceName) {
|
||||
this.resourceName = resourceName;
|
||||
}
|
||||
|
||||
public @NotNull List<Mod> getMods() {
|
||||
List<Mod> mods = this.mods;
|
||||
if (mods != null)
|
||||
return mods;
|
||||
|
||||
synchronized (this) {
|
||||
mods = this.mods;
|
||||
if (mods != null)
|
||||
return mods;
|
||||
|
||||
if (StringUtils.isBlank(resourceName)) {
|
||||
return this.mods = List.of();
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
ModTranslations.class.getResourceAsStream(resourceName), StandardCharsets.UTF_8))) {
|
||||
return this.mods = reader.lines().filter(line -> !line.startsWith("#")).map(Mod::new).toList();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load " + resourceName, e);
|
||||
return this.mods = List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getModIdMap() {
|
||||
Map<String, Mod> modIdMap = this.modIdMap;
|
||||
if (modIdMap != null)
|
||||
return modIdMap;
|
||||
synchronized (this) {
|
||||
modIdMap = this.modIdMap;
|
||||
if (modIdMap != null)
|
||||
return modIdMap;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
modIdMap = new HashMap<>(mods.size());
|
||||
for (Mod mod : mods) {
|
||||
for (String id : mod.getModIds()) {
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
modIdMap.putIfAbsent(id, mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.modIdMap = modIdMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getSubnameMap() {
|
||||
Map<String, Mod> subnameMap = this.subnameMap;
|
||||
if (subnameMap != null)
|
||||
return subnameMap;
|
||||
synchronized (this) {
|
||||
subnameMap = this.subnameMap;
|
||||
if (subnameMap != null)
|
||||
return subnameMap;
|
||||
|
||||
subnameMap = new HashMap<>();
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
for (Mod mod : mods) {
|
||||
String subname = cleanSubname(mod.getSubname());
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
subnameMap.putIfAbsent(subname, mod);
|
||||
}
|
||||
}
|
||||
|
||||
return this.subnameMap = subnameMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getCurseForgeMap() {
|
||||
Map<String, Mod> curseForgeMap = this.curseForgeMap;
|
||||
if (curseForgeMap != null)
|
||||
return curseForgeMap;
|
||||
|
||||
synchronized (this) {
|
||||
curseForgeMap = this.curseForgeMap;
|
||||
if (curseForgeMap != null)
|
||||
return curseForgeMap;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
curseForgeMap = new HashMap<>(mods.size());
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getCurseforge())) {
|
||||
curseForgeMap.putIfAbsent(mod.getCurseforge(), mod);
|
||||
}
|
||||
}
|
||||
|
||||
return this.curseForgeMap = curseForgeMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull List<Pair<String, Mod>> getKeywords() {
|
||||
List<Pair<String, Mod>> keywords = this.keywords;
|
||||
if (keywords != null)
|
||||
return keywords;
|
||||
|
||||
synchronized (this) {
|
||||
keywords = this.keywords;
|
||||
if (keywords != null)
|
||||
return keywords;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
|
||||
keywords = new ArrayList<>();
|
||||
int maxKeywordLength = -1;
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getName())) {
|
||||
keywords.add(pair(mod.getName(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getName().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getSubname())) {
|
||||
keywords.add(pair(mod.getSubname(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getSubname().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getAbbr())) {
|
||||
keywords.add(pair(mod.getAbbr(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getAbbr().length());
|
||||
}
|
||||
}
|
||||
|
||||
this.maxKeywordLength = maxKeywordLength;
|
||||
return this.keywords = keywords;
|
||||
}
|
||||
}
|
||||
|
||||
private int getMaxKeywordLength() {
|
||||
int maxKeywordLength = this.maxKeywordLength;
|
||||
if (maxKeywordLength >= 0)
|
||||
return maxKeywordLength;
|
||||
|
||||
// Ensure maxKeywordLength is initialized
|
||||
getKeywords();
|
||||
return this.maxKeywordLength;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Mod getModByCurseForgeId(String id) {
|
||||
if (StringUtils.isBlank(id)) return null;
|
||||
|
||||
return getCurseForgeMap().get(id);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Mod getMod(String id, String subname) {
|
||||
subname = cleanSubname(subname);
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
Mod mod = getSubnameMap().get(subname);
|
||||
if (mod != null && (StringUtils.isBlank(id) || mod.getModIds().contains(id)))
|
||||
return mod;
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(id))
|
||||
return getModIdMap().get(id);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public abstract String getMcmodUrl(Mod mod);
|
||||
|
||||
public List<Mod> searchMod(String query) {
|
||||
StringBuilder newQuery = query.chars()
|
||||
.filter(ch -> !Character.isSpaceChar(ch))
|
||||
.collect(StringBuilder::new, (sb, value) -> sb.append((char) value), StringBuilder::append);
|
||||
query = newQuery.toString();
|
||||
|
||||
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), getMaxKeywordLength());
|
||||
List<Pair<Integer, Mod>> modList = new ArrayList<>();
|
||||
for (Pair<String, Mod> keyword : getKeywords()) {
|
||||
int value = lcs.calc(query, keyword.getKey());
|
||||
if (value >= Math.max(1, query.length() - 3)) {
|
||||
modList.add(pair(value, keyword.getValue()));
|
||||
}
|
||||
}
|
||||
return modList.stream()
|
||||
.sorted((a, b) -> -a.getKey().compareTo(b.getKey()))
|
||||
.map(Pair::getValue)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static final class Mod {
|
||||
private final String curseforge;
|
||||
private final String mcmod;
|
||||
private final List<String> modIds;
|
||||
private final String name;
|
||||
private final String subname;
|
||||
private final String abbr;
|
||||
|
||||
public Mod(String line) {
|
||||
String[] items = line.split(";", -1);
|
||||
if (items.length != 6) {
|
||||
throw new IllegalArgumentException("Illegal mod data line, 6 items expected " + line);
|
||||
}
|
||||
|
||||
curseforge = items[0];
|
||||
mcmod = items[1];
|
||||
modIds = List.of(items[2].split(","));
|
||||
name = items[3];
|
||||
subname = items[4];
|
||||
abbr = items[5];
|
||||
}
|
||||
|
||||
public Mod(String curseforge, String mcmod, List<String> modIds, String name, String subname, String abbr) {
|
||||
this.curseforge = curseforge;
|
||||
this.mcmod = mcmod;
|
||||
this.modIds = modIds;
|
||||
this.name = name;
|
||||
this.subname = subname;
|
||||
this.abbr = abbr;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (StringUtils.isNotBlank(abbr)) {
|
||||
builder.append("[").append(abbr.trim()).append("] ");
|
||||
}
|
||||
builder.append(name);
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
builder.append(" (").append(subname).append(")");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public String getCurseforge() {
|
||||
return curseforge;
|
||||
}
|
||||
|
||||
public String getMcmod() {
|
||||
return mcmod;
|
||||
}
|
||||
|
||||
public List<String> getModIds() {
|
||||
return modIds;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getSubname() {
|
||||
return subname;
|
||||
}
|
||||
|
||||
public String getAbbr() {
|
||||
return abbr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
|
||||
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
@@ -261,18 +262,20 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
return version != GameVersionNumber.unknown()
|
||||
? new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(),
|
||||
version.toString(), resourcePacks)
|
||||
: null;
|
||||
if (version != GameVersionNumber.unknown()) {
|
||||
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), resourcePacks);
|
||||
} else {
|
||||
LOG.warning("Failed to check for updates, due to unable to get instance game version");
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null || result == null) {
|
||||
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
|
||||
} else if (result.isEmpty()) {
|
||||
} else if (result.commonUpdates().isEmpty()) {
|
||||
Controllers.dialog(i18n("addon.check_update.empty"));
|
||||
} else {
|
||||
Controllers.navigateForward(new AddonUpdatesPage<>(resourcePackManager, result));
|
||||
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), resourcePackManager.getDirectory(), result));
|
||||
}
|
||||
})
|
||||
.withStagesHints("update.checking"),
|
||||
@@ -483,7 +486,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
LocalAddonFile.Description description = resourcePack.getDescription();
|
||||
Stream<String> descriptionParts = description == null
|
||||
? Stream.empty()
|
||||
: description.getParts().stream().map(LocalAddonFile.Description.Part::getText);
|
||||
: description.parts().stream().map(LocalAddonFile.Description.Part::text);
|
||||
if (predicate.test(resourcePack.getFileNameWithExtension())
|
||||
|| predicate.test(resourcePack.getFileName())
|
||||
|| descriptionParts.anyMatch(predicate)) {
|
||||
@@ -662,8 +665,8 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
setBody(descriptionPane);
|
||||
|
||||
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.RESOURCE_PACKS),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.RESOURCE_PACKS)
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
|
||||
)) {
|
||||
RemoteAddonRepository repository = item.getValue();
|
||||
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
|
||||
|
||||
@@ -87,7 +87,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
private static final SynchronizedExceptionalLazy<RemoteAddon> litematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
|
||||
try {
|
||||
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "litematica");
|
||||
return ModrinthRemoteAddonRepository.getInstance().getAddonById(DownloadProviders.getDownloadProvider(), "litematica");
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to fetch litematica", e);
|
||||
throw e;
|
||||
@@ -95,7 +95,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
});
|
||||
private static final SynchronizedExceptionalLazy<RemoteAddon> forgematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
|
||||
try {
|
||||
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "forgematica");
|
||||
return ModrinthRemoteAddonRepository.getInstance().getAddonById(DownloadProviders.getDownloadProvider(), "forgematica");
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to fetch forgematica", e);
|
||||
throw e;
|
||||
@@ -742,10 +742,10 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
{
|
||||
var fo = SVG.FOLDER_OPEN.createIcon();
|
||||
var f = SVG.FOLDER.createIcon();
|
||||
btnReveal.graphicProperty().bind(isDirectoryProperty.map(b -> b ? fo : f));
|
||||
btnReveal.graphicProperty().bind(Bindings.when(isDirectoryProperty).then(fo).otherwise(f));
|
||||
|
||||
var tooltip = new Tooltip();
|
||||
tooltip.textProperty().bind(isDirectoryProperty.map(b -> b ? i18n("button.reveal_dir") : i18n("reveal.in_file_manager")));
|
||||
tooltip.textProperty().bind(Bindings.when(isDirectoryProperty).then(i18n("button.reveal_dir")).otherwise(i18n("reveal.in_file_manager")));
|
||||
FXUtils.installFastTooltip(btnReveal, tooltip);
|
||||
}
|
||||
btnReveal.setOnAction(event -> {
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* 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.instances;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.BackgroundType;
|
||||
import org.jackhuang.hmcl.setting.LauncherSettings;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
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.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Lists screenshots of the current game instance and provides preview and file actions.
|
||||
public final class ScreenshotsPage extends ListPageBase<ScreenshotsPage.Screenshot> {
|
||||
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private @Nullable Path screenshotsDir;
|
||||
|
||||
public ScreenshotsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new ScreenshotsPageSkin(this);
|
||||
}
|
||||
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
HMCLGameInstance gameInstance = instance.instance();
|
||||
if (gameInstance == null) return;
|
||||
screenshotsDir = gameInstance.getRunDirectory().resolve("screenshots");
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (screenshotsDir == null) return;
|
||||
setLoading(true);
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
if (screenshotsDir == null) return List.<Screenshot>of();
|
||||
try (Stream<Path> stream = Files.list(screenshotsDir)) {
|
||||
return stream.map(Screenshot::fromFile).filter(Objects::nonNull).sorted(Comparator.reverseOrder()).toList();
|
||||
}
|
||||
}).whenComplete(Schedulers.javafx(), (list, exception) -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to load screenshots in: " + screenshotsDir, exception);
|
||||
getItems().clear();
|
||||
} else {
|
||||
getItems().setAll(list);
|
||||
}
|
||||
setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void revealFolder() {
|
||||
if (screenshotsDir != null) FXUtils.openFolder(screenshotsDir);
|
||||
}
|
||||
|
||||
private void deleteAt(Path path) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete screenshot: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void delete(Screenshot screenshot) {
|
||||
deleteAt(screenshot.getPath());
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void delete(Collection<Screenshot> screenshots) {
|
||||
screenshots.stream().map(Screenshot::getPath).forEach(this::deleteAt);
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void clear() {
|
||||
if (screenshotsDir == null) return;
|
||||
try (var stream = Files.list(screenshotsDir)) {
|
||||
stream.filter(Screenshot::isFileScreenshot).forEach(this::deleteAt);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to clear screenshots at: " + screenshotsDir, e);
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void selectDate(JFXListView<Screenshot> listView) {
|
||||
FXUtils.chooseDateRange(pair -> {
|
||||
Instant from = pair.key() != null ? pair.key().atStartOfDay().atZone(ZoneId.systemDefault()).toInstant() : Instant.MIN;
|
||||
Instant to = pair.value() != null ? pair.value().plusDays(1).atStartOfDay().atZone(ZoneId.systemDefault()).toInstant() : Instant.MAX;
|
||||
if (from.compareTo(to) >= 0) return;
|
||||
List<Screenshot> items = listView.getItems();
|
||||
int start = -1, end = -1;
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
// Reversed order
|
||||
var creationTime = items.get(i).getCreationTime();
|
||||
if ((creationTime == null || creationTime.compareTo(from) < 0) && end < 0) end = i;
|
||||
if ((creationTime == null || creationTime.compareTo(to) < 0) && start < 0) start = i;
|
||||
}
|
||||
if (start < 0) return;
|
||||
if (end < 0) end = items.size();
|
||||
listView.getSelectionModel().selectRange(start, end);
|
||||
listView.scrollTo(start);
|
||||
});
|
||||
}
|
||||
|
||||
/// Describes a screenshot file and loads its preview images on demand.
|
||||
public static final class Screenshot implements Comparable<Screenshot> {
|
||||
private final Path path;
|
||||
private final String fileName;
|
||||
private final BasicFileAttributes attributes;
|
||||
|
||||
/// Caches asynchronous thumbnail loads with a soft reference.
|
||||
private final ItemPropertyAsyncCache<@Nullable Image, Screenshot> thumbnailCache =
|
||||
new ItemPropertyAsyncCache.Soft<>(this, this::loadThumbnail, null);
|
||||
|
||||
/// Retains the full image after a successful load, or is null before one succeeds.
|
||||
private @Nullable Image fullImage;
|
||||
|
||||
public static boolean isFileScreenshot(Path path) {
|
||||
return Files.isRegularFile(path) && "png".equalsIgnoreCase(FileUtils.getExtension(path));
|
||||
}
|
||||
|
||||
public static Screenshot fromFile(Path path) {
|
||||
if (!isFileScreenshot(path)) return null;
|
||||
BasicFileAttributes attr = null;
|
||||
try {
|
||||
attr = Files.readAttributes(path, BasicFileAttributes.class);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read screenshot attributes at: " + path, e);
|
||||
}
|
||||
return new Screenshot(path, FileUtils.getName(path), attr);
|
||||
}
|
||||
|
||||
public Screenshot(Path path, String fileName, BasicFileAttributes attributes) {
|
||||
this.path = path;
|
||||
this.fileName = fileName;
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public BasicFileAttributes getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public Instant getCreationTime() {
|
||||
if (attributes == null) return null;
|
||||
return attributes.creationTime().toInstant();
|
||||
}
|
||||
|
||||
public long getFileSize() {
|
||||
if (attributes == null) return -1;
|
||||
return attributes.size();
|
||||
}
|
||||
|
||||
public boolean isFullImageLoaded() {
|
||||
return fullImage != null;
|
||||
}
|
||||
|
||||
/// Loads an aspect-ratio-preserving thumbnail bounded by 72 by 72 pixels.
|
||||
///
|
||||
/// @return the thumbnail, or null if loading throws an exception
|
||||
@Nullable
|
||||
private Image loadThumbnail() {
|
||||
try {
|
||||
return FXUtils.loadImage(path, 72, 72, true, false);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load screenshot thumbnail at: " + path, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Image getFullImage() {
|
||||
if (fullImage == null) {
|
||||
try {
|
||||
fullImage = FXUtils.loadImage(path);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load screenshot content at: " + path, e);
|
||||
}
|
||||
}
|
||||
return fullImage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
return obj instanceof Screenshot that && Objects.equals(this.path, that.path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(@NotNull ScreenshotsPage.Screenshot o) {
|
||||
return this.path.compareTo(o.path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Displays a screenshot thumbnail, metadata, and file actions in a reusable cell.
|
||||
private static final class ScreenshotCell extends MDListCell<Screenshot> {
|
||||
|
||||
/// Holds the thumbnail or the placeholder icon when no thumbnail is available.
|
||||
private final StackPane imagePane = new StackPane();
|
||||
/// Receives the cached thumbnail for the current item.
|
||||
private final ObjectProperty<@Nullable Image> thumbnail = new SimpleObjectProperty<>();
|
||||
/// Displays the screenshot file name and creation time.
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
|
||||
/// Creates a cell whose file actions operate on the supplied page.
|
||||
public ScreenshotCell(JFXListView<Screenshot> listView, ScreenshotsPage page) {
|
||||
super(listView);
|
||||
|
||||
setSelectable();
|
||||
|
||||
FXUtils.onChangeAndOperate(thumbnail, image -> imagePane.getChildren().setAll(
|
||||
image == null ? SVG.SCREENSHOT_MONITOR.createIcon(36) : new ImageContainer(image, 36, 36)));
|
||||
|
||||
HBox container = new HBox(8);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
content.setMouseTransparent(true);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
|
||||
JFXButton copyButton = FXUtils.newToggleButton4(SVG.CONTENT_COPY);
|
||||
copyButton.setOnAction(e -> {
|
||||
@Nullable Screenshot screenshot = getItem();
|
||||
if (screenshot != null) {
|
||||
@Nullable Image image = screenshot.getFullImage();
|
||||
if (image != null) FXUtils.copyImage(image);
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
revealButton.setOnAction(e -> {
|
||||
@Nullable Screenshot screenshot = getItem();
|
||||
if (screenshot != null) {
|
||||
FXUtils.showFileInExplorer(screenshot.getPath());
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
infoButton.setOnAction(e -> {
|
||||
@Nullable Screenshot screenshot = getItem();
|
||||
if (screenshot != null) {
|
||||
Controllers.dialog(new ScreenshotDialog(screenshot));
|
||||
}
|
||||
});
|
||||
FXUtils.onDoubleClicked(this, infoButton::fire);
|
||||
|
||||
JFXButton deleteButton = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
|
||||
deleteButton.setOnAction(e -> {
|
||||
@Nullable Screenshot screenshot = getItem();
|
||||
if (screenshot != null) {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
|
||||
() -> page.delete(screenshot), null);
|
||||
}
|
||||
});
|
||||
|
||||
container.getChildren().setAll(imagePane, content, copyButton, revealButton, infoButton, deleteButton);
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
getContainer().getChildren().setAll(container);
|
||||
}
|
||||
|
||||
/// Attaches the current item's cached thumbnail and updates its metadata.
|
||||
/// Clears the displayed thumbnail when the cell becomes empty.
|
||||
@Override
|
||||
protected void updateControl(@Nullable Screenshot item, boolean empty) {
|
||||
if (item == null || empty) {
|
||||
thumbnail.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
item.thumbnailCache.attachValue(thumbnail, new WeakReference<>(itemProperty()));
|
||||
|
||||
content.setTitle(item.getFileName());
|
||||
@Nullable Instant creationTime = item.getCreationTime();
|
||||
if (creationTime != null) content.setSubtitle(I18n.formatDateTime(creationTime));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ScreenshotDialog extends JFXDialogLayout {
|
||||
|
||||
public ScreenshotDialog(Screenshot screenshot) {
|
||||
TwoLineListItem head = new TwoLineListItem();
|
||||
head.setTitle(screenshot.getFileName());
|
||||
setHeading(head);
|
||||
|
||||
var image = screenshot.getFullImage();
|
||||
if (image == null) {
|
||||
setBody(SVG.SCREENSHOT_MONITOR.createIcon(360));
|
||||
if (screenshot.getAttributes() != null)
|
||||
head.setSubtitle(I18n.formatDateTime(screenshot.getCreationTime()) + " " + FileUtils.parseFileSize(screenshot.getFileSize()));
|
||||
} else {
|
||||
setBody(new ImageContainer(image, Math.min(Controllers.getDecorator().contentWidthProperty().get() * 0.8, image.getWidth()), Controllers.getDecorator().contentHeightProperty().get() * 0.57));
|
||||
if (screenshot.getAttributes() != null)
|
||||
head.setSubtitle(
|
||||
I18n.formatDateTime(screenshot.getCreationTime())
|
||||
+ " " + FileUtils.parseFileSize(screenshot.getFileSize())
|
||||
+ " " + (int) image.getWidth() + " * " + (int) image.getHeight()
|
||||
);
|
||||
else head.setSubtitle((int) image.getWidth() + " * " + (int) image.getHeight());
|
||||
}
|
||||
|
||||
JFXButton setAsBgButton = new JFXButton(i18n("screenshots.set_as_bg"));
|
||||
setAsBgButton.getStyleClass().add("dialog-accept");
|
||||
setAsBgButton.setOnAction(e -> {
|
||||
SettingsManager.settings().getThemeAppearanceOverrides().add(LauncherSettings.THEME_APPEARANCE_BACKGROUND);
|
||||
SettingsManager.settings().backgroundTypeProperty().set(BackgroundType.CUSTOM);
|
||||
SettingsManager.settings().customBackgroundImagePathProperty().set(screenshot.getPath().toString());
|
||||
});
|
||||
|
||||
JFXButton copyButton = new JFXButton(i18n("menu.copy"));
|
||||
copyButton.getStyleClass().add("dialog-accept");
|
||||
copyButton.setDisable(image == null);
|
||||
copyButton.setOnAction(e -> {
|
||||
if (image != null) FXUtils.copyImage(image);
|
||||
});
|
||||
|
||||
JFXButton okButton = new JFXButton(i18n("button.ok"));
|
||||
okButton.getStyleClass().add("dialog-accept");
|
||||
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
FXUtils.onEscPressed(this, okButton::fire);
|
||||
setActions(setAsBgButton, copyButton, okButton);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ScreenshotsPageSkin extends SkinBase<ScreenshotsPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox toolbarNormal;
|
||||
private final HBox toolbarSelecting;
|
||||
|
||||
private final JFXListView<Screenshot> listView;
|
||||
|
||||
public ScreenshotsPageSkin(ScreenshotsPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
StackPane pane = new StackPane();
|
||||
pane.setPadding(new Insets(10));
|
||||
pane.getStyleClass().addAll("notice-pane");
|
||||
|
||||
ComponentList root = new ComponentList();
|
||||
root.getStyleClass().add("no-padding");
|
||||
listView = new JFXListView<>();
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
|
||||
toolbarNormal = new HBox();
|
||||
toolbarSelecting = new HBox();
|
||||
|
||||
// Toolbar Normal
|
||||
toolbarNormal.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::revealFolder),
|
||||
createToolbarButton2(i18n("button.clear"), SVG.DELETE_FOREVER, () -> {
|
||||
Controllers.confirm(i18n("screenshots.clear.confirm"), i18n("button.clear"), skinnable::clear, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("button.select_date"), SVG.DATE_RANGE, () -> skinnable.selectDate(listView))
|
||||
);
|
||||
|
||||
// Toolbar Selecting
|
||||
toolbarSelecting.getChildren().setAll(
|
||||
createToolbarButton2(i18n("menu.copy"), SVG.FOLDER_COPY, () -> {
|
||||
FXUtils.copyFiles(listView.getSelectionModel().getSelectedItems().stream().map(Screenshot::getPath).toList());
|
||||
}),
|
||||
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () ->
|
||||
skinnable.delete(listView.getSelectionModel().getSelectedItems()), null);
|
||||
}),
|
||||
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
|
||||
listView.getSelectionModel().selectAll()),
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
|
||||
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
|
||||
selectedItem -> {
|
||||
if (selectedItem == null)
|
||||
changeToolbar(toolbarNormal);
|
||||
else
|
||||
changeToolbar(toolbarSelecting);
|
||||
});
|
||||
root.getContent().add(toolbarPane);
|
||||
|
||||
// Clear selection when pressing ESC
|
||||
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
|
||||
if (e.getCode() == KeyCode.ESCAPE) {
|
||||
if (listView.getSelectionModel().getSelectedItem() != null) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
e.consume();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(skinnable.loadingProperty());
|
||||
|
||||
listView.setCellFactory(x -> new ScreenshotCell(listView, skinnable));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
|
||||
listView.setOnContextMenuRequested(event -> {
|
||||
Screenshot selectedItem = listView.getSelectionModel().getSelectedItem();
|
||||
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
Controllers.dialog(new ScreenshotDialog(selectedItem));
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
pane.getChildren().setAll(root);
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
private void changeToolbar(HBox newToolbar) {
|
||||
if (newToolbar != toolbarPane.getCurrentNode()) toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -65,8 +65,8 @@ public class DownloadSettingsPage extends StackPane {
|
||||
getChildren().setAll(scrollPane);
|
||||
|
||||
{
|
||||
var downloadSource = new ComponentList();
|
||||
downloadSource.getStyleClass().add("card-non-transparent");
|
||||
var gameContent = new ComponentList();
|
||||
gameContent.getStyleClass().add("card-non-transparent");
|
||||
{
|
||||
Function<DownloadSource, String> converter = source -> switch (source) {
|
||||
case DEFAULT -> i18n("settings.launcher.download_source.auto");
|
||||
@@ -102,10 +102,10 @@ public class DownloadSettingsPage extends StackPane {
|
||||
defaultAddonSourcePane.setItems("modrinth", "curseforge");
|
||||
defaultAddonSourcePane.valueProperty().bindBidirectional(settings().defaultAddonSourceProperty());
|
||||
|
||||
downloadSource.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane);
|
||||
gameContent.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane);
|
||||
}
|
||||
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.download_source")), downloadSource);
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.game_content")), gameContent);
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -57,6 +57,7 @@ import org.jackhuang.hmcl.util.Result;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
@@ -200,7 +201,7 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
|
||||
if (exceptionToDisplay != null) {
|
||||
LOG.warning("Failed to download java", exceptionToDisplay);
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(exceptionToDisplay), i18n("install.failed"));
|
||||
Controllers.dialog(I18n.localizeErrorMessage(exceptionToDisplay), i18n("install.failed"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -230,9 +231,11 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
|
||||
this.remoteVersionBox = new JFXComboBox<>();
|
||||
this.remoteVersionBox.setConverter(FXUtils.stringConverter(JavaRemoteVersion::getDistributionVersion));
|
||||
this.remoteVersionBox.disableProperty().bind(Bindings.isNull(distributionBox.getSelectionModel().selectedItemProperty()));
|
||||
|
||||
this.packageTypeBox = new JFXComboBox<>(FXCollections.observableArrayList());
|
||||
this.packageTypeBox.setConverter(FXUtils.stringConverter(JavaPackageType::getDisplayName));
|
||||
this.packageTypeBox.disableProperty().bind(Bindings.isNull(distributionBox.getSelectionModel().selectedItemProperty()));
|
||||
|
||||
this.downloadButton = new JFXButton(i18n("download"));
|
||||
downloadButton.setOnAction(e -> onDownload());
|
||||
@@ -312,8 +315,6 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
Consumer<DiscoJavaVersionList> updateListStatus = list -> {
|
||||
remoteVersionBox.setItems(null);
|
||||
packageTypeBox.getItems().clear();
|
||||
remoteVersionBox.setDisable(true);
|
||||
packageTypeBox.setDisable(true);
|
||||
warningLabel.setText(null);
|
||||
|
||||
if (list == null || (list.versions != null && list.versions.isEmpty()))
|
||||
@@ -326,9 +327,6 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
if (list.status == DiscoJavaVersionList.Status.SUCCESS) {
|
||||
packageTypeBox.getItems().setAll(list.versions.keySet());
|
||||
packageTypeBox.getSelectionModel().selectFirst();
|
||||
|
||||
remoteVersionBox.setDisable(false);
|
||||
packageTypeBox.setDisable(false);
|
||||
} else
|
||||
warningLabel.setText(i18n("java.download.load_list.failed"));
|
||||
}
|
||||
@@ -372,7 +370,7 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
if (version == null)
|
||||
return;
|
||||
|
||||
Controllers.taskDialog(new GetTask(downloadProvider.injectURLWithCandidates(version.getLinks().pkgInfoUri()))
|
||||
Controllers.taskDialog(new GetTask(downloadProvider.getDownloadCandidates(version.getLinks().pkgInfoUri()))
|
||||
.setExecutor(Schedulers.io())
|
||||
.thenComposeAsync(json -> {
|
||||
DiscoResult<DiscoRemoteFileInfo> result = JsonUtils.fromNonNullJson(json, DiscoResult.typeOf(DiscoRemoteFileInfo.class));
|
||||
@@ -392,8 +390,8 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
Task<FileDownloadTask.IntegrityCheck> getIntegrityCheck;
|
||||
if (StringUtils.isNotBlank(fileInfo.checksum()))
|
||||
getIntegrityCheck = Task.completed(new FileDownloadTask.IntegrityCheck(fileInfo.checksumType(), fileInfo.checksum()));
|
||||
else if (StringUtils.isNotBlank(fileInfo.checksumUri()))
|
||||
getIntegrityCheck = new GetTask(downloadProvider.injectURLWithCandidates(fileInfo.checksumUri()))
|
||||
else if (StringUtils.isNotBlank(fileInfo.checksumUri())) {
|
||||
getIntegrityCheck = new GetTask(downloadProvider.getDownloadCandidates(fileInfo.checksumUri()))
|
||||
.thenApplyAsync(checksum -> {
|
||||
checksum = checksum.trim();
|
||||
|
||||
@@ -403,13 +401,16 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
|
||||
return new FileDownloadTask.IntegrityCheck(fileInfo.checksumType(), checksum);
|
||||
});
|
||||
}
|
||||
else
|
||||
getIntegrityCheck = Task.completed(null);
|
||||
|
||||
return getIntegrityCheck
|
||||
.thenComposeAsync(integrityCheck ->
|
||||
new FileDownloadTask(downloadProvider.injectURLWithCandidates(fileInfo.directDownloadUri()),
|
||||
targetFile, integrityCheck).setName(fileInfo.fileName()))
|
||||
{
|
||||
return new FileDownloadTask(downloadProvider.getDownloadCandidates(fileInfo.directDownloadUri()),
|
||||
targetFile, integrityCheck).setName(fileInfo.fileName());
|
||||
})
|
||||
.thenSupplyAsync(() -> targetFile);
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), ((result, exception) -> {
|
||||
@@ -432,7 +433,7 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
LOG.warning("Failed to download java", exception);
|
||||
Throwable resolvedException = resolveException(exception);
|
||||
if (!(resolvedException instanceof CancellationException)) {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
}
|
||||
}
|
||||
})), i18n("java.download"), TaskCancellationAction.NORMAL);
|
||||
|
||||
@@ -182,15 +182,23 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
|
||||
|
||||
@FXThread
|
||||
private void loadJava(Collection<JavaRuntime> javaRuntimes) {
|
||||
if (javaRuntimes != null) {
|
||||
// JavaRuntime equality is path-based. setAll can therefore retain a stale item when the
|
||||
// Java installation at an existing path has been upgraded.
|
||||
this.getItems().clear();
|
||||
this.getItems().addAll(javaRuntimes);
|
||||
this.setLoading(false);
|
||||
} else {
|
||||
if (javaRuntimes == null) {
|
||||
this.setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// JavaRuntime equality is path-based. setAll can therefore retain a stale item when the
|
||||
// Java installation at an existing path has been upgraded.
|
||||
this.getItems().clear();
|
||||
this.getItems().addAll(javaRuntimes);
|
||||
|
||||
if (javaRuntimes.isEmpty()) {
|
||||
this.setFailedReason(i18n("java.empty"));
|
||||
} else {
|
||||
this.setFailedReason(null);
|
||||
}
|
||||
|
||||
this.setLoading(false);
|
||||
}
|
||||
|
||||
private static final class JavaPageSkin extends ToolbarListPageSkin<JavaRuntime, JavaManagementPage> {
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
|
||||
@@ -80,6 +81,10 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
|
||||
return a.path.compareTo(b.path);
|
||||
});
|
||||
this.setItems(FXCollections.observableList(result));
|
||||
|
||||
if (result.isEmpty()) {
|
||||
fireEvent(new PageCloseEvent());
|
||||
}
|
||||
};
|
||||
disabledJava.addListener(new WeakInvalidationListener(listener));
|
||||
listener.invalidated(disabledJava);
|
||||
|
||||
@@ -45,7 +45,6 @@ import javafx.scene.text.TextFlow;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.ComponentVersionList;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
@@ -71,7 +70,6 @@ import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
@@ -360,13 +358,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
private void launchNoGame() {
|
||||
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
|
||||
ComponentVersionList<?> versionList = downloadProvider.getVersionList(GameComponentType.GAME);
|
||||
|
||||
Holder<GameInstanceID> instanceHolder = new Holder<>();
|
||||
Task<?> task = versionList.refreshAsync("")
|
||||
.thenSupplyAsync(() -> versionList.getVersions("").stream()
|
||||
Task<?> task = downloadProvider.getVersionsAsync(GameComponentType.GAME, null, false)
|
||||
.thenApplyAsync(versions -> versions.stream()
|
||||
.filter(it -> it.getVersionType() == RELEASE)
|
||||
.filter(it -> NativePatcher.checkSupportedStatus(GameVersionNumber.asGameVersion(it.getGameVersion()), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
|
||||
.filter(it -> NativePatcher.checkSupportedStatus(it.getGameVersion(), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
|
||||
.sorted()
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IOException("No versions found")))
|
||||
@@ -374,7 +371,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||
HMCLDependencyManager dependency = repository.getDependency();
|
||||
|
||||
String gameVersion = version.getGameVersion();
|
||||
String gameVersion = version.getGameVersion().toString();
|
||||
GameInstanceID instanceId = new GameInstanceID(gameVersion);
|
||||
|
||||
instanceHolder.value = instanceId;
|
||||
|
||||
@@ -53,8 +53,8 @@ import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
@@ -540,7 +540,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
|
||||
VBox content = new VBox(10);
|
||||
content.getChildren().add(ComponentList.createComponentListTitle(i18n("terracotta.status")));
|
||||
if (!LocaleUtils.IS_CHINA_MAINLAND) {
|
||||
if (!RegionUtils.isIsChinaMainlandMachine()) {
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
hintPane.setText(i18n("terracotta.unsupported.region"));
|
||||
content.getChildren().add(hintPane);
|
||||
|
||||
+2
-3
@@ -27,7 +27,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Queue;
|
||||
@@ -46,7 +45,7 @@ public abstract class TaskExecutorDialogWizardDisplayer extends AbstractWizardDi
|
||||
@Override
|
||||
public void handleTask(SettingsMap settings, Task<?> task) {
|
||||
@Nullable WizardProvider.TaskCleanup cleanup = settings.remove(WizardProvider.TaskCleanup.KEY);
|
||||
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(new TaskCancellationAction(it -> {
|
||||
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(it -> {
|
||||
if (cleanup != null) {
|
||||
// Keep the installation modal until onStop has released its repository draft.
|
||||
it.setCancel(null);
|
||||
@@ -54,7 +53,7 @@ public abstract class TaskExecutorDialogWizardDisplayer extends AbstractWizardDi
|
||||
}
|
||||
it.fireEvent(new DialogCloseEvent());
|
||||
onEnd();
|
||||
}));
|
||||
});
|
||||
|
||||
pane.setTitle(i18n("message.doing"));
|
||||
if (settings.containsKey("title")) {
|
||||
|
||||
@@ -37,9 +37,9 @@ public final class ChunkBaseApp {
|
||||
};
|
||||
|
||||
public static final String[] STRONGHOLD_FINDER_GAME_VERSIONS = {
|
||||
"26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2",
|
||||
"1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15",
|
||||
"1.14", "1.13", "1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
|
||||
"26.3", "26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4",
|
||||
"1.21.2", "1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16",
|
||||
"1.15", "1.14", "1.13", "1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
|
||||
};
|
||||
|
||||
public static final String[] NETHER_FORTRESS_GAME_VERSIONS = {
|
||||
@@ -49,9 +49,9 @@ public final class ChunkBaseApp {
|
||||
};
|
||||
|
||||
public static final String[] END_CITY_GAME_VERSIONS = {
|
||||
"26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2",
|
||||
"1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15",
|
||||
"1.14", "1.13"
|
||||
"26.3", "26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4",
|
||||
"1.21.2", "1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16",
|
||||
"1.15", "1.14", "1.13"
|
||||
};
|
||||
|
||||
public static boolean isSupported(@NotNull World world) {
|
||||
|
||||
@@ -67,6 +67,11 @@ public final class CrashReporter implements Thread.UncaughtExceptionHandler {
|
||||
alert.setHeaderText(i18n("message.info"));
|
||||
alert.showAndWait();
|
||||
} catch (Throwable t) {
|
||||
try {
|
||||
SwingUtils.showErrorDialog(info);
|
||||
} catch (Throwable t2) {
|
||||
t.addSuppressed(t2);
|
||||
}
|
||||
LOG.error("Unable to show message", t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@NotNullByDefault
|
||||
public final class RegionUtils {
|
||||
|
||||
/// Pre-check if the network is in China Mainland to avoid blocking the UI thread.
|
||||
public static void init() {
|
||||
if (IS_CHINA_MAINLAND_MACHINE) {
|
||||
Schedulers.io().execute(RegionUtils::isIsChinaMainlandNetwork);
|
||||
}
|
||||
}
|
||||
|
||||
private static final boolean IS_CHINA_MAINLAND_MACHINE = checkMachineIsChinaMainland();
|
||||
|
||||
private static boolean checkMachineIsChinaMainland() {
|
||||
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
|
||||
return true;
|
||||
|
||||
// Check if the time zone is UTC+8
|
||||
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
|
||||
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
|
||||
return true;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
|
||||
Kernel32 kernel32 = Kernel32.INSTANCE;
|
||||
|
||||
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
|
||||
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isIsChinaMainlandMachine() {
|
||||
return IS_CHINA_MAINLAND_MACHINE;
|
||||
}
|
||||
|
||||
private static volatile @Nullable Boolean isChinaMainlandNetwork;
|
||||
|
||||
private static boolean checkChinaMainlandNetwork() {
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
record IpWhoIsResponse(boolean success, String country_code) {
|
||||
}
|
||||
|
||||
try {
|
||||
LOG.info("Fetching network information");
|
||||
IpWhoIsResponse result = JsonUtils.fromNonNullJson(NetworkUtils.doGet("https://ipwho.is"), IpWhoIsResponse.class);
|
||||
return !result.success || "CN".equalsIgnoreCase(result.country_code);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get the network information", e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isIsChinaMainlandNetwork() {
|
||||
if (!IS_CHINA_MAINLAND_MACHINE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Boolean value = isChinaMainlandNetwork;
|
||||
if (value != null)
|
||||
return value;
|
||||
|
||||
synchronized (RegionUtils.class) {
|
||||
value = isChinaMainlandNetwork;
|
||||
|
||||
if (value != null)
|
||||
return value;
|
||||
|
||||
boolean result = checkChinaMainlandNetwork();
|
||||
isChinaMainlandNetwork = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private RegionUtils() {
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ package org.jackhuang.hmcl.util;
|
||||
|
||||
import javafx.beans.value.WritableValue;
|
||||
import javafx.scene.image.Image;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.DownloadCandidates;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -27,7 +29,6 @@ import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -35,9 +36,12 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
/// @author Glavo
|
||||
public abstract class RemoteImageLoader {
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final Map<URI, WeakReference<Image>> cache = new HashMap<>();
|
||||
private final Map<URI, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
|
||||
private final WeakHashMap<WritableValue<Image>, URI> reverseLookup = new WeakHashMap<>();
|
||||
/// Loaded images indexed by normalized URL; image references do not prevent garbage collection.
|
||||
private final Map<WebURL, WeakReference<@Nullable Image>> cache = new HashMap<>();
|
||||
/// Image targets awaiting each URL's active load task.
|
||||
private final Map<WebURL, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
|
||||
/// Current requested URL for each target, used to discard obsolete load completions.
|
||||
private final WeakHashMap<WritableValue<Image>, WebURL> reverseLookup = new WeakHashMap<>();
|
||||
|
||||
public RemoteImageLoader(DownloadProvider downloadProvider) {
|
||||
this.downloadProvider = downloadProvider;
|
||||
@@ -47,59 +51,61 @@ public abstract class RemoteImageLoader {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected abstract @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris);
|
||||
/// Creates a task that loads an image from candidate URLs in attempt order.
|
||||
protected abstract @NotNull Task<Image> createLoadTask(DownloadCandidates candidates);
|
||||
|
||||
/// Loads an image or assigns the placeholder when the URL is absent or invalid.
|
||||
@FXThread
|
||||
public void load(@NotNull WritableValue<Image> writableValue, String url) {
|
||||
URI uri = NetworkUtils.toURIOrNull(url);
|
||||
if (uri == null) {
|
||||
public void load(@NotNull WritableValue<Image> writableValue, @Nullable String url) {
|
||||
@Nullable WebURL webUrl = NetworkUtils.toWebURLOrNull(url);
|
||||
if (webUrl == null) {
|
||||
reverseLookup.remove(writableValue);
|
||||
writableValue.setValue(getPlaceholder());
|
||||
return;
|
||||
}
|
||||
|
||||
WeakReference<Image> reference = cache.get(uri);
|
||||
@Nullable WeakReference<@Nullable Image> reference = cache.get(webUrl);
|
||||
if (reference != null) {
|
||||
Image image = reference.get();
|
||||
@Nullable Image image = reference.get();
|
||||
if (image != null) {
|
||||
reverseLookup.remove(writableValue);
|
||||
writableValue.setValue(image);
|
||||
return;
|
||||
}
|
||||
cache.remove(uri);
|
||||
cache.remove(webUrl);
|
||||
}
|
||||
|
||||
writableValue.setValue(getPlaceholder());
|
||||
|
||||
{
|
||||
List<WeakReference<WritableValue<Image>>> list = pendingRequests.get(uri);
|
||||
@Nullable List<WeakReference<WritableValue<Image>>> list = pendingRequests.get(webUrl);
|
||||
if (list != null) {
|
||||
list.add(new WeakReference<>(writableValue));
|
||||
reverseLookup.put(writableValue, uri);
|
||||
reverseLookup.put(writableValue, webUrl);
|
||||
return;
|
||||
} else {
|
||||
list = new ArrayList<>(1);
|
||||
list.add(new WeakReference<>(writableValue));
|
||||
pendingRequests.put(uri, list);
|
||||
reverseLookup.put(writableValue, uri);
|
||||
pendingRequests.put(webUrl, list);
|
||||
reverseLookup.put(writableValue, webUrl);
|
||||
}
|
||||
}
|
||||
|
||||
createLoadTask(downloadProvider.injectURLWithCandidates(url)).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
Image image;
|
||||
createLoadTask(downloadProvider.getDownloadCandidates(url)).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
@Nullable Image image;
|
||||
if (exception == null) {
|
||||
image = result;
|
||||
} else {
|
||||
LOG.warning("Failed to load image from " + uri, exception);
|
||||
LOG.warning("Failed to load image from " + webUrl, exception);
|
||||
image = getPlaceholder();
|
||||
}
|
||||
|
||||
cache.put(uri, new WeakReference<>(image));
|
||||
List<WeakReference<WritableValue<Image>>> list = pendingRequests.remove(uri);
|
||||
cache.put(webUrl, new WeakReference<>(image));
|
||||
@Nullable List<WeakReference<WritableValue<Image>>> list = pendingRequests.remove(webUrl);
|
||||
if (list != null) {
|
||||
for (WeakReference<WritableValue<Image>> ref : list) {
|
||||
WritableValue<Image> target = ref.get();
|
||||
if (target != null && uri.equals(reverseLookup.get(target))) {
|
||||
@Nullable WritableValue<Image> target = ref.get();
|
||||
if (target != null && webUrl.equals(reverseLookup.get(target))) {
|
||||
reverseLookup.remove(target);
|
||||
target.setValue(image);
|
||||
}
|
||||
|
||||
@@ -18,25 +18,30 @@
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class TaskCancellationAction {
|
||||
public static TaskCancellationAction NO_CANCEL = new TaskCancellationAction((Consumer<TaskExecutorDialogPane>) null);
|
||||
public static TaskCancellationAction NORMAL = new TaskCancellationAction(() -> {
|
||||
});
|
||||
@NotNullByDefault
|
||||
@FunctionalInterface
|
||||
public interface TaskCancellationAction extends Consumer<TaskExecutorDialogPane> {
|
||||
|
||||
private final Consumer<TaskExecutorDialogPane> cancellationAction;
|
||||
TaskCancellationAction NO_CANCEL = new TaskCancellationAction() {
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public TaskCancellationAction(Runnable cancellationAction) {
|
||||
this.cancellationAction = it -> cancellationAction.run();
|
||||
@Override
|
||||
public void accept(TaskExecutorDialogPane taskExecutorDialogPane) {
|
||||
}
|
||||
};
|
||||
|
||||
TaskCancellationAction NORMAL = ignored -> {
|
||||
};
|
||||
|
||||
default boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public TaskCancellationAction(Consumer<TaskExecutorDialogPane> cancellationAction) {
|
||||
this.cancellationAction = cancellationAction;
|
||||
}
|
||||
|
||||
public Consumer<TaskExecutorDialogPane> getCancellationAction() {
|
||||
return cancellationAction;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.util.i18n;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.AddonLoaderType;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.download.ArtifactMalformedException;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
|
||||
@@ -53,18 +54,25 @@ import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackLocalInstallTask;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.translator.Translator;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.PropertyKey;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
public final class I18n {
|
||||
|
||||
@@ -238,6 +246,39 @@ public final class I18n {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String localizeErrorMessage(Throwable exception) {
|
||||
if (exception instanceof DownloadException de) {
|
||||
String url = de.getUrl();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
return i18n("install.failed.downloading.timeout", url);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
return i18n("download.code." + responseCodeException.getResponseCode(), url);
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception.getCause() instanceof FileNotFoundException) {
|
||||
return i18n("download.code.404", url);
|
||||
} else if (exception.getCause() instanceof AccessDeniedException) {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
|
||||
} else if (exception.getCause() instanceof ArtifactMalformedException) {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.artifact_malformed");
|
||||
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
|
||||
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.dns.pollution");
|
||||
}
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.ssl_handshake");
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception instanceof ArtifactMalformedException) {
|
||||
return i18n("exception.artifact_malformed");
|
||||
} else if (exception instanceof CancellationException) {
|
||||
return i18n("message.cancelled");
|
||||
}
|
||||
return StringUtils.getStackTrace(exception);
|
||||
}
|
||||
|
||||
public static boolean hasKey(String key) {
|
||||
return getResourceBundle().containsKey(key);
|
||||
}
|
||||
|
||||
+46
-16
@@ -17,45 +17,65 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.url.data;
|
||||
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class DataUri {
|
||||
/// Parses a data URL's media type, charset, and percent-decoded payload.
|
||||
/// Query content is included in the payload; the fragment is excluded.
|
||||
/// Percent escapes are decoded as UTF-8 before the declared charset is applied by the read methods.
|
||||
///
|
||||
/// @author Glavo
|
||||
public final class DataURL {
|
||||
/// Scheme identifying inline data URLs.
|
||||
public static final String SCHEME = "data";
|
||||
|
||||
private static IllegalArgumentException invalidUri(URI uri) {
|
||||
return new IllegalArgumentException("Invalid data URI: " + uri);
|
||||
/// Creates a parsing failure for a data URL without a metadata/payload separator.
|
||||
private static IllegalArgumentException invalidUri(WebURL url) {
|
||||
return new IllegalArgumentException("Invalid data URL: " + url);
|
||||
}
|
||||
|
||||
public static boolean isDataUri(URI uri) {
|
||||
return uri != null && SCHEME.equals(uri.getScheme());
|
||||
/// Returns whether the URL uses the data scheme, or `false` for `null`.
|
||||
public static boolean isDataUri(@Nullable WebURL url) {
|
||||
return url != null && SCHEME.equals(url.getScheme());
|
||||
}
|
||||
|
||||
/// Trimmed media type and parameters, excluding the Base64 marker.
|
||||
private final @NotNull String mediaType;
|
||||
/// Declared charset, or UTF-8 when absent or unrecognized.
|
||||
private final @NotNull Charset charset;
|
||||
/// Whether the metadata ends with the case-sensitive Base64 marker.
|
||||
private final boolean base64;
|
||||
/// Percent-decoded payload before optional Base64 decoding.
|
||||
private final @NotNull String rawData;
|
||||
|
||||
public DataUri(URI uri) {
|
||||
if (!uri.getScheme().equals(SCHEME)) {
|
||||
throw new IllegalArgumentException("URI scheme must be " + SCHEME);
|
||||
/// Parses metadata and payload without decoding Base64 content.
|
||||
///
|
||||
/// @param url the data URL
|
||||
/// @throws IllegalArgumentException if the scheme is not data or the decoded content has no comma
|
||||
public DataURL(WebURL url) {
|
||||
if (!SCHEME.equals(url.getScheme())) {
|
||||
throw new IllegalArgumentException("URL scheme must be " + SCHEME);
|
||||
}
|
||||
|
||||
String schemeSpecificPart = uri.getSchemeSpecificPart();
|
||||
if (schemeSpecificPart == null)
|
||||
throw invalidUri(uri);
|
||||
String schemeSpecificPart = url.getPath();
|
||||
@Nullable String authority = url.getAuthority();
|
||||
if (authority != null)
|
||||
schemeSpecificPart = "//" + authority + schemeSpecificPart;
|
||||
|
||||
// The query delimiter and content belong to the data, while the fragment does not.
|
||||
@Nullable String query = url.getQuery();
|
||||
if (query != null)
|
||||
schemeSpecificPart += "?" + query;
|
||||
|
||||
int comma = schemeSpecificPart.indexOf(',');
|
||||
if (comma < 0)
|
||||
throw invalidUri(uri);
|
||||
throw invalidUri(url);
|
||||
|
||||
String mediaType = schemeSpecificPart.substring(0, comma);
|
||||
boolean base64 = mediaType.endsWith(";base64");
|
||||
@@ -68,22 +88,29 @@ public final class DataUri {
|
||||
this.rawData = schemeSpecificPart.substring(comma + 1);
|
||||
}
|
||||
|
||||
/// Returns the trimmed media type and parameters without the Base64 marker; may be empty.
|
||||
public @NotNull String getMediaType() {
|
||||
return mediaType;
|
||||
}
|
||||
|
||||
/// Returns the declared charset, or UTF-8 when absent or unrecognized.
|
||||
public @NotNull Charset getCharset() {
|
||||
return charset;
|
||||
}
|
||||
|
||||
/// Returns whether the payload is marked for Base64 decoding.
|
||||
public boolean isBase64() {
|
||||
return base64;
|
||||
}
|
||||
|
||||
/// Returns the percent-decoded payload before optional Base64 decoding, preserving literal plus signs.
|
||||
public @NotNull String getRawData() {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
/// Returns a new byte array by decoding Base64 or encoding the text payload with [#getCharset()].
|
||||
///
|
||||
/// @throws IOException if a Base64 payload cannot be decoded
|
||||
public byte[] readBytes() throws IOException {
|
||||
if (base64) {
|
||||
try {
|
||||
@@ -96,6 +123,9 @@ public final class DataUri {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the text payload, decoding Base64 bytes with [#getCharset()] when marked as Base64.
|
||||
///
|
||||
/// @throws IOException if a Base64 payload cannot be decoded
|
||||
public String readString() throws IOException {
|
||||
if (base64) {
|
||||
try {
|
||||
@@ -27,20 +27,20 @@ import java.net.URLConnection;
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class DataURLConnection extends URLConnection {
|
||||
private final DataUri dataUri;
|
||||
private final DataURL dataUrl;
|
||||
private byte[] data;
|
||||
private InputStream inputStream;
|
||||
|
||||
DataURLConnection(URL url, DataUri dataUri) {
|
||||
DataURLConnection(URL url, DataURL dataUrl) {
|
||||
super(url);
|
||||
this.dataUri = dataUri;
|
||||
this.dataUrl = dataUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() throws IOException {
|
||||
if (!connected) {
|
||||
connected = true;
|
||||
data = dataUri.readBytes();
|
||||
data = dataUrl.readBytes();
|
||||
inputStream = new ByteArrayInputStream(this.data);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public final class DataURLConnection extends URLConnection {
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return dataUri.getMediaType();
|
||||
return dataUrl.getMediaType();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -17,23 +17,26 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.url.data;
|
||||
|
||||
import org.glavo.url.WebURL;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
/// Opens data URL connections using [WebURL] to parse the address.
|
||||
///
|
||||
/// @author Glavo
|
||||
public final class DataURLHandle extends URLStreamHandler {
|
||||
/// Creates a connection without decoding its Base64 payload.
|
||||
///
|
||||
/// @throws MalformedURLException if the address cannot be parsed as a data URL
|
||||
@Override
|
||||
protected URLConnection openConnection(URL u) throws IOException {
|
||||
try {
|
||||
URI uri = u.toURI();
|
||||
if (!DataUri.isDataUri(uri))
|
||||
throw new MalformedURLException("URI is not a data URI: " + u);
|
||||
|
||||
return new DataURLConnection(u, new DataUri(uri));
|
||||
} catch (URISyntaxException e) {
|
||||
throw new MalformedURLException(e.getMessage());
|
||||
return new DataURLConnection(u, new DataURL(WebURL.of(u)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
MalformedURLException exception = new MalformedURLException(e.getMessage());
|
||||
exception.initCause(e);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2009,3 +2009,82 @@
|
||||
.addon-changelog-table-cell {
|
||||
-fx-text-fill: -monet-primary;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
* *
|
||||
* JFX Date Picker *
|
||||
* *
|
||||
******************************************************************************/
|
||||
|
||||
.jfx-date-picker {
|
||||
-jfx-default-color: -monet-primary;
|
||||
}
|
||||
|
||||
.date-picker-popup {
|
||||
-fx-padding: 5;
|
||||
-fx-background-color: -monet-surface-container-high;
|
||||
-fx-background-radius: 4;
|
||||
}
|
||||
|
||||
.date-picker-popup .spinner .spinner-label {
|
||||
-fx-text-fill: -monet-on-surface;
|
||||
}
|
||||
|
||||
.date-picker-popup .month-year-pane,
|
||||
.date-picker-popup .content {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.date-picker-popup .separator .line {
|
||||
-fx-border-width: 1;
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
}
|
||||
|
||||
.date-picker-popup .content .spinner-label,
|
||||
.date-picker-popup .data-picker-list-cell .spinner-label {
|
||||
-fx-text-fill: -monet-on-surface;
|
||||
}
|
||||
|
||||
.date-picker-popup .data-picker-list-cell:selected {
|
||||
-fx-background-color: -monet-secondary-container;
|
||||
}
|
||||
|
||||
.date-picker-popup .date-cell {
|
||||
-fx-text-fill: -monet-on-surface;
|
||||
}
|
||||
|
||||
.date-picker-popup .day-cell {
|
||||
-fx-border-radius: 20;
|
||||
-fx-background-radius: 20;
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.date-picker-popup .day-cell.today {
|
||||
-fx-border-color: -monet-primary;
|
||||
-fx-text-fill: -monet-primary;
|
||||
}
|
||||
|
||||
.date-picker-popup .day-cell.previous-month,
|
||||
.date-picker-popup .day-cell.next-month {
|
||||
-fx-background-color: transparent;
|
||||
-fx-text-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
.date-picker-popup .day-cell.hovered {
|
||||
-fx-background-color: -monet-surface-container-highest;
|
||||
}
|
||||
|
||||
.date-picker-popup .day-cell.selected,
|
||||
.date-picker-popup .day-cell.selected.hovered{
|
||||
-fx-background-color: -monet-primary;
|
||||
-fx-text-fill: -monet-on-primary;
|
||||
}
|
||||
|
||||
.date-picker-popup .left-button .jfx-svg-glyph,
|
||||
.date-picker-popup .right-button .jfx-svg-glyph {
|
||||
-fx-fill: -monet-on-surface-variant;
|
||||
}
|
||||
|
||||
.date-picker-popup .title {
|
||||
-fx-text-fill: -monet-on-surface-variant;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ account.methods.microsoft.close_page=Microsoft account authorization is now comp
|
||||
There are some extra work for us, but you can safely close this tab for now.
|
||||
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=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.missing_xbox_account=Please confirm that you have purchased Minecraft. 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\
|
||||
@@ -175,16 +175,15 @@ addon.check_update.empty=All files are up-to-date
|
||||
addon.check_update.failed_check=Failed to check for updates.
|
||||
addon.check_update.failed_download=Failed to download some files.
|
||||
addon.check_update.file=File
|
||||
addon.check_update.only_preview=Updates are available only for preview versions.\nEnable "Show preview versions" below to view them.
|
||||
addon.check_update.preview=Show preview versions
|
||||
addon.check_update.source=Source
|
||||
addon.check_update.target_version=Target Version
|
||||
addon.curseforge=CurseForge
|
||||
addon.dependency.embedded=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately)
|
||||
addon.dependency.optional=Optional Dependencies (If missing, the game will run normally, but the addon features may be missing)
|
||||
addon.dependency.required=Required Dependencies (Must be downloaded separately. Missing may prevent the game from launching)
|
||||
addon.dependency.tool=Required Dependencies (Must be downloaded separately. Missing may prevent the game from launching)
|
||||
addon.dependency.include=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately)
|
||||
addon.dependency.incompatible=Incompatible Addons (Installing these addons at the same time will prevent the game from launching)
|
||||
addon.dependency.broken=Broken Dependencies (This addon existed before, but it does not exist anymore. Try using another download source.)
|
||||
addon.dependencies.has_broken=This version has broken dependencies. Please try another download source, or installation could be unsuccessful.
|
||||
addon.download.recommend=Recommended Version for Minecraft %1s
|
||||
addon.download.title.release=Minecraft %s
|
||||
@@ -195,6 +194,7 @@ addon.sort.last_updated=Last Updated
|
||||
addon.sort.popularity=Popularity
|
||||
addon.sort.relevancy=Relevancy
|
||||
addon.sort.total_downloads=Total Downloads
|
||||
addon.version_type=Type
|
||||
|
||||
archive.author=Author(s)
|
||||
archive.date=Publish Date
|
||||
@@ -228,6 +228,9 @@ button.remove.confirm=Are you sure you want to permanently remove it? This actio
|
||||
button.save=Save
|
||||
button.save_as=Save As
|
||||
button.select_all=Select All
|
||||
button.select_date=Select by date
|
||||
button.select_date.from=From
|
||||
button.select_date.to=To
|
||||
button.view=View
|
||||
button.yes=Yes
|
||||
|
||||
@@ -379,6 +382,7 @@ curse.category.6555=Vanilla
|
||||
datetime.format=MMM d, yyyy, h\:mm\:ss a
|
||||
|
||||
download=Download
|
||||
download.china=Due to relevant laws and regulations, HMCL does not currently support downloading game-related content in Chinese Mainland. Please visit the <a href="https://mc.163.com/">official Minecraft website</a> to download Minecraft China Edition.
|
||||
download.code.404=File "%s" not found on the remote server.
|
||||
download.content=Addons
|
||||
download.shader=Shaders
|
||||
@@ -857,6 +861,7 @@ java.download.prompt=Please choose the Java version you want to download:
|
||||
java.download.distribution=Distribution
|
||||
java.download.version=Version
|
||||
java.download.packageType=Package Type
|
||||
java.empty=Could not find Java
|
||||
java.management=Java Management
|
||||
java.info.architecture=Architecture
|
||||
java.info.vendor=Vendor
|
||||
@@ -990,7 +995,7 @@ message.question=Question
|
||||
modpack=Modpacks
|
||||
modpack.choose=Choose Modpack
|
||||
modpack.choose.local=Import from Local File
|
||||
modpack.choose.local.detail=You can drag the modpack file here.
|
||||
modpack.choose.local.detail=You can drag the modpack file here to install it.
|
||||
modpack.choose.remote=Download from URL
|
||||
modpack.choose.remote.detail=A direct download link to the remote modpack file is required.
|
||||
modpack.choose.repository=Download Modpack from CurseForge or Modrinth
|
||||
@@ -1168,6 +1173,7 @@ mods.add=Add
|
||||
mods.add.failed=Failed to add mod %s.
|
||||
mods.add.success=%s was successfully added.
|
||||
mods.add.title=Choose mod file you want to add
|
||||
mods.delete.failed=Failed to delete mod(s). The mod file(s) may be in use by another program.
|
||||
mods.disable=Disable
|
||||
mods.download=Download Mod
|
||||
mods.download.title=Download Mod - %1s
|
||||
@@ -1181,6 +1187,7 @@ mods.mcmod.page=MCMod Page
|
||||
mods.mcmod.search=Search in MCMod
|
||||
mods.name=Name
|
||||
mods.restore=Restore
|
||||
mods.toggle.failed=Failed to enable or disable mod(s). The mod file(s) may be in use by another program.
|
||||
mods.url=Official Page
|
||||
mods.update_modpack_mod.warning=Updating mods in a modpack can lead to irreparable results, possibly corrupting the modpack so that it cannot launch. Are you sure you want to update?
|
||||
mods.warning.loader_mismatch=Mod loader mismatch
|
||||
@@ -1204,9 +1211,11 @@ nbt.title=View File - %s
|
||||
datapack=Data Packs
|
||||
datapack.add=Add
|
||||
datapack.add.title=Choose data pack archive you want to add
|
||||
datapack.delete.failed=Failed to delete data pack(s). The data pack file(s) may be in use by another program.
|
||||
datapack.empty=No Data Packs
|
||||
datapack.reload.toast=Minecraft is running. Use the /reload command to reload data packs
|
||||
datapack.title=World [%s] - Data Packs
|
||||
datapack.toggle.failed=Failed to enable or disable data pack(s). The data pack file(s) may be in use by another program.
|
||||
|
||||
web.failed=Failed to load page
|
||||
web.open_in_browser=Do you want to open this address in a browser:\n%s
|
||||
@@ -1358,6 +1367,10 @@ schematics.install_mod=Install Litematica Mod
|
||||
schematics.manage=Schematics
|
||||
schematics.sub_items=%d sub-item(s)
|
||||
|
||||
screenshots.clear.confirm=Are you sure you want to permanently remove the screenshots? This action cannot be undone!
|
||||
screenshots.manage=Screenshots
|
||||
screenshots.set_as_bg=Set as Launcher Background
|
||||
|
||||
search=Search
|
||||
search.hint.chinese=Search in English and Chinese
|
||||
search.hint.english=Search in English only
|
||||
@@ -1421,6 +1434,7 @@ settings.advanced.natives_directory=Native Library Path
|
||||
settings.advanced.natives_directory.custom.enabled=Use Custom Native Libraries
|
||||
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.not_use_javaw=Do not use javaw.exe
|
||||
settings.advanced.precall_command=Pre-launch Command
|
||||
settings.advanced.precall_command.prompt=Commands to execute before the game launches
|
||||
settings.advanced.process_priority=Process Priority
|
||||
@@ -1567,6 +1581,7 @@ settings.launcher.font.anti_aliasing.auto=Auto
|
||||
settings.launcher.font.anti_aliasing.gray=Grayscale
|
||||
settings.launcher.font.anti_aliasing.lcd=Sub-pixel
|
||||
settings.launcher.fonts=Fonts
|
||||
settings.launcher.game_content=Game Content
|
||||
settings.launcher.general=General
|
||||
settings.launcher.language=Language
|
||||
settings.launcher.launcher_log.export=Export Launcher Logs
|
||||
@@ -1775,6 +1790,7 @@ unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee i
|
||||
update=Update
|
||||
update.accept=Update
|
||||
update.changelog=Changelog
|
||||
update.changelog.empty=Changelog has not been uploaded yet
|
||||
update.channel.dev=Beta
|
||||
update.channel.dev.hint=You are currently using a Beta channel build of the launcher. While it may include some extra features, it is also sometimes less stable than the Stable channel builds.\n\
|
||||
\n\
|
||||
|
||||
@@ -176,16 +176,15 @@ addon.check_update.empty=جميع المودات محدّثة
|
||||
addon.check_update.failed_check=فشل التحقق من التحديثات.
|
||||
addon.check_update.failed_download=فشل تنزيل بعض الملفات.
|
||||
addon.check_update.file=الملف
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=المصدر
|
||||
addon.check_update.target_version=الإصدار المستهدف
|
||||
addon.curseforge=CurseForge
|
||||
# addon.dependency.embedded=
|
||||
# addon.dependency.optional=
|
||||
# addon.dependency.required=
|
||||
# addon.dependency.tool=
|
||||
# addon.dependency.include=
|
||||
# addon.dependency.incompatible=
|
||||
# addon.dependency.broken=
|
||||
# addon.dependencies.has_broken=
|
||||
# addon.download.recommend=
|
||||
# addon.download.title.release=
|
||||
@@ -196,6 +195,7 @@ addon.sort.last_updated=آخر تحديث
|
||||
addon.sort.popularity=الشعبية
|
||||
# addon.sort.relevancy=
|
||||
addon.sort.total_downloads=إجمالي التنزيلات
|
||||
# addon.version_type=
|
||||
|
||||
archive.author=المؤلف/المؤلفون
|
||||
archive.date=تاريخ النشر
|
||||
@@ -229,6 +229,9 @@ button.remove.confirm=هل أنت متأكد من الإزالة النهائي
|
||||
button.save=حفظ
|
||||
button.save_as=حفظ باسم
|
||||
button.select_all=تحديد الكل
|
||||
# button.select_date=
|
||||
# button.select_date.from=
|
||||
# button.select_date.to=
|
||||
button.view=عرض
|
||||
button.yes=نعم
|
||||
|
||||
@@ -381,6 +384,7 @@ curse.category.4552=سكريبتات
|
||||
datetime.format=MMM d, yyyy, h\:mm\:ss a
|
||||
|
||||
download=تنزيل
|
||||
# download.china=
|
||||
download.code.404=الملف "%s" غير موجود على الخادم البعيد.
|
||||
download.content=الإضافات
|
||||
download.shader=الظلال
|
||||
@@ -858,6 +862,7 @@ java.download.prompt=يُرجى اختيار إصدار Java الذي تريد
|
||||
java.download.distribution=التوزيع
|
||||
java.download.version=الإصدار
|
||||
java.download.packageType=نوع الحزمة
|
||||
# java.empty=
|
||||
java.management=إدارة Java
|
||||
java.info.architecture=المعمارية
|
||||
java.info.vendor=المزوّد
|
||||
@@ -1168,6 +1173,7 @@ mods.add=إضافة
|
||||
mods.add.failed=فشل إضافة المود %s.
|
||||
mods.add.success=تمت إضافة %s بنجاح.
|
||||
mods.add.title=اختر ملف المود الذي تريد إضافته
|
||||
# mods.delete.failed=
|
||||
mods.disable=تعطيل
|
||||
mods.download=تنزيل مود
|
||||
mods.download.title=تنزيل مود - %1s
|
||||
@@ -1181,6 +1187,7 @@ mods.mcmod.page=صفحة MCMod
|
||||
mods.mcmod.search=البحث في MCMod
|
||||
mods.name=الاسم
|
||||
mods.restore=استعادة
|
||||
# mods.toggle.failed=
|
||||
mods.url=الصفحة الرسمية
|
||||
mods.update_modpack_mod.warning=تحديث المودات في حزمة مودات قد يؤدي إلى نتائج لا يمكن إصلاحها، وقد يتسبب في تلف الحزمة بحيث لا تعمل. هل أنت متأكد من التحديث؟
|
||||
mods.warning.loader_mismatch=عدم تطابق محمّل المودات
|
||||
@@ -1204,9 +1211,11 @@ nbt.title=عرض الملف - %s
|
||||
datapack=حزم البيانات
|
||||
datapack.add=إضافة
|
||||
datapack.add.title=اختر أرشيف حزمة البيانات التي تريد إضافتها
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
datapack.reload.toast=اللعبة تعمل حالياً، يُرجى استخدام الأمر /reload لإعادة تحميل حزمة البيانات
|
||||
datapack.title=العالم [%s] - حزم البيانات
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=فشل تحميل الصفحة
|
||||
web.open_in_browser=هل تريد فتح هذا العنوان في المتصفح:\n%s
|
||||
@@ -1358,6 +1367,10 @@ schematics.info.version=إصدار المخطط
|
||||
schematics.manage=المخططات
|
||||
schematics.sub_items=%d عنصر فرعي
|
||||
|
||||
# screenshots.clear.confirm=
|
||||
# screenshots.manage=
|
||||
# screenshots.set_as_bg=
|
||||
|
||||
search=بحث
|
||||
search.hint.chinese=البحث بالإنجليزية والصينية
|
||||
search.hint.english=البحث بالإنجليزية فقط
|
||||
@@ -1421,6 +1434,7 @@ settings.advanced.natives_directory=مسار المكتبات الأصلية
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
settings.advanced.no_jvm_args=عدم إضافة وسيطات JVM الافتراضية
|
||||
settings.advanced.no_optimizing_jvm_args=عدم إضافة وسيطات تحسين JVM الافتراضية
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=أمر ما قبل التشغيل
|
||||
settings.advanced.precall_command.prompt=أوامر تُنفَّذ قبل تشغيل اللعبة
|
||||
settings.advanced.process_priority=أولوية العملية
|
||||
@@ -1565,6 +1579,7 @@ settings.launcher.font.anti_aliasing.auto=تلقائي
|
||||
settings.launcher.font.anti_aliasing.gray=تدرج رمادي
|
||||
settings.launcher.font.anti_aliasing.lcd=بكسل فرعي
|
||||
# settings.launcher.fonts=
|
||||
# settings.launcher.game_content=
|
||||
settings.launcher.general=عام
|
||||
settings.launcher.language=اللغة
|
||||
settings.launcher.launcher_log.export=تصدير سجلات المشغّل
|
||||
@@ -1772,6 +1787,7 @@ unofficial.hint=أنت تستخدم إصداراً غير رسمي من HMCL. ل
|
||||
update=تحديث
|
||||
update.accept=تحديث
|
||||
update.changelog=سجل التغييرات
|
||||
# update.changelog.empty=
|
||||
update.channel.dev=تجريبي
|
||||
update.channel.dev.hint=أنت تستخدم حالياً إصدار القناة التجريبية من المشغّل. على الرغم من أنه قد يتضمن ميزات إضافية، إلا أنه أقل استقراراً أحياناً من إصدارات قناة الإصدار الرسمي.\n\
|
||||
\n\
|
||||
|
||||
@@ -105,7 +105,7 @@ account.methods.microsoft.close_page=Die Microsoft-Kontoautorisierung ist nun ab
|
||||
Es gibt noch einige zusätzliche Arbeiten für uns, aber Sie können diesen Tab vorerst sicher schließen.
|
||||
account.methods.microsoft.error.add_family=Bitte klicken Sie <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">hier</a>, um das Geburtsdatum Ihres Kontos auf über 18 Jahre zu ändern, oder fügen Sie Ihr Konto zu einer Familie hinzu.
|
||||
account.methods.microsoft.error.country_unavailable=XBOX Live ist in Ihrem aktuellen Land/Ihrer aktuellen Region nicht verfügbar.
|
||||
account.methods.microsoft.error.missing_xbox_account=Bitte bestätigen Sie, dass Sie Minecraft: Java Edition gekauft haben. Wenn Sie dies getan haben, klicken Sie bitte <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a>, um Ihr Spielprofil zu erstellen.
|
||||
account.methods.microsoft.error.missing_xbox_account=Bitte bestätigen Sie, dass Sie Minecraft gekauft haben. Wenn Sie dies getan haben, klicken Sie bitte <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a>, um Ihr Spielprofil zu erstellen.
|
||||
account.methods.microsoft.error.no_license=Kein Kaufbeleg für Minecraft gefunden. Bitte klicken Sie <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">hier</a>, um das Spiel zu erhalten.\nWenn Sie den Game Pass verwenden, erstellen Sie bitte <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a> Ihr Spielprofil. Wenn Sie das Spiel bereits gekauft haben, überprüfen Sie bitte, ob Sie mit dem richtigen Konto angemeldet sind.
|
||||
account.methods.microsoft.error.no_character=Sie haben noch kein Spielprofil erstellt. Bitte klicken Sie <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a>, um eines zu erstellen.
|
||||
account.methods.microsoft.error.banned=Ihr Konto wurde möglicherweise von XBOX Live gesperrt.\n\
|
||||
@@ -174,16 +174,15 @@ addon.check_update.empty=Alle Dateien sind auf dem neuesten Stand
|
||||
addon.check_update.failed_check=Fehler beim Suchen nach Aktualisierungen.
|
||||
addon.check_update.failed_download=Fehler beim Herunterladen einiger Dateien.
|
||||
addon.check_update.file=Datei
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Quelle
|
||||
addon.check_update.target_version=Zielversion
|
||||
addon.curseforge=CurseForge
|
||||
addon.dependency.embedded=Integrierte Abhängigkeiten (Bereits in der Addon-Datei vom Autor gepackt. Muss nicht separat heruntergeladen werden)
|
||||
addon.dependency.optional=Optionale Abhängigkeiten (Wenn diese fehlen, läuft das Spiel normal, aber die Addon-Funktionen fehlen möglicherweise)
|
||||
addon.dependency.required=Erforderliche Abhängigkeiten (Müssen separat heruntergeladen werden. Ein Fehlen kann den Start des Spiels verhindern)
|
||||
addon.dependency.tool=Erforderliche Abhängigkeiten (Müssen separat heruntergeladen werden. Ein Fehlen kann den Start des Spiels verhindern)
|
||||
addon.dependency.include=Integrierte Abhängigkeiten (Bereits in der Addon-Datei vom Autor gepackt. Muss nicht separat heruntergeladen werden)
|
||||
addon.dependency.incompatible=Inkompatible Addons (Wenn diese Addons gleichzeitig installiert werden, kann das Spiel nicht gestartet werden)
|
||||
addon.dependency.broken=Fehlerhafte Abhängigkeiten (Dieses Addon existierte vorher, ist aber nicht mehr vorhanden. Versuchen Sie, eine andere Download-Quelle zu verwenden.)
|
||||
# addon.dependencies.has_broken=
|
||||
addon.download.recommend=Empfohlene Version für Minecraft %1s
|
||||
addon.download.title.release=Minecraft %s
|
||||
@@ -194,6 +193,7 @@ addon.sort.last_updated=Zuletzt aktualisiert
|
||||
addon.sort.popularity=Beliebtheit
|
||||
# addon.sort.relevancy=
|
||||
addon.sort.total_downloads=Downloads insgesamt
|
||||
# addon.version_type=
|
||||
|
||||
archive.author=Autor(en)
|
||||
archive.date=Veröffentlichungsdatum
|
||||
@@ -227,6 +227,9 @@ button.remove.confirm=Sind Sie sicher, dass Sie dies dauerhaft entfernen möchte
|
||||
button.save=Speichern
|
||||
button.save_as=Speichern unter
|
||||
button.select_all=Alles auswählen
|
||||
# button.select_date=
|
||||
# button.select_date.from=
|
||||
# button.select_date.to=
|
||||
button.view=Anzeigen
|
||||
button.yes=Ja
|
||||
|
||||
@@ -378,6 +381,7 @@ curse.category.6555=Vanilla
|
||||
datetime.format=dd.MM.yyyy, HH\:mm\:ss
|
||||
|
||||
download=Herunterladen
|
||||
# download.china=
|
||||
download.code.404=Datei "%s" wurde nicht auf dem Remote-Server gefunden.
|
||||
download.content=Addons
|
||||
download.shader=Shaderpakete
|
||||
@@ -856,6 +860,7 @@ java.download.prompt=Bitte wählen Sie die Java-Version, die Sie herunterladen m
|
||||
java.download.distribution=Distribution
|
||||
java.download.version=Version
|
||||
java.download.packageType=Pakettyp
|
||||
# java.empty=
|
||||
java.management=Java-Verwaltung
|
||||
java.info.architecture=Architektur
|
||||
java.info.vendor=Anbieter
|
||||
@@ -1167,6 +1172,7 @@ mods.add=Hinzufügen
|
||||
mods.add.failed=Mod %s konnte nicht hinzugefügt werden.
|
||||
mods.add.success=%s wurde erfolgreich hinzugefügt.
|
||||
mods.add.title=Wählen Sie die Mod-Datei, die Sie hinzufügen möchten
|
||||
# mods.delete.failed=
|
||||
mods.disable=Deaktivieren
|
||||
mods.download=Mod herunterladen
|
||||
mods.download.title=Mod herunterladen - %1s
|
||||
@@ -1180,6 +1186,7 @@ mods.mcmod.page=MCMod-Seite
|
||||
mods.mcmod.search=In MCMod suchen
|
||||
mods.name=Name
|
||||
mods.restore=Wiederherstellen
|
||||
# mods.toggle.failed=
|
||||
mods.url=Offizielle Seite
|
||||
mods.update_modpack_mod.warning=Das Aktualisieren von Mods in einem Modpack kann zu irreparablen Ergebnissen führen, wodurch das Modpack möglicherweise beschädigt wird, sodass es nicht mehr gestartet werden kann. Sind Sie sicher, dass Sie aktualisieren möchten?
|
||||
mods.warning.loader_mismatch=Modloader stimmt nicht überein
|
||||
@@ -1203,9 +1210,11 @@ nbt.title=Datei ansehen - %s
|
||||
datapack=Datenpakete
|
||||
datapack.add=Hinzufügen
|
||||
datapack.add.title=Wählen Sie das Datenpaket-Archiv, das Sie hinzufügen möchten
|
||||
# datapack.delete.failed=
|
||||
datapack.empty=Keine Datenpakete
|
||||
datapack.reload.toast=Minecraft läuft. Verwenden Sie den Befehl /reload, um Datenpakete neu zu laden
|
||||
datapack.title=Welt [%s] - Datenpakete
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=Seite konnte nicht geladen werden
|
||||
web.open_in_browser=Möchten Sie diese Adresse in einem Browser öffnen:\n%s
|
||||
@@ -1357,6 +1366,10 @@ schematics.info.version=Schematik-Version
|
||||
schematics.manage=Schematiken
|
||||
schematics.sub_items=%d Unterelement(e)
|
||||
|
||||
# screenshots.clear.confirm=
|
||||
# screenshots.manage=
|
||||
# screenshots.set_as_bg=
|
||||
|
||||
search=Suchen
|
||||
search.hint.chinese=Auf Englisch und Chinesisch suchen
|
||||
search.hint.english=Nur auf Englisch suchen
|
||||
@@ -1420,6 +1433,7 @@ settings.advanced.natives_directory=Pfad der nativen Bibliotheken
|
||||
settings.advanced.natives_directory.custom.enabled=Benutzerdefinierte native Bibliotheken verwenden
|
||||
settings.advanced.no_jvm_args=Standard-JVM-Argumente nicht hinzufügen
|
||||
settings.advanced.no_optimizing_jvm_args=Standard-JVM-Optimierungsargumente nicht hinzufügen
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=Pre-Launch-Befehl
|
||||
settings.advanced.precall_command.prompt=Befehle, die vor dem Spielstart ausgeführt werden sollen
|
||||
settings.advanced.process_priority=Prozesspriorität
|
||||
@@ -1566,6 +1580,7 @@ settings.launcher.font.anti_aliasing.auto=Auto
|
||||
settings.launcher.font.anti_aliasing.gray=Graustufen
|
||||
settings.launcher.font.anti_aliasing.lcd=Sub-Pixel
|
||||
settings.launcher.fonts=Schriftarten
|
||||
# settings.launcher.game_content=
|
||||
settings.launcher.general=Allgemein
|
||||
settings.launcher.language=Sprache
|
||||
settings.launcher.launcher_log.export=Launcher-Protokolle exportieren
|
||||
@@ -1774,6 +1789,7 @@ unofficial.hint=Sie verwenden einen inoffiziellen Build von HMCL. Wir können di
|
||||
update=Aktualisieren
|
||||
update.accept=Aktualisieren
|
||||
update.changelog=Änderungsprotokoll
|
||||
# update.changelog.empty=
|
||||
update.channel.dev=Beta
|
||||
update.channel.dev.hint=Sie verwenden derzeit einen Beta-Kanal-Build des Launchers. Obwohl er möglicherweise einige zusätzliche Funktionen enthält, ist er manchmal auch weniger stabil als die Builds des stabilen Kanals.\n\
|
||||
\n\
|
||||
|
||||
@@ -174,16 +174,15 @@ addon.check_update.empty=Todos los mods están actualizados
|
||||
addon.check_update.failed_check=No se ha podido comprobar si hay actualizaciones.
|
||||
addon.check_update.failed_download=No se han podido descargar algunos de los archivos.
|
||||
addon.check_update.file=Archivo
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Fuente
|
||||
addon.check_update.target_version=Versión de destino
|
||||
addon.curseforge=CurseForge
|
||||
# addon.dependency.embedded=
|
||||
# addon.dependency.optional=
|
||||
# addon.dependency.required=
|
||||
# addon.dependency.tool=
|
||||
# addon.dependency.include=
|
||||
# addon.dependency.incompatible=
|
||||
# addon.dependency.broken=
|
||||
# addon.dependencies.has_broken=
|
||||
addon.download.recommend=Versión recomendada - Minecraft %1s
|
||||
# addon.download.title.release=
|
||||
@@ -194,6 +193,7 @@ addon.sort.last_updated=Última actualización
|
||||
addon.sort.popularity=Popularidad
|
||||
# addon.sort.relevancy=
|
||||
addon.sort.total_downloads=Descargas totales
|
||||
# addon.version_type=
|
||||
|
||||
archive.author=Autor(es)
|
||||
archive.date=Fecha de publicación
|
||||
@@ -227,6 +227,9 @@ button.remove.confirm=¿Estás seguro de que deseas eliminarlo de forma permanen
|
||||
button.save=Guardar
|
||||
button.save_as=Guardar como
|
||||
button.select_all=Seleccionar todo
|
||||
# button.select_date=
|
||||
# button.select_date.from=
|
||||
# button.select_date.to=
|
||||
button.view=Vista
|
||||
button.yes=Sí
|
||||
|
||||
@@ -379,6 +382,7 @@ curse.category.4552=Scripts
|
||||
datetime.format=d MMM yyyy, H\:mm\:ss
|
||||
|
||||
download=Descargar
|
||||
# download.china=
|
||||
download.code.404=Archivo no encontrado en el servidor remoto: %s
|
||||
download.content=Complementos
|
||||
download.shader=Sombreadores
|
||||
@@ -850,6 +854,7 @@ java.download.prompt=Elija la versión de Java que desea descargar:
|
||||
java.download.distribution=Distribución
|
||||
java.download.version=Versión
|
||||
java.download.packageType=Tipo de paquete
|
||||
# java.empty=
|
||||
java.management=Gestión Java
|
||||
java.info.architecture=Arquitectura
|
||||
java.info.vendor=Proveedor
|
||||
@@ -1160,6 +1165,7 @@ mods=Mods
|
||||
mods.add.failed=No se ha podido añadir el mod %s.
|
||||
mods.add.success=%s se ha añadido correctamente.
|
||||
# mods.add.title=
|
||||
# mods.delete.failed=
|
||||
mods.disable=Desactivar
|
||||
mods.download=Descargar mod
|
||||
mods.download.title=Descargar mod - %1s
|
||||
@@ -1173,6 +1179,7 @@ mods.mcmod.page=Página de MCMod
|
||||
mods.mcmod.search=Búsqueda en MCMod
|
||||
mods.name=Nombre
|
||||
mods.restore=Restaurar
|
||||
# mods.toggle.failed=
|
||||
mods.url=Página oficial
|
||||
mods.update_modpack_mod.warning=Actualizar mods en un modpack puede generar resultados irreparables, posiblemente corrompiendo el modpack para que no pueda iniciarse. ¿Seguro que quieres actualizar?
|
||||
# mods.warning.loader_mismatch=
|
||||
@@ -1196,9 +1203,11 @@ nbt.title=Ver archivo - %s
|
||||
datapack=Paquetes de datos
|
||||
# datapack.add=
|
||||
# datapack.add.title=
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
# datapack.reload.toast=
|
||||
datapack.title=Mundo [%s] - Paquetes de datos
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=No se ha podido cargar la página
|
||||
web.open_in_browser=Desea abrir esta dirección en un navegador:\n%s
|
||||
@@ -1350,6 +1359,10 @@ schematics.info.version=Versión de esquema
|
||||
schematics.manage=Esquemas
|
||||
schematics.sub_items=%d elemento(s)
|
||||
|
||||
# screenshots.clear.confirm=
|
||||
# screenshots.manage=
|
||||
# screenshots.set_as_bg=
|
||||
|
||||
search=Búsqueda
|
||||
search.hint.chinese=Buscar en inglés y chino
|
||||
search.hint.english=Buscar sólo en inglés
|
||||
@@ -1413,6 +1426,7 @@ settings.advanced.natives_directory=Ruta de la biblioteca nativa
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
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.not_use_javaw=
|
||||
settings.advanced.precall_command=Comando pre-lanzamiento:
|
||||
settings.advanced.precall_command.prompt=El comando se ejecuta antes de que los juegos se lance
|
||||
settings.advanced.process_priority=Prioridad del proceso
|
||||
@@ -1557,6 +1571,7 @@ settings.launcher.font.anti_aliasing.auto=Automático
|
||||
settings.launcher.font.anti_aliasing.gray=Escala de grises
|
||||
settings.launcher.font.anti_aliasing.lcd=Subpíxel
|
||||
# settings.launcher.fonts=
|
||||
# settings.launcher.game_content=
|
||||
settings.launcher.general=General
|
||||
settings.launcher.language=Idioma
|
||||
settings.launcher.launcher_log.export=Exportar registros del launcher
|
||||
@@ -1762,6 +1777,7 @@ unofficial.hint=Está utilizando una versión no oficial de HMCL. No podemos gar
|
||||
update=Actualizar
|
||||
update.accept=Actualizar
|
||||
update.changelog=Registro de cambios
|
||||
# update.changelog.empty=
|
||||
update.channel.dev=Beta
|
||||
update.channel.dev.hint=Actualmente está utilizando una versión beta del launcher, que puede incluir algunas características adicionales, pero también es más inestable que las versiones de lanzamiento.\n\
|
||||
\n\
|
||||
|
||||
@@ -163,16 +163,15 @@ addon.check_update.current_version=Current
|
||||
addon.check_update.failed_check=更新のチェックに失敗しました
|
||||
addon.check_update.failed_download=一部のファイルのダウンロードに失敗しました
|
||||
addon.check_update.file=ファイル
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Source
|
||||
addon.check_update.target_version=Target
|
||||
addon.curseforge=CurseForge
|
||||
# addon.dependency.embedded=
|
||||
# addon.dependency.optional=
|
||||
# addon.dependency.required=
|
||||
# addon.dependency.tool=
|
||||
# addon.dependency.include=
|
||||
# addon.dependency.incompatible=
|
||||
# addon.dependency.broken=
|
||||
# addon.dependencies.has_broken=
|
||||
# addon.download.recommend=
|
||||
# addon.download.title.release=
|
||||
@@ -183,6 +182,7 @@ addon.sort.last_updated=最終更新
|
||||
addon.sort.popularity=人気
|
||||
# addon.sort.relevancy=
|
||||
addon.sort.total_downloads=合計ダウンロード数
|
||||
# addon.version_type=
|
||||
|
||||
archive.author=作成者
|
||||
archive.date=公開日
|
||||
@@ -216,6 +216,9 @@ button.remove.confirm=本当に完全に削除しますか?この操作は元
|
||||
button.save=保存
|
||||
button.save_as=名前を付けて保存
|
||||
button.select_all=すべて選択
|
||||
# button.select_date=
|
||||
# button.select_date.from=
|
||||
# button.select_date.to=
|
||||
button.view=読む
|
||||
button.yes=はい
|
||||
|
||||
@@ -367,6 +370,7 @@ curse.category.4552=スクリプト
|
||||
datetime.format=yyyy/MM/dd H:mm:ss
|
||||
|
||||
download=ダウンロード
|
||||
# download.china=
|
||||
download.code.404=リモートサーバーにファイルが見つかりません:%s
|
||||
download.content=ゲームコンテンツ
|
||||
download.shader=シェーダー
|
||||
@@ -660,6 +664,7 @@ java.download.prompt=ダウンロードしたいJavaのバージョンを選択
|
||||
java.download.distribution=配布の種類
|
||||
java.download.version=バージョン
|
||||
java.download.packageType=パッケージの種類
|
||||
# java.empty=
|
||||
java.management=Javaの管理
|
||||
java.info.architecture=アーキテクチャ
|
||||
java.info.vendor=発行元
|
||||
@@ -968,6 +973,7 @@ mods=Mods
|
||||
mods.add.failed=mods %s の追加に失敗しました。
|
||||
mods.add.success=mods %s が正常に追加されました。
|
||||
# mods.add.title=
|
||||
# mods.delete.failed=
|
||||
mods.disable=無効にする
|
||||
mods.download=Modのダウンロード
|
||||
mods.download.title=Modダウンロード- %1s
|
||||
@@ -981,6 +987,7 @@ mods.mcmod.page=MCMOD
|
||||
mods.mcmod.search=MCMODで検索
|
||||
mods.name=Name
|
||||
mods.restore=Restore
|
||||
# mods.toggle.failed=
|
||||
mods.url=公式ページ
|
||||
mods.update_modpack_mod.warning=modpack 内の mod を更新すると、修復不可能な結果につながる可能性があり、modpack が破損して起動できなくなる可能性があります。更新してもよろしいですか?
|
||||
# mods.warning.loader_mismatch=
|
||||
@@ -1004,9 +1011,11 @@ mods.update_modpack_mod.warning=modpack 内の mod を更新すると、修復
|
||||
datapack=Datapacks
|
||||
# datapack.add=
|
||||
# datapack.add.title=
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
# datapack.reload.toast=
|
||||
datapack.title=World %s -データパック
|
||||
# datapack.toggle.failed=
|
||||
|
||||
# web.failed=
|
||||
# web.open_in_browser=
|
||||
@@ -1154,6 +1163,10 @@ reveal.in_file_manager=ファイルマネージャーで表示する
|
||||
# schematics.manage=
|
||||
# schematics.sub_items=
|
||||
|
||||
# screenshots.clear.confirm=
|
||||
# screenshots.manage=
|
||||
# screenshots.set_as_bg=
|
||||
|
||||
search=検索
|
||||
search.hint.chinese=中国語と英語での検索をサポート
|
||||
search.hint.english=英語でのみ検索をサポート
|
||||
@@ -1217,6 +1230,7 @@ settings.advanced.natives_directory=ネイティブライブラリパス(例
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
settings.advanced.no_jvm_args=デフォルトのJVM引数なし
|
||||
# settings.advanced.no_optimizing_jvm_args=
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=起動前コマンド
|
||||
settings.advanced.precall_command.prompt=ゲーム開始前に実行されます
|
||||
settings.advanced.process_priority=プロセスの優先度
|
||||
@@ -1361,6 +1375,7 @@ settings.launcher.font=フォント
|
||||
# settings.launcher.font.anti_aliasing.gray=
|
||||
# settings.launcher.font.anti_aliasing.lcd=
|
||||
# settings.launcher.fonts=
|
||||
# settings.launcher.game_content=
|
||||
settings.launcher.general=全般的
|
||||
settings.launcher.language=言語
|
||||
settings.launcher.launcher_log.export=ランチャーログのエクスポート
|
||||
@@ -1566,6 +1581,7 @@ system.operating_system=OS
|
||||
update=更新
|
||||
update.accept=更新
|
||||
update.changelog=変更
|
||||
# update.changelog.empty=
|
||||
update.channel.dev=ベータ
|
||||
update.channel.dev.hint=ベータ版を使用しています。ベータ版には、リリースバージョンと比較していくつかの追加機能が含まれている可能性があり、テストにのみ使用されます。ベータ版は不安定なはずです!\n\
|
||||
\n\
|
||||
|
||||
@@ -174,16 +174,15 @@ addon.check_update.empty=無改囊可迭更
|
||||
addon.check_update.failed_check=檢囊迭更未成
|
||||
addon.check_update.failed_download=有引案未成
|
||||
addon.check_update.file=案
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=源
|
||||
addon.check_update.target_version=將至之版
|
||||
addon.curseforge=CurseForge
|
||||
# addon.dependency.embedded=
|
||||
# addon.dependency.optional=
|
||||
# addon.dependency.required=
|
||||
# addon.dependency.tool=
|
||||
# addon.dependency.include=
|
||||
# addon.dependency.incompatible=
|
||||
# addon.dependency.broken=
|
||||
# addon.dependencies.has_broken=
|
||||
addon.download.recommend=宜版 - 礦藝 %1s
|
||||
# addon.download.title.release=
|
||||
@@ -194,6 +193,7 @@ addon.sort.last_updated=近日迭更
|
||||
addon.sort.popularity=幾熱
|
||||
# addon.sort.relevancy=
|
||||
addon.sort.total_downloads=引數
|
||||
# addon.version_type=
|
||||
|
||||
archive.author=作者
|
||||
archive.date=廣佈之期
|
||||
@@ -227,6 +227,9 @@ button.remove.confirm=誠刪之?刪則不復!
|
||||
button.save=存
|
||||
button.save_as=另存
|
||||
button.select_all=悉擇之
|
||||
# button.select_date=
|
||||
# button.select_date.from=
|
||||
# button.select_date.to=
|
||||
button.view=覽
|
||||
button.yes=然
|
||||
|
||||
@@ -378,6 +381,7 @@ curse.category.4552=角本
|
||||
datetime.format=yyyy 年 MM 月 dd 日 HH:mm:ss
|
||||
|
||||
download=引
|
||||
# download.china=
|
||||
download.code.404=伺服器無案曰:%s\n君可求助於右上之鈕。
|
||||
download.content=戲案
|
||||
download.shader=光影
|
||||
@@ -672,6 +676,7 @@ java.download.prompt=須擇一爪哇版以引:
|
||||
java.download.distribution=廣布版
|
||||
java.download.version=版
|
||||
java.download.packageType=裝包類
|
||||
# java.empty=
|
||||
java.management=司爪哇
|
||||
java.info.architecture=大構
|
||||
java.info.vendor=供者
|
||||
@@ -980,6 +985,7 @@ mods.add=增改囊
|
||||
mods.add.failed=增改囊「%s」未成。\n君可求助於右上之鈕。
|
||||
mods.add.success=增改囊 %s 畢。
|
||||
mods.add.title=擇改囊
|
||||
# mods.delete.failed=
|
||||
mods.disable=禁此改囊
|
||||
mods.download=引改囊
|
||||
mods.download.title=引改囊 - %1s
|
||||
@@ -993,6 +999,7 @@ mods.mcmod.page=礦藝改囊典頁
|
||||
mods.mcmod.search=礦藝改囊典查
|
||||
mods.name=名
|
||||
mods.restore=復
|
||||
# mods.toggle.failed=
|
||||
mods.url=改囊官網
|
||||
mods.update_modpack_mod.warning=更迭改囊集之改囊或損之,以不能啟。行而不復,誠更迭乎?
|
||||
# mods.warning.loader_mismatch=
|
||||
@@ -1016,9 +1023,11 @@ nbt.title=覽案 - %s
|
||||
datapack=錄囊
|
||||
datapack.add=增錄囊
|
||||
datapack.add.title=擇須導入之錄囊緊囊
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
# datapack.reload.toast=
|
||||
datapack.title=生界 [%s] - 錄囊
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=載網葉未成
|
||||
web.open_in_browser=誠啟此鏈於瀏覽器乎:\n%s
|
||||
@@ -1166,6 +1175,10 @@ schematics.info.version=蜃圖之版
|
||||
schematics.manage=司蜃圖
|
||||
schematics.sub_items=%d 子项
|
||||
|
||||
# screenshots.clear.confirm=
|
||||
# screenshots.manage=
|
||||
# screenshots.set_as_bg=
|
||||
|
||||
search=尋
|
||||
search.hint.chinese=以中英文尋
|
||||
search.hint.english=惟以英文尋
|
||||
@@ -1229,6 +1242,7 @@ settings.advanced.natives_directory=府庫之徑 (LWJGL)
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
settings.advanced.no_jvm_args=無添爪哇虛機之本通弦
|
||||
# settings.advanced.no_optimizing_jvm_args=
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=令於戲前
|
||||
settings.advanced.precall_command.prompt=將前戲啟而行
|
||||
settings.advanced.process_priority=進程之次
|
||||
@@ -1373,6 +1387,7 @@ settings.launcher.font.anti_aliasing.auto=自調
|
||||
settings.launcher.font.anti_aliasing.gray=灰階
|
||||
settings.launcher.font.anti_aliasing.lcd=子像素
|
||||
# settings.launcher.fonts=
|
||||
# settings.launcher.game_content=
|
||||
settings.launcher.general=貫用
|
||||
settings.launcher.language=文
|
||||
settings.launcher.launcher_log.export=錄出啟者之誌
|
||||
@@ -1578,6 +1593,7 @@ unofficial.hint=子方用非官築之 HMCL。余無能證其安,請君自辨
|
||||
update=啟者迭更
|
||||
update.accept=迭更
|
||||
update.changelog=迭更誌
|
||||
# update.changelog.empty=
|
||||
update.channel.dev=開發版
|
||||
update.channel.dev.hint=君所用之 HMCL 乃開發版也。開發版納試功之未行於定版者,蓋所以驗新者也。預版曾不以全驗,安能穩也!<a href="https://hmcl.huangyuhui.net/download">引定版</a>\n\
|
||||
\n\
|
||||
|
||||
@@ -103,7 +103,7 @@ account.methods.microsoft.code=Код (скопирован в буфер обм
|
||||
account.methods.microsoft.close_page=Авторизация аккаунта Microsoft завершена. Осталось выполнить некоторые шаги, которые необходимо завершить позже. Вы можете закрыть эту страницу.
|
||||
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=Пожалуйста, подтвердите, что вы приобрели Minecraft: Java Edition. Если да, нажмите <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">здесь</a>, чтобы создать свой игровой профиль.
|
||||
account.methods.microsoft.error.missing_xbox_account=Пожалуйста, подтвердите, что вы приобрели Minecraft. Если да, нажмите <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>, чтобы создать его.
|
||||
@@ -170,16 +170,15 @@ addon.check_update.empty=Все файлы актуальны
|
||||
addon.check_update.failed_check=Не удалось проверить обновления.
|
||||
addon.check_update.failed_download=Не удалось скачать некоторые файлы.
|
||||
addon.check_update.file=Файл
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Источник
|
||||
addon.check_update.target_version=Целевая версия
|
||||
addon.curseforge=CurseForge
|
||||
addon.dependency.embedded=Встроенные зависимости (уже включены автором в файл аддона, скачивать отдельно не нужно)
|
||||
addon.dependency.optional=Необязательные зависимости (без них игра запустится, но часть функций аддона может не работать)
|
||||
addon.dependency.required=Обязательные зависимости (нужно скачать отдельно, без них игра может не запуститься)
|
||||
addon.dependency.tool=Обязательные зависимости (нужно скачать отдельно, без них игра может не запуститься)
|
||||
addon.dependency.include=Встроенные зависимости (уже включены автором в файл аддона, скачивать отдельно не нужно)
|
||||
addon.dependency.incompatible=Несовместимые аддоны (если установить их одновременно, игра не запустится)
|
||||
addon.dependency.broken=Сломанные зависимости (этот аддон раньше существовал, но теперь недоступен. Попробуйте другой источник скачивания.)
|
||||
addon.dependencies.has_broken=У этой версии есть сломанные зависимости. Попробуйте другой источник скачивания, иначе установка может не удаться.
|
||||
addon.download.recommend=Рекомендуемая версия для Minecraft %1s
|
||||
addon.download.title.release=Minecraft %s
|
||||
@@ -190,6 +189,7 @@ addon.sort.last_updated=Обновлён
|
||||
addon.sort.popularity=Популярность
|
||||
addon.sort.relevancy=Релевантность
|
||||
addon.sort.total_downloads=Скачиваний
|
||||
# addon.version_type=
|
||||
|
||||
archive.author=Автор(ы)
|
||||
archive.date=Дата публикации
|
||||
@@ -223,6 +223,9 @@ button.remove.confirm=Удалить навсегда? Это действие
|
||||
button.save=Сохранить
|
||||
button.save_as=Сохранить как
|
||||
button.select_all=Выбрать все
|
||||
# button.select_date=
|
||||
# button.select_date.from=
|
||||
# button.select_date.to=
|
||||
button.view=Просмотреть
|
||||
button.yes=Да
|
||||
|
||||
@@ -375,6 +378,7 @@ curse.category.6555=Ванильный
|
||||
datetime.format=d MMM yyyy г., HH\:mm\:ss
|
||||
|
||||
download=Скачать
|
||||
# download.china=
|
||||
download.code.404=Файл «%s» не найден на удаленном сервере.
|
||||
download.content=Аддоны
|
||||
download.shader=Шейдеры
|
||||
@@ -787,6 +791,7 @@ java.download.prompt=Выберите версию Java, которую вы х
|
||||
java.download.distribution=Дистрибутив
|
||||
java.download.version=Версия
|
||||
java.download.packageType=Тип пакета
|
||||
# java.empty=
|
||||
java.management=Управление Java
|
||||
java.info.architecture=Архитектура
|
||||
java.info.vendor=Поставщик
|
||||
@@ -1100,6 +1105,7 @@ mods.add=Добавить
|
||||
mods.add.failed=Не удалось установить мод %s.
|
||||
mods.add.success=%s был успешно добавлен.
|
||||
mods.add.title=Выберите файл мода, который хотите добавить
|
||||
# mods.delete.failed=
|
||||
mods.disable=Отключить
|
||||
mods.download=Скачать мод
|
||||
mods.download.title=Скачать мод - %1s
|
||||
@@ -1113,6 +1119,7 @@ mods.mcmod.page=Страница MCMod
|
||||
mods.mcmod.search=Искать на MCMod
|
||||
mods.name=Название
|
||||
mods.restore=Восстановить
|
||||
# mods.toggle.failed=
|
||||
mods.url=Официальная страница
|
||||
mods.update_modpack_mod.warning=Обновление модов в модпаке может привести к непоправимым последствиям: модпак может повредиться и перестать запускаться. Вы уверены, что хотите обновить?
|
||||
mods.warning.loader_mismatch=Не тот загрузчик модов
|
||||
@@ -1136,9 +1143,11 @@ nbt.title=Просмотр файла - %s
|
||||
datapack=Наборы данных
|
||||
datapack.add=Добавить
|
||||
datapack.add.title=Выберите архив набора данных, который хотите добавить
|
||||
# datapack.delete.failed=
|
||||
datapack.empty=Нет наборов данных
|
||||
datapack.reload.toast=Minecraft запущен. Чтобы перезагрузить наборы данных, используйте команду /reload
|
||||
datapack.title=Мир [%s] - Наборы данных
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=Не удалось загрузить страницу
|
||||
web.open_in_browser=Хотите ли вы открыть этот адрес в браузере?\n\n%s
|
||||
@@ -1290,6 +1299,10 @@ schematics.info.version=Версия схемы
|
||||
schematics.manage=Схемы
|
||||
schematics.sub_items=%d элемент(ов)
|
||||
|
||||
# screenshots.clear.confirm=
|
||||
# screenshots.manage=
|
||||
# screenshots.set_as_bg=
|
||||
|
||||
search=Поиск
|
||||
search.hint.chinese=Поиск на китайском и английском языках
|
||||
search.hint.english=Поиск только на английском языке
|
||||
@@ -1353,6 +1366,7 @@ settings.advanced.natives_directory=Папка с нативными библи
|
||||
settings.advanced.natives_directory.custom.enabled=Использовать свои нативные библиотеки
|
||||
settings.advanced.no_jvm_args=Не добавлять аргументы JVM по умолчанию
|
||||
settings.advanced.no_optimizing_jvm_args=Не добавлять аргументы оптимизации JVM по умолчанию
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=Команда перед запуском
|
||||
settings.advanced.precall_command.prompt=Команды, которые необходимо выполнить перед запуском игры
|
||||
settings.advanced.process_priority=Приоритет процесса
|
||||
@@ -1497,6 +1511,7 @@ settings.launcher.font.anti_aliasing.auto=Автоматическое
|
||||
settings.launcher.font.anti_aliasing.gray=Оттенки серого
|
||||
settings.launcher.font.anti_aliasing.lcd=Субпиксельное
|
||||
settings.launcher.fonts=Шрифты
|
||||
# settings.launcher.game_content=
|
||||
settings.launcher.general=Общие
|
||||
settings.launcher.language=Язык
|
||||
settings.launcher.launcher_log.export=Экспорт логов лаунчера
|
||||
@@ -1702,6 +1717,7 @@ unofficial.hint=Вы используете неофициальную верс
|
||||
update=Обновление
|
||||
update.accept=Обновить
|
||||
update.changelog=Изменения
|
||||
# update.changelog.empty=
|
||||
update.channel.dev=Бета
|
||||
update.channel.dev.hint=Вы используете бета-версию лаунчера. В ней могут быть дополнительные функции, но иногда она работает менее стабильно, чем стабильная версия.\n\nЕсли вы столкнулись с ошибками или проблемами, сообщите о них через каналы, указанные на странице <a href="hmcl://settings/feedback">«Обратная связь»</a>.\n\nПодпишитесь на <a href="https://space.bilibili.com/1445341">@huanghongxun</a> в Bilibili, чтобы следить за важными новостями HMCL, или на <a href="https://space.bilibili.com/20314891">@Glavo</a>, чтобы следить за ходом разработки HMCL.
|
||||
update.channel.dev.title=Уведомление о бета-версии
|
||||
|
||||
@@ -106,7 +106,7 @@ account.methods.microsoft.close_page=Авторизація облікового
|
||||
Нам залишилося виконати ще трохи роботи, але ви можете безпечно закрити цю вкладку зараз.
|
||||
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=Будь ласка, підтвердіть, що ви придбали Minecraft: Java Edition. Якщо так, натисніть <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">тут</a>, щоб створити свій ігровий профіль.
|
||||
account.methods.microsoft.error.missing_xbox_account=Будь ласка, підтвердіть, що ви придбали Minecraft. Якщо так, натисніть <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\
|
||||
@@ -176,16 +176,15 @@ addon.check_update.empty=Усі моди оновлені
|
||||
addon.check_update.failed_check=Не вдалося перевірити оновлення.
|
||||
addon.check_update.failed_download=Не вдалося завантажити деякі файли.
|
||||
addon.check_update.file=Файл
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Джерело
|
||||
addon.check_update.target_version=Цільова версія
|
||||
addon.curseforge=CurseForge
|
||||
addon.dependency.embedded=Вбудовані залежності (Вже упаковані у файл доповнення автором. Не потрібно завантажувати окремо)
|
||||
addon.dependency.optional=Необов'язкові залежності (Якщо відсутні, гра буде працювати нормально, але функції доповнення можуть бути відсутні)
|
||||
addon.dependency.required=Обов'язкові залежності (Необхідно завантажити окремо. Їх відсутність може перешкодити запуску гри)
|
||||
addon.dependency.tool=Обов'язкові залежності (Необхідно завантажити окремо. Їх відсутність може перешкодити запуску гри)
|
||||
addon.dependency.include=Вбудовані залежності (Вже упаковані у файл доповнення автором. Не потрібно завантажувати окремо)
|
||||
addon.dependency.incompatible=Несумісні доповнення (Одночасне встановлення цих доповнень перешкодить запуску гри)
|
||||
addon.dependency.broken=Зламані залежності (Це доповнення існувало раніше, але його більше немає. Спробуйте використати інше джерело завантаження.)
|
||||
# addon.dependencies.has_broken=
|
||||
addon.download.recommend=Рекомендована версія для Minecraft %1s
|
||||
addon.download.title.release=Minecraft %s
|
||||
@@ -196,6 +195,7 @@ addon.sort.last_updated=Останнє оновлення
|
||||
addon.sort.popularity=Популярність
|
||||
# addon.sort.relevancy=
|
||||
addon.sort.total_downloads=Всього завантажень
|
||||
# addon.version_type=
|
||||
|
||||
archive.author=Автор(и)
|
||||
archive.date=Дата публікації
|
||||
@@ -229,6 +229,9 @@ button.remove.confirm=Ви впевнені, що хочете назавжди
|
||||
button.save=Зберегти
|
||||
button.save_as=Зберегти як
|
||||
button.select_all=Вибрати все
|
||||
# button.select_date=
|
||||
# button.select_date.from=
|
||||
# button.select_date.to=
|
||||
button.view=Переглянути
|
||||
button.yes=Так
|
||||
|
||||
@@ -375,6 +378,7 @@ curse.category.6555=Ваніла
|
||||
datetime.format=dd.MM.yyyy, HH:mm:ss
|
||||
|
||||
download=Завантажити
|
||||
# download.china=
|
||||
download.code.404=Файл "%s" не знайдено на віддаленому сервері.
|
||||
download.content=Додатки
|
||||
download.shader=Шейдери
|
||||
@@ -811,6 +815,7 @@ java.download.prompt=Будь ласка, виберіть версію Java, я
|
||||
java.download.distribution=Дистрибутив
|
||||
java.download.version=Версія
|
||||
java.download.packageType=Тип пакета
|
||||
# java.empty=
|
||||
java.management=Керування Java
|
||||
java.info.architecture=Архітектура
|
||||
java.info.vendor=Постачальник
|
||||
@@ -1123,6 +1128,7 @@ mods.add=Додати
|
||||
mods.add.failed=Не вдалося додати мод %s.
|
||||
mods.add.success=%s успішно додано.
|
||||
mods.add.title=Виберіть файл мода, який ви хочете додати
|
||||
# mods.delete.failed=
|
||||
mods.disable=Вимкнути
|
||||
mods.download=Завантажити мод
|
||||
mods.download.title=Завантажити мод - %1s
|
||||
@@ -1136,6 +1142,7 @@ mods.mcmod.page=Сторінка MCMod
|
||||
mods.mcmod.search=Пошук в MCMod
|
||||
mods.name=Назва
|
||||
mods.restore=Відновити
|
||||
# mods.toggle.failed=
|
||||
mods.url=Офіційна сторінка
|
||||
mods.update_modpack_mod.warning=Оновлення модів у модпаку може призвести до непоправних наслідків, можливо, пошкодивши модпак так, що він не зможе запуститися. Ви впевнені, що хочете оновити?
|
||||
mods.warning.loader_mismatch=Невідповідність завантажувача модів
|
||||
@@ -1159,9 +1166,11 @@ nbt.title=Перегляд файлу - %s
|
||||
datapack=Datapacks
|
||||
datapack.add=Додати
|
||||
datapack.add.title=Виберіть архів пакета даних, який ви хочете додати
|
||||
# datapack.delete.failed=
|
||||
datapack.empty=Немає пакетів даних
|
||||
datapack.reload.toast=Minecraft запущено. Використовуйте команду /reload, щоб перезавантажити пакети даних
|
||||
datapack.title=Світ [%s] - Datapacks
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=Не вдалося завантажити сторінку
|
||||
web.open_in_browser=Бажаєте відкрити цю адресу в браузері: \n%s
|
||||
@@ -1313,6 +1322,10 @@ schematics.info.version=Версія схематики
|
||||
schematics.manage=Схематики
|
||||
schematics.sub_items=%d підпунктів
|
||||
|
||||
# screenshots.clear.confirm=
|
||||
# screenshots.manage=
|
||||
# screenshots.set_as_bg=
|
||||
|
||||
search=Пошук
|
||||
search.hint.chinese=Пошук англійською та китайською
|
||||
search.hint.english=Пошук лише англійською
|
||||
@@ -1376,6 +1389,7 @@ settings.advanced.natives_directory=Шлях до нативних бібліо
|
||||
settings.advanced.natives_directory.custom.enabled=Використовувати власні нативні бібліотеки
|
||||
settings.advanced.no_jvm_args=Не додавати типові аргументи JVM
|
||||
settings.advanced.no_optimizing_jvm_args=Не додавати типові аргументи оптимізації JVM
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=Команда перед запуском
|
||||
settings.advanced.precall_command.prompt=Команди для виконання перед запуском гри
|
||||
settings.advanced.process_priority=Пріоритет процесу
|
||||
@@ -1520,6 +1534,7 @@ settings.launcher.font.anti_aliasing.auto=Авто
|
||||
settings.launcher.font.anti_aliasing.gray=Відтінки сірого
|
||||
settings.launcher.font.anti_aliasing.lcd=Субпіксельне
|
||||
settings.launcher.fonts=Шрифти
|
||||
# settings.launcher.game_content=
|
||||
settings.launcher.general=Загальні
|
||||
settings.launcher.language=Мова
|
||||
settings.launcher.launcher_log.export=Експортувати журнали лаунчера
|
||||
@@ -1725,6 +1740,7 @@ unofficial.hint=Ви використовуєте неофіційну збір
|
||||
update=Оновлення
|
||||
update.accept=Оновити
|
||||
update.changelog=Журнал змін
|
||||
# update.changelog.empty=
|
||||
update.channel.dev=Бета
|
||||
update.channel.dev.hint=Ви зараз використовуєте збірку лаунчера каналу Бета. Хоча вона може включати деякі додаткові функції, іноді вона менш стабільна, ніж збірки каналу Реліз. \n\
|
||||
\n\
|
||||
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user