Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f921370c21 | ||
|
|
61774c2efd | ||
|
|
4c209d1eb6 | ||
|
|
a1c9f1be7d | ||
|
|
3d1bd96d56 | ||
|
|
d48a3c2109 | ||
|
|
a0f9ba1976 | ||
|
|
b8df46ef87 | ||
|
|
0c04a013c8 | ||
|
|
3cf0acc505 | ||
|
|
3be5ea409b | ||
|
|
42f03411de | ||
|
|
941e7cd858 | ||
|
|
61c1b041cd | ||
|
|
7a48be5364 | ||
|
|
7d76fea48d | ||
|
|
600bd6e9ee | ||
|
|
ccc64db805 | ||
|
|
ea407be5f0 | ||
|
|
9b9471da49 | ||
|
|
b1402d3821 | ||
|
|
62f0d0a1c0 | ||
|
|
2865f2ffca | ||
|
|
3860e68caa | ||
|
|
74d0a89820 | ||
|
|
91e4c85a28 | ||
|
|
a05b24cfd7 | ||
|
|
72e3925696 | ||
|
|
9ea419c05b | ||
|
|
3dc1631207 | ||
|
|
ab5ef6e0ff | ||
|
|
61113d5c08 | ||
|
|
d73d7330eb | ||
|
|
45a85e006b | ||
|
|
06cce5259b | ||
|
|
3e393982e0 | ||
|
|
db2e2d89ee | ||
|
|
a3d9e29350 | ||
|
|
682d88df02 | ||
|
|
b5defd31ed | ||
|
|
553e67bda7 | ||
|
|
95bb669ffc | ||
|
|
505752476c | ||
|
|
c723acfe30 | ||
|
|
2fd7076ccf | ||
|
|
0705465ba4 | ||
|
|
7a4649f0cc | ||
|
|
8b8a62ccf1 | ||
|
|
243f3e7371 | ||
|
|
4ad368560c | ||
|
|
c6c302e3d6 | ||
|
|
686f59e21b | ||
|
|
f1bd4bffb7 | ||
|
|
18a8556997 | ||
|
|
f10c842aa4 | ||
|
|
74d379a09c | ||
|
|
3dd1d5bd62 | ||
|
|
3247b77410 |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
|
||||
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
||||
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
@@ -240,6 +241,11 @@ tasks.processResources {
|
||||
from(upsideDownTranslate.map { it.outputFile })
|
||||
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
|
||||
}
|
||||
|
||||
inputs.property("terracotta_version", libs.versions.terracotta)
|
||||
doLast {
|
||||
upgradeTerracottaConfig.get().checkValid()
|
||||
}
|
||||
}
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
@@ -362,6 +368,26 @@ tasks.register<JavaExec>("run") {
|
||||
}
|
||||
}
|
||||
|
||||
// terracotta
|
||||
|
||||
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
|
||||
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
|
||||
val source = layout.projectDirectory.file("terracotta-template.json");
|
||||
|
||||
classifiers.set(listOf(
|
||||
"windows-x86_64", "windows-arm64",
|
||||
"macos-x86_64", "macos-arm64",
|
||||
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
|
||||
"freebsd-x86_64"
|
||||
))
|
||||
|
||||
version.set(libs.versions.terracotta)
|
||||
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
|
||||
|
||||
templateFile.set(source)
|
||||
outputFile.set(destination)
|
||||
}
|
||||
|
||||
// Check Translations
|
||||
|
||||
tasks.register<CheckTranslations>("checkTranslations") {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.converters.ButtonTypeConverter;
|
||||
import com.jfoenix.skins.JFXButtonSkin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.css.CssMetaData;
|
||||
import javafx.css.SimpleStyleableObjectProperty;
|
||||
import javafx.css.Styleable;
|
||||
import javafx.css.StyleableObjectProperty;
|
||||
import javafx.css.StyleableProperty;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Labeled;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.paint.Paint;
|
||||
|
||||
public class JFXButton extends Button {
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-button";
|
||||
|
||||
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
|
||||
|
||||
public JFXButton() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public JFXButton(String text) {
|
||||
super(text);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public JFXButton(String text, Node graphic) {
|
||||
super(text, graphic);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXButtonSkin(this);
|
||||
}
|
||||
|
||||
private final ObjectProperty<Paint> ripplerFill = new SimpleObjectProperty<>(this, "ripplerFill", null);
|
||||
|
||||
public final ObjectProperty<Paint> ripplerFillProperty() {
|
||||
return this.ripplerFill;
|
||||
}
|
||||
|
||||
public final Paint getRipplerFill() {
|
||||
return this.ripplerFillProperty().get();
|
||||
}
|
||||
|
||||
public final void setRipplerFill(Paint ripplerFill) {
|
||||
this.ripplerFillProperty().set(ripplerFill);
|
||||
}
|
||||
|
||||
private final StyleableObjectProperty<ButtonType> buttonType = new SimpleStyleableObjectProperty<>(
|
||||
JFXButton.StyleableProperties.BUTTON_TYPE, this, "buttonType", JFXButton.ButtonType.FLAT);
|
||||
|
||||
public ButtonType getButtonType() {
|
||||
return this.buttonType == null ? JFXButton.ButtonType.FLAT : this.buttonType.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<ButtonType> buttonTypeProperty() {
|
||||
return this.buttonType;
|
||||
}
|
||||
|
||||
public void setButtonType(ButtonType type) {
|
||||
this.buttonType.set(type);
|
||||
}
|
||||
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
if (this.STYLEABLES == null) {
|
||||
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
styleables.addAll(getClassCssMetaData());
|
||||
styleables.addAll(Labeled.getClassCssMetaData());
|
||||
this.STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
|
||||
return this.STYLEABLES;
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return JFXButton.StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
this.setNeedsLayout(false);
|
||||
}
|
||||
|
||||
public enum ButtonType {
|
||||
FLAT,
|
||||
RAISED;
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXButton, ButtonType> BUTTON_TYPE;
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
BUTTON_TYPE = new CssMetaData<>("-jfx-button-type", ButtonTypeConverter.getInstance(), JFXButton.ButtonType.FLAT) {
|
||||
public boolean isSettable(JFXButton control) {
|
||||
return control.buttonType == null || !control.buttonType.isBound();
|
||||
}
|
||||
|
||||
public StyleableProperty<ButtonType> getStyleableProperty(JFXButton control) {
|
||||
return control.buttonTypeProperty();
|
||||
}
|
||||
};
|
||||
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
Collections.addAll(styleables, BUTTON_TYPE);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import javafx.geometry.Bounds;
|
||||
import javafx.scene.CacheHint;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.Region;
|
||||
@@ -77,14 +78,13 @@ public class JFXRippler extends StackPane {
|
||||
protected Node control;
|
||||
|
||||
protected static final double RIPPLE_MAX_RADIUS = 300;
|
||||
|
||||
private boolean enabled = true;
|
||||
private boolean forceOverlay = false;
|
||||
private final Interpolator rippleInterpolator = Interpolator.SPLINE(0.0825,
|
||||
private static final Interpolator RIPPLE_INTERPOLATOR = Interpolator.SPLINE(0.0825,
|
||||
0.3025,
|
||||
0.0875,
|
||||
0.9975); //0.1, 0.54, 0.28, 0.95);
|
||||
|
||||
private boolean forceOverlay = false;
|
||||
|
||||
/// creates empty rippler node
|
||||
public JFXRippler() {
|
||||
this(null, RipplerMask.RECT, RipplerPos.FRONT);
|
||||
@@ -176,10 +176,6 @@ public class JFXRippler extends StackPane {
|
||||
return control;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enable) {
|
||||
this.enabled = enable;
|
||||
}
|
||||
|
||||
// methods that can be changed by extending the rippler class
|
||||
|
||||
/// generate the clipping mask
|
||||
@@ -255,10 +251,15 @@ public class JFXRippler extends StackPane {
|
||||
if (getChildren().contains(control)) {
|
||||
control.boundsInParentProperty().addListener(observable -> resetRippler());
|
||||
}
|
||||
control.addEventHandler(MouseEvent.MOUSE_PRESSED,
|
||||
(event) -> createRipple(event.getX(), event.getY()));
|
||||
control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
|
||||
if (event.getButton() == MouseButton.PRIMARY)
|
||||
createRipple(event.getX(), event.getY());
|
||||
});
|
||||
// create fade out transition for the ripple
|
||||
control.addEventHandler(MouseEvent.MOUSE_RELEASED, e -> releaseRipple());
|
||||
control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
|
||||
if (event.getButton() == MouseButton.PRIMARY)
|
||||
releaseRipple();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,25 +364,23 @@ public class JFXRippler extends StackPane {
|
||||
}
|
||||
|
||||
void createRipple() {
|
||||
if (enabled) {
|
||||
if (!generating.getAndSet(true)) {
|
||||
// create overlay once then change its color later
|
||||
createOverlay();
|
||||
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
|
||||
this.setClip(getMask());
|
||||
}
|
||||
this.resetClip = false;
|
||||
|
||||
// create the ripple effect
|
||||
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
|
||||
getChildren().add(ripple);
|
||||
ripplesQueue.add(ripple);
|
||||
|
||||
// animate the ripple
|
||||
overlayRect.outAnimation.stop();
|
||||
overlayRect.inAnimation.play();
|
||||
ripple.inAnimation.play();
|
||||
if (!generating.getAndSet(true)) {
|
||||
// create overlay once then change its color later
|
||||
createOverlay();
|
||||
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
|
||||
this.setClip(getMask());
|
||||
}
|
||||
this.resetClip = false;
|
||||
|
||||
// create the ripple effect
|
||||
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
|
||||
getChildren().add(ripple);
|
||||
ripplesQueue.add(ripple);
|
||||
|
||||
// animate the ripple
|
||||
overlayRect.outAnimation.stop();
|
||||
overlayRect.inAnimation.play();
|
||||
ripple.inAnimation.play();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,15 +414,15 @@ public class JFXRippler extends StackPane {
|
||||
overlayRect.setClip(getMask());
|
||||
getChildren().add(0, overlayRect);
|
||||
overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
if (ripplerFill.get() instanceof Color) {
|
||||
return new Color(((Color) ripplerFill.get()).getRed(),
|
||||
((Color) ripplerFill.get()).getGreen(),
|
||||
((Color) ripplerFill.get()).getBlue(),
|
||||
if (getRipplerFill() instanceof Color fill) {
|
||||
return new Color(fill.getRed(),
|
||||
fill.getGreen(),
|
||||
fill.getBlue(),
|
||||
0.2);
|
||||
} else {
|
||||
return Color.TRANSPARENT;
|
||||
}
|
||||
}, ripplerFill));
|
||||
}, ripplerFillProperty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,8 +472,8 @@ public class JFXRippler extends StackPane {
|
||||
private Ripple(double centerX, double centerY) {
|
||||
super(centerX,
|
||||
centerY,
|
||||
ripplerRadius.get().doubleValue() == Region.USE_COMPUTED_SIZE ?
|
||||
computeRippleRadius() : ripplerRadius.get().doubleValue(), null);
|
||||
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
|
||||
computeRippleRadius() : getRipplerRadius(), null);
|
||||
setCache(true);
|
||||
setCacheHint(CacheHint.SPEED);
|
||||
setCacheShape(true);
|
||||
@@ -484,12 +483,12 @@ public class JFXRippler extends StackPane {
|
||||
KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2];
|
||||
outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3];
|
||||
|
||||
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, rippleInterpolator);
|
||||
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, rippleInterpolator);
|
||||
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR);
|
||||
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR);
|
||||
|
||||
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, rippleInterpolator);
|
||||
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, rippleInterpolator);
|
||||
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, rippleInterpolator);
|
||||
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR);
|
||||
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR);
|
||||
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR);
|
||||
|
||||
if (isRipplerRecenter()) {
|
||||
double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55;
|
||||
@@ -497,42 +496,42 @@ public class JFXRippler extends StackPane {
|
||||
inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(),
|
||||
Math.signum(dx) * Math.min(Math.abs(dx),
|
||||
this.getRadius() / 2),
|
||||
rippleInterpolator);
|
||||
RIPPLE_INTERPOLATOR);
|
||||
inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(),
|
||||
Math.signum(dy) * Math.min(Math.abs(dy),
|
||||
this.getRadius() / 2),
|
||||
rippleInterpolator);
|
||||
RIPPLE_INTERPOLATOR);
|
||||
}
|
||||
inAnimation = new Timeline(new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(scaleXProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(scaleYProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(translateXProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(translateYProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(opacityProperty(),
|
||||
1,
|
||||
rippleInterpolator)
|
||||
RIPPLE_INTERPOLATOR)
|
||||
), new KeyFrame(Duration.millis(900), inKeyValues));
|
||||
|
||||
setScaleX(0);
|
||||
setScaleY(0);
|
||||
if (ripplerFill.get() instanceof Color) {
|
||||
Color circleColor = new Color(((Color) ripplerFill.get()).getRed(),
|
||||
((Color) ripplerFill.get()).getGreen(),
|
||||
((Color) ripplerFill.get()).getBlue(),
|
||||
if (getRipplerFill() instanceof Color fill) {
|
||||
Color circleColor = new Color(fill.getRed(),
|
||||
fill.getGreen(),
|
||||
fill.getBlue(),
|
||||
0.3);
|
||||
setStroke(circleColor);
|
||||
setFill(circleColor);
|
||||
} else {
|
||||
setStroke(ripplerFill.get());
|
||||
setFill(ripplerFill.get());
|
||||
setStroke(getRipplerFill());
|
||||
setFill(getRipplerFill());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,110 +584,125 @@ public class JFXRippler extends StackPane {
|
||||
* the ripple recenter property, by default it's false.
|
||||
* if true the ripple effect will show gravitational pull to the center of its control
|
||||
*/
|
||||
private final StyleableObjectProperty<Boolean> ripplerRecenter = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.RIPPLER_RECENTER,
|
||||
JFXRippler.this,
|
||||
"ripplerRecenter",
|
||||
false);
|
||||
private StyleableBooleanProperty ripplerRecenter;
|
||||
|
||||
public Boolean isRipplerRecenter() {
|
||||
public boolean isRipplerRecenter() {
|
||||
return ripplerRecenter != null && ripplerRecenter.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Boolean> ripplerRecenterProperty() {
|
||||
public StyleableBooleanProperty ripplerRecenterProperty() {
|
||||
if (this.ripplerRecenter == null) {
|
||||
this.ripplerRecenter = new SimpleStyleableBooleanProperty(
|
||||
StyleableProperties.RIPPLER_RECENTER,
|
||||
JFXRippler.this,
|
||||
"ripplerRecenter",
|
||||
false);
|
||||
}
|
||||
return this.ripplerRecenter;
|
||||
}
|
||||
|
||||
public void setRipplerRecenter(Boolean radius) {
|
||||
this.ripplerRecenter.set(radius);
|
||||
public void setRipplerRecenter(boolean recenter) {
|
||||
ripplerRecenterProperty().set(recenter);
|
||||
}
|
||||
|
||||
/**
|
||||
* the ripple radius size, by default it will be automatically computed.
|
||||
*/
|
||||
private final StyleableObjectProperty<Number> ripplerRadius = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.RIPPLER_RADIUS,
|
||||
JFXRippler.this,
|
||||
"ripplerRadius",
|
||||
Region.USE_COMPUTED_SIZE);
|
||||
private StyleableDoubleProperty ripplerRadius;
|
||||
|
||||
public Number getRipplerRadius() {
|
||||
public double getRipplerRadius() {
|
||||
return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Number> ripplerRadiusProperty() {
|
||||
public StyleableDoubleProperty ripplerRadiusProperty() {
|
||||
if (this.ripplerRadius == null) {
|
||||
this.ripplerRadius = new SimpleStyleableDoubleProperty(
|
||||
StyleableProperties.RIPPLER_RADIUS,
|
||||
JFXRippler.this,
|
||||
"ripplerRadius",
|
||||
Region.USE_COMPUTED_SIZE);
|
||||
}
|
||||
return this.ripplerRadius;
|
||||
}
|
||||
|
||||
public void setRipplerRadius(Number radius) {
|
||||
this.ripplerRadius.set(radius);
|
||||
public void setRipplerRadius(double radius) {
|
||||
ripplerRadiusProperty().set(radius);
|
||||
}
|
||||
|
||||
private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255);
|
||||
|
||||
/**
|
||||
* the default color of the ripple effect
|
||||
*/
|
||||
private final StyleableObjectProperty<Paint> ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
|
||||
JFXRippler.this,
|
||||
"ripplerFill",
|
||||
Color.rgb(0,
|
||||
200,
|
||||
255));
|
||||
private StyleableObjectProperty<Paint> ripplerFill;
|
||||
|
||||
public Paint getRipplerFill() {
|
||||
return ripplerFill == null ? Color.rgb(0, 200, 255) : ripplerFill.get();
|
||||
return ripplerFill == null ? DEFAULT_RIPPLER_FILL : ripplerFill.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> ripplerFillProperty() {
|
||||
if (this.ripplerFill == null) {
|
||||
this.ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
|
||||
JFXRippler.this,
|
||||
"ripplerFill",
|
||||
DEFAULT_RIPPLER_FILL);
|
||||
}
|
||||
return this.ripplerFill;
|
||||
}
|
||||
|
||||
public void setRipplerFill(Paint color) {
|
||||
this.ripplerFill.set(color);
|
||||
ripplerFillProperty().set(color);
|
||||
}
|
||||
|
||||
/// mask property used for clipping the rippler.
|
||||
/// can be either CIRCLE/RECT
|
||||
private final StyleableObjectProperty<RipplerMask> maskType = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.MASK_TYPE,
|
||||
JFXRippler.this,
|
||||
"maskType",
|
||||
RipplerMask.RECT);
|
||||
private StyleableObjectProperty<RipplerMask> maskType;
|
||||
|
||||
public RipplerMask getMaskType() {
|
||||
return maskType == null ? RipplerMask.RECT : maskType.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<RipplerMask> maskTypeProperty() {
|
||||
if (this.maskType == null) {
|
||||
this.maskType = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.MASK_TYPE,
|
||||
JFXRippler.this,
|
||||
"maskType",
|
||||
RipplerMask.RECT);
|
||||
}
|
||||
return this.maskType;
|
||||
}
|
||||
|
||||
public void setMaskType(RipplerMask type) {
|
||||
this.maskType.set(type);
|
||||
if (this.maskType != null || type != RipplerMask.RECT)
|
||||
maskTypeProperty().set(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* the ripple disable, by default it's false.
|
||||
* if true the ripple effect will be hidden
|
||||
*/
|
||||
private final StyleableBooleanProperty ripplerDisabled = new SimpleStyleableBooleanProperty(
|
||||
StyleableProperties.RIPPLER_DISABLED,
|
||||
JFXRippler.this,
|
||||
"ripplerDisabled",
|
||||
false);
|
||||
private StyleableBooleanProperty ripplerDisabled;
|
||||
|
||||
public Boolean isRipplerDisabled() {
|
||||
public boolean isRipplerDisabled() {
|
||||
return ripplerDisabled != null && ripplerDisabled.get();
|
||||
}
|
||||
|
||||
public StyleableBooleanProperty ripplerDisabledProperty() {
|
||||
if (this.ripplerDisabled == null) {
|
||||
this.ripplerDisabled = new SimpleStyleableBooleanProperty(
|
||||
StyleableProperties.RIPPLER_DISABLED,
|
||||
JFXRippler.this,
|
||||
"ripplerDisabled",
|
||||
false);
|
||||
}
|
||||
return this.ripplerDisabled;
|
||||
}
|
||||
|
||||
public void setRipplerDisabled(Boolean disabled) {
|
||||
this.ripplerDisabled.set(disabled);
|
||||
public void setRipplerDisabled(boolean disabled) {
|
||||
ripplerDisabledProperty().set(disabled);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* indicates whether the ripple effect is infront of or behind the node
|
||||
*/
|
||||
@@ -735,7 +749,7 @@ public class JFXRippler extends StackPane {
|
||||
};
|
||||
private static final CssMetaData<JFXRippler, Paint> RIPPLER_FILL =
|
||||
new CssMetaData<>("-jfx-rippler-fill",
|
||||
PaintConverter.getInstance(), Color.rgb(0, 200, 255)) {
|
||||
PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) {
|
||||
@Override
|
||||
public boolean isSettable(JFXRippler control) {
|
||||
return control.ripplerFill == null || !control.ripplerFill.isBound();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (c) 2016 JFoenix
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.jfoenix.effects;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.effect.BlurType;
|
||||
import javafx.scene.effect.DropShadow;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
/**
|
||||
* it will create a shadow effect for a given node and a specified depth level.
|
||||
* depth levels are {0,1,2,3,4,5}
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public final class JFXDepthManager {
|
||||
private JFXDepthManager() {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
private static final DropShadow[] depth = new DropShadow[] {
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 1),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 6, 0, 0, 2),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 10, 0, 0, 3),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 14, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 6)
|
||||
};
|
||||
|
||||
/**
|
||||
* this method is used to add shadow effect to the node,
|
||||
* however the shadow is not real ( gets affected with node transformations)
|
||||
* <p>
|
||||
* use {@link #createMaterialNode(Node, int)} instead to generate a real shadow
|
||||
*/
|
||||
public static void setDepth(Node control, int level) {
|
||||
level = level < 0 ? 0 : level;
|
||||
level = level > 5 ? 5 : level;
|
||||
control.setEffect(new DropShadow(BlurType.GAUSSIAN,
|
||||
depth[level].getColor(),
|
||||
depth[level].getRadius(),
|
||||
depth[level].getSpread(),
|
||||
depth[level].getOffsetX(),
|
||||
depth[level].getOffsetY()));
|
||||
}
|
||||
|
||||
public static int getLevels() {
|
||||
return depth.length;
|
||||
}
|
||||
|
||||
public static DropShadow getShadowAt(int level) {
|
||||
return depth[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* this method will generate a new container node that prevent
|
||||
* control transformation to be applied to the shadow effect
|
||||
* (which makes it looks as a real shadow)
|
||||
*/
|
||||
public static Node createMaterialNode(Node control, int level) {
|
||||
Node container = new Pane(control) {
|
||||
@Override
|
||||
protected double computeMaxWidth(double height) {
|
||||
return computePrefWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxHeight(double width) {
|
||||
return computePrefHeight(width);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(double height) {
|
||||
return control.prefWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width) {
|
||||
return control.prefHeight(width);
|
||||
}
|
||||
};
|
||||
container.getStyleClass().add("depth-container");
|
||||
container.setPickOnBounds(false);
|
||||
level = level < 0 ? 0 : level;
|
||||
level = level > 5 ? 5 : level;
|
||||
container.setEffect(new DropShadow(BlurType.GAUSSIAN,
|
||||
depth[level].getColor(),
|
||||
depth[level].getRadius(),
|
||||
depth[level].getSpread(),
|
||||
depth[level].getOffsetX(),
|
||||
depth[level].getOffsetY()));
|
||||
return container;
|
||||
}
|
||||
|
||||
public static void pop(Node control) {
|
||||
control.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.26), 5, 0.05, 0, 1));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ButtonSkin;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXRippler;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.effect.DropShadow;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Shape;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
public class JFXButtonSkin extends ButtonSkin {
|
||||
private final StackPane buttonContainer = new StackPane();
|
||||
private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) {
|
||||
protected Node getMask() {
|
||||
StackPane mask = new StackPane();
|
||||
mask.shapeProperty().bind(JFXButtonSkin.this.buttonContainer.shapeProperty());
|
||||
mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background(
|
||||
new BackgroundFill(Color.WHITE,
|
||||
JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty()
|
||||
? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getRadii()
|
||||
: JFXButtonSkin.this.defaultRadii, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)),
|
||||
JFXButtonSkin.this.buttonContainer.backgroundProperty()));
|
||||
mask.resize(JFXButtonSkin.this.buttonContainer.getWidth() - JFXButtonSkin.this.buttonContainer.snappedRightInset() - JFXButtonSkin.this.buttonContainer.snappedLeftInset(), JFXButtonSkin.this.buttonContainer.getHeight() - JFXButtonSkin.this.buttonContainer.snappedBottomInset() - JFXButtonSkin.this.buttonContainer.snappedTopInset());
|
||||
return mask;
|
||||
}
|
||||
|
||||
private void initListeners() {
|
||||
this.ripplerPane.setOnMousePressed((event) -> {
|
||||
if (JFXButtonSkin.this.releaseManualRippler != null) {
|
||||
JFXButtonSkin.this.releaseManualRippler.run();
|
||||
}
|
||||
|
||||
JFXButtonSkin.this.releaseManualRippler = null;
|
||||
this.createRipple(event.getX(), event.getY());
|
||||
});
|
||||
}
|
||||
};
|
||||
private Transition clickedAnimation;
|
||||
private final CornerRadii defaultRadii = new CornerRadii(3.0);
|
||||
private boolean invalid = true;
|
||||
private Runnable releaseManualRippler = null;
|
||||
|
||||
public JFXButtonSkin(JFXButton button) {
|
||||
super(button);
|
||||
this.getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.releaseManualRippler = this.buttonRippler.createManualRipple();
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(1.0);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
} else {
|
||||
if (this.releaseManualRippler != null) {
|
||||
this.releaseManualRippler.run();
|
||||
}
|
||||
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(-1.0);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
this.buttonContainer.getChildren().add(this.buttonRippler);
|
||||
button.buttonTypeProperty().addListener((o, oldVal, newVal) -> this.updateButtonType(newVal));
|
||||
button.setOnMousePressed((e) -> {
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(1.0F);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
});
|
||||
button.setOnMouseReleased((e) -> {
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(-1.0F);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
});
|
||||
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(button);
|
||||
if (focusVisibleProperty == null) {
|
||||
focusVisibleProperty = button.focusedProperty();
|
||||
}
|
||||
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
if (!this.getSkinnable().isPressed()) {
|
||||
this.buttonRippler.showOverlay();
|
||||
}
|
||||
} else {
|
||||
this.buttonRippler.hideOverlay();
|
||||
}
|
||||
});
|
||||
button.pressedProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.hideOverlay());
|
||||
button.setPickOnBounds(false);
|
||||
this.buttonContainer.setPickOnBounds(false);
|
||||
this.buttonContainer.shapeProperty().bind(this.getSkinnable().shapeProperty());
|
||||
this.buttonContainer.borderProperty().bind(this.getSkinnable().borderProperty());
|
||||
this.buttonContainer.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground()) || this.isJavaDefaultClickedBackground(button.getBackground())) {
|
||||
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
|
||||
}
|
||||
|
||||
try {
|
||||
return new Background(new BackgroundFill(this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getFill() : Color.TRANSPARENT, this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getRadii() : this.defaultRadii, Insets.EMPTY));
|
||||
} catch (Exception var3) {
|
||||
return this.getSkinnable().getBackground();
|
||||
}
|
||||
}, this.getSkinnable().backgroundProperty()));
|
||||
button.ripplerFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
|
||||
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground())) {
|
||||
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
|
||||
}
|
||||
|
||||
this.updateButtonType(button.getButtonType());
|
||||
this.updateChildren();
|
||||
}
|
||||
|
||||
protected void updateChildren() {
|
||||
super.updateChildren();
|
||||
if (this.buttonContainer != null) {
|
||||
this.getChildren().add(0, this.buttonContainer);
|
||||
}
|
||||
|
||||
for (int i = 1; i < this.getChildren().size(); ++i) {
|
||||
this.getChildren().get(i).setMouseTransparent(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
if (this.invalid) {
|
||||
if (((JFXButton) this.getSkinnable()).getRipplerFill() == null) {
|
||||
for (int i = this.getChildren().size() - 1; i >= 1; --i) {
|
||||
if (this.getChildren().get(i) instanceof Shape shape) {
|
||||
this.buttonRippler.setRipplerFill(shape.getFill());
|
||||
shape.fillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.getChildren().get(i) instanceof Label label) {
|
||||
this.buttonRippler.setRipplerFill(label.getTextFill());
|
||||
label.textFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.buttonRippler.setRipplerFill(((JFXButton) this.getSkinnable()).getRipplerFill());
|
||||
}
|
||||
|
||||
this.invalid = false;
|
||||
}
|
||||
|
||||
double shift = 1.0F;
|
||||
this.buttonContainer.resizeRelocate(this.getSkinnable().getLayoutBounds().getMinX() - shift, this.getSkinnable().getLayoutBounds().getMinY() - shift, this.getSkinnable().getWidth() + (double) 2.0F * shift, this.getSkinnable().getHeight() + (double) 2.0F * shift);
|
||||
this.layoutLabelInArea(x, y, w, h);
|
||||
}
|
||||
|
||||
private boolean isJavaDefaultBackground(Background background) {
|
||||
try {
|
||||
String firstFill = background.getFills().get(0).getFill().toString();
|
||||
return "0xffffffba".equals(firstFill) || "0xffffffbf".equals(firstFill) || "0xffffff12".equals(firstFill) || "0xffffffbd".equals(firstFill);
|
||||
} catch (Exception var3) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isJavaDefaultClickedBackground(Background background) {
|
||||
try {
|
||||
return "0x039ed3ff".equals(background.getFills().get(0).getFill().toString());
|
||||
} catch (Exception var3) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateButtonType(JFXButton.ButtonType type) {
|
||||
switch (type) {
|
||||
case RAISED -> {
|
||||
JFXDepthManager.setDepth(this.buttonContainer, 2);
|
||||
this.clickedAnimation = new ButtonClickTransition();
|
||||
}
|
||||
case FLAT -> this.buttonContainer.setEffect(null);
|
||||
}
|
||||
}
|
||||
|
||||
private class ButtonClickTransition extends CachedTransition {
|
||||
public ButtonClickTransition() {
|
||||
super(JFXButtonSkin.this.buttonContainer, new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), Interpolator.EASE_BOTH)),
|
||||
new KeyFrame(Duration.millis(1000.0F),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), Interpolator.EASE_BOTH))));
|
||||
this.setCycleDuration(Duration.seconds(0.2));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
@@ -35,6 +36,7 @@ import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final StackPane box = new StackPane();
|
||||
@@ -75,7 +77,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
this.updateRippleColor();
|
||||
this.playSelectAnimation(newVal);
|
||||
});
|
||||
control.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||
if (focusVisibleProperty == null)
|
||||
focusVisibleProperty = control.focusedProperty();
|
||||
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
if (!this.getSkinnable().isPressed()) {
|
||||
this.rippler.showOverlay();
|
||||
|
||||
@@ -12,6 +12,7 @@ import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
@@ -23,6 +24,7 @@ import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
public class JFXRadioButtonSkin extends RadioButtonSkin {
|
||||
@@ -58,7 +60,12 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
|
||||
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
|
||||
|
||||
this.updateChildren();
|
||||
control.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||
if (focusVisibleProperty == null) {
|
||||
focusVisibleProperty = control.focusedProperty();
|
||||
}
|
||||
|
||||
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
if (!this.getSkinnable().isPressed()) {
|
||||
this.rippler.showOverlay();
|
||||
@@ -66,7 +73,6 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
|
||||
} else {
|
||||
this.rippler.hideOverlay();
|
||||
}
|
||||
|
||||
});
|
||||
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
|
||||
this.registerChangeListener(control.selectedColorProperty(), ignored -> updateColors());
|
||||
|
||||
@@ -414,6 +414,12 @@ public final class Accounts {
|
||||
return i18n("account.failed.no_character");
|
||||
} else if (exception instanceof ServerDisconnectException) {
|
||||
if (exception.getCause() instanceof SSLException) {
|
||||
if (exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated")) {
|
||||
return i18n("account.failed.connect_authentication_server");
|
||||
}
|
||||
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
|
||||
return i18n("account.failed.dns");
|
||||
}
|
||||
return i18n("account.failed.ssl");
|
||||
} else {
|
||||
return i18n("account.failed.connect_authentication_server");
|
||||
|
||||
@@ -134,7 +134,10 @@ public final class DownloadProviders {
|
||||
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) {
|
||||
} 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());
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.text.Font;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -94,31 +95,26 @@ public final class FontManager {
|
||||
return null;
|
||||
});
|
||||
|
||||
private static final ObjectProperty<FontReference> fontProperty;
|
||||
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
|
||||
|
||||
static {
|
||||
updateFont();
|
||||
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
|
||||
}
|
||||
|
||||
private static void updateFont() {
|
||||
String fontFamily = config().getLauncherFontFamily();
|
||||
if (fontFamily == null)
|
||||
fontFamily = System.getProperty("hmcl.font.override");
|
||||
if (fontFamily == null)
|
||||
fontFamily = System.getenv("HMCL_FONT");
|
||||
|
||||
FontReference fontReference;
|
||||
if (fontFamily == null) {
|
||||
Font defaultFont = DEFAULT_FONT.get();
|
||||
fontReference = defaultFont != null ? new FontReference(defaultFont) : null;
|
||||
} else
|
||||
fontReference = new FontReference(fontFamily);
|
||||
|
||||
fontProperty = new SimpleObjectProperty<>(fontReference);
|
||||
|
||||
LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System"));
|
||||
fontProperty.addListener((obs, oldValue, newValue) -> {
|
||||
if (newValue != null)
|
||||
config().setLauncherFontFamily(newValue.getFamily());
|
||||
else
|
||||
config().setLauncherFontFamily(null);
|
||||
});
|
||||
font.set(defaultFont != null ? new FontReference(defaultFont) : null);
|
||||
} else {
|
||||
font.set(new FontReference(fontFamily));
|
||||
}
|
||||
}
|
||||
|
||||
private static Font tryLoadDefaultFont(Path dir) {
|
||||
@@ -231,64 +227,35 @@ public final class FontManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static ObjectProperty<FontReference> fontProperty() {
|
||||
return fontProperty;
|
||||
public static ReadOnlyObjectProperty<FontReference> fontProperty() {
|
||||
return font;
|
||||
}
|
||||
|
||||
public static FontReference getFont() {
|
||||
return fontProperty.get();
|
||||
}
|
||||
|
||||
public static void setFont(FontReference font) {
|
||||
fontProperty.set(font);
|
||||
return font.get();
|
||||
}
|
||||
|
||||
public static void setFontFamily(String fontFamily) {
|
||||
setFont(fontFamily != null ? new FontReference(fontFamily) : null);
|
||||
config().setLauncherFontFamily(fontFamily);
|
||||
updateFont();
|
||||
}
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4072
|
||||
public static final class FontReference {
|
||||
private final @NotNull String family;
|
||||
private final @Nullable String style;
|
||||
public record FontReference(@NotNull String family, @Nullable String style) {
|
||||
public FontReference {
|
||||
Objects.requireNonNull(family);
|
||||
}
|
||||
|
||||
public FontReference(@NotNull String family) {
|
||||
this.family = Objects.requireNonNull(family);
|
||||
this.style = null;
|
||||
this(family, null);
|
||||
}
|
||||
|
||||
public FontReference(@NotNull Font font) {
|
||||
this.family = font.getFamily();
|
||||
this.style = font.getStyle();
|
||||
}
|
||||
|
||||
public @NotNull String getFamily() {
|
||||
return family;
|
||||
}
|
||||
|
||||
public @Nullable String getStyle() {
|
||||
return style;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof FontReference))
|
||||
return false;
|
||||
FontReference that = (FontReference) o;
|
||||
return Objects.equals(family, that.family) && Objects.equals(style, that.style);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(family, style);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("FontReference[family='%s', style='%s']", family, style);
|
||||
this(font.getFamily(), font.getStyle());
|
||||
}
|
||||
}
|
||||
|
||||
private FontManager() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.InvalidationListener;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -114,6 +115,8 @@ public final class ProxyManager {
|
||||
config().hasProxyAuthProperty().addListener(updateAuthenticator);
|
||||
config().proxyUserProperty().addListener(updateAuthenticator);
|
||||
config().proxyPassProperty().addListener(updateAuthenticator);
|
||||
|
||||
FetchTask.notifyInitialized();
|
||||
}
|
||||
|
||||
private static abstract class AbstractProxySelector extends ProxySelector {
|
||||
|
||||
@@ -90,11 +90,11 @@ public final class StyleSheets {
|
||||
final String defaultCss = "/assets/css/font.css";
|
||||
final FontManager.FontReference font = FontManager.getFont();
|
||||
|
||||
if (font == null || "System".equals(font.getFamily()))
|
||||
if (font == null || "System".equals(font.family()))
|
||||
return defaultCss;
|
||||
|
||||
String fontFamily = font.getFamily();
|
||||
String style = font.getStyle();
|
||||
String fontFamily = font.family();
|
||||
String style = font.style();
|
||||
String weight = null;
|
||||
String posture = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import org.jackhuang.hmcl.download.ArtifactMalformedException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class TerracottaBundle {
|
||||
private final Path root;
|
||||
|
||||
private final List<URI> 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) {
|
||||
this.root = root;
|
||||
this.links = links;
|
||||
this.hash = hash;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
public Task<Path> download(AbstractTerracottaProvider.DownloadContext context) {
|
||||
return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz"))
|
||||
.thenComposeAsync(Schedulers.javafx(), pkg -> {
|
||||
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
|
||||
return new Context() {
|
||||
@Override
|
||||
public void withResult(boolean success) {
|
||||
delegate.withResult(success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||
context.checkCancellation();
|
||||
delegate.write(buffer, offset, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (isSuccess()) {
|
||||
context.checkCancellation();
|
||||
}
|
||||
|
||||
delegate.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
context.bindProgress(download.progressProperty());
|
||||
return download.thenSupplyAsync(() -> pkg);
|
||||
});
|
||||
}
|
||||
|
||||
public Task<?> install(Path pkg) {
|
||||
return Task.runAsync(() -> {
|
||||
Files.createDirectories(root);
|
||||
FileUtils.cleanDirectory(root);
|
||||
|
||||
try (TarFileTree tree = TarFileTree.open(pkg)) {
|
||||
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
|
||||
String file = entry.getKey();
|
||||
FileDownloadTask.IntegrityCheck check = entry.getValue();
|
||||
|
||||
Path path = root.resolve(file);
|
||||
TarArchiveEntry archive = tree.getEntry("/" + file);
|
||||
if (archive == null) {
|
||||
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
|
||||
}
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
|
||||
try (
|
||||
InputStream is = tree.getInputStream(archive);
|
||||
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
|
||||
) {
|
||||
is.transferTo(os);
|
||||
}
|
||||
|
||||
String hash = HexFormat.of().formatHex(digest.digest());
|
||||
if (!check.getChecksum().equalsIgnoreCase(hash)) {
|
||||
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
|
||||
}
|
||||
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}).whenComplete(exception -> {
|
||||
if (exception != null) {
|
||||
FileUtils.deleteDirectory(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Path locate(String file) {
|
||||
FileDownloadTask.IntegrityCheck check = files.get(file);
|
||||
if (check == null) {
|
||||
throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file));
|
||||
}
|
||||
return root.resolve(file).toAbsolutePath();
|
||||
}
|
||||
|
||||
public AbstractTerracottaProvider.Status status() throws IOException {
|
||||
if (Files.exists(root) && isLocalBundleValid()) {
|
||||
return AbstractTerracottaProvider.Status.READY;
|
||||
}
|
||||
|
||||
try {
|
||||
if (TerracottaMetadata.hasLegacyVersionFiles()) {
|
||||
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Logger.LOG.warning("Cannot determine whether legacy versions exist.", e);
|
||||
}
|
||||
return AbstractTerracottaProvider.Status.NOT_EXIST;
|
||||
}
|
||||
|
||||
private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer.
|
||||
long total = 0;
|
||||
byte[] buffer = new byte[8192];
|
||||
|
||||
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
|
||||
Path path = root.resolve(entry.getKey());
|
||||
FileDownloadTask.IntegrityCheck check = entry.getValue();
|
||||
if (!Files.isReadable(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
|
||||
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
|
||||
int n;
|
||||
while ((n = is.read(buffer)) >= 0) {
|
||||
total += n;
|
||||
if (total >= 50 * 1024 * 1024) { // >=50MB
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
@@ -29,27 +28,32 @@ import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.GetTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.InvocationDispatcher;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -70,9 +74,10 @@ public final class TerracottaManager {
|
||||
switch (TerracottaMetadata.PROVIDER.status()) {
|
||||
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
|
||||
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
|
||||
case READY -> launch(setState(new TerracottaState.Launching()));
|
||||
case READY -> launch(setState(new TerracottaState.Launching()), false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Cannot initialize Terracotta.", e);
|
||||
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||
}
|
||||
});
|
||||
@@ -82,114 +87,145 @@ public final class TerracottaManager {
|
||||
return STATE.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
static {
|
||||
Lang.thread(() -> {
|
||||
while (true) {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
|
||||
LockSupport.parkNanos(500_000);
|
||||
private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true);
|
||||
|
||||
@FXThread // Written in FXThread, read-only on background daemon
|
||||
private static volatile boolean daemonRunning = false;
|
||||
|
||||
private static void runBackground() {
|
||||
final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500);
|
||||
final long BACKGROUND = TimeUnit.SECONDS.toMillis(15);
|
||||
|
||||
while (true) {
|
||||
if (daemonRunning) {
|
||||
LockSupport.parkNanos(ACTIVE);
|
||||
} else {
|
||||
long deadline = System.currentTimeMillis() + BACKGROUND;
|
||||
do {
|
||||
LockSupport.parkUntil(deadline);
|
||||
} while (!daemonRunning && System.currentTimeMillis() < deadline - 100);
|
||||
}
|
||||
|
||||
if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) {
|
||||
continue;
|
||||
}
|
||||
int port = state.port;
|
||||
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
|
||||
|
||||
TerracottaState next;
|
||||
try {
|
||||
TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port))
|
||||
.retry(5)
|
||||
.getJson(TerracottaState.Ready.class);
|
||||
if (object.index <= index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int port = portSpecific.port;
|
||||
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
|
||||
|
||||
TerracottaState next;
|
||||
try {
|
||||
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenApplyAsync(jsonString -> {
|
||||
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
|
||||
if (object.index <= index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
object.port = port;
|
||||
return object;
|
||||
})
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.run();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Cannot fetch state from Terracotta.", e);
|
||||
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||
}
|
||||
|
||||
if (next != null) {
|
||||
compareAndSet(state, next);
|
||||
}
|
||||
|
||||
LockSupport.parkNanos(500_000);
|
||||
object.port = port;
|
||||
next = object;
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Cannot fetch state from Terracotta.", e);
|
||||
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||
}
|
||||
}, "Terracotta Background Daemon", true);
|
||||
|
||||
compareAndSet(state, next);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean validate(Path file) {
|
||||
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
|
||||
}
|
||||
|
||||
public static TerracottaState.Preparing install(@Nullable Path file) {
|
||||
@FXThread
|
||||
public static void switchDaemon(boolean active) {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.Uninitialized ||
|
||||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
|
||||
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
return file != null ? TarFileTree.open(file) : null;
|
||||
}).thenComposeAsync(Schedulers.javafx(), tree -> {
|
||||
return getProvider().install(preparing, tree).whenComplete(exception -> {
|
||||
if (tree != null) {
|
||||
tree.close();
|
||||
}
|
||||
if (exception != null) {
|
||||
throw exception;
|
||||
}
|
||||
});
|
||||
}).whenComplete(exception -> {
|
||||
if (exception == null) {
|
||||
try {
|
||||
TerracottaMetadata.removeLegacyVersionFiles();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to remove legacy terracotta files.", e);
|
||||
}
|
||||
|
||||
TerracottaState.Launching launching = new TerracottaState.Launching();
|
||||
if (compareAndSet(preparing, launching)) {
|
||||
launch(launching);
|
||||
}
|
||||
} else if (exception instanceof CancellationException) {
|
||||
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
|
||||
LOG.warning("Cannot install terracotta from local package.", exception);
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
} else if (exception instanceof DownloadException) {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
|
||||
} else {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
boolean dr = daemonRunning;
|
||||
if (dr != active) {
|
||||
daemonRunning = active;
|
||||
if (active) {
|
||||
LockSupport.unpark(DAEMON);
|
||||
}
|
||||
}).start();
|
||||
|
||||
return compareAndSet(state, preparing) ? preparing : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ITerracottaProvider getProvider() {
|
||||
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
|
||||
private static AbstractTerracottaProvider getProvider() {
|
||||
AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER;
|
||||
if (provider == null) {
|
||||
throw new AssertionError("Terracotta Provider must NOT be null.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
public static TerracottaState recover(@Nullable Path file) {
|
||||
public static boolean isInvalidBundle(Path file) {
|
||||
return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static TerracottaState.Preparing download() {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true);
|
||||
|
||||
Task.composeAsync(() -> getProvider().download(preparing))
|
||||
.thenComposeAsync(pkg -> {
|
||||
if (!preparing.requestInstallFence()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getProvider().install(pkg).thenRunAsync(() -> {
|
||||
TerracottaState.Launching launching = new TerracottaState.Launching();
|
||||
if (compareAndSet(preparing, launching)) {
|
||||
launch(launching, true);
|
||||
}
|
||||
});
|
||||
}).whenComplete(exception -> {
|
||||
if (exception instanceof CancellationException) {
|
||||
// no-op
|
||||
} else if (exception instanceof DownloadException) {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
|
||||
} else {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
}
|
||||
}).start();
|
||||
|
||||
return compareAndSet(state, preparing) ? preparing : null;
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static TerracottaState.Preparing install(Path bundle) {
|
||||
FXUtils.checkFxUserThread();
|
||||
if (isInvalidBundle(bundle)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
TerracottaState.Preparing preparing;
|
||||
if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) {
|
||||
preparing = previousPreparing;
|
||||
} else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) {
|
||||
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
Task.composeAsync(() -> getProvider().install(bundle))
|
||||
.thenRunAsync(() -> {
|
||||
TerracottaState.Launching launching = new TerracottaState.Launching();
|
||||
if (compareAndSet(preparing, launching)) {
|
||||
launch(launching, true);
|
||||
}
|
||||
})
|
||||
.whenComplete(exception -> {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
}).start();
|
||||
|
||||
return state != preparing && compareAndSet(state, preparing) ? preparing : null;
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static TerracottaState recover() {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
@@ -198,21 +234,23 @@ public final class TerracottaManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making
|
||||
// hash check accept 50MB at most. Calling it on JavaFX should be safe.
|
||||
return switch (getProvider().status()) {
|
||||
case NOT_EXIST, LEGACY_VERSION -> install(file);
|
||||
case NOT_EXIST, LEGACY_VERSION -> download();
|
||||
case READY -> {
|
||||
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
|
||||
launch(launching);
|
||||
launch(launching, false);
|
||||
yield launching;
|
||||
}
|
||||
};
|
||||
} catch (NullPointerException | IOException e) {
|
||||
} catch (RuntimeException | IOException e) {
|
||||
LOG.warning("Cannot determine Terracotta state.", e);
|
||||
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||
}
|
||||
}
|
||||
|
||||
private static void launch(TerracottaState.Launching state) {
|
||||
private static void launch(TerracottaState.Launching state, boolean removeLegacy) {
|
||||
Task.supplyAsync(() -> {
|
||||
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
|
||||
@@ -230,7 +268,7 @@ public final class TerracottaManager {
|
||||
if (exitTime == -1) {
|
||||
exitTime = System.currentTimeMillis();
|
||||
} else if (System.currentTimeMillis() - exitTime >= 10000) {
|
||||
throw new IllegalStateException("Process has exited for 10s.");
|
||||
throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,6 +276,9 @@ public final class TerracottaManager {
|
||||
TerracottaState next;
|
||||
if (exception == null) {
|
||||
next = new TerracottaState.Unknown(port);
|
||||
if (removeLegacy) {
|
||||
TerracottaMetadata.removeLegacyVersionFiles();
|
||||
}
|
||||
} else {
|
||||
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||
}
|
||||
@@ -272,24 +313,39 @@ public final class TerracottaManager {
|
||||
public static TerracottaState.HostScanning setScanning() {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
new GetTask(NetworkUtils.toURI(String.format(
|
||||
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
|
||||
).setSignificance(Task.TaskSignificance.MINOR).start();
|
||||
Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
|
||||
.thenComposeAsync(nodes -> {
|
||||
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
|
||||
query.add(pair("player", getPlayerName()));
|
||||
for (URI node : nodes) {
|
||||
query.add(pair("public_nodes", node.toString()));
|
||||
}
|
||||
return new GetTask(NetworkUtils.withQuery(
|
||||
"http://127.0.0.1:%d/state/scanning".formatted(portSpecific.port), query
|
||||
)).setSignificance(Task.TaskSignificance.MINOR);
|
||||
}).start();
|
||||
|
||||
return new TerracottaState.HostScanning(-1, -1, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
|
||||
public static Task<TerracottaState.GuestConnecting> setGuesting(String room) {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
return new GetTask(NetworkUtils.toURI(String.format(
|
||||
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
|
||||
)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
return Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
|
||||
.thenComposeAsync(nodes -> {
|
||||
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
|
||||
query.add(pair("room", room));
|
||||
query.add(pair("player", getPlayerName()));
|
||||
for (URI 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))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ package org.jackhuang.hmcl.terracotta;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
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.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
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;
|
||||
@@ -32,6 +32,8 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||
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 java.io.IOException;
|
||||
@@ -40,11 +42,11 @@ import java.net.URI;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -52,46 +54,60 @@ public final class TerracottaMetadata {
|
||||
private TerracottaMetadata() {
|
||||
}
|
||||
|
||||
public record Link(@SerializedName("desc") LocalizedText description, String link) {
|
||||
private record Options(String version, String classifier) {
|
||||
public String replace(String value) {
|
||||
return value.replace("${version}", version).replace("${classifier}", classifier);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable
|
||||
public record Link(
|
||||
@SerializedName("desc") LocalizedText description,
|
||||
@SerializedName("link") String link
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonSerializable
|
||||
private record Package(
|
||||
@SerializedName("hash") String hash,
|
||||
@SerializedName("files") Map<String, String> files
|
||||
) {
|
||||
}
|
||||
|
||||
@JsonSerializable
|
||||
private record Config(
|
||||
@SerializedName("version_legacy") String legacy,
|
||||
@SerializedName("version_recent") List<String> recent,
|
||||
@SerializedName("version_latest") String latest,
|
||||
|
||||
@SerializedName("classifiers") Map<String, String> classifiers,
|
||||
@SerializedName("packages") Map<String, Package> pkgs,
|
||||
@SerializedName("downloads") List<String> downloads,
|
||||
@SerializedName("downloads_CN") List<String> downloadsCN,
|
||||
@SerializedName("links") List<Link> links
|
||||
) {
|
||||
private @Nullable TerracottaNative of(String classifier) {
|
||||
String hash = this.classifiers.get(classifier);
|
||||
if (hash == null)
|
||||
private @Nullable TerracottaBundle resolve(Options options) {
|
||||
Package pkg = pkgs.get(options.classifier);
|
||||
if (pkg == null) {
|
||||
return null;
|
||||
|
||||
if (!hash.startsWith("sha256:"))
|
||||
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
|
||||
hash = hash.substring("sha256:".length());
|
||||
|
||||
List<URI> links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size());
|
||||
for (String download : LocaleUtils.IS_CHINA_MAINLAND
|
||||
? Lang.merge(this.downloadsCN, this.downloads)
|
||||
: Lang.merge(this.downloads, this.downloadsCN)) {
|
||||
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
|
||||
}
|
||||
|
||||
return new TerracottaNative(
|
||||
Collections.unmodifiableList(links),
|
||||
Metadata.DEPENDENCIES_DIRECTORY.resolve(
|
||||
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
|
||||
).toAbsolutePath(),
|
||||
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
|
||||
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)))
|
||||
.toList();
|
||||
|
||||
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue())
|
||||
));
|
||||
|
||||
return new TerracottaBundle(
|
||||
Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(),
|
||||
links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash),
|
||||
files
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static final ITerracottaProvider PROVIDER;
|
||||
public static final AbstractTerracottaProvider PROVIDER;
|
||||
public static final String PACKAGE_NAME;
|
||||
public static final List<Link> PACKAGE_LINKS;
|
||||
public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of(
|
||||
@@ -99,8 +115,6 @@ public final class TerracottaMetadata {
|
||||
"launcher_version", Metadata.VERSION
|
||||
));
|
||||
|
||||
private static final Pattern LEGACY;
|
||||
private static final List<String> RECENT;
|
||||
private static final String LATEST;
|
||||
|
||||
static {
|
||||
@@ -111,95 +125,80 @@ public final class TerracottaMetadata {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
|
||||
LEGACY = Pattern.compile(config.legacy);
|
||||
RECENT = config.recent;
|
||||
LATEST = config.latest;
|
||||
|
||||
ProviderContext context = locateProvider(config);
|
||||
PROVIDER = context != null ? context.provider() : null;
|
||||
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
|
||||
|
||||
if (context != null) {
|
||||
List<Link> packageLinks = new ArrayList<>(config.links.size());
|
||||
for (Link link : config.links) {
|
||||
packageLinks.add(new Link(
|
||||
link.description,
|
||||
link.link.replace("${version}", LATEST)
|
||||
.replace("${classifier}", context.branch)
|
||||
));
|
||||
}
|
||||
Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName());
|
||||
TerracottaBundle bundle = config.resolve(options);
|
||||
AbstractTerracottaProvider provider;
|
||||
if (bundle == null || (provider = locateProvider(bundle, options)) == null) {
|
||||
PROVIDER = null;
|
||||
PACKAGE_NAME = null;
|
||||
PACKAGE_LINKS = null;
|
||||
} else {
|
||||
PROVIDER = provider;
|
||||
PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz");
|
||||
|
||||
List<Link> packageLinks = config.links.stream()
|
||||
.map(link -> new Link(link.description, options.replace(link.link)))
|
||||
.collect(Collectors.toList());
|
||||
Collections.shuffle(packageLinks);
|
||||
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
|
||||
} else {
|
||||
PACKAGE_LINKS = null;
|
||||
}
|
||||
}
|
||||
|
||||
private record ProviderContext(ITerracottaProvider provider, String branch) {
|
||||
ProviderContext(ITerracottaProvider provider, String system, String arch) {
|
||||
this(provider, system + "-" + arch);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ProviderContext locateProvider(Config config) {
|
||||
String arch = Architecture.SYSTEM_ARCH.getCheckedName();
|
||||
private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) {
|
||||
String prefix = options.replace("terracotta-${version}-${classifier}");
|
||||
|
||||
// FIXME: As HMCL is a cross-platform application, developers may mistakenly locate
|
||||
// non-existent files in non-native platform logic without assertion errors during debugging.
|
||||
return switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS -> {
|
||||
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
|
||||
yield null;
|
||||
|
||||
TerracottaNative target = config.of("windows-%s.exe".formatted(arch));
|
||||
yield target != null
|
||||
? new ProviderContext(new GeneralProvider(target), "windows", arch)
|
||||
: null;
|
||||
}
|
||||
case LINUX -> {
|
||||
TerracottaNative target = config.of("linux-%s".formatted(arch));
|
||||
yield target != null
|
||||
? new ProviderContext(new GeneralProvider(target), "linux", arch)
|
||||
: null;
|
||||
}
|
||||
case MACOS -> {
|
||||
TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch));
|
||||
TerracottaNative binary = config.of("macos-%s".formatted(arch));
|
||||
|
||||
yield installer != null && binary != null
|
||||
? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch)
|
||||
: null;
|
||||
yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe"));
|
||||
}
|
||||
case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix));
|
||||
case MACOS -> new MacOSProvider(
|
||||
bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg")
|
||||
);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public static void removeLegacyVersionFiles() throws IOException {
|
||||
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||
for (Path path : terracotta) {
|
||||
String name = FileUtils.getName(path);
|
||||
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
|
||||
continue;
|
||||
}
|
||||
public static void removeLegacyVersionFiles() {
|
||||
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
|
||||
if (terracotta == null)
|
||||
return;
|
||||
|
||||
for (Path path : terracotta) {
|
||||
try {
|
||||
FileUtils.deleteDirectory(path);
|
||||
} catch (IOException e) {
|
||||
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to remove legacy terracotta files.", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasLegacyVersionFiles() throws IOException {
|
||||
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||
for (Path path : terracotta) {
|
||||
String name = FileUtils.getName(path);
|
||||
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
|
||||
return terracotta != null && terracotta.iterator().hasNext();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
private static @Nullable DirectoryStream<Path> collectLegacyVersionFiles() throws IOException {
|
||||
Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta");
|
||||
if (Files.notExists(terracottaDir))
|
||||
return null;
|
||||
|
||||
VersionRange<VersionNumber> range = VersionNumber.atMost(LATEST);
|
||||
return Files.newDirectoryStream(terracottaDir, path -> {
|
||||
String name = FileUtils.getName(path);
|
||||
return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
public final class TerracottaNative {
|
||||
private final List<URI> links;
|
||||
private final FileDownloadTask.IntegrityCheck checking;
|
||||
private final Path path;
|
||||
|
||||
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
|
||||
this.links = links;
|
||||
this.path = path;
|
||||
this.checking = checking;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
|
||||
if (tree == null) {
|
||||
return new FileDownloadTask(links, path, checking) {
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
Context delegate = super.getContext(response, checkETag, bmclapiHash);
|
||||
return new Context() {
|
||||
@Override
|
||||
public void withResult(boolean success) {
|
||||
delegate.withResult(success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||
if (!context.hasInstallFence()) {
|
||||
throw new CancellationException("User has installed terracotta from local archives.");
|
||||
}
|
||||
delegate.write(buffer, offset, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (isSuccess() && !context.requestInstallFence()) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
|
||||
delegate.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return Task.runAsync(() -> {
|
||||
String name = FileUtils.getName(path);
|
||||
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
|
||||
if (entry == null) {
|
||||
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
|
||||
}
|
||||
|
||||
if (!context.requestInstallFence()) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
|
||||
Files.createDirectories(path.toAbsolutePath().getParent());
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
|
||||
try (
|
||||
InputStream stream = tree.getInputStream(entry);
|
||||
OutputStream os = Files.newOutputStream(path)
|
||||
) {
|
||||
stream.transferTo(new OutputStream() {
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
os.write(b);
|
||||
digest.update((byte) b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
|
||||
os.write(buffer, offset, len);
|
||||
digest.update(buffer, offset, len);
|
||||
}
|
||||
});
|
||||
}
|
||||
String checksum = HexFormat.of().formatHex(digest.digest());
|
||||
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
|
||||
Files.delete(path);
|
||||
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ITerracottaProvider.Status status() throws IOException {
|
||||
if (Files.exists(path)) {
|
||||
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
|
||||
return ITerracottaProvider.Status.READY;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (TerracottaMetadata.hasLegacyVersionFiles()) {
|
||||
return ITerracottaProvider.Status.LEGACY_VERSION;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
|
||||
}
|
||||
return ITerracottaProvider.Status.NOT_EXIST;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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.terracotta;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
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 java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
public final class TerracottaNodeList {
|
||||
private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes";
|
||||
|
||||
@JsonSerializable
|
||||
private record TerracottaNode(String url, @Nullable String region) implements Validation {
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
|
||||
try {
|
||||
new URI(url);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new JsonParseException("Invalid URL: " + url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static volatile List<URI> list;
|
||||
|
||||
public static List<URI> fetch() {
|
||||
List<URI> list = TerracottaNodeList.list;
|
||||
if (list != null)
|
||||
return list;
|
||||
|
||||
synchronized (TerracottaNodeList.class) {
|
||||
list = TerracottaNodeList.list;
|
||||
if (list != null)
|
||||
return list;
|
||||
|
||||
try {
|
||||
List<TerracottaNode> nodes = HttpRequest.GET(NODE_LIST_URL)
|
||||
.getJson(JsonUtils.listTypeOf(TerracottaNode.class));
|
||||
|
||||
if (nodes == null) {
|
||||
list = List.of();
|
||||
LOG.info("No available Terracotta nodes found");
|
||||
} else {
|
||||
list = nodes.stream()
|
||||
.filter(node -> {
|
||||
if (node == null)
|
||||
return false;
|
||||
|
||||
try {
|
||||
node.validate();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Invalid terracotta node: " + node, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
|
||||
})
|
||||
.map(it -> URI.create(it.url()))
|
||||
.toList();
|
||||
LOG.info("Terracotta node list: " + list);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to fetch terracotta node list", e);
|
||||
list = List.of();
|
||||
}
|
||||
|
||||
TerracottaNodeList.list = list;
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
private TerracottaNodeList() {
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,16 @@ import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ReadOnlyDoubleProperty;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.beans.value.ObservableDoubleValue;
|
||||
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSubtype;
|
||||
import org.jackhuang.hmcl.util.gson.JsonType;
|
||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public abstract sealed class TerracottaState {
|
||||
@@ -63,32 +64,38 @@ public abstract sealed class TerracottaState {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
|
||||
public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext {
|
||||
private final ReadOnlyDoubleWrapper progress;
|
||||
|
||||
private final AtomicBoolean installFence = new AtomicBoolean(false);
|
||||
private final AtomicBoolean hasInstallFence;
|
||||
|
||||
Preparing(ReadOnlyDoubleWrapper progress) {
|
||||
Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) {
|
||||
this.progress = progress;
|
||||
this.hasInstallFence = new AtomicBoolean(hasInstallFence);
|
||||
}
|
||||
|
||||
public ReadOnlyDoubleProperty progressProperty() {
|
||||
return progress.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bindProgress(ObservableValue<? extends Number> value) {
|
||||
progress.bind(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestInstallFence() {
|
||||
return installFence.compareAndSet(false, true);
|
||||
return hasInstallFence.compareAndSet(true, false);
|
||||
}
|
||||
|
||||
public boolean hasInstallFence() {
|
||||
return hasInstallFence.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasInstallFence() {
|
||||
return !installFence.get();
|
||||
public void bindProgress(ObservableDoubleValue progress) {
|
||||
this.progress.bind(progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkCancellation() {
|
||||
if (!hasInstallFence()) {
|
||||
throw new CancellationException("User has installed terracotta from local archives.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +119,7 @@ public abstract sealed class TerracottaState {
|
||||
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
|
||||
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
|
||||
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
|
||||
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
|
||||
@JsonSubtype(clazz = GuestConnecting.class, name = "guest-connecting"),
|
||||
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
|
||||
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
|
||||
@JsonSubtype(clazz = Exception.class, name = "exception"),
|
||||
@@ -202,12 +209,34 @@ public abstract sealed class TerracottaState {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GuestStarting extends Ready {
|
||||
GuestStarting(int port, int index, String state) {
|
||||
public static final class GuestConnecting extends Ready {
|
||||
GuestConnecting(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GuestStarting extends Ready {
|
||||
public enum Difficulty {
|
||||
UNKNOWN,
|
||||
EASIEST,
|
||||
SIMPLE,
|
||||
MEDIUM,
|
||||
TOUGH
|
||||
}
|
||||
|
||||
@SerializedName("difficulty")
|
||||
private final Difficulty difficulty;
|
||||
|
||||
GuestStarting(int port, int index, String state, Difficulty difficulty) {
|
||||
super(port, index, state);
|
||||
this.difficulty = difficulty;
|
||||
}
|
||||
|
||||
public Difficulty getDifficulty() {
|
||||
return difficulty;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GuestOK extends Ready implements Validation {
|
||||
@SerializedName("url")
|
||||
private final String url;
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import javafx.beans.value.ObservableDoubleValue;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
public abstract class AbstractTerracottaProvider {
|
||||
public enum Status {
|
||||
NOT_EXIST,
|
||||
LEGACY_VERSION,
|
||||
READY
|
||||
}
|
||||
|
||||
public interface DownloadContext {
|
||||
@FXThread
|
||||
void bindProgress(ObservableDoubleValue progress);
|
||||
|
||||
void checkCancellation() throws CancellationException;
|
||||
}
|
||||
|
||||
protected final TerracottaBundle bundle;
|
||||
|
||||
protected AbstractTerracottaProvider(TerracottaBundle bundle) {
|
||||
this.bundle = bundle;
|
||||
}
|
||||
|
||||
public Status status() throws IOException {
|
||||
return bundle.status();
|
||||
}
|
||||
|
||||
public final Task<Path> download(DownloadContext progress) {
|
||||
return bundle.download(progress);
|
||||
}
|
||||
|
||||
public Task<?> install(Path pkg) throws IOException {
|
||||
return bundle.install(pkg);
|
||||
}
|
||||
|
||||
public abstract List<String> ofCommandLine(Path portTransfer);
|
||||
}
|
||||
@@ -17,51 +17,21 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class GeneralProvider implements ITerracottaProvider {
|
||||
private final TerracottaNative target;
|
||||
public final class GeneralProvider extends AbstractTerracottaProvider {
|
||||
private final Path executable;
|
||||
|
||||
public GeneralProvider(TerracottaNative target) {
|
||||
this.target = target;
|
||||
public GeneralProvider(TerracottaBundle bundle, Path executable) {
|
||||
super(bundle);
|
||||
this.executable = executable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status status() throws IOException {
|
||||
return target.status();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||
Task<?> task = target.install(context, tree);
|
||||
context.bindProgress(task.progressProperty());
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
PosixFilePermission.GROUP_READ,
|
||||
PosixFilePermission.GROUP_EXECUTE,
|
||||
PosixFilePermission.OTHERS_READ,
|
||||
PosixFilePermission.OTHERS_EXECUTE
|
||||
)));
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> ofCommandLine(Path path) {
|
||||
return List.of(target.getPath().toString(), "--hmcl", path.toString());
|
||||
public List<String> ofCommandLine(Path portTransfer) {
|
||||
return List.of(executable.toString(), "--hmcl", portTransfer.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public interface ITerracottaProvider {
|
||||
enum Status {
|
||||
NOT_EXIST,
|
||||
LEGACY_VERSION,
|
||||
READY
|
||||
}
|
||||
|
||||
interface Context {
|
||||
void bindProgress(ObservableValue<? extends Number> value);
|
||||
|
||||
boolean requestInstallFence();
|
||||
|
||||
boolean hasInstallFence();
|
||||
}
|
||||
|
||||
abstract class ProviderException extends IOException {
|
||||
public ProviderException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ProviderException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ProviderException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
final class ArchiveFileMissingException extends ProviderException {
|
||||
public ArchiveFileMissingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ArchiveFileMissingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ArchiveFileMissingException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
Status status() throws IOException;
|
||||
|
||||
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
|
||||
|
||||
List<String> ofCommandLine(Path path);
|
||||
}
|
||||
@@ -19,103 +19,77 @@ package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class MacOSProvider implements ITerracottaProvider {
|
||||
public final TerracottaNative installer, binary;
|
||||
public final class MacOSProvider extends AbstractTerracottaProvider {
|
||||
private final Path executable, installer;
|
||||
|
||||
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
|
||||
public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) {
|
||||
super(bundle);
|
||||
this.executable = executable;
|
||||
this.installer = installer;
|
||||
this.binary = binary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status status() throws IOException {
|
||||
assert binary != null;
|
||||
|
||||
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
|
||||
return Status.NOT_EXIST;
|
||||
}
|
||||
|
||||
return binary.status();
|
||||
return bundle.status();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||
assert installer != null && binary != null;
|
||||
public Task<?> install(Path pkg) throws IOException {
|
||||
return super.install(pkg).thenComposeAsync(() -> {
|
||||
Path osascript = SystemUtils.which("osascript");
|
||||
if (osascript == null) {
|
||||
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
|
||||
}
|
||||
|
||||
Task<?> installerTask = installer.install(context, tree);
|
||||
Task<?> binaryTask = binary.install(context, tree);
|
||||
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
|
||||
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
|
||||
.toRealPath()
|
||||
.resolve(FileUtils.getName(installer));
|
||||
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
return Task.allOf(
|
||||
installerTask.thenComposeAsync(() -> {
|
||||
Path osascript = SystemUtils.which("osascript");
|
||||
if (osascript == null) {
|
||||
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
|
||||
}
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
|
||||
osascript.toString(), "-e", String.format(
|
||||
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
|
||||
movedInstaller, i18n("terracotta.sudo_installing")
|
||||
)));
|
||||
process.pumpInputStream(SystemUtils::onLogLine);
|
||||
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||
|
||||
Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
|
||||
.toRealPath()
|
||||
.resolve(FileUtils.getName(installer.getPath()));
|
||||
Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING);
|
||||
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
|
||||
try {
|
||||
FileUtils.cleanDirectory(movedInstaller.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Cannot remove temporary Terracotta package file.", e);
|
||||
}
|
||||
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
|
||||
osascript.toString(), "-e", String.format(
|
||||
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
|
||||
pkg, i18n("terracotta.sudo_installing")
|
||||
)));
|
||||
process.pumpInputStream(SystemUtils::onLogLine);
|
||||
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||
|
||||
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
|
||||
try {
|
||||
FileUtils.cleanDirectory(pkg.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Cannot remove temporary Terracotta package file.", e);
|
||||
}
|
||||
|
||||
if (process.getExitCode() != 0) {
|
||||
throw new IllegalStateException(String.format(
|
||||
"Cannot install Terracotta %s: system installer exited with code %d",
|
||||
pkg,
|
||||
process.getExitCode()
|
||||
));
|
||||
}
|
||||
});
|
||||
}),
|
||||
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
PosixFilePermission.GROUP_READ,
|
||||
PosixFilePermission.GROUP_EXECUTE,
|
||||
PosixFilePermission.OTHERS_READ,
|
||||
PosixFilePermission.OTHERS_EXECUTE
|
||||
)))
|
||||
);
|
||||
if (process.getExitCode() != 0) {
|
||||
throw new IllegalStateException(String.format(
|
||||
"Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode()
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> ofCommandLine(Path path) {
|
||||
assert binary != null;
|
||||
|
||||
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
|
||||
return List.of(executable.toString(), "--hmcl", path.toString());
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ public final class FXUtils {
|
||||
public static final @Nullable ReadOnlyObjectProperty<Color> ACCENT_COLOR;
|
||||
|
||||
public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY;
|
||||
public static final @Nullable MethodHandle FOCUS_VISIBLE_PROPERTY;
|
||||
|
||||
static {
|
||||
String jfxVersion = System.getProperty("javafx.version");
|
||||
@@ -203,6 +204,20 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
TEXT_TRUNCATED_PROPERTY = textTruncatedProperty;
|
||||
|
||||
MethodHandle focusVisibleProperty = null;
|
||||
if (JAVAFX_MAJOR_VERSION >= 19) {
|
||||
try {
|
||||
focusVisibleProperty = MethodHandles.publicLookup().findVirtual(
|
||||
Node.class,
|
||||
"focusVisibleProperty",
|
||||
MethodType.methodType(ReadOnlyBooleanProperty.class)
|
||||
);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to lookup focusVisibleProperty", e);
|
||||
}
|
||||
}
|
||||
FOCUS_VISIBLE_PROPERTY = focusVisibleProperty;
|
||||
}
|
||||
|
||||
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
|
||||
@@ -423,6 +438,20 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static @Nullable ReadOnlyBooleanProperty focusVisibleProperty(Node node) {
|
||||
if (FOCUS_VISIBLE_PROPERTY != null) {
|
||||
try {
|
||||
return (ReadOnlyBooleanProperty) FOCUS_VISIBLE_PROPERTY.invokeExact(node);
|
||||
} catch (RuntimeException | Error e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50);
|
||||
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
|
||||
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
|
||||
@@ -1188,12 +1217,14 @@ public final class FXUtils {
|
||||
|
||||
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
return new CacheFileTask(url)
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
|
||||
public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
return new CacheFileTask(uri)
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
@@ -1208,6 +1239,7 @@ public final class FXUtils {
|
||||
LOG.warning("An exception encountered while loading remote image: " + url, exception);
|
||||
}
|
||||
})
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.start();
|
||||
return image;
|
||||
}
|
||||
@@ -1404,16 +1436,6 @@ public final class FXUtils {
|
||||
});
|
||||
}
|
||||
|
||||
public static void clearFocus(Node node) {
|
||||
Scene scene = node.getScene();
|
||||
if (scene != null) {
|
||||
Parent root = scene.getRoot();
|
||||
if (root != null) {
|
||||
root.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyOnDoubleClick(Labeled label) {
|
||||
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.ui;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXRippler;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
@@ -62,7 +63,7 @@ public class InstallerItem extends Control {
|
||||
private final ObjectProperty<Runnable> onInstall = new SimpleObjectProperty<>(this, "onInstall");
|
||||
private final ObjectProperty<Runnable> onRemove = new SimpleObjectProperty<>(this, "onRemove");
|
||||
|
||||
public interface State {
|
||||
public sealed interface State {
|
||||
}
|
||||
|
||||
public static final class InstallableState implements State {
|
||||
@@ -72,46 +73,10 @@ public class InstallerItem extends Control {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class IncompatibleState implements State {
|
||||
private final String incompatibleItemName;
|
||||
private final String incompatibleItemVersion;
|
||||
|
||||
public IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) {
|
||||
this.incompatibleItemName = incompatibleItemName;
|
||||
this.incompatibleItemVersion = incompatibleItemVersion;
|
||||
}
|
||||
|
||||
public String getIncompatibleItemName() {
|
||||
return incompatibleItemName;
|
||||
}
|
||||
|
||||
public String getIncompatibleItemVersion() {
|
||||
return incompatibleItemVersion;
|
||||
}
|
||||
public record IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) implements State {
|
||||
}
|
||||
|
||||
public static final class InstalledState implements State {
|
||||
private final String version;
|
||||
private final boolean external;
|
||||
private final boolean incompatibleWithGame;
|
||||
|
||||
public InstalledState(String version, boolean external, boolean incompatibleWithGame) {
|
||||
this.version = version;
|
||||
this.external = external;
|
||||
this.incompatibleWithGame = incompatibleWithGame;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public boolean isExternal() {
|
||||
return external;
|
||||
}
|
||||
|
||||
public boolean isIncompatibleWithGame() {
|
||||
return incompatibleWithGame;
|
||||
}
|
||||
public record InstalledState(String version, boolean external, boolean incompatibleWithGame) implements State {
|
||||
}
|
||||
|
||||
public enum Style {
|
||||
@@ -335,10 +300,15 @@ public class InstallerItem extends Control {
|
||||
}
|
||||
pane.getStyleClass().add("installer-item");
|
||||
RipplerContainer container = new RipplerContainer(pane);
|
||||
getChildren().setAll(container);
|
||||
container.setPosition(JFXRippler.RipplerPos.BACK);
|
||||
StackPane paneWrapper = new StackPane();
|
||||
paneWrapper.getStyleClass().add("installer-item-wrapper");
|
||||
paneWrapper.getChildren().setAll(container);
|
||||
getChildren().setAll(paneWrapper);
|
||||
|
||||
pane.pseudoClassStateChanged(LIST_ITEM, control.style == Style.LIST_ITEM);
|
||||
pane.pseudoClassStateChanged(CARD, control.style == Style.CARD);
|
||||
paneWrapper.pseudoClassStateChanged(CARD, control.style == Style.CARD);
|
||||
|
||||
if (control.iconType != null) {
|
||||
ImageView view = new ImageView(control.iconType.getIcon());
|
||||
@@ -367,21 +337,20 @@ public class InstallerItem extends Control {
|
||||
statusLabel.textProperty().bind(Bindings.createStringBinding(() -> {
|
||||
State state = control.resolvedStateProperty.get();
|
||||
|
||||
if (state instanceof InstalledState) {
|
||||
InstalledState s = (InstalledState) state;
|
||||
if (s.incompatibleWithGame) {
|
||||
return i18n("install.installer.change_version", s.version);
|
||||
if (state instanceof InstalledState installedState) {
|
||||
if (installedState.incompatibleWithGame) {
|
||||
return i18n("install.installer.change_version", installedState.version);
|
||||
}
|
||||
if (s.external) {
|
||||
return i18n("install.installer.external_version", s.version);
|
||||
if (installedState.external) {
|
||||
return i18n("install.installer.external_version", installedState.version);
|
||||
}
|
||||
return i18n("install.installer.version", s.version);
|
||||
return i18n("install.installer.version", installedState.version);
|
||||
} else if (state instanceof InstallableState) {
|
||||
return control.style == Style.CARD
|
||||
? i18n("install.installer.do_not_install")
|
||||
: i18n("install.installer.not_installed");
|
||||
} else if (state instanceof IncompatibleState) {
|
||||
return i18n("install.installer.incompatible", i18n("install.installer." + ((IncompatibleState) state).incompatibleItemName));
|
||||
} else if (state instanceof IncompatibleState incompatibleState) {
|
||||
return i18n("install.installer.incompatible", i18n("install.installer." + incompatibleState.incompatibleItemName));
|
||||
} else {
|
||||
throw new AssertionError("Unknown state type: " + state.getClass());
|
||||
}
|
||||
@@ -402,7 +371,7 @@ public class InstallerItem extends Control {
|
||||
} else {
|
||||
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
|
||||
State state = control.resolvedStateProperty.get();
|
||||
return state instanceof InstalledState && !((InstalledState) state).external;
|
||||
return state instanceof InstalledState installedState && !installedState.external;
|
||||
}, control.resolvedStateProperty));
|
||||
}
|
||||
removeButton.managedProperty().bind(removeButton.visibleProperty());
|
||||
|
||||
@@ -54,6 +54,8 @@ final class ScrollUtils {
|
||||
private static final double DEFAULT_SPEED = 1.0;
|
||||
private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0;
|
||||
|
||||
private static final double CUTOFF_DELTA = 0.01;
|
||||
|
||||
/**
|
||||
* Determines if the given ScrollEvent comes from a trackpad.
|
||||
* <p></p>
|
||||
@@ -210,7 +212,7 @@ final class ScrollUtils {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Math.abs(dy) < 0.001) {
|
||||
if (Math.abs(dy) < CUTOFF_DELTA) {
|
||||
timeline.stop();
|
||||
}
|
||||
}));
|
||||
@@ -255,7 +257,7 @@ final class ScrollUtils {
|
||||
double dy = derivatives[derivatives.length - 1];
|
||||
virtualFlow.scrollPixels(dy);
|
||||
|
||||
if (Math.abs(dy) < 0.001) {
|
||||
if (Math.abs(dy) < CUTOFF_DELTA) {
|
||||
timeline.stop();
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -18,24 +18,26 @@
|
||||
package org.jackhuang.hmcl.ui;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>> extends SkinBase<T> {
|
||||
public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends SkinBase<P> {
|
||||
|
||||
public ToolbarListPageSkin(T skinnable) {
|
||||
protected final JFXListView<E> listView;
|
||||
|
||||
public ToolbarListPageSkin(P skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
@@ -58,18 +60,12 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
|
||||
}
|
||||
|
||||
{
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
ComponentList.setVgrow(scrollPane, Priority.ALWAYS);
|
||||
scrollPane.setFitToWidth(true);
|
||||
|
||||
VBox content = new VBox();
|
||||
|
||||
Bindings.bindContent(content.getChildren(), skinnable.itemsProperty());
|
||||
|
||||
scrollPane.setContent(content);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
|
||||
root.getContent().add(scrollPane);
|
||||
this.listView = new JFXListView<>();
|
||||
this.listView.setPadding(Insets.EMPTY);
|
||||
this.listView.setCellFactory(listView -> createListCell((JFXListView<E>) listView));
|
||||
ComponentList.setVgrow(listView, Priority.ALWAYS);
|
||||
Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty());
|
||||
root.getContent().add(listView);
|
||||
}
|
||||
|
||||
spinnerPane.setContent(root);
|
||||
@@ -89,10 +85,7 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
|
||||
ret.getStyleClass().add("jfx-tool-bar-button");
|
||||
ret.setGraphic(wrap(svg.createIcon()));
|
||||
ret.setText(text);
|
||||
ret.setOnAction(e -> {
|
||||
onClick.run();
|
||||
FXUtils.clearFocus(ret);
|
||||
});
|
||||
ret.setOnAction(e -> onClick.run());
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -105,5 +98,21 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
|
||||
return ret;
|
||||
}
|
||||
|
||||
protected abstract List<Node> initializeToolbar(T skinnable);
|
||||
protected abstract List<Node> initializeToolbar(P skinnable);
|
||||
|
||||
protected ListCell<E> createListCell(JFXListView<E> listView) {
|
||||
return new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(E item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (!empty && item instanceof Node node) {
|
||||
setGraphic(node);
|
||||
setText(null);
|
||||
} else {
|
||||
setGraphic(null);
|
||||
setText(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,8 @@ public class WeakListenerHolder {
|
||||
public boolean remove(Object obj) {
|
||||
return refs.remove(obj);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
refs.clear();
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
|
||||
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
|
||||
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
|
||||
btnRefresh.setDisable(true);
|
||||
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
|
||||
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot.tooltip"));
|
||||
}
|
||||
btnRefresh.setOnAction(e -> {
|
||||
spinnerRefresh.showSpinner();
|
||||
|
||||
@@ -164,6 +164,12 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
|
||||
|
||||
private String resolveFetchExceptionMessage(Throwable exception) {
|
||||
if (exception instanceof SSLException) {
|
||||
if (exception.getMessage() != null && exception.getMessage().contains("Remote host terminated")) {
|
||||
return i18n("account.failed.connect_injector_server");
|
||||
}
|
||||
if (exception.getMessage() != null && (exception.getMessage().contains("No name matching") || exception.getMessage().contains("No subject alternative DNS name matching"))) {
|
||||
return i18n("account.failed.dns");
|
||||
}
|
||||
return i18n("account.failed.ssl");
|
||||
} else if (exception instanceof IOException) {
|
||||
return i18n("account.failed.connect_injector_server");
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.input.DragEvent;
|
||||
import javafx.scene.input.TransferMode;
|
||||
@@ -174,11 +175,19 @@ public class OfflineAccountSkinPane extends StackPane {
|
||||
case LITTLE_SKIN:
|
||||
HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
hint.setText(i18n("account.skin.type.little_skin.hint"));
|
||||
// Allow the tooltip to span two columns and expand horizontally to avoid overlapping with the dialog action area/top-right help button
|
||||
|
||||
// Spanning two columns and expanding horizontally
|
||||
GridPane.setColumnSpan(hint, 2);
|
||||
GridPane.setHgrow(hint, Priority.ALWAYS);
|
||||
hint.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
// Force top alignment within cells (to avoid vertical offset caused by the baseline)
|
||||
GridPane.setValignment(hint, VPos.TOP);
|
||||
|
||||
// Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space.
|
||||
hint.setMaxHeight(Region.USE_PREF_SIZE);
|
||||
hint.setMinHeight(Region.USE_PREF_SIZE);
|
||||
|
||||
gridPane.addRow(0, hint);
|
||||
break;
|
||||
case LOCAL_FILE:
|
||||
|
||||
@@ -82,7 +82,9 @@ public class TransitionPane extends StackPane {
|
||||
duration, interpolator);
|
||||
newAnimation.setOnFinished(e -> {
|
||||
setMouseTransparent(false);
|
||||
getChildren().remove(previousNode);
|
||||
if (previousNode != currentNode) {
|
||||
getChildren().remove(previousNode);
|
||||
}
|
||||
|
||||
if (cacheHint != null) {
|
||||
newView.setCache(false);
|
||||
|
||||
@@ -87,7 +87,7 @@ public final class MessageDialogPane extends HBox {
|
||||
{
|
||||
StackPane titlePane = new StackPane();
|
||||
titlePane.getStyleClass().addAll("jfx-layout-heading", "title");
|
||||
titlePane.getChildren().setAll(new Label(title != null ? title : i18n(type.getDisplayName())));
|
||||
titlePane.getChildren().setAll(new Label(title != null ? title : type.getDisplayName()));
|
||||
|
||||
StackPane content = new StackPane();
|
||||
content.getStyleClass().add("jfx-layout-body");
|
||||
@@ -190,6 +190,7 @@ public final class MessageDialogPane extends HBox {
|
||||
|
||||
public Builder addCancel(String cancelText, @Nullable Runnable cancel) {
|
||||
JFXButton btnCancel = new JFXButton(cancelText);
|
||||
btnCancel.setButtonType(JFXButton.ButtonType.FLAT);
|
||||
btnCancel.getStyleClass().add("dialog-cancel");
|
||||
if (cancel != null) {
|
||||
btnCancel.setOnAction(e -> cancel.run());
|
||||
|
||||
@@ -181,6 +181,7 @@ public final class MultiFileItem<T> extends VBox {
|
||||
center.setWrapText(true);
|
||||
center.getStyleClass().add("subtitle-label");
|
||||
center.setStyle("-fx-font-size: 10;");
|
||||
center.setPadding(new Insets(0, 0, 0, 15));
|
||||
pane.setCenter(center);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,12 +144,20 @@ public class RipplerContainer extends StackPane {
|
||||
}
|
||||
|
||||
protected void updateChildren() {
|
||||
getChildren().addAll(buttonContainer, getContainer());
|
||||
if (buttonRippler.getPosition() == JFXRippler.RipplerPos.BACK)
|
||||
getChildren().setAll(buttonContainer, getContainer());
|
||||
else
|
||||
getChildren().setAll(getContainer(), buttonContainer);
|
||||
|
||||
for (int i = 1; i < getChildren().size(); ++i)
|
||||
getChildren().get(i).setPickOnBounds(false);
|
||||
}
|
||||
|
||||
public void setPosition(JFXRippler.RipplerPos pos) {
|
||||
buttonRippler.setPosition(pos);
|
||||
updateChildren();
|
||||
}
|
||||
|
||||
public Node getContainer() {
|
||||
return container.get();
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ public class DecoratorController {
|
||||
private Background createBackgroundWithOpacity(Image image, int opacity) {
|
||||
if (opacity <= 0) {
|
||||
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
|
||||
} else if (opacity >= 100) {
|
||||
} else if (opacity >= 100 || image.getPixelReader() == null) {
|
||||
return new Background(new BackgroundImage(
|
||||
image,
|
||||
BackgroundRepeat.NO_REPEAT,
|
||||
|
||||
@@ -50,7 +50,7 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return group.getGame().versionProperty().get().getVersion();
|
||||
return group.getGame().versionProperty().get().version();
|
||||
}
|
||||
|
||||
private String getVersion(String id) {
|
||||
@@ -111,7 +111,7 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
}
|
||||
|
||||
private void setTxtNameWithLoaders() {
|
||||
StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().getVersion());
|
||||
StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().version());
|
||||
|
||||
for (InstallerItem library : group.getLibraries()) {
|
||||
String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), "");
|
||||
|
||||
@@ -139,7 +139,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
|
||||
} else {
|
||||
reject.accept(e.getMessage());
|
||||
}
|
||||
}).executor(true), i18n("message.downloading"), TaskCancellationAction.NORMAL);
|
||||
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
|
||||
} else {
|
||||
// otherwise we still consider the file as modpack zip file
|
||||
// since casually the url may not ends with ".zip"
|
||||
@@ -156,7 +156,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
|
||||
} else {
|
||||
reject.accept(e.getMessage());
|
||||
}
|
||||
}).executor(true),
|
||||
}),
|
||||
i18n("message.downloading"),
|
||||
TaskCancellationAction.NORMAL
|
||||
);
|
||||
|
||||
@@ -175,10 +175,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
{
|
||||
if ("game".equals(control.libraryId)) {
|
||||
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
|
||||
wikiButton.setOnAction(event -> {
|
||||
onOpenWiki();
|
||||
FXUtils.clearFocus(wikiButton);
|
||||
});
|
||||
wikiButton.setOnAction(event -> onOpenWiki());
|
||||
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
|
||||
actions.getChildren().add(wikiButton);
|
||||
}
|
||||
|
||||
@@ -18,17 +18,19 @@
|
||||
package org.jackhuang.hmcl.ui.main;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.java.JavaInfo;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
@@ -42,6 +44,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
@@ -62,7 +65,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class JavaManagementPage extends ListPageBase<JavaManagementPage.JavaItem> {
|
||||
public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
|
||||
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private final ChangeListener<Collection<JavaRuntime>> listener;
|
||||
@@ -172,130 +175,17 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
|
||||
}).start();
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
private void loadJava(Collection<JavaRuntime> javaRuntimes) {
|
||||
if (javaRuntimes != null) {
|
||||
List<JavaItem> items = new ArrayList<>();
|
||||
for (JavaRuntime java : javaRuntimes) {
|
||||
items.add(new JavaItem(java));
|
||||
}
|
||||
this.setItems(FXCollections.observableList(items));
|
||||
this.setItems(FXCollections.observableArrayList(javaRuntimes));
|
||||
this.setLoading(false);
|
||||
} else
|
||||
} else {
|
||||
this.setLoading(true);
|
||||
}
|
||||
|
||||
static final class JavaItem extends Control {
|
||||
private final JavaRuntime java;
|
||||
|
||||
public JavaItem(JavaRuntime java) {
|
||||
this.java = java;
|
||||
}
|
||||
|
||||
public JavaRuntime getJava() {
|
||||
return java;
|
||||
}
|
||||
|
||||
public void onReveal() {
|
||||
Path target;
|
||||
Path parent = java.getBinary().getParent();
|
||||
if (parent != null
|
||||
&& parent.getParent() != null
|
||||
&& parent.getFileName() != null
|
||||
&& parent.getFileName().toString().equals("bin")
|
||||
&& Files.exists(parent.getParent().resolve("release"))) {
|
||||
target = parent.getParent();
|
||||
} else {
|
||||
target = java.getBinary();
|
||||
}
|
||||
|
||||
FXUtils.showFileInExplorer(target);
|
||||
}
|
||||
|
||||
public void onRemove() {
|
||||
if (java.isManaged()) {
|
||||
Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL);
|
||||
} else {
|
||||
String path = java.getBinary().toString();
|
||||
ConfigHolder.globalConfig().getUserJava().remove(path);
|
||||
ConfigHolder.globalConfig().getDisabledJava().add(path);
|
||||
try {
|
||||
JavaManager.removeJava(java);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JavaRuntimeItemSkin(this);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class JavaRuntimeItemSkin extends SkinBase<JavaItem> {
|
||||
|
||||
JavaRuntimeItemSkin(JavaItem control) {
|
||||
super(control);
|
||||
JavaRuntime java = control.getJava();
|
||||
String vendor = JavaInfo.normalizeVendor(java.getVendor());
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
|
||||
HBox center = new HBox();
|
||||
center.setMouseTransparent(true);
|
||||
center.setSpacing(8);
|
||||
center.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
item.setTitle((java.isJDK() ? "JDK" : "JRE") + " " + java.getVersion());
|
||||
item.setSubtitle(java.getBinary().toString());
|
||||
item.addTag(i18n("java.info.architecture") + ": " + java.getArchitecture().getDisplayName());
|
||||
if (vendor != null)
|
||||
item.addTag(i18n("java.info.vendor") + ": " + vendor);
|
||||
BorderPane.setAlignment(item, Pos.CENTER);
|
||||
center.getChildren().setAll(item);
|
||||
root.setCenter(center);
|
||||
|
||||
HBox right = new HBox();
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
{
|
||||
JFXButton revealButton = new JFXButton();
|
||||
revealButton.getStyleClass().add("toggle-icon4");
|
||||
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
|
||||
revealButton.setOnAction(e -> control.onReveal());
|
||||
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
|
||||
|
||||
JFXButton removeButton = new JFXButton();
|
||||
removeButton.getStyleClass().add("toggle-icon4");
|
||||
removeButton.setOnAction(e -> Controllers.confirm(
|
||||
java.isManaged() ? i18n("java.uninstall.confirm") : i18n("java.disable.confirm"),
|
||||
i18n("message.warning"),
|
||||
control::onRemove,
|
||||
null
|
||||
));
|
||||
if (java.isManaged()) {
|
||||
removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE_FOREVER.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(removeButton, i18n("java.uninstall"));
|
||||
if (JavaRuntime.CURRENT_JAVA != null && java.getBinary().equals(JavaRuntime.CURRENT_JAVA.getBinary()))
|
||||
removeButton.setDisable(true);
|
||||
} else {
|
||||
removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(removeButton, i18n("java.disable"));
|
||||
}
|
||||
|
||||
right.getChildren().setAll(revealButton, removeButton);
|
||||
}
|
||||
root.setRight(right);
|
||||
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8));
|
||||
|
||||
getChildren().setAll(new RipplerContainer(root));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class JavaPageSkin extends ToolbarListPageSkin<JavaManagementPage> {
|
||||
private static final class JavaPageSkin extends ToolbarListPageSkin<JavaRuntime, JavaManagementPage> {
|
||||
|
||||
JavaPageSkin(JavaManagementPage skinnable) {
|
||||
super(skinnable);
|
||||
@@ -317,5 +207,139 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ListCell<JavaRuntime> createListCell(JFXListView<JavaRuntime> listView) {
|
||||
return new JavaItemCell();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class JavaItemCell extends ListCell<JavaRuntime> {
|
||||
private final Node graphic;
|
||||
private final TwoLineListItem content;
|
||||
|
||||
private SVG removeIcon;
|
||||
private final StackPane removeIconPane;
|
||||
private final Tooltip removeTooltip = new Tooltip();
|
||||
|
||||
JavaItemCell() {
|
||||
BorderPane root = new BorderPane();
|
||||
|
||||
HBox center = new HBox();
|
||||
center.setMouseTransparent(true);
|
||||
center.setSpacing(8);
|
||||
center.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
this.content = new TwoLineListItem();
|
||||
|
||||
BorderPane.setAlignment(content, Pos.CENTER);
|
||||
center.getChildren().setAll(content);
|
||||
root.setCenter(center);
|
||||
|
||||
HBox right = new HBox();
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
{
|
||||
JFXButton revealButton = new JFXButton();
|
||||
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
|
||||
revealButton.getStyleClass().add("toggle-icon4");
|
||||
revealButton.setOnAction(e -> {
|
||||
JavaRuntime java = getItem();
|
||||
if (java != null)
|
||||
onReveal(java);
|
||||
});
|
||||
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
|
||||
|
||||
JFXButton removeButton = new JFXButton();
|
||||
removeButton.getStyleClass().add("toggle-icon4");
|
||||
removeButton.setOnAction(e -> {
|
||||
JavaRuntime java = getItem();
|
||||
if (java != null)
|
||||
onRemove(java);
|
||||
});
|
||||
FXUtils.installFastTooltip(removeButton, removeTooltip);
|
||||
|
||||
this.removeIconPane = new StackPane();
|
||||
removeIconPane.setAlignment(Pos.CENTER);
|
||||
FXUtils.setLimitWidth(removeIconPane, 24);
|
||||
FXUtils.setLimitHeight(removeIconPane, 24);
|
||||
removeButton.setGraphic(removeIconPane);
|
||||
|
||||
right.getChildren().setAll(revealButton, removeButton);
|
||||
}
|
||||
root.setRight(right);
|
||||
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8));
|
||||
|
||||
this.graphic = new RipplerContainer(root);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(JavaRuntime item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
content.setTitle((item.isJDK() ? "JDK" : "JRE") + " " + item.getVersion());
|
||||
content.setSubtitle(item.getBinary().toString());
|
||||
|
||||
content.getTags().clear();
|
||||
content.addTag(i18n("java.info.architecture") + ": " + item.getArchitecture().getDisplayName());
|
||||
String vendor = JavaInfo.normalizeVendor(item.getVendor());
|
||||
if (vendor != null)
|
||||
content.addTag(i18n("java.info.vendor") + ": " + vendor);
|
||||
|
||||
SVG newRemoveIcon = item.isManaged() ? SVG.DELETE_FOREVER : SVG.DELETE;
|
||||
if (removeIcon != newRemoveIcon) {
|
||||
removeIcon = newRemoveIcon;
|
||||
removeIconPane.getChildren().setAll(removeIcon.createIcon(24));
|
||||
removeTooltip.setText(item.isManaged() ? i18n("java.uninstall") : i18n("java.disable"));
|
||||
}
|
||||
|
||||
setGraphic(graphic);
|
||||
}
|
||||
}
|
||||
|
||||
private void onReveal(JavaRuntime java) {
|
||||
Path target;
|
||||
Path parent = java.getBinary().getParent();
|
||||
if (parent != null
|
||||
&& parent.getParent() != null
|
||||
&& parent.getFileName() != null
|
||||
&& parent.getFileName().toString().equals("bin")
|
||||
&& Files.exists(parent.getParent().resolve("release"))) {
|
||||
target = parent.getParent();
|
||||
} else {
|
||||
target = java.getBinary();
|
||||
}
|
||||
|
||||
FXUtils.showFileInExplorer(target);
|
||||
}
|
||||
|
||||
private void onRemove(JavaRuntime java) {
|
||||
if (java.isManaged()) {
|
||||
Controllers.confirm(
|
||||
i18n("java.uninstall.confirm"),
|
||||
i18n("message.warning"),
|
||||
() -> Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL),
|
||||
null
|
||||
);
|
||||
} else {
|
||||
Controllers.confirm(
|
||||
i18n("java.disable.confirm"),
|
||||
i18n("message.warning"),
|
||||
() -> {
|
||||
String path = java.getBinary().toString();
|
||||
ConfigHolder.globalConfig().getUserJava().remove(path);
|
||||
ConfigHolder.globalConfig().getDisabledJava().add(path);
|
||||
try {
|
||||
JavaManager.removeJava(java);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
},
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
private static final class JavaRestorePageSkin extends ToolbarListPageSkin<JavaRestorePage> {
|
||||
private static final class JavaRestorePageSkin extends ToolbarListPageSkin<DisabledJavaItem, JavaRestorePage> {
|
||||
JavaRestorePageSkin(JavaRestorePage skinnable) {
|
||||
super(skinnable);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,12 @@ import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.image.ImageView;
|
||||
@@ -59,10 +57,10 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.versions.GameItem;
|
||||
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.upgrade.RemoteVersion;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
@@ -73,7 +71,6 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@@ -92,16 +89,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
private final PopupMenu menu = new PopupMenu();
|
||||
|
||||
private final StackPane popupWrapper = new StackPane(menu);
|
||||
private final JFXPopup popup = new JFXPopup(popupWrapper);
|
||||
|
||||
private final StringProperty currentGame = new SimpleStringProperty(this, "currentGame");
|
||||
private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
|
||||
private final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>(this, "latestVersion");
|
||||
private final ObservableList<Version> versions = FXCollections.observableArrayList();
|
||||
private final ObservableList<Node> versionNodes;
|
||||
private Profile profile;
|
||||
|
||||
private TransitionPane announcementPane;
|
||||
@@ -273,19 +264,6 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
getChildren().addAll(updatePane, launchPane);
|
||||
|
||||
menu.setMaxHeight(365);
|
||||
menu.setMaxWidth(545);
|
||||
menu.setAlwaysShowingVBar(false);
|
||||
FXUtils.onClicked(menu, popup::hide);
|
||||
versionNodes = MappedObservableList.create(versions, version -> {
|
||||
Node node = PopupMenu.wrapPopupMenuItem(new GameItem(profile, version.getId()));
|
||||
FXUtils.onClicked(node, () -> {
|
||||
profile.setSelectedVersion(version.getId());
|
||||
popup.hide();
|
||||
});
|
||||
return node;
|
||||
});
|
||||
Bindings.bindContent(menu.getContent(), versionNodes);
|
||||
}
|
||||
|
||||
private void showUpdate(boolean show) {
|
||||
@@ -365,20 +343,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
}
|
||||
|
||||
private void onMenu() {
|
||||
Node contentNode;
|
||||
if (menu.getContent().isEmpty()) {
|
||||
Label placeholder = new Label(i18n("version.empty"));
|
||||
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
|
||||
contentNode = placeholder;
|
||||
} else {
|
||||
contentNode = menu;
|
||||
}
|
||||
|
||||
popupWrapper.getChildren().setAll(contentNode);
|
||||
|
||||
if (popup.isShowing()) {
|
||||
popup.hide();
|
||||
}
|
||||
GameListPopupMenu menu = new GameListPopupMenu();
|
||||
menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList());
|
||||
JFXPopup popup = new JFXPopup(menu);
|
||||
popup.show(
|
||||
menuButton,
|
||||
JFXPopup.PopupVPosition.BOTTOM,
|
||||
|
||||
@@ -238,7 +238,14 @@ public class PersonalizationPage extends StackPane {
|
||||
.fallbackTo(12.0)
|
||||
.asPredicate(Validator.addTo(txtLogFontSize)));
|
||||
|
||||
hBox.getChildren().setAll(cboLogFont, txtLogFontSize);
|
||||
JFXButton clearButton = new JFXButton();
|
||||
clearButton.getStyleClass().add("toggle-icon4");
|
||||
clearButton.setGraphic(SVG.RESTORE.createIcon());
|
||||
clearButton.setOnAction(e -> cboLogFont.setValue(null));
|
||||
|
||||
FXUtils.installFastTooltip(clearButton, i18n("button.reset"));
|
||||
|
||||
hBox.getChildren().setAll(cboLogFont, txtLogFontSize, clearButton);
|
||||
|
||||
borderPane.setRight(hBox);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,13 @@ import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
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.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
|
||||
import org.jackhuang.hmcl.ui.construct.HintPane;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
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.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
@@ -96,6 +102,11 @@ public class TerracottaControllerPage extends StackPane {
|
||||
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
|
||||
Consider moving UI into multiple files for each state respectively. */
|
||||
public TerracottaControllerPage() {
|
||||
holder.add(FXUtils.observeWeak(() -> {
|
||||
// Run daemon process only if HMCL is focused and is displaying current node.
|
||||
TerracottaManager.switchDaemon(getScene() != null && Controllers.getStage().isFocused());
|
||||
}, this.sceneProperty(), Controllers.getStage().focusedProperty()));
|
||||
|
||||
TransitionPane transition = new TransitionPane();
|
||||
|
||||
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
|
||||
@@ -107,7 +118,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
|
||||
if (state instanceof TerracottaState.Uninitialized ||
|
||||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||
state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK
|
||||
state instanceof TerracottaState.Fatal fatal && fatal.isRecoverable()
|
||||
) {
|
||||
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
|
||||
} else {
|
||||
@@ -116,7 +127,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
}, files -> {
|
||||
Path path = files.get(0);
|
||||
|
||||
if (!TerracottaManager.validate(path)) {
|
||||
if (TerracottaManager.isInvalidBundle(path)) {
|
||||
Controllers.dialog(
|
||||
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
|
||||
i18n("message.error"),
|
||||
@@ -125,25 +136,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
return;
|
||||
}
|
||||
|
||||
TerracottaState state = UI_STATE.get(), next;
|
||||
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
|
||||
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
|
||||
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5,
|
||||
MessageDialogPane.MessageType.INFO, () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(path);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, null);
|
||||
return;
|
||||
}
|
||||
|
||||
next = TerracottaManager.install(path);
|
||||
} else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
|
||||
next = TerracottaManager.recover(path);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
TerracottaState.Preparing next = TerracottaManager.install(path);
|
||||
if (next != null) {
|
||||
UI_STATE.set(next);
|
||||
}
|
||||
@@ -176,7 +169,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
|
||||
download.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(download, () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||
TerracottaState.Preparing s = TerracottaManager.download();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
@@ -253,7 +246,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
guest.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(guest, () -> {
|
||||
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> {
|
||||
Task<TerracottaState.GuestStarting> task = TerracottaManager.setGuesting(code);
|
||||
Task<TerracottaState.GuestConnecting> task = TerracottaManager.setGuesting(code);
|
||||
if (task != null) {
|
||||
task.whenComplete(Schedulers.javafx(), (s, e) -> {
|
||||
if (e != null) {
|
||||
@@ -372,7 +365,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
|
||||
}
|
||||
}
|
||||
} else if (state instanceof TerracottaState.GuestStarting) {
|
||||
} else if (state instanceof TerracottaState.GuestConnecting || state instanceof TerracottaState.GuestStarting) {
|
||||
statusProperty.set(i18n("terracotta.status.guest_starting"));
|
||||
progressProperty.set(-1);
|
||||
|
||||
@@ -387,7 +380,26 @@ public class TerracottaControllerPage extends StackPane {
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(room);
|
||||
nodesProperty.clear();
|
||||
if (state instanceof TerracottaState.GuestStarting) {
|
||||
TerracottaState.GuestStarting.Difficulty difficulty = ((TerracottaState.GuestStarting) state).getDifficulty();
|
||||
if (difficulty != null && difficulty != TerracottaState.GuestStarting.Difficulty.UNKNOWN) {
|
||||
LineButton info = LineButton.of();
|
||||
info.setLeftIcon(switch (difficulty) {
|
||||
case UNKNOWN -> throw new AssertionError();
|
||||
case EASIEST, SIMPLE -> SVG.INFO;
|
||||
case MEDIUM, TOUGH -> SVG.WARNING;
|
||||
});
|
||||
|
||||
String difficultyID = difficulty.name().toLowerCase(Locale.ROOT);
|
||||
info.setTitle(i18n(String.format("terracotta.difficulty.%s", difficultyID)));
|
||||
info.setSubtitle(i18n("terracotta.difficulty.estimate_only"));
|
||||
|
||||
nodesProperty.add(info);
|
||||
}
|
||||
}
|
||||
|
||||
nodesProperty.add(room);
|
||||
} else if (state instanceof TerracottaState.GuestOK guestOK) {
|
||||
if (guestOK.isForkOf(legacyState)) {
|
||||
if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) {
|
||||
@@ -486,7 +498,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
retry.setTitle(i18n("terracotta.status.fatal.retry"));
|
||||
retry.setSubtitle(message);
|
||||
FXUtils.onClicked(retry, () -> {
|
||||
TerracottaState s = TerracottaManager.recover(null);
|
||||
TerracottaState s = TerracottaManager.recover();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
|
||||
@@ -85,8 +85,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, it -> mainPage.getProfile().setSelectedVersion(it.getId()));
|
||||
})
|
||||
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK))
|
||||
.addNavigationDrawerItem(i18n("terracotta.easytier"), SVG.HOST, () -> FXUtils.openLink("https://easytier.cn/"));
|
||||
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
|
||||
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
|
||||
left.setBottom(toolbar);
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
|
||||
getChildren().setAll(scrollPane);
|
||||
|
||||
VBox rootPane = new VBox();
|
||||
rootPane.setFocusTraversable(true);
|
||||
rootPane.setFillWidth(true);
|
||||
scrollPane.setContent(rootPane);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
|
||||
@@ -48,14 +48,12 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
|
||||
|
||||
public DatapackListPage(WorldManagePage worldManagePage) {
|
||||
this.worldDir = worldManagePage.getWorld().getFile();
|
||||
|
||||
datapack = new Datapack(worldDir.resolve("datapacks"));
|
||||
datapack.loadFromDir();
|
||||
|
||||
setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new));
|
||||
|
||||
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
|
||||
mods -> mods.forEach(this::installSingleDatapack), this::refresh);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void installSingleDatapack(Path datapack) {
|
||||
@@ -74,9 +72,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
|
||||
public void refresh() {
|
||||
setLoading(true);
|
||||
Task.runAsync(datapack::loadFromDir)
|
||||
.withRunAsync(Schedulers.javafx(), () -> {
|
||||
setLoading(false);
|
||||
})
|
||||
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
|
||||
.start();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,29 +56,20 @@ import org.jackhuang.hmcl.ui.construct.FloatListCell;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.AggregatedObservableList;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class DownloadListPage extends Control implements DecoratorPage, VersionPage.VersionLoadable {
|
||||
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
|
||||
@@ -208,6 +199,10 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
: i18n("curse.category." + category);
|
||||
}
|
||||
|
||||
protected boolean shouldDisplayCategory(String category) {
|
||||
return !"minecraft".equals(category);
|
||||
}
|
||||
|
||||
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
|
||||
return StringUtils.repeats(' ', category.indent * 4) +
|
||||
(category.getCategory() == null
|
||||
@@ -243,6 +238,12 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
|
||||
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
|
||||
private final JFXListView<RemoteMod> listView = new JFXListView<>();
|
||||
private final RemoteImageLoader iconLoader = new RemoteImageLoader() {
|
||||
@Override
|
||||
protected @NotNull Task<Image> createLoadTask(@NotNull URI uri) {
|
||||
return FXUtils.getRemoteImageTask(uri, 80, 80, true, true);
|
||||
}
|
||||
};
|
||||
|
||||
protected ModDownloadListPageSkin(DownloadListPage control) {
|
||||
super(control);
|
||||
@@ -373,6 +374,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
|
||||
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
|
||||
EventHandler<ActionEvent> searchAction = e -> {
|
||||
iconLoader.clearInvalidCache();
|
||||
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
|
||||
control.pageOffset.set(0);
|
||||
}
|
||||
@@ -532,7 +534,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
|
||||
// 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);
|
||||
var iconCache = new WeakHashMap<String, WeakReference<CompletableFuture<Image>>>();
|
||||
|
||||
listView.setCellFactory(x -> new FloatListCell<>(listView) {
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final ImageView imageView = new ImageView();
|
||||
@@ -556,69 +558,13 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : dataItem.getTitle());
|
||||
content.setSubtitle(dataItem.getDescription());
|
||||
content.getTags().clear();
|
||||
dataItem.getCategories().stream()
|
||||
.map(category -> getSkinnable().getLocalizedCategory(category))
|
||||
.forEach(content::addTag);
|
||||
loadIcon(dataItem);
|
||||
for (String category : dataItem.getCategories()) {
|
||||
if (getSkinnable().shouldDisplayCategory(category))
|
||||
content.addTag(getSkinnable().getLocalizedCategory(category));
|
||||
}
|
||||
iconLoader.load(imageView.imageProperty(), dataItem.getIconUrl());
|
||||
}
|
||||
|
||||
private void loadIcon(RemoteMod mod) {
|
||||
if (StringUtils.isBlank(mod.getIconUrl())) {
|
||||
imageView.setImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
WeakReference<CompletableFuture<Image>> cacheRef = iconCache.get(mod.getIconUrl());
|
||||
CompletableFuture<Image> cache;
|
||||
if (cacheRef != null && (cache = cacheRef.get()) != null) {
|
||||
loadIcon(cache, mod.getIconUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
URI iconUrl = NetworkUtils.toURIOrNull(mod.getIconUrl());
|
||||
if (iconUrl == null) {
|
||||
imageView.setImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
CompletableFuture<Image> future = new CompletableFuture<>();
|
||||
WeakReference<CompletableFuture<Image>> futureRef = new WeakReference<>(future);
|
||||
iconCache.put(mod.getIconUrl(), futureRef);
|
||||
|
||||
FXUtils.getRemoteImageTask(iconUrl, 80, 80, true, true)
|
||||
.whenComplete(Schedulers.defaultScheduler(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
future.complete(result);
|
||||
} else {
|
||||
LOG.warning("Failed to load image from " + iconUrl, exception);
|
||||
future.completeExceptionally(exception);
|
||||
}
|
||||
}).start();
|
||||
loadIcon(future, mod.getIconUrl());
|
||||
}
|
||||
|
||||
private void loadIcon(@NotNull CompletableFuture<Image> future,
|
||||
@NotNull String iconUrl) {
|
||||
Image image;
|
||||
try {
|
||||
image = future.getNow(null);
|
||||
} catch (CancellationException | CompletionException ignored) {
|
||||
imageView.setImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
imageView.setImage(image);
|
||||
} else {
|
||||
imageView.setImage(null);
|
||||
future.thenAcceptAsync(result -> {
|
||||
RemoteMod item = getItem();
|
||||
if (item != null && iconUrl.equals(item.getIconUrl())) {
|
||||
this.imageView.setImage(result);
|
||||
}
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -357,9 +357,10 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(page.repository.getType()).getModByCurseForgeId(addon.getSlug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.getTitle());
|
||||
content.setSubtitle(addon.getDescription());
|
||||
addon.getCategories().stream()
|
||||
.map(page::getLocalizedCategory)
|
||||
.forEach(content::addTag);
|
||||
for (String category : addon.getCategories()) {
|
||||
if (page.shouldDisplayCategory(category))
|
||||
content.addTag(page.getLocalizedCategory(category));
|
||||
}
|
||||
if (StringUtils.isNotBlank(addon.getIconUrl())) {
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 80, 80, true, true));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 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
|
||||
@@ -17,107 +17,122 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
|
||||
import static org.jackhuang.hmcl.util.Lang.handleUncaught;
|
||||
import static org.jackhuang.hmcl.util.Lang.threadPool;
|
||||
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 class GameItem extends Control {
|
||||
public class GameItem {
|
||||
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
|
||||
|
||||
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 1, TimeUnit.SECONDS);
|
||||
protected final Profile profile;
|
||||
protected final String id;
|
||||
|
||||
private final Profile profile;
|
||||
private final String version;
|
||||
private final StringProperty title = new SimpleStringProperty();
|
||||
private final StringProperty tag = new SimpleStringProperty();
|
||||
private final StringProperty subtitle = new SimpleStringProperty();
|
||||
private final ObjectProperty<Image> image = new SimpleObjectProperty<>();
|
||||
private boolean initialized = false;
|
||||
private StringProperty title;
|
||||
private StringProperty tag;
|
||||
private StringProperty subtitle;
|
||||
private ObjectProperty<Image> image;
|
||||
|
||||
public GameItem(Profile profile, String id) {
|
||||
this.profile = profile;
|
||||
this.version = id;
|
||||
|
||||
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
|
||||
CompletableFuture.supplyAsync(() -> profile.getRepository().getGameVersion(id), POOL_VERSION_RESOLVE)
|
||||
.thenAcceptAsync(game -> {
|
||||
StringBuilder libraries = new StringBuilder(game.orElse(i18n("message.unknown")));
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), game.orElse(null));
|
||||
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
|
||||
String libraryId = mark.getLibraryId();
|
||||
String libraryVersion = mark.getLibraryVersion();
|
||||
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
|
||||
if (I18n.hasKey("install.installer." + libraryId)) {
|
||||
libraries.append(", ").append(i18n("install.installer." + libraryId));
|
||||
if (libraryVersion != null)
|
||||
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.set(libraries.toString());
|
||||
}, Platform::runLater)
|
||||
.exceptionally(handleUncaught);
|
||||
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
ModpackConfiguration<?> config = profile.getRepository().readModpackConfiguration(version);
|
||||
if (config == null) return;
|
||||
tag.set(config.getVersion());
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to read modpack configuration from " + version, e);
|
||||
}
|
||||
}, Platform::runLater)
|
||||
.exceptionally(handleUncaught);
|
||||
|
||||
title.set(id);
|
||||
image.set(profile.getRepository().getVersionIconImage(version));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new GameItemSkin(this);
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public StringProperty titleProperty() {
|
||||
private void init() {
|
||||
if (initialized)
|
||||
return;
|
||||
|
||||
initialized = true;
|
||||
title = new SimpleStringProperty();
|
||||
tag = new SimpleStringProperty();
|
||||
subtitle = new SimpleStringProperty();
|
||||
image = new SimpleObjectProperty<>();
|
||||
|
||||
record Result(@Nullable String gameVersion, @Nullable String tag) {
|
||||
}
|
||||
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
|
||||
Optional<String> gameVersion = profile.getRepository().getGameVersion(id);
|
||||
String modPackVersion = null;
|
||||
try {
|
||||
ModpackConfiguration<?> config = profile.getRepository().readModpackConfiguration(id);
|
||||
modPackVersion = config != null ? config.getVersion() : null;
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read modpack configuration from " + id, e);
|
||||
}
|
||||
return new Result(gameVersion.orElse(null), modPackVersion);
|
||||
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
|
||||
if (exception == null) {
|
||||
if (result.tag != null) {
|
||||
tag.set(result.tag);
|
||||
}
|
||||
|
||||
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), result.gameVersion);
|
||||
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
|
||||
String libraryId = mark.getLibraryId();
|
||||
String libraryVersion = mark.getLibraryVersion();
|
||||
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
|
||||
if (I18n.hasKey("install.installer." + libraryId)) {
|
||||
libraries.append(", ").append(i18n("install.installer." + libraryId));
|
||||
if (libraryVersion != null)
|
||||
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.set(libraries.toString());
|
||||
} else {
|
||||
LOG.warning("Failed to read version info from " + id, exception);
|
||||
}
|
||||
}, Schedulers.javafx());
|
||||
|
||||
title.set(id);
|
||||
image.set(profile.getRepository().getVersionIconImage(id));
|
||||
}
|
||||
|
||||
public ReadOnlyStringProperty titleProperty() {
|
||||
init();
|
||||
return title;
|
||||
}
|
||||
|
||||
public StringProperty tagProperty() {
|
||||
public ReadOnlyStringProperty tagProperty() {
|
||||
init();
|
||||
return tag;
|
||||
}
|
||||
|
||||
public StringProperty subtitleProperty() {
|
||||
public ReadOnlyStringProperty subtitleProperty() {
|
||||
init();
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
public ObjectProperty<Image> imageProperty() {
|
||||
public ReadOnlyObjectProperty<Image> imageProperty() {
|
||||
init();
|
||||
return image;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
public class GameItemSkin extends SkinBase<GameItem> {
|
||||
public GameItemSkin(GameItem skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
HBox center = new HBox();
|
||||
center.setSpacing(8);
|
||||
center.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
StackPane imageViewContainer = new StackPane();
|
||||
FXUtils.setLimitWidth(imageViewContainer, 32);
|
||||
FXUtils.setLimitHeight(imageViewContainer, 32);
|
||||
|
||||
ImageView imageView = new ImageView();
|
||||
FXUtils.limitSize(imageView, 32, 32);
|
||||
imageView.imageProperty().bind(skinnable.imageProperty());
|
||||
imageViewContainer.getChildren().setAll(imageView);
|
||||
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
item.titleProperty().bind(skinnable.titleProperty());
|
||||
FXUtils.onChangeAndOperate(skinnable.tagProperty(), tag -> {
|
||||
item.getTags().clear();
|
||||
if (StringUtils.isNotBlank(tag))
|
||||
item.addTag(tag);
|
||||
});
|
||||
item.subtitleProperty().bind(skinnable.subtitleProperty());
|
||||
BorderPane.setAlignment(item, Pos.CENTER);
|
||||
center.getChildren().setAll(imageView, item);
|
||||
getChildren().setAll(center);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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.versions;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class GameListCell extends ListCell<GameListItem> {
|
||||
|
||||
private final Region graphic;
|
||||
|
||||
private final ImageView imageView;
|
||||
private final TwoLineListItem content;
|
||||
|
||||
private final JFXRadioButton chkSelected;
|
||||
private final JFXButton btnUpgrade;
|
||||
private final JFXButton btnLaunch;
|
||||
private final JFXButton btnManage;
|
||||
|
||||
private final HBox right;
|
||||
|
||||
private final StringProperty tag = new SimpleStringProperty();
|
||||
|
||||
public GameListCell() {
|
||||
BorderPane root = new BorderPane();
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8, 8, 8, 0));
|
||||
|
||||
RipplerContainer container = new RipplerContainer(root);
|
||||
this.graphic = container;
|
||||
|
||||
{
|
||||
this.chkSelected = new JFXRadioButton() {
|
||||
@Override
|
||||
public void fire() {
|
||||
if (!isDisable() && !isSelected()) {
|
||||
fireEvent(new ActionEvent());
|
||||
GameListItem item = GameListCell.this.getItem();
|
||||
if (item != null) {
|
||||
item.getProfile().setSelectedVersion(item.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
root.setLeft(chkSelected);
|
||||
BorderPane.setAlignment(chkSelected, Pos.CENTER);
|
||||
}
|
||||
|
||||
{
|
||||
HBox center = new HBox();
|
||||
center.setMouseTransparent(true);
|
||||
root.setCenter(center);
|
||||
center.setPrefWidth(Region.USE_PREF_SIZE);
|
||||
center.setSpacing(8);
|
||||
center.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
this.imageView = new ImageView();
|
||||
FXUtils.limitSize(imageView, 32, 32);
|
||||
|
||||
this.content = new TwoLineListItem();
|
||||
BorderPane.setAlignment(content, Pos.CENTER);
|
||||
|
||||
FXUtils.onChangeAndOperate(tag, tag -> {
|
||||
content.getTags().clear();
|
||||
if (StringUtils.isNotBlank(tag))
|
||||
content.addTag(tag);
|
||||
});
|
||||
|
||||
center.getChildren().setAll(imageView, content);
|
||||
}
|
||||
|
||||
{
|
||||
this.right = new HBox();
|
||||
root.setRight(right);
|
||||
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
this.btnUpgrade = new JFXButton();
|
||||
btnUpgrade.setOnAction(e -> {
|
||||
GameListItem item = this.getItem();
|
||||
if (item != null)
|
||||
item.update();
|
||||
});
|
||||
btnUpgrade.getStyleClass().add("toggle-icon4");
|
||||
btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(btnUpgrade, i18n("version.update"));
|
||||
right.getChildren().add(btnUpgrade);
|
||||
|
||||
this.btnLaunch = new JFXButton();
|
||||
btnLaunch.setOnAction(e -> {
|
||||
GameListItem item = this.getItem();
|
||||
if (item != null)
|
||||
item.testGame();
|
||||
});
|
||||
btnLaunch.getStyleClass().add("toggle-icon4");
|
||||
BorderPane.setAlignment(btnLaunch, Pos.CENTER);
|
||||
btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test"));
|
||||
right.getChildren().add(btnLaunch);
|
||||
|
||||
this.btnManage = new JFXButton();
|
||||
btnManage.setOnAction(e -> {
|
||||
GameListItem item = this.getItem();
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
JFXPopup popup = getPopup(item);
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
|
||||
popup.show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
|
||||
});
|
||||
btnManage.getStyleClass().add("toggle-icon4");
|
||||
BorderPane.setAlignment(btnManage, Pos.CENTER);
|
||||
btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
|
||||
right.getChildren().add(btnManage);
|
||||
}
|
||||
|
||||
root.setCursor(Cursor.HAND);
|
||||
container.setOnMouseClicked(e -> {
|
||||
GameListItem item = getItem();
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
if (e.getButton() == MouseButton.PRIMARY) {
|
||||
if (e.getClickCount() == 1) {
|
||||
item.modifyGameSettings();
|
||||
}
|
||||
} else if (e.getButton() == MouseButton.SECONDARY) {
|
||||
JFXPopup popup = getPopup(item);
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
|
||||
popup.show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateItem(GameListItem item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
this.imageView.imageProperty().unbind();
|
||||
this.content.titleProperty().unbind();
|
||||
this.content.subtitleProperty().unbind();
|
||||
this.tag.unbind();
|
||||
this.right.getChildren().clear();
|
||||
this.chkSelected.selectedProperty().unbind();
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setGraphic(this.graphic);
|
||||
|
||||
this.chkSelected.selectedProperty().bind(item.selectedProperty());
|
||||
this.imageView.imageProperty().bind(item.imageProperty());
|
||||
this.content.titleProperty().bind(item.titleProperty());
|
||||
this.content.subtitleProperty().bind(item.subtitleProperty());
|
||||
this.tag.bind(item.tagProperty());
|
||||
if (item.canUpdate())
|
||||
this.right.getChildren().add(btnUpgrade);
|
||||
this.right.getChildren().addAll(btnLaunch, btnManage);
|
||||
}
|
||||
}
|
||||
|
||||
private static JFXPopup getPopup(GameListItem item) {
|
||||
PopupMenu menu = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(menu);
|
||||
|
||||
menu.getContent().setAll(
|
||||
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), item::testGame, popup),
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), item::modifyGameSettings, popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), item::rename, popup),
|
||||
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), item::duplicate, popup),
|
||||
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), item::remove, popup),
|
||||
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), item::export, popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
|
||||
return popup;
|
||||
}
|
||||
}
|
||||
@@ -18,87 +18,58 @@
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
|
||||
public class GameListItem extends Control {
|
||||
private final Profile profile;
|
||||
private final String version;
|
||||
public class GameListItem extends GameItem {
|
||||
private final boolean isModpack;
|
||||
private final ToggleGroup toggleGroup;
|
||||
private final BooleanProperty selected = new SimpleBooleanProperty();
|
||||
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
|
||||
|
||||
public GameListItem(ToggleGroup toggleGroup, Profile profile, String id) {
|
||||
this.profile = profile;
|
||||
this.version = id;
|
||||
this.toggleGroup = toggleGroup;
|
||||
public GameListItem(Profile profile, String id) {
|
||||
super(profile, id);
|
||||
this.isModpack = profile.getRepository().isModpack(id);
|
||||
|
||||
selected.set(id.equals(profile.getSelectedVersion()));
|
||||
selected.bind(profile.selectedVersionProperty().isEqualTo(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new GameListItemSkin(this);
|
||||
}
|
||||
|
||||
public ToggleGroup getToggleGroup() {
|
||||
return toggleGroup;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public BooleanProperty selectedProperty() {
|
||||
public ReadOnlyBooleanProperty selectedProperty() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
public void checkSelection() {
|
||||
selected.set(version.equals(profile.getSelectedVersion()));
|
||||
}
|
||||
|
||||
public void rename() {
|
||||
Versions.renameVersion(profile, version);
|
||||
Versions.renameVersion(profile, id);
|
||||
}
|
||||
|
||||
public void duplicate() {
|
||||
Versions.duplicateVersion(profile, version);
|
||||
Versions.duplicateVersion(profile, id);
|
||||
}
|
||||
|
||||
public void remove() {
|
||||
Versions.deleteVersion(profile, version);
|
||||
Versions.deleteVersion(profile, id);
|
||||
}
|
||||
|
||||
public void export() {
|
||||
Versions.exportVersion(profile, version);
|
||||
Versions.exportVersion(profile, id);
|
||||
}
|
||||
|
||||
public void browse() {
|
||||
Versions.openFolder(profile, version);
|
||||
Versions.openFolder(profile, id);
|
||||
}
|
||||
|
||||
public void testGame() {
|
||||
Versions.testGame(profile, version);
|
||||
Versions.testGame(profile, id);
|
||||
}
|
||||
|
||||
public void launch() {
|
||||
Versions.launch(profile, version);
|
||||
Versions.launch(profile, id);
|
||||
}
|
||||
|
||||
public void modifyGameSettings() {
|
||||
Versions.modifyGameSettings(profile, version);
|
||||
Versions.modifyGameSettings(profile, id);
|
||||
}
|
||||
|
||||
public void generateLaunchScript() {
|
||||
Versions.generateLaunchScript(profile, version);
|
||||
Versions.generateLaunchScript(profile, id);
|
||||
}
|
||||
|
||||
public boolean canUpdate() {
|
||||
@@ -106,6 +77,6 @@ public class GameListItem extends Control {
|
||||
}
|
||||
|
||||
public void update() {
|
||||
Versions.updateVersion(profile, version);
|
||||
Versions.updateVersion(profile, id);
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import com.jfoenix.controls.JFXRadioButton;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
|
||||
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
|
||||
import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class GameListItemSkin extends SkinBase<GameListItem> {
|
||||
private static GameListItem currentSkinnable;
|
||||
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> {
|
||||
PopupMenu menu = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(menu);
|
||||
|
||||
menu.getContent().setAll(
|
||||
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), () -> currentSkinnable.testGame(), popup),
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> currentSkinnable.generateLaunchScript(), popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), () -> currentSkinnable.modifyGameSettings(), popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), () -> currentSkinnable.rename(), popup),
|
||||
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), () -> currentSkinnable.duplicate(), popup),
|
||||
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), () -> currentSkinnable.remove(), popup),
|
||||
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), () -> currentSkinnable.export(), popup),
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), () -> currentSkinnable.browse(), popup));
|
||||
return popup;
|
||||
});
|
||||
|
||||
public GameListItemSkin(GameListItem skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
|
||||
JFXRadioButton chkSelected = new JFXRadioButton();
|
||||
BorderPane.setAlignment(chkSelected, Pos.CENTER);
|
||||
chkSelected.setUserData(skinnable);
|
||||
chkSelected.selectedProperty().bindBidirectional(skinnable.selectedProperty());
|
||||
chkSelected.setToggleGroup(skinnable.getToggleGroup());
|
||||
root.setLeft(chkSelected);
|
||||
|
||||
GameItem gameItem = new GameItem(skinnable.getProfile(), skinnable.getVersion());
|
||||
gameItem.setMouseTransparent(true);
|
||||
root.setCenter(gameItem);
|
||||
|
||||
HBox right = new HBox();
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
if (skinnable.canUpdate()) {
|
||||
JFXButton btnUpgrade = new JFXButton();
|
||||
btnUpgrade.setOnAction(e -> skinnable.update());
|
||||
btnUpgrade.getStyleClass().add("toggle-icon4");
|
||||
btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(btnUpgrade, i18n("version.update"));
|
||||
right.getChildren().add(btnUpgrade);
|
||||
}
|
||||
|
||||
{
|
||||
JFXButton btnLaunch = new JFXButton();
|
||||
btnLaunch.setOnAction(e -> skinnable.testGame());
|
||||
btnLaunch.getStyleClass().add("toggle-icon4");
|
||||
BorderPane.setAlignment(btnLaunch, Pos.CENTER);
|
||||
btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test"));
|
||||
right.getChildren().add(btnLaunch);
|
||||
}
|
||||
|
||||
{
|
||||
JFXButton btnManage = new JFXButton();
|
||||
btnManage.setOnAction(e -> {
|
||||
currentSkinnable = skinnable;
|
||||
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get());
|
||||
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
|
||||
});
|
||||
btnManage.getStyleClass().add("toggle-icon4");
|
||||
BorderPane.setAlignment(btnManage, Pos.CENTER);
|
||||
btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(24), 24, 24));
|
||||
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
|
||||
right.getChildren().add(btnManage);
|
||||
}
|
||||
|
||||
root.setRight(right);
|
||||
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setStyle("-fx-padding: 8 8 8 0");
|
||||
|
||||
RipplerContainer container = new RipplerContainer(root);
|
||||
getChildren().setAll(container);
|
||||
|
||||
root.setCursor(Cursor.HAND);
|
||||
container.setOnMouseClicked(e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY) {
|
||||
if (e.getClickCount() == 1) {
|
||||
skinnable.modifyGameSettings();
|
||||
}
|
||||
} else if (e.getButton() == MouseButton.SECONDARY) {
|
||||
currentSkinnable = skinnable;
|
||||
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get());
|
||||
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,16 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
@@ -36,13 +36,12 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.profile.ProfileListItem;
|
||||
import org.jackhuang.hmcl.ui.profile.ProfilePage;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
|
||||
|
||||
@@ -53,8 +52,6 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
private final ObservableList<ProfileListItem> profileListItems;
|
||||
private final ObjectProperty<Profile> selectedProfile;
|
||||
|
||||
private ToggleGroup toggleGroup;
|
||||
|
||||
public GameListPage() {
|
||||
profileListItems = MappedObservableList.create(profilesProperty(), profile -> {
|
||||
ProfileListItem item = new ProfileListItem(profile);
|
||||
@@ -123,50 +120,31 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
return state.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
private class GameList extends ListPageBase<GameListItem> {
|
||||
public GameList() {
|
||||
super();
|
||||
private static class GameList extends ListPageBase<GameListItem> {
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
public GameList() {
|
||||
Profiles.registerVersionsListener(this::loadVersions);
|
||||
|
||||
setOnFailedAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
|
||||
}
|
||||
|
||||
@FXThread
|
||||
private void loadVersions(Profile profile) {
|
||||
listenerHolder.clear();
|
||||
setLoading(true);
|
||||
setFailedReason(null);
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
toggleGroup = new ToggleGroup();
|
||||
WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
toggleGroup.getProperties().put("ReferenceHolder", listenerHolder);
|
||||
runInFX(() -> {
|
||||
if (profile == Profiles.getSelectedProfile()) {
|
||||
setLoading(false);
|
||||
List<GameListItem> children = repository.getDisplayVersions()
|
||||
.map(version -> new GameListItem(toggleGroup, profile, version.getId()))
|
||||
.collect(Collectors.toList());
|
||||
itemsProperty().setAll(children);
|
||||
children.forEach(GameListItem::checkSelection);
|
||||
if (profile != Profiles.getSelectedProfile())
|
||||
return;
|
||||
|
||||
if (children.isEmpty()) {
|
||||
setFailedReason(i18n("version.empty.hint"));
|
||||
}
|
||||
|
||||
profile.selectedVersionProperty().addListener(listenerHolder.weak((a, b, newValue) -> {
|
||||
FXUtils.checkFxUserThread();
|
||||
children.forEach(it -> it.selectedProperty().set(false));
|
||||
children.stream()
|
||||
.filter(it -> it.getVersion().equals(newValue))
|
||||
.findFirst()
|
||||
.ifPresent(it -> it.selectedProperty().set(true));
|
||||
}));
|
||||
}
|
||||
toggleGroup.selectedToggleProperty().addListener((o, a, toggle) -> {
|
||||
if (toggle == null) return;
|
||||
GameListItem model = (GameListItem) toggle.getUserData();
|
||||
model.getProfile().setSelectedVersion(model.getVersion());
|
||||
});
|
||||
});
|
||||
ObservableList<GameListItem> children = FXCollections.observableList(profile.getRepository().getDisplayVersions()
|
||||
.map(instance -> new GameListItem(profile, instance.getId()))
|
||||
.toList());
|
||||
setItems(children);
|
||||
if (children.isEmpty()) {
|
||||
setFailedReason(i18n("version.empty.hint"));
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
public void refreshList() {
|
||||
@@ -178,7 +156,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
return new GameListSkin();
|
||||
}
|
||||
|
||||
private class GameListSkin extends ToolbarListPageSkin<GameList> {
|
||||
private class GameListSkin extends ToolbarListPageSkin<GameListItem, GameList> {
|
||||
|
||||
public GameListSkin() {
|
||||
super(GameList.this);
|
||||
@@ -188,6 +166,11 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
protected List<Node> initializeToolbar(GameList skinnable) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ListCell<GameListItem> createListCell(JFXListView<GameListItem> listView) {
|
||||
return new GameListCell();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.versions;
|
||||
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// @author Glavo
|
||||
public final class GameListPopupMenu extends StackPane {
|
||||
private final JFXListView<GameItem> listView = new JFXListView<>();
|
||||
private final BooleanBinding isEmpty = Bindings.isEmpty(listView.getItems());
|
||||
|
||||
public GameListPopupMenu() {
|
||||
this.setMaxHeight(365);
|
||||
this.getStyleClass().add("popup-menu-content");
|
||||
|
||||
listView.setCellFactory(Cell::new);
|
||||
listView.setFixedCellSize(60);
|
||||
listView.setPrefWidth(300);
|
||||
|
||||
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(60).add(2));
|
||||
|
||||
Label placeholder = new Label(i18n("version.empty"));
|
||||
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
|
||||
|
||||
FXUtils.onChangeAndOperate(isEmpty, empty -> {
|
||||
getChildren().setAll(empty ? placeholder : listView);
|
||||
});
|
||||
}
|
||||
|
||||
public ObservableList<GameItem> getItems() {
|
||||
return listView.getItems();
|
||||
}
|
||||
|
||||
private static final class Cell extends ListCell<GameItem> {
|
||||
|
||||
private final Region graphic;
|
||||
|
||||
private final ImageView imageView;
|
||||
private final TwoLineListItem content;
|
||||
|
||||
private final StringProperty tag = new SimpleStringProperty();
|
||||
|
||||
public Cell(ListView<GameItem> listView) {
|
||||
this.setPadding(Insets.EMPTY);
|
||||
HBox root = new HBox();
|
||||
|
||||
root.setSpacing(8);
|
||||
root.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
StackPane imageViewContainer = new StackPane();
|
||||
FXUtils.setLimitWidth(imageViewContainer, 32);
|
||||
FXUtils.setLimitHeight(imageViewContainer, 32);
|
||||
|
||||
this.imageView = new ImageView();
|
||||
FXUtils.limitSize(imageView, 32, 32);
|
||||
imageViewContainer.getChildren().setAll(imageView);
|
||||
|
||||
this.content = new TwoLineListItem();
|
||||
FXUtils.onChangeAndOperate(tag, tag -> {
|
||||
content.getTags().clear();
|
||||
if (StringUtils.isNotBlank(tag)) {
|
||||
content.addTag(tag);
|
||||
}
|
||||
});
|
||||
BorderPane.setAlignment(content, Pos.CENTER);
|
||||
root.getChildren().setAll(imageView, content);
|
||||
|
||||
StackPane pane = new StackPane();
|
||||
pane.getChildren().setAll(root);
|
||||
pane.getStyleClass().add("menu-container");
|
||||
root.setMouseTransparent(true);
|
||||
|
||||
RipplerContainer ripplerContainer = new RipplerContainer(pane);
|
||||
FXUtils.onClicked(ripplerContainer, () -> {
|
||||
GameItem item = getItem();
|
||||
if (item != null) {
|
||||
item.getProfile().setSelectedVersion(item.getId());
|
||||
if (getScene().getWindow() instanceof JFXPopup popup)
|
||||
popup.hide();
|
||||
}
|
||||
});
|
||||
this.graphic = ripplerContainer;
|
||||
ripplerContainer.maxWidthProperty().bind(listView.widthProperty().subtract(5));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(GameItem item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
this.imageView.imageProperty().unbind();
|
||||
this.content.titleProperty().unbind();
|
||||
this.content.subtitleProperty().unbind();
|
||||
this.tag.unbind();
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setGraphic(this.graphic);
|
||||
|
||||
this.imageView.imageProperty().bind(item.imageProperty());
|
||||
this.content.titleProperty().bind(item.titleProperty());
|
||||
this.content.subtitleProperty().bind(item.subtitleProperty());
|
||||
this.tag.bind(item.tagProperty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ve
|
||||
executor.start();
|
||||
}
|
||||
|
||||
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerListPage> {
|
||||
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
|
||||
|
||||
InstallerListPageSkin() {
|
||||
super(InstallerListPage.this);
|
||||
|
||||
@@ -229,12 +229,13 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
FXUtils.openFolder(profile.getRepository().getRunDirectory(instanceId).resolve("mods"));
|
||||
}
|
||||
|
||||
public void checkUpdates() {
|
||||
public void checkUpdates(Collection<LocalModFile> mods) {
|
||||
Objects.requireNonNull(mods);
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
Optional<String> gameVersion = profile.getRepository().getGameVersion(instanceId);
|
||||
if (gameVersion.isPresent()) {
|
||||
return new ModCheckUpdatesTask(gameVersion.get(), modManager.getMods());
|
||||
return new ModCheckUpdatesTask(gameVersion.get(), mods);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
|
||||
@@ -141,7 +141,13 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
|
||||
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, skinnable::checkUpdates),
|
||||
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getItems().stream()
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
createToolbarButton2(i18n("download"), SVG.DOWNLOAD, skinnable::download),
|
||||
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
|
||||
);
|
||||
@@ -157,6 +163,13 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getSelectionModel().getSelectedItems().stream()
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
|
||||
listView.getSelectionModel().selectAll()),
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
|
||||
@@ -599,7 +599,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
}
|
||||
}
|
||||
|
||||
private final class SchematicsPageSkin extends ToolbarListPageSkin<SchematicsPage> {
|
||||
private final class SchematicsPageSkin extends ToolbarListPageSkin<Item, SchematicsPage> {
|
||||
SchematicsPageSkin() {
|
||||
super(SchematicsPage.this);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ public final class Versions {
|
||||
i18n("install.failed.downloading.detail", file.getFile().getUrl()) + "\n" + StringUtils.getStackTrace(e),
|
||||
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
}).executor(true),
|
||||
}),
|
||||
i18n("message.downloading"),
|
||||
TaskCancellationAction.NORMAL
|
||||
);
|
||||
@@ -130,6 +130,10 @@ public final class Versions {
|
||||
|
||||
public static CompletableFuture<String> renameVersion(Profile profile, String version) {
|
||||
return Controllers.prompt(i18n("version.manage.rename.message"), (newName, resolve, reject) -> {
|
||||
if (newName.equals(version)) {
|
||||
resolve.run();
|
||||
return;
|
||||
}
|
||||
if (profile.getRepository().renameVersion(version, newName)) {
|
||||
resolve.run();
|
||||
profile.getRepository().refreshVersionsAsync()
|
||||
@@ -142,7 +146,7 @@ public final class Versions {
|
||||
reject.accept(i18n("version.manage.rename.fail"));
|
||||
}
|
||||
}, version, new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId),
|
||||
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName)));
|
||||
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName) || newVersionName.equals(version)));
|
||||
}
|
||||
|
||||
public static void exportVersion(Profile profile, String version) {
|
||||
|
||||
@@ -155,7 +155,7 @@ public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.Backup
|
||||
}), i18n("world.backup"), null);
|
||||
}
|
||||
|
||||
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<WorldBackupsPage> {
|
||||
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
|
||||
|
||||
WorldBackupsPageSkin() {
|
||||
super(WorldBackupsPage.this);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.github.steveice10.opennbt.tag.builtin.*;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
@@ -28,27 +29,36 @@ import javafx.scene.Cursor;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.effect.BoxBlur;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.glavo.png.javafx.PNGJavaFXUtils;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
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.jetbrains.annotations.PropertyKey;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.DecimalFormat;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
@@ -58,6 +68,8 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
private final World world;
|
||||
private CompoundTag levelDat;
|
||||
|
||||
ImageView iconImageView = new ImageView();
|
||||
|
||||
public WorldInfoPage(WorldManagePage worldManagePage) {
|
||||
this.worldManagePage = worldManagePage;
|
||||
this.world = worldManagePage.getWorld();
|
||||
@@ -80,7 +92,7 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
if (!Files.isDirectory(world.getFile()))
|
||||
throw new IOException("Not a valid world directory");
|
||||
|
||||
return world.readLevelDat();
|
||||
return world.getLevelData();
|
||||
}
|
||||
|
||||
private void updateControls() {
|
||||
@@ -103,98 +115,132 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
{
|
||||
BorderPane worldNamePane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.name"));
|
||||
worldNamePane.setLeft(label);
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
setLeftLabel(worldNamePane, "world.name");
|
||||
JFXTextField worldNameField = new JFXTextField();
|
||||
setRightTextField(worldNamePane, worldNameField, 200);
|
||||
|
||||
Label worldNameLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(worldNameLabel);
|
||||
worldNameLabel.setText(world.getWorldName());
|
||||
BorderPane.setAlignment(worldNameLabel, Pos.CENTER_RIGHT);
|
||||
worldNamePane.setRight(worldNameLabel);
|
||||
Tag tag = dataTag.get("LevelName");
|
||||
if (tag instanceof StringTag stringTag) {
|
||||
worldNameField.setText(stringTag.getValue());
|
||||
|
||||
worldNameField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
world.setWorldName(newValue);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to set world name", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
worldNameField.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
BorderPane gameVersionPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.game_version"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
gameVersionPane.setLeft(label);
|
||||
|
||||
setLeftLabel(gameVersionPane, "world.info.game_version");
|
||||
Label gameVersionLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(gameVersionLabel);
|
||||
if (world.getGameVersion() != null)
|
||||
gameVersionLabel.setText(world.getGameVersion().toNormalizedString());
|
||||
BorderPane.setAlignment(gameVersionLabel, Pos.CENTER_RIGHT);
|
||||
gameVersionPane.setRight(gameVersionLabel);
|
||||
setRightTextLabel(gameVersionPane, gameVersionLabel, () -> world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString());
|
||||
}
|
||||
|
||||
BorderPane randomSeedPane = new BorderPane();
|
||||
BorderPane iconPane = new BorderPane();
|
||||
{
|
||||
setLeftLabel(iconPane, "world.icon");
|
||||
|
||||
HBox left = new HBox(8);
|
||||
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
|
||||
left.setAlignment(Pos.CENTER_LEFT);
|
||||
randomSeedPane.setLeft(left);
|
||||
Runnable onClickAction = () -> Controllers.confirm(
|
||||
i18n("world.icon.change.tip"), i18n("world.icon.change"), MessageDialogPane.MessageType.INFO,
|
||||
this::changeWorldIcon,
|
||||
null
|
||||
);
|
||||
|
||||
Label label = new Label(i18n("world.info.random_seed"));
|
||||
FXUtils.limitSize(iconImageView, 32, 32);
|
||||
{
|
||||
iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
|
||||
}
|
||||
|
||||
JFXButton editIconButton = new JFXButton();
|
||||
JFXButton resetIconButton = new JFXButton();
|
||||
{
|
||||
editIconButton.setGraphic(SVG.EDIT.createIcon(20));
|
||||
editIconButton.setDisable(worldManagePage.isReadOnly());
|
||||
FXUtils.onClicked(editIconButton, onClickAction);
|
||||
FXUtils.installFastTooltip(editIconButton, i18n("button.edit"));
|
||||
editIconButton.getStyleClass().add("toggle-icon4");
|
||||
|
||||
resetIconButton.setGraphic(SVG.RESTORE.createIcon(20));
|
||||
resetIconButton.setDisable(worldManagePage.isReadOnly());
|
||||
FXUtils.onClicked(resetIconButton, this::clearWorldIcon);
|
||||
FXUtils.installFastTooltip(resetIconButton, i18n("button.reset"));
|
||||
resetIconButton.getStyleClass().add("toggle-icon4");
|
||||
}
|
||||
|
||||
HBox hBox = new HBox(8);
|
||||
hBox.setAlignment(Pos.CENTER_LEFT);
|
||||
hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton);
|
||||
|
||||
iconPane.setRight(hBox);
|
||||
}
|
||||
|
||||
BorderPane seedPane = new BorderPane();
|
||||
{
|
||||
setLeftLabel(seedPane, "world.info.random_seed");
|
||||
|
||||
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
|
||||
StackPane visibilityButton = new StackPane();
|
||||
visibilityButton.setCursor(Cursor.HAND);
|
||||
FXUtils.setLimitWidth(visibilityButton, 12);
|
||||
FXUtils.setLimitHeight(visibilityButton, 12);
|
||||
FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get()));
|
||||
|
||||
left.getChildren().setAll(label, visibilityButton);
|
||||
|
||||
Label randomSeedLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(randomSeedLabel);
|
||||
BorderPane.setAlignment(randomSeedLabel, Pos.CENTER_RIGHT);
|
||||
randomSeedPane.setRight(randomSeedLabel);
|
||||
|
||||
Tag tag = worldGenSettings != null ? worldGenSettings.get("seed") : dataTag.get("RandomSeed");
|
||||
if (tag instanceof LongTag) {
|
||||
randomSeedLabel.setText(tag.getValue().toString());
|
||||
{
|
||||
visibilityButton.setCursor(Cursor.HAND);
|
||||
visibilityButton.setAlignment(Pos.BOTTOM_RIGHT);
|
||||
FXUtils.setLimitWidth(visibilityButton, 12);
|
||||
FXUtils.setLimitHeight(visibilityButton, 12);
|
||||
FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get()));
|
||||
}
|
||||
|
||||
BoxBlur blur = new BoxBlur();
|
||||
blur.setIterations(3);
|
||||
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
|
||||
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
|
||||
visibilityButton.getChildren().setAll(icon.createIcon(12));
|
||||
randomSeedLabel.setEffect(isVisibility ? null : blur);
|
||||
});
|
||||
Label seedLabel = new Label();
|
||||
{
|
||||
FXUtils.copyOnDoubleClick(seedLabel);
|
||||
seedLabel.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : "");
|
||||
|
||||
BoxBlur blur = new BoxBlur();
|
||||
blur.setIterations(3);
|
||||
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
|
||||
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
|
||||
visibilityButton.getChildren().setAll(icon.createIcon(12));
|
||||
seedLabel.setEffect(isVisibility ? null : blur);
|
||||
});
|
||||
}
|
||||
|
||||
HBox right = new HBox(8);
|
||||
{
|
||||
BorderPane.setAlignment(right, Pos.CENTER_RIGHT);
|
||||
right.getChildren().setAll(visibilityButton, seedLabel);
|
||||
seedPane.setRight(right);
|
||||
}
|
||||
}
|
||||
|
||||
BorderPane lastPlayedPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.last_played"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
lastPlayedPane.setLeft(label);
|
||||
|
||||
setLeftLabel(lastPlayedPane, "world.info.last_played");
|
||||
Label lastPlayedLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(lastPlayedLabel);
|
||||
lastPlayedLabel.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
|
||||
BorderPane.setAlignment(lastPlayedLabel, Pos.CENTER_RIGHT);
|
||||
lastPlayedPane.setRight(lastPlayedLabel);
|
||||
setRightTextLabel(lastPlayedPane, lastPlayedLabel, () -> formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
|
||||
}
|
||||
|
||||
BorderPane timePane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.time"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
timePane.setLeft(label);
|
||||
setLeftLabel(timePane, "world.info.time");
|
||||
|
||||
Label timeLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(timeLabel);
|
||||
BorderPane.setAlignment(timeLabel, Pos.CENTER_RIGHT);
|
||||
timePane.setRight(timeLabel);
|
||||
|
||||
Tag tag = dataTag.get("Time");
|
||||
if (tag instanceof LongTag) {
|
||||
long days = ((LongTag) tag).getValue() / 24000;
|
||||
timeLabel.setText(i18n("world.info.time.format", days));
|
||||
}
|
||||
setRightTextLabel(timePane, timeLabel, () -> {
|
||||
Tag tag = dataTag.get("Time");
|
||||
if (tag instanceof LongTag) {
|
||||
long days = ((LongTag) tag).getValue() / 24000;
|
||||
return i18n("world.info.time.format", days);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
OptionToggleButton allowCheatsButton = new OptionToggleButton();
|
||||
@@ -203,21 +249,7 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
allowCheatsButton.setDisable(worldManagePage.isReadOnly());
|
||||
Tag tag = dataTag.get("allowCommands");
|
||||
|
||||
if (tag instanceof ByteTag) {
|
||||
ByteTag byteTag = (ByteTag) tag;
|
||||
byte value = byteTag.getValue();
|
||||
if (value == 0 || value == 1) {
|
||||
allowCheatsButton.setSelected(value == 1);
|
||||
allowCheatsButton.selectedProperty().addListener((o, oldValue, newValue) -> {
|
||||
byteTag.setValue(newValue ? (byte) 1 : (byte) 0);
|
||||
saveLevelDat();
|
||||
});
|
||||
} else {
|
||||
allowCheatsButton.setDisable(true);
|
||||
}
|
||||
} else {
|
||||
allowCheatsButton.setDisable(true);
|
||||
}
|
||||
checkTagAndSetListener(tag, allowCheatsButton);
|
||||
}
|
||||
|
||||
OptionToggleButton generateFeaturesButton = new OptionToggleButton();
|
||||
@@ -226,28 +258,12 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
generateFeaturesButton.setDisable(worldManagePage.isReadOnly());
|
||||
Tag tag = worldGenSettings != null ? worldGenSettings.get("generate_features") : dataTag.get("MapFeatures");
|
||||
|
||||
if (tag instanceof ByteTag) {
|
||||
ByteTag byteTag = (ByteTag) tag;
|
||||
byte value = byteTag.getValue();
|
||||
if (value == 0 || value == 1) {
|
||||
generateFeaturesButton.setSelected(value == 1);
|
||||
generateFeaturesButton.selectedProperty().addListener((o, oldValue, newValue) -> {
|
||||
byteTag.setValue(newValue ? (byte) 1 : (byte) 0);
|
||||
saveLevelDat();
|
||||
});
|
||||
} else {
|
||||
generateFeaturesButton.setDisable(true);
|
||||
}
|
||||
} else {
|
||||
generateFeaturesButton.setDisable(true);
|
||||
}
|
||||
checkTagAndSetListener(tag, generateFeaturesButton);
|
||||
}
|
||||
|
||||
BorderPane difficultyPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.difficulty"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
difficultyPane.setLeft(label);
|
||||
setLeftLabel(difficultyPane, "world.info.difficulty");
|
||||
|
||||
JFXComboBox<Difficulty> difficultyBox = new JFXComboBox<>(Difficulty.items);
|
||||
difficultyBox.setDisable(worldManagePage.isReadOnly());
|
||||
@@ -255,8 +271,7 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
difficultyPane.setRight(difficultyBox);
|
||||
|
||||
Tag tag = dataTag.get("Difficulty");
|
||||
if (tag instanceof ByteTag) {
|
||||
ByteTag byteTag = (ByteTag) tag;
|
||||
if (tag instanceof ByteTag byteTag) {
|
||||
Difficulty difficulty = Difficulty.of(byteTag.getValue());
|
||||
if (difficulty != null) {
|
||||
difficultyBox.setValue(difficulty);
|
||||
@@ -274,86 +289,93 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
}
|
||||
}
|
||||
|
||||
OptionToggleButton difficultyLockPane = new OptionToggleButton();
|
||||
{
|
||||
difficultyLockPane.setTitle(i18n("world.info.difficulty_lock"));
|
||||
difficultyLockPane.setDisable(worldManagePage.isReadOnly());
|
||||
|
||||
Tag tag = dataTag.get("DifficultyLocked");
|
||||
checkTagAndSetListener(tag, difficultyLockPane);
|
||||
}
|
||||
|
||||
basicInfo.getContent().setAll(
|
||||
worldNamePane, gameVersionPane, randomSeedPane, lastPlayedPane, timePane,
|
||||
allowCheatsButton, generateFeaturesButton, difficultyPane);
|
||||
worldNamePane, gameVersionPane, iconPane, seedPane, lastPlayedPane, timePane,
|
||||
allowCheatsButton, generateFeaturesButton, difficultyPane, difficultyLockPane);
|
||||
|
||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.basic")), basicInfo);
|
||||
}
|
||||
|
||||
Tag playerTag = dataTag.get("Player");
|
||||
if (playerTag instanceof CompoundTag) {
|
||||
CompoundTag player = (CompoundTag) playerTag;
|
||||
if (playerTag instanceof CompoundTag player) {
|
||||
ComponentList playerInfo = new ComponentList();
|
||||
|
||||
BorderPane locationPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.player.location"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
locationPane.setLeft(label);
|
||||
|
||||
setLeftLabel(locationPane, "world.info.player.location");
|
||||
Label locationLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(locationLabel);
|
||||
BorderPane.setAlignment(locationLabel, Pos.CENTER_RIGHT);
|
||||
locationPane.setRight(locationLabel);
|
||||
|
||||
Dimension dim = Dimension.of(player.get("Dimension"));
|
||||
if (dim != null) {
|
||||
String posString = dim.formatPosition(player.get("Pos"));
|
||||
if (posString != null)
|
||||
locationLabel.setText(posString);
|
||||
}
|
||||
setRightTextLabel(locationPane, locationLabel, () -> {
|
||||
Dimension dim = Dimension.of(player.get("Dimension"));
|
||||
if (dim != null) {
|
||||
String posString = dim.formatPosition(player.get("Pos"));
|
||||
if (posString != null)
|
||||
return posString;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
BorderPane lastDeathLocationPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.player.last_death_location"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
lastDeathLocationPane.setLeft(label);
|
||||
|
||||
setLeftLabel(lastDeathLocationPane, "world.info.player.last_death_location");
|
||||
Label lastDeathLocationLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(lastDeathLocationLabel);
|
||||
BorderPane.setAlignment(lastDeathLocationLabel, Pos.CENTER_RIGHT);
|
||||
lastDeathLocationPane.setRight(lastDeathLocationLabel);
|
||||
|
||||
Tag tag = player.get("LastDeathLocation");
|
||||
if (tag instanceof CompoundTag) {
|
||||
Dimension dim = Dimension.of(((CompoundTag) tag).get("dimension"));
|
||||
if (dim != null) {
|
||||
String posString = dim.formatPosition(((CompoundTag) tag).get("pos"));
|
||||
if (posString != null)
|
||||
lastDeathLocationLabel.setText(posString);
|
||||
setRightTextLabel(lastDeathLocationPane, lastDeathLocationLabel, () -> {
|
||||
Tag tag = player.get("LastDeathLocation");// Valid after 22w14a; prior to this version, the game did not record the last death location data.
|
||||
if (tag instanceof CompoundTag compoundTag) {
|
||||
Dimension dim = Dimension.of(compoundTag.get("dimension"));
|
||||
if (dim != null) {
|
||||
String posString = dim.formatPosition(compoundTag.get("pos"));
|
||||
if (posString != null)
|
||||
return posString;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
BorderPane spawnPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.player.spawn"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
spawnPane.setLeft(label);
|
||||
|
||||
setLeftLabel(spawnPane, "world.info.player.spawn");
|
||||
Label spawnLabel = new Label();
|
||||
FXUtils.copyOnDoubleClick(spawnLabel);
|
||||
BorderPane.setAlignment(spawnLabel, Pos.CENTER_RIGHT);
|
||||
spawnPane.setRight(spawnLabel);
|
||||
setRightTextLabel(spawnPane, spawnLabel, () -> {
|
||||
|
||||
Dimension dim = Dimension.of(player.get("SpawnDimension"));
|
||||
if (dim != null) {
|
||||
Tag x = player.get("SpawnX");
|
||||
Tag y = player.get("SpawnY");
|
||||
Tag z = player.get("SpawnZ");
|
||||
Dimension dimension;
|
||||
if (player.get("respawn") instanceof CompoundTag respawnTag && respawnTag.get("dimension") != null) { // Valid after 25w07a
|
||||
dimension = Dimension.of(respawnTag.get("dimension"));
|
||||
Tag posTag = respawnTag.get("pos");
|
||||
|
||||
if (x instanceof IntTag && y instanceof IntTag && z instanceof IntTag)
|
||||
spawnLabel.setText(dim.formatPosition(((IntTag) x).getValue(), ((IntTag) y).getValue(), ((IntTag) z).getValue()));
|
||||
}
|
||||
if (posTag instanceof IntArrayTag intArrayTag && intArrayTag.length() >= 3) {
|
||||
return dimension.formatPosition(intArrayTag.getValue(0), intArrayTag.getValue(1), intArrayTag.getValue(2));
|
||||
}
|
||||
} else if (player.get("SpawnX") instanceof IntTag intX
|
||||
&& player.get("SpawnY") instanceof IntTag intY
|
||||
&& player.get("SpawnZ") instanceof IntTag intZ) { // Valid before 25w07a
|
||||
// SpawnDimension tag is valid after 20w12a. Prior to this version, the game did not record the respawn point dimension and respawned in the Overworld.
|
||||
dimension = Dimension.of(player.get("SpawnDimension") == null ? new IntTag("SpawnDimension", 0) : player.get("SpawnDimension"));
|
||||
if (dimension == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return dimension.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue());
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
BorderPane playerGameTypePane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.player.game_type"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
playerGameTypePane.setLeft(label);
|
||||
setLeftLabel(playerGameTypePane, "world.info.player.game_type");
|
||||
|
||||
JFXComboBox<GameType> gameTypeBox = new JFXComboBox<>(GameType.items);
|
||||
gameTypeBox.setDisable(worldManagePage.isReadOnly());
|
||||
@@ -364,8 +386,7 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
Tag hardcoreTag = dataTag.get("hardcore");
|
||||
boolean isHardcore = hardcoreTag instanceof ByteTag && ((ByteTag) hardcoreTag).getValue() == 1;
|
||||
|
||||
if (tag instanceof IntTag) {
|
||||
IntTag intTag = (IntTag) tag;
|
||||
if (tag instanceof IntTag intTag) {
|
||||
GameType gameType = GameType.of(intTag.getValue(), isHardcore);
|
||||
if (gameType != null) {
|
||||
gameTypeBox.setValue(gameType);
|
||||
@@ -395,33 +416,13 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
|
||||
BorderPane healthPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.player.health"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
healthPane.setLeft(label);
|
||||
|
||||
setLeftLabel(healthPane, "world.info.player.health");
|
||||
JFXTextField healthField = new JFXTextField();
|
||||
healthField.setDisable(worldManagePage.isReadOnly());
|
||||
healthField.setPrefWidth(50);
|
||||
healthField.setAlignment(Pos.CENTER_RIGHT);
|
||||
BorderPane.setAlignment(healthField, Pos.CENTER_RIGHT);
|
||||
healthPane.setRight(healthField);
|
||||
setRightTextField(healthPane, healthField, 50);
|
||||
|
||||
Tag tag = player.get("Health");
|
||||
if (tag instanceof FloatTag) {
|
||||
FloatTag floatTag = (FloatTag) tag;
|
||||
healthField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue()));
|
||||
|
||||
healthField.textProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
floatTag.setValue(Float.parseFloat(newValue));
|
||||
saveLevelDat();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.setValidateWhileTextChanged(healthField, true);
|
||||
healthField.setValidators(new DoubleValidator(i18n("input.number"), true));
|
||||
if (tag instanceof FloatTag floatTag) {
|
||||
setTagAndTextField(floatTag, healthField);
|
||||
} else {
|
||||
healthField.setDisable(true);
|
||||
}
|
||||
@@ -429,33 +430,13 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
|
||||
BorderPane foodLevelPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.player.food_level"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
foodLevelPane.setLeft(label);
|
||||
|
||||
setLeftLabel(foodLevelPane, "world.info.player.food_level");
|
||||
JFXTextField foodLevelField = new JFXTextField();
|
||||
foodLevelField.setDisable(worldManagePage.isReadOnly());
|
||||
foodLevelField.setPrefWidth(50);
|
||||
foodLevelField.setAlignment(Pos.CENTER_RIGHT);
|
||||
BorderPane.setAlignment(foodLevelField, Pos.CENTER_RIGHT);
|
||||
foodLevelPane.setRight(foodLevelField);
|
||||
setRightTextField(foodLevelPane, foodLevelField, 50);
|
||||
|
||||
Tag tag = player.get("foodLevel");
|
||||
if (tag instanceof IntTag) {
|
||||
IntTag intTag = (IntTag) tag;
|
||||
foodLevelField.setText(String.valueOf(intTag.getValue()));
|
||||
|
||||
foodLevelField.textProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
intTag.setValue(Integer.parseInt(newValue));
|
||||
saveLevelDat();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.setValidateWhileTextChanged(foodLevelField, true);
|
||||
foodLevelField.setValidators(new NumberValidator(i18n("input.number"), true));
|
||||
if (tag instanceof IntTag intTag) {
|
||||
setTagAndTextField(intTag, foodLevelField);
|
||||
} else {
|
||||
foodLevelField.setDisable(true);
|
||||
}
|
||||
@@ -463,33 +444,13 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
|
||||
BorderPane xpLevelPane = new BorderPane();
|
||||
{
|
||||
Label label = new Label(i18n("world.info.player.xp_level"));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
xpLevelPane.setLeft(label);
|
||||
|
||||
setLeftLabel(xpLevelPane, "world.info.player.xp_level");
|
||||
JFXTextField xpLevelField = new JFXTextField();
|
||||
xpLevelField.setDisable(worldManagePage.isReadOnly());
|
||||
xpLevelField.setPrefWidth(50);
|
||||
xpLevelField.setAlignment(Pos.CENTER_RIGHT);
|
||||
BorderPane.setAlignment(xpLevelField, Pos.CENTER_RIGHT);
|
||||
xpLevelPane.setRight(xpLevelField);
|
||||
setRightTextField(xpLevelPane, xpLevelField, 50);
|
||||
|
||||
Tag tag = player.get("XpLevel");
|
||||
if (tag instanceof IntTag) {
|
||||
IntTag intTag = (IntTag) tag;
|
||||
xpLevelField.setText(String.valueOf(intTag.getValue()));
|
||||
|
||||
xpLevelField.textProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
intTag.setValue(Integer.parseInt(newValue));
|
||||
saveLevelDat();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.setValidateWhileTextChanged(xpLevelField, true);
|
||||
xpLevelField.setValidators(new NumberValidator(i18n("input.number"), true));
|
||||
if (tag instanceof IntTag intTag) {
|
||||
setTagAndTextField(intTag, xpLevelField);
|
||||
} else {
|
||||
xpLevelField.setDisable(true);
|
||||
}
|
||||
@@ -504,6 +465,88 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
}
|
||||
}
|
||||
|
||||
private void setLeftLabel(BorderPane borderPane, @PropertyKey(resourceBundle = "assets.lang.I18N") String key) {
|
||||
Label label = new Label(i18n(key));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
borderPane.setLeft(label);
|
||||
}
|
||||
|
||||
private void setRightTextField(BorderPane borderPane, JFXTextField textField, int perfWidth) {
|
||||
textField.setDisable(worldManagePage.isReadOnly());
|
||||
textField.setPrefWidth(perfWidth);
|
||||
textField.setAlignment(Pos.CENTER_RIGHT);
|
||||
borderPane.setRight(textField);
|
||||
}
|
||||
|
||||
private void setRightTextLabel(BorderPane borderPane, Label label, Callable<String> setNameCall) {
|
||||
FXUtils.copyOnDoubleClick(label);
|
||||
BorderPane.setAlignment(label, Pos.CENTER_RIGHT);
|
||||
try {
|
||||
label.setText(setNameCall.call());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Exception happened when setting name", e);
|
||||
}
|
||||
borderPane.setRight(label);
|
||||
}
|
||||
|
||||
private void checkTagAndSetListener(Tag tag, OptionToggleButton toggleButton) {
|
||||
if (tag instanceof ByteTag byteTag) {
|
||||
byte value = byteTag.getValue();
|
||||
if (value == 0 || value == 1) {
|
||||
toggleButton.setSelected(value == 1);
|
||||
toggleButton.selectedProperty().addListener((o, oldValue, newValue) -> {
|
||||
try {
|
||||
byteTag.setValue((byte) (newValue ? 1 : 0));
|
||||
saveLevelDat();
|
||||
} catch (Exception e) {
|
||||
toggleButton.setSelected(oldValue);
|
||||
LOG.warning("Exception happened when saving level.dat", e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
toggleButton.setDisable(true);
|
||||
}
|
||||
} else {
|
||||
toggleButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setTagAndTextField(IntTag intTag, JFXTextField jfxTextField) {
|
||||
jfxTextField.setText(String.valueOf(intTag.getValue()));
|
||||
|
||||
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
intTag.setValue(Integer.parseInt(newValue));
|
||||
saveLevelDat();
|
||||
} catch (Exception e) {
|
||||
jfxTextField.setText(oldValue);
|
||||
LOG.warning("Exception happened when saving level.dat", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
|
||||
jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true));
|
||||
}
|
||||
|
||||
private void setTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) {
|
||||
jfxTextField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue()));
|
||||
|
||||
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
try {
|
||||
floatTag.setValue(Float.parseFloat(newValue));
|
||||
saveLevelDat();
|
||||
} catch (Exception e) {
|
||||
jfxTextField.setText(oldValue);
|
||||
LOG.warning("Exception happened when saving level.dat", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
|
||||
jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true));
|
||||
}
|
||||
|
||||
private void saveLevelDat() {
|
||||
LOG.info("Saving level.dat of world " + world.getWorldName());
|
||||
try {
|
||||
@@ -513,54 +556,34 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Dimension {
|
||||
private record Dimension(String name) {
|
||||
static final Dimension OVERWORLD = new Dimension(null);
|
||||
static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether"));
|
||||
static final Dimension THE_END = new Dimension(i18n("world.info.dimension.the_end"));
|
||||
|
||||
final String name;
|
||||
|
||||
static Dimension of(Tag tag) {
|
||||
if (tag instanceof IntTag) {
|
||||
switch (((IntTag) tag).getValue()) {
|
||||
case 0:
|
||||
return OVERWORLD;
|
||||
case 1:
|
||||
return THE_NETHER;
|
||||
case 2:
|
||||
return THE_END;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else if (tag instanceof StringTag) {
|
||||
String id = ((StringTag) tag).getValue();
|
||||
switch (id) {
|
||||
case "overworld":
|
||||
case "minecraft:overworld":
|
||||
return OVERWORLD;
|
||||
case "the_nether":
|
||||
case "minecraft:the_nether":
|
||||
return THE_NETHER;
|
||||
case "the_end":
|
||||
case "minecraft:the_end":
|
||||
return THE_END;
|
||||
default:
|
||||
return new Dimension(id);
|
||||
}
|
||||
if (tag instanceof IntTag intTag) {
|
||||
return switch (intTag.getValue()) {
|
||||
case 0 -> OVERWORLD;
|
||||
case 1 -> THE_NETHER;
|
||||
case 2 -> THE_END;
|
||||
default -> null;
|
||||
};
|
||||
} else if (tag instanceof StringTag stringTag) {
|
||||
String id = stringTag.getValue();
|
||||
return switch (id) {
|
||||
case "overworld", "minecraft:overworld" -> OVERWORLD;
|
||||
case "the_nether", "minecraft:the_nether" -> THE_NETHER;
|
||||
case "the_end", "minecraft:the_end" -> THE_END;
|
||||
default -> new Dimension(id);
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Dimension(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
String formatPosition(Tag tag) {
|
||||
if (tag instanceof ListTag) {
|
||||
ListTag listTag = (ListTag) tag;
|
||||
if (listTag.size() != 3)
|
||||
return null;
|
||||
if (tag instanceof ListTag listTag && listTag.size() == 3) {
|
||||
|
||||
Tag x = listTag.get(0);
|
||||
Tag y = listTag.get(1);
|
||||
@@ -575,8 +598,7 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tag instanceof IntArrayTag) {
|
||||
IntArrayTag intArrayTag = (IntArrayTag) tag;
|
||||
if (tag instanceof IntArrayTag intArrayTag) {
|
||||
|
||||
int x = intArrayTag.getValue(0);
|
||||
int y = intArrayTag.getValue(1);
|
||||
@@ -609,7 +631,7 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
static final ObservableList<Difficulty> items = FXCollections.observableList(Arrays.asList(values()));
|
||||
|
||||
static Difficulty of(int d) {
|
||||
return d >= 0 && d <= items.size() ? items.get(d) : null;
|
||||
return (d >= 0 && d < items.size()) ? items.get(d) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -633,4 +655,51 @@ public final class WorldInfoPage extends SpinnerPane {
|
||||
return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
private void changeWorldIcon() {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("world.icon.choose.title"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png"));
|
||||
fileChooser.setInitialFileName("icon.png");
|
||||
|
||||
File file = fileChooser.showOpenDialog(Controllers.getStage());
|
||||
if (file == null) return;
|
||||
|
||||
Image image;
|
||||
try {
|
||||
image = FXUtils.loadImage(file.toPath());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load image", e);
|
||||
Controllers.dialog(i18n("world.icon.change.fail.load.text"), i18n("world.icon.change.fail.load.title"), MessageDialogPane.MessageType.ERROR);
|
||||
return;
|
||||
}
|
||||
if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) {
|
||||
Path output = world.getFile().resolve("icon.png");
|
||||
saveImage(image, output);
|
||||
} else {
|
||||
Controllers.dialog(i18n("world.icon.change.fail.not_64x64.text", (int) image.getWidth(), (int) image.getHeight()), i18n("world.icon.change.fail.not_64x64.title"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveImage(Image image, Path path) {
|
||||
Image oldImage = iconImageView.getImage();
|
||||
try {
|
||||
PNGJavaFXUtils.writeImage(image, path);
|
||||
iconImageView.setImage(image);
|
||||
Controllers.showToast(i18n("world.icon.change.succeed.toast"));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save world icon " + e.getMessage());
|
||||
iconImageView.setImage(oldImage);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearWorldIcon() {
|
||||
Path output = world.getFile().resolve("icon.png");
|
||||
try {
|
||||
Files.deleteIfExists(output);
|
||||
iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png"));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete world icon " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.game.WorldLockedException;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
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.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class WorldListItem extends Control {
|
||||
private final World world;
|
||||
private final Path backupsDir;
|
||||
private final WorldListPage parent;
|
||||
private final Profile profile;
|
||||
private final String id;
|
||||
|
||||
public WorldListItem(WorldListPage parent, World world, Path backupsDir, Profile profile, String id) {
|
||||
this.world = world;
|
||||
this.backupsDir = backupsDir;
|
||||
this.parent = parent;
|
||||
this.profile = profile;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new WorldListItemSkin(this);
|
||||
}
|
||||
|
||||
public World getWorld() {
|
||||
return world;
|
||||
}
|
||||
|
||||
public void export() {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("world.export.title"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
|
||||
fileChooser.setInitialFileName(world.getWorldName());
|
||||
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
|
||||
}
|
||||
|
||||
public void delete() {
|
||||
Controllers.confirm(
|
||||
i18n("button.remove.confirm"),
|
||||
i18n("world.delete"),
|
||||
() -> Task.runAsync(world::delete)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
parent.remove(this);
|
||||
} else if (exception instanceof WorldLockedException) {
|
||||
Controllers.dialog(i18n("world.locked.failed"), null, MessageType.WARNING);
|
||||
} else {
|
||||
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageType.WARNING);
|
||||
}
|
||||
}).start(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public void reveal() {
|
||||
FXUtils.openFolder(world.getFile());
|
||||
}
|
||||
|
||||
public void showManagePage() {
|
||||
Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id));
|
||||
}
|
||||
|
||||
public void launch() {
|
||||
Versions.launchAndEnterWorld(profile, id, world.getFileName());
|
||||
}
|
||||
|
||||
public void generateLaunchScript() {
|
||||
Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName());
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.ChunkBaseApp;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class WorldListItemSkin extends SkinBase<WorldListItem> {
|
||||
|
||||
private final BorderPane root;
|
||||
|
||||
public WorldListItemSkin(WorldListItem skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
World world = skinnable.getWorld();
|
||||
|
||||
root = new BorderPane();
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8));
|
||||
|
||||
{
|
||||
StackPane left = new StackPane();
|
||||
FXUtils.installSlowTooltip(left, world.getFile().toString());
|
||||
root.setLeft(left);
|
||||
left.setPadding(new Insets(0, 8, 0, 0));
|
||||
|
||||
ImageView imageView = new ImageView();
|
||||
left.getChildren().add(imageView);
|
||||
FXUtils.limitSize(imageView, 32, 32);
|
||||
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
|
||||
}
|
||||
|
||||
{
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
root.setCenter(item);
|
||||
item.setMouseTransparent(true);
|
||||
if (world.getWorldName() != null)
|
||||
item.setTitle(parseColorEscapes(world.getWorldName()));
|
||||
item.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
|
||||
|
||||
if (world.getGameVersion() != null)
|
||||
item.addTag(I18n.getDisplayVersion(world.getGameVersion()));
|
||||
if (world.isLocked())
|
||||
item.addTag(i18n("world.locked"));
|
||||
}
|
||||
|
||||
{
|
||||
HBox right = new HBox(8);
|
||||
root.setRight(right);
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
JFXButton btnMore = new JFXButton();
|
||||
right.getChildren().add(btnMore);
|
||||
btnMore.getStyleClass().add("toggle-icon4");
|
||||
btnMore.setGraphic(SVG.MORE_VERT.createIcon());
|
||||
btnMore.setOnAction(event -> showPopupMenu(JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight()));
|
||||
}
|
||||
|
||||
RipplerContainer container = new RipplerContainer(root);
|
||||
container.setOnMouseClicked(event -> {
|
||||
if (event.getClickCount() != 1)
|
||||
return;
|
||||
|
||||
if (event.getButton() == MouseButton.PRIMARY)
|
||||
skinnable.showManagePage();
|
||||
else if (event.getButton() == MouseButton.SECONDARY)
|
||||
showPopupMenu(JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
|
||||
});
|
||||
|
||||
getChildren().setAll(container);
|
||||
}
|
||||
|
||||
// Popup Menu
|
||||
|
||||
public void showPopupMenu(JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
|
||||
PopupMenu popupMenu = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(popupMenu);
|
||||
|
||||
WorldListItem item = getSkinnable();
|
||||
World world = item.getWorld();
|
||||
|
||||
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
|
||||
|
||||
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), item::launch, popup);
|
||||
launchItem.setDisable(world.isLocked());
|
||||
popupMenu.getContent().add(launchItem);
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup),
|
||||
new MenuSeparator()
|
||||
);
|
||||
}
|
||||
|
||||
popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), item::showManagePage, popup));
|
||||
|
||||
if (ChunkBaseApp.isSupported(world)) {
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
|
||||
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
|
||||
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
|
||||
);
|
||||
|
||||
if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) {
|
||||
popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"),
|
||||
() -> ChunkBaseApp.openEndCityFinder(world), popup));
|
||||
}
|
||||
}
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), item::export, popup),
|
||||
new IconedMenuItem(SVG.DELETE, i18n("world.delete"), item::delete, popup),
|
||||
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), item::reveal, popup));
|
||||
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
|
||||
|
||||
popup.show(root, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY);
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,32 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.ChunkBaseApp;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
@@ -35,15 +50,18 @@ import java.io.IOException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class WorldListPage extends ListPageBase<WorldListItem> implements VersionPage.VersionLoadable {
|
||||
public final class WorldListPage extends ListPageBase<World> implements VersionPage.VersionLoadable {
|
||||
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
|
||||
|
||||
private Path savesDir;
|
||||
@@ -51,23 +69,19 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
|
||||
private List<World> worlds;
|
||||
private Profile profile;
|
||||
private String id;
|
||||
private GameVersionNumber gameVersion;
|
||||
|
||||
private int refreshCount = 0;
|
||||
|
||||
public WorldListPage() {
|
||||
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
|
||||
installWorld(modpacks.get(0));
|
||||
});
|
||||
|
||||
showAll.addListener(e -> {
|
||||
if (worlds != null)
|
||||
itemsProperty().setAll(worlds.stream()
|
||||
.filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
|
||||
.map(world -> new WorldListItem(this, world, backupsDir, profile, id)).toList());
|
||||
});
|
||||
showAll.addListener(e -> updateWorldList());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ToolbarListPageSkin<WorldListPage> createDefaultSkin() {
|
||||
protected Skin<WorldListPage> createDefaultSkin() {
|
||||
return new WorldListPageSkin();
|
||||
}
|
||||
|
||||
@@ -80,33 +94,46 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void remove(WorldListItem item) {
|
||||
itemsProperty().remove(item);
|
||||
private void updateWorldList() {
|
||||
if (worlds == null) {
|
||||
getItems().clear();
|
||||
} else if (showAll.get()) {
|
||||
getItems().setAll(worlds);
|
||||
} else {
|
||||
GameVersionNumber gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null);
|
||||
getItems().setAll(worlds.stream()
|
||||
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (profile == null || id == null)
|
||||
return;
|
||||
|
||||
int currentRefresh = ++refreshCount;
|
||||
|
||||
setLoading(true);
|
||||
Task.runAsync(() -> gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null))
|
||||
.thenApplyAsync(unused -> {
|
||||
try (Stream<World> stream = World.getWorlds(savesDir)) {
|
||||
return stream.parallel().collect(Collectors.toList());
|
||||
}
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
worlds = result;
|
||||
setLoading(false);
|
||||
if (exception == null) {
|
||||
itemsProperty().setAll(result.stream()
|
||||
.filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
|
||||
.map(world -> new WorldListItem(this, world, backupsDir, profile, id))
|
||||
.collect(Collectors.toList()));
|
||||
} else {
|
||||
LOG.warning("Failed to load world list page", exception);
|
||||
}
|
||||
}).start();
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
// Ensure the game version number is parsed
|
||||
profile.getRepository().getGameVersion(id);
|
||||
try (Stream<World> stream = World.getWorlds(savesDir)) {
|
||||
return stream.toList();
|
||||
}
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (refreshCount != currentRefresh) {
|
||||
// A newer refresh task is running, discard this result
|
||||
return;
|
||||
}
|
||||
|
||||
worlds = result;
|
||||
updateWorldList();
|
||||
|
||||
if (exception != null)
|
||||
LOG.warning("Failed to load world list page", exception);
|
||||
|
||||
setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void add() {
|
||||
@@ -132,8 +159,8 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
|
||||
Controllers.prompt(i18n("world.name.enter"), (name, resolve, reject) -> {
|
||||
Task.runAsync(() -> world.install(savesDir, name))
|
||||
.whenComplete(Schedulers.javafx(), () -> {
|
||||
itemsProperty().add(new WorldListItem(this, new World(savesDir.resolve(name)), backupsDir, profile, id));
|
||||
resolve.run();
|
||||
refresh();
|
||||
}, e -> {
|
||||
if (e instanceof FileAlreadyExistsException)
|
||||
reject.accept(i18n("world.import.failed", i18n("world.import.already_exists")));
|
||||
@@ -149,19 +176,39 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
|
||||
}).start();
|
||||
}
|
||||
|
||||
public boolean isShowAll() {
|
||||
return showAll.get();
|
||||
private void showManagePage(World world) {
|
||||
Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id));
|
||||
}
|
||||
|
||||
public void export(World world) {
|
||||
WorldManageUIUtils.export(world);
|
||||
}
|
||||
|
||||
public void delete(World world) {
|
||||
WorldManageUIUtils.delete(world, this::refresh);
|
||||
}
|
||||
|
||||
public void copy(World world) {
|
||||
WorldManageUIUtils.copyWorld(world, this::refresh);
|
||||
}
|
||||
|
||||
public void reveal(World world) {
|
||||
FXUtils.openFolder(world.getFile());
|
||||
}
|
||||
|
||||
public void launch(World world) {
|
||||
Versions.launchAndEnterWorld(profile, id, world.getFileName());
|
||||
}
|
||||
|
||||
public void generateLaunchScript(World world) {
|
||||
Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName());
|
||||
}
|
||||
|
||||
public BooleanProperty showAllProperty() {
|
||||
return showAll;
|
||||
}
|
||||
|
||||
public void setShowAll(boolean showAll) {
|
||||
this.showAll.set(showAll);
|
||||
}
|
||||
|
||||
private final class WorldListPageSkin extends ToolbarListPageSkin<WorldListPage> {
|
||||
private final class WorldListPageSkin extends ToolbarListPageSkin<World, WorldListPage> {
|
||||
|
||||
WorldListPageSkin() {
|
||||
super(WorldListPage.this);
|
||||
@@ -178,5 +225,162 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
|
||||
createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("world.download"), SVG.DOWNLOAD, skinnable::download));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ListCell<World> createListCell(JFXListView<World> listView) {
|
||||
return new WorldListCell(getSkinnable());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class WorldListCell extends ListCell<World> {
|
||||
|
||||
private final WorldListPage page;
|
||||
|
||||
private final RipplerContainer graphic;
|
||||
private final ImageView imageView;
|
||||
private final Tooltip leftTooltip;
|
||||
private final TwoLineListItem content;
|
||||
|
||||
public WorldListCell(WorldListPage page) {
|
||||
this.page = page;
|
||||
|
||||
var root = new BorderPane();
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8));
|
||||
|
||||
{
|
||||
StackPane left = new StackPane();
|
||||
this.leftTooltip = new Tooltip();
|
||||
FXUtils.installSlowTooltip(left, leftTooltip);
|
||||
root.setLeft(left);
|
||||
left.setPadding(new Insets(0, 8, 0, 0));
|
||||
|
||||
this.imageView = new ImageView();
|
||||
left.getChildren().add(imageView);
|
||||
FXUtils.limitSize(imageView, 32, 32);
|
||||
}
|
||||
|
||||
{
|
||||
this.content = new TwoLineListItem();
|
||||
root.setCenter(content);
|
||||
content.setMouseTransparent(true);
|
||||
}
|
||||
|
||||
{
|
||||
HBox right = new HBox(8);
|
||||
root.setRight(right);
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
JFXButton btnMore = new JFXButton();
|
||||
right.getChildren().add(btnMore);
|
||||
btnMore.getStyleClass().add("toggle-icon4");
|
||||
btnMore.setGraphic(SVG.MORE_VERT.createIcon());
|
||||
btnMore.setOnAction(event -> {
|
||||
World world = getItem();
|
||||
if (world != null)
|
||||
showPopupMenu(world, JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight());
|
||||
});
|
||||
}
|
||||
|
||||
this.graphic = new RipplerContainer(root);
|
||||
graphic.setOnMouseClicked(event -> {
|
||||
if (event.getClickCount() != 1)
|
||||
return;
|
||||
|
||||
World world = getItem();
|
||||
if (world == null)
|
||||
return;
|
||||
|
||||
if (event.getButton() == MouseButton.PRIMARY)
|
||||
page.showManagePage(world);
|
||||
else if (event.getButton() == MouseButton.SECONDARY)
|
||||
showPopupMenu(world, JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(World world, boolean empty) {
|
||||
super.updateItem(world, empty);
|
||||
|
||||
this.content.getTags().clear();
|
||||
|
||||
if (empty || world == null) {
|
||||
setGraphic(null);
|
||||
imageView.setImage(null);
|
||||
leftTooltip.setText("");
|
||||
content.setTitle("");
|
||||
content.setSubtitle("");
|
||||
} else {
|
||||
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
|
||||
leftTooltip.setText(world.getFile().toString());
|
||||
content.setTitle(world.getWorldName() != null ? parseColorEscapes(world.getWorldName()) : "");
|
||||
|
||||
if (world.getGameVersion() != null)
|
||||
content.addTag(I18n.getDisplayVersion(world.getGameVersion()));
|
||||
if (world.isLocked())
|
||||
content.addTag(i18n("world.locked"));
|
||||
|
||||
content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
|
||||
|
||||
setGraphic(graphic);
|
||||
}
|
||||
}
|
||||
|
||||
// Popup Menu
|
||||
|
||||
public void showPopupMenu(World world, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
|
||||
PopupMenu popupMenu = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(popupMenu);
|
||||
|
||||
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
|
||||
|
||||
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), () -> page.launch(world), popup);
|
||||
launchItem.setDisable(world.isLocked());
|
||||
popupMenu.getContent().add(launchItem);
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> page.generateLaunchScript(world), popup),
|
||||
new MenuSeparator()
|
||||
);
|
||||
}
|
||||
|
||||
popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), () -> page.showManagePage(world), popup));
|
||||
|
||||
if (ChunkBaseApp.isSupported(world)) {
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
|
||||
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
|
||||
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
|
||||
);
|
||||
|
||||
if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) {
|
||||
popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"),
|
||||
() -> ChunkBaseApp.openEndCityFinder(world), popup));
|
||||
}
|
||||
}
|
||||
|
||||
IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup);
|
||||
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> page.delete(world), popup);
|
||||
IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup);
|
||||
boolean worldLocked = world.isLocked();
|
||||
Stream.of(exportMenuItem, deleteMenuItem, duplicateMenuItem)
|
||||
.forEach(iconedMenuItem -> iconedMenuItem.setDisable(worldLocked));
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
exportMenuItem,
|
||||
deleteMenuItem,
|
||||
duplicateMenuItem
|
||||
);
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new MenuSeparator(),
|
||||
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), () -> page.reveal(world), popup)
|
||||
);
|
||||
|
||||
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup);
|
||||
popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
@@ -27,6 +28,7 @@ import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
@@ -34,6 +36,7 @@ import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.ChunkBaseApp;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
@@ -53,6 +56,8 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
private final Profile profile;
|
||||
private final String id;
|
||||
|
||||
private boolean loadFailed = false;
|
||||
|
||||
private final TabHeader header;
|
||||
private final TabHeader.Tab<WorldInfoPage> worldInfoTab = new TabHeader.Tab<>("worldInfoPage");
|
||||
private final TabHeader.Tab<WorldBackupsPage> worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage");
|
||||
@@ -65,25 +70,25 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
public WorldManagePage(World world, Path backupsDir, Profile profile, String id) {
|
||||
this.world = world;
|
||||
this.backupsDir = backupsDir;
|
||||
|
||||
this.profile = profile;
|
||||
this.id = id;
|
||||
|
||||
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
|
||||
try {
|
||||
world.reloadLevelDat();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Can not load world level.dat of world: " + world.getFile(), e);
|
||||
loadFailed = true;
|
||||
}
|
||||
|
||||
this.worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this));
|
||||
this.worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this));
|
||||
this.datapackTab.setNodeSupplier(() -> new DatapackListPage(this));
|
||||
|
||||
this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", world.getWorldName())));
|
||||
this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName()))));
|
||||
this.header = new TabHeader(transitionPane, worldInfoTab, worldBackupsTab);
|
||||
header.select(worldInfoTab);
|
||||
|
||||
// Does it need to be done in the background?
|
||||
try {
|
||||
sessionLockChannel = world.lock();
|
||||
LOG.info("Acquired lock on world " + world.getFileName());
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
setCenter(transitionPane);
|
||||
|
||||
BorderPane left = new BorderPane();
|
||||
@@ -106,37 +111,87 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
AdvancedListBox toolbar = new AdvancedListBox();
|
||||
|
||||
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
|
||||
toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, null);
|
||||
toolbar.addNavigationDrawerItem(i18n("version.launch_script"), SVG.SCRIPT, this::generateLaunchScript, null);
|
||||
toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, advancedListItem -> advancedListItem.setDisable(isReadOnly()));
|
||||
}
|
||||
|
||||
if (ChunkBaseApp.isSupported(world)) {
|
||||
PopupMenu popupMenu = new PopupMenu();
|
||||
JFXPopup popup = new JFXPopup(popupMenu);
|
||||
PopupMenu chunkBasePopupMenu = new PopupMenu();
|
||||
JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu);
|
||||
|
||||
popupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
|
||||
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
|
||||
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
|
||||
chunkBasePopupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), chunkBasePopup),
|
||||
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), chunkBasePopup),
|
||||
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), chunkBasePopup)
|
||||
);
|
||||
|
||||
if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) {
|
||||
popupMenu.getContent().add(
|
||||
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup));
|
||||
chunkBasePopupMenu.getContent().add(
|
||||
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), chunkBasePopup));
|
||||
}
|
||||
|
||||
toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem ->
|
||||
chunkBaseMenuItem.setOnAction(e ->
|
||||
popup.show(chunkBaseMenuItem,
|
||||
chunkBasePopup.show(chunkBaseMenuItem,
|
||||
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
|
||||
chunkBaseMenuItem.getWidth(), 0)));
|
||||
}
|
||||
|
||||
toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(world.getFile()), null);
|
||||
|
||||
{
|
||||
PopupMenu managePopupMenu = new PopupMenu();
|
||||
JFXPopup managePopup = new JFXPopup(managePopupMenu);
|
||||
|
||||
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
|
||||
managePopupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), this::launch, managePopup),
|
||||
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), this::generateLaunchScript, managePopup),
|
||||
new MenuSeparator()
|
||||
);
|
||||
}
|
||||
|
||||
managePopupMenu.getContent().addAll(
|
||||
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup),
|
||||
new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> WorldManageUIUtils.delete(world, () -> fireEvent(new PageCloseEvent()), sessionLockChannel), managePopup),
|
||||
new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(world, null), managePopup)
|
||||
);
|
||||
|
||||
toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem ->
|
||||
{
|
||||
managePopupMenuItem.setOnAction(e ->
|
||||
managePopup.show(managePopupMenuItem,
|
||||
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
|
||||
managePopupMenuItem.getWidth(), 0));
|
||||
managePopupMenuItem.setDisable(isReadOnly());
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
|
||||
left.setBottom(toolbar);
|
||||
|
||||
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
|
||||
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
|
||||
}
|
||||
|
||||
private void onNavigated(Navigator.NavigationEvent event) {
|
||||
if (loadFailed) {
|
||||
Platform.runLater(() -> {
|
||||
fireEvent(new PageCloseEvent());
|
||||
Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sessionLockChannel == null || !sessionLockChannel.isOpen()) {
|
||||
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
|
||||
}
|
||||
}
|
||||
|
||||
public void onExited(Navigator.NavigationEvent event) {
|
||||
try {
|
||||
WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -156,19 +211,6 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
return sessionLockChannel == null;
|
||||
}
|
||||
|
||||
public void onExited(Navigator.NavigationEvent event) {
|
||||
if (sessionLockChannel != null) {
|
||||
try {
|
||||
sessionLockChannel.close();
|
||||
LOG.info("Releases the lock on world " + world.getFileName());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to close session lock channel", e);
|
||||
}
|
||||
|
||||
sessionLockChannel = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void launch() {
|
||||
fireEvent(new PageCloseEvent());
|
||||
Versions.launchAndEnterWorld(profile, id, world.getFileName());
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.game.WorldLockedException;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.construct.InputDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class WorldManageUIUtils {
|
||||
private WorldManageUIUtils() {
|
||||
}
|
||||
|
||||
public static void delete(World world, Runnable runnable) {
|
||||
delete(world, runnable, null);
|
||||
}
|
||||
|
||||
public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) {
|
||||
Controllers.confirm(
|
||||
i18n("button.remove.confirm"),
|
||||
i18n("world.delete"),
|
||||
() -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel))
|
||||
.thenRunAsync(world::delete)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
runnable.run();
|
||||
} else if (exception instanceof WorldLockedException) {
|
||||
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
|
||||
} else {
|
||||
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
|
||||
}
|
||||
}).start(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static void export(World world) {
|
||||
export(world, null);
|
||||
}
|
||||
|
||||
public static void export(World world, FileChannel sessionLockChannel) {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("world.export.title"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
|
||||
fileChooser.setInitialFileName(world.getWorldName());
|
||||
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
closeSessionLockChannel(world, sessionLockChannel);
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
}
|
||||
|
||||
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
|
||||
}
|
||||
|
||||
public static void copyWorld(World world, Runnable runnable) {
|
||||
Path worldPath = world.getFile();
|
||||
Controllers.dialog(new InputDialogPane(
|
||||
i18n("world.duplicate.prompt"),
|
||||
"",
|
||||
(result, resolve, reject) -> {
|
||||
if (StringUtils.isBlank(result)) {
|
||||
reject.accept(i18n("world.duplicate.failed.empty_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) {
|
||||
reject.accept(i18n("world.duplicate.failed.invalid_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
Path targetDir = worldPath.resolveSibling(result);
|
||||
if (Files.exists(targetDir)) {
|
||||
reject.accept(i18n("world.duplicate.failed.already_exists"));
|
||||
return;
|
||||
}
|
||||
|
||||
Task.runAsync(Schedulers.io(), () -> world.copy(result))
|
||||
.thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast")))
|
||||
.thenAcceptAsync(Schedulers.javafx(), (Void) -> {
|
||||
if (runnable != null) {
|
||||
runnable.run();
|
||||
}
|
||||
}
|
||||
).whenComplete(Schedulers.javafx(), (throwable) -> {
|
||||
if (throwable == null) {
|
||||
resolve.run();
|
||||
} else {
|
||||
reject.accept(i18n("world.duplicate.failed"));
|
||||
LOG.warning("Failed to duplicate world " + world.getFile(), throwable);
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}));
|
||||
}
|
||||
|
||||
public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException {
|
||||
if (sessionLockChannel != null) {
|
||||
try {
|
||||
sessionLockChannel.close();
|
||||
LOG.info("Closed session lock channel of the world " + world.getFileName());
|
||||
} catch (IOException e) {
|
||||
throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static FileChannel getSessionLockChannel(World world) {
|
||||
try {
|
||||
FileChannel lock = world.lock();
|
||||
LOG.info("Acquired lock on world " + world.getFileName());
|
||||
return lock;
|
||||
} catch (IOException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 javafx.beans.value.WritableValue;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
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;
|
||||
|
||||
/// @author Glavo
|
||||
public abstract class RemoteImageLoader {
|
||||
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<>();
|
||||
|
||||
public RemoteImageLoader() {
|
||||
}
|
||||
|
||||
protected @Nullable Image getPlaceholder() {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected abstract @NotNull Task<Image> createLoadTask(@NotNull URI uri);
|
||||
|
||||
@FXThread
|
||||
public void load(@NotNull WritableValue<Image> writableValue, String url) {
|
||||
URI uri = NetworkUtils.toURIOrNull(url);
|
||||
if (uri == null) {
|
||||
reverseLookup.remove(writableValue);
|
||||
writableValue.setValue(getPlaceholder());
|
||||
return;
|
||||
}
|
||||
|
||||
WeakReference<Image> reference = cache.get(uri);
|
||||
if (reference != null) {
|
||||
Image image = reference.get();
|
||||
if (image != null) {
|
||||
reverseLookup.remove(writableValue);
|
||||
writableValue.setValue(image);
|
||||
return;
|
||||
}
|
||||
cache.remove(uri);
|
||||
}
|
||||
|
||||
writableValue.setValue(getPlaceholder());
|
||||
|
||||
{
|
||||
List<WeakReference<WritableValue<Image>>> list = pendingRequests.get(uri);
|
||||
if (list != null) {
|
||||
list.add(new WeakReference<>(writableValue));
|
||||
reverseLookup.put(writableValue, uri);
|
||||
return;
|
||||
} else {
|
||||
list = new ArrayList<>(1);
|
||||
list.add(new WeakReference<>(writableValue));
|
||||
pendingRequests.put(uri, list);
|
||||
reverseLookup.put(writableValue, uri);
|
||||
}
|
||||
}
|
||||
|
||||
createLoadTask(uri).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
Image image;
|
||||
if (exception == null) {
|
||||
image = result;
|
||||
} else {
|
||||
LOG.warning("Failed to load image from " + uri, exception);
|
||||
image = getPlaceholder();
|
||||
}
|
||||
|
||||
cache.put(uri, new WeakReference<>(image));
|
||||
List<WeakReference<WritableValue<Image>>> list = pendingRequests.remove(uri);
|
||||
if (list != null) {
|
||||
for (WeakReference<WritableValue<Image>> ref : list) {
|
||||
WritableValue<Image> target = ref.get();
|
||||
if (target != null && uri.equals(reverseLookup.get(target))) {
|
||||
reverseLookup.remove(target);
|
||||
target.setValue(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public void unload(@NotNull WritableValue<Image> writableValue) {
|
||||
reverseLookup.remove(writableValue);
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public void clearInvalidCache() {
|
||||
cache.entrySet().removeIf(entry -> entry.getValue().get() == null);
|
||||
}
|
||||
}
|
||||
@@ -388,31 +388,33 @@
|
||||
-fx-cursor: hand;
|
||||
}
|
||||
|
||||
.installer-item {
|
||||
-fx-padding: 8px;
|
||||
.installer-item-wrapper {
|
||||
-fx-background-color: -monet-surface;
|
||||
-fx-background-radius: 4;
|
||||
-fx-pref-width: 180px;
|
||||
}
|
||||
|
||||
.installer-item:list-item {
|
||||
.installer-item-wrapper:card {
|
||||
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2);
|
||||
}
|
||||
|
||||
.installer-item-wrapper .installer-item:list-item {
|
||||
-fx-padding: 8px;
|
||||
-fx-border-color: -monet-outline-variant;
|
||||
-fx-border-width: 0 0 1 0;
|
||||
-fx-alignment: center-left;
|
||||
}
|
||||
|
||||
.installer-item:list-item > .installer-item-name {
|
||||
.installer-item-wrapper .installer-item:list-item > .installer-item-name {
|
||||
-fx-pref-width: 80px;
|
||||
}
|
||||
|
||||
.installer-item:list-item > .installer-item-status {
|
||||
.installer-item-wrapper .installer-item:list-item > .installer-item-status {
|
||||
-fx-max-width: infinity;
|
||||
}
|
||||
|
||||
.installer-item:card {
|
||||
-fx-background-color: -monet-surface;
|
||||
-fx-background-radius: 4;
|
||||
.installer-item-wrapper .installer-item:card {
|
||||
-fx-alignment: center;
|
||||
-fx-pref-width: 180px;
|
||||
|
||||
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -75,6 +75,7 @@ account.failed.server_disconnected=Failed to connect to the authentication serve
|
||||
If the issue persists after multiple attempts, please try logging in to the account again.
|
||||
account.failed.server_response_malformed=Invalid server response. The authentication server may be down.
|
||||
account.failed.ssl=An SSL error occurred while connecting to the server. Please try updating your Java.
|
||||
account.failed.dns=An SSL error occurred while connecting to the server. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service.
|
||||
account.failed.wrong_account=You have logged in to the wrong account.
|
||||
account.hmcl.hint=You need to click "Log in" and complete the process in the opened browser window.
|
||||
account.injector.add=New Auth Server
|
||||
@@ -125,6 +126,7 @@ account.methods.microsoft.manual=Please enter the code shown above on the pop-up
|
||||
account.methods.microsoft.profile=Account Profile
|
||||
account.methods.microsoft.purchase=Buy Minecraft
|
||||
account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the <a href="https://hmcl.huangyuhui.net/download">official build</a> to log in.
|
||||
account.methods.microsoft.snapshot.tooltip=You are using an unofficial build of HMCL. Please download the official build to refresh the account.
|
||||
account.methods.microsoft.snapshot.website=Official Website
|
||||
account.methods.offline=Offline
|
||||
account.methods.offline.name.special_characters=Use only letters, numbers, and underscores (max 16 chars)
|
||||
@@ -380,6 +382,7 @@ exception.access_denied=HMCL is unable to access the file "%s". It may be locked
|
||||
If not, please check if your user account has adequate permissions to access it.
|
||||
exception.artifact_malformed=Cannot verify the integrity of the downloaded files.
|
||||
exception.ssl_handshake=Failed to establish SSL connection because the SSL certificate is missing from the current Java installation. You can try opening HMCL with another Java installation and try again.
|
||||
exception.dns.pollution=Failed to establish an SSL connection. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service.
|
||||
|
||||
extension.bat=Windows Batch File
|
||||
extension.mod=Mod File
|
||||
@@ -1131,6 +1134,13 @@ world.chunkbase.end_city=End City
|
||||
world.chunkbase.seed_map=Seed Map
|
||||
world.chunkbase.stronghold=Stronghold
|
||||
world.chunkbase.nether_fortress=Nether Fortress
|
||||
world.duplicate=Duplicate the World
|
||||
world.duplicate.prompt=Please enter the name of the duplicated world
|
||||
world.duplicate.failed.already_exists=Directory already exists
|
||||
world.duplicate.failed.empty_name=Name cannot be empty
|
||||
world.duplicate.failed.invalid_name=Name contains invalid characters
|
||||
world.duplicate.failed=Failed to duplicate the world
|
||||
world.duplicate.success.toast=Successfully duplicated the world
|
||||
world.datapack=Datapacks
|
||||
world.datetime=Last played on %s
|
||||
world.delete=Delete the World
|
||||
@@ -1143,6 +1153,15 @@ world.export.location=Save As
|
||||
world.export.wizard=Export World "%s"
|
||||
world.extension=World Archive
|
||||
world.game_version=Game Version
|
||||
world.icon=World Icon
|
||||
world.icon.change=Change world icon
|
||||
world.icon.change.fail.load.title=Failed to parse image
|
||||
world.icon.change.fail.load.text=This image appears to be corrupted, and HMCL cannot parse it.
|
||||
world.icon.change.fail.not_64x64.title=Image size error
|
||||
world.icon.change.fail.not_64x64.text=The image resolution is %d×%d instead of 64×64. Please provide a 64×64 image and try again.
|
||||
world.icon.change.succeed.toast=Successfully updated the world icon.
|
||||
world.icon.change.tip=A 64×64 PNG image is required. Images with an incorrect resolution cannot be parsed by Minecraft.
|
||||
world.icon.choose.title=Select world icon
|
||||
world.import.already_exists=This world already exists.
|
||||
world.import.choose=Choose world archive you want to import
|
||||
world.import.failed=Failed to import this world: %s
|
||||
@@ -1157,6 +1176,7 @@ world.info.difficulty.peaceful=Peaceful
|
||||
world.info.difficulty.easy=Easy
|
||||
world.info.difficulty.normal=Normal
|
||||
world.info.difficulty.hard=Hard
|
||||
world.info.difficulty_lock=Lock Difficulty
|
||||
world.info.failed=Failed to read the world info
|
||||
world.info.game_version=Game Version
|
||||
world.info.last_played=Last Played
|
||||
@@ -1177,6 +1197,7 @@ world.info.player.xp_level=Experience Level
|
||||
world.info.random_seed=Seed
|
||||
world.info.time=Game Time
|
||||
world.info.time.format=%s days
|
||||
world.load.fail=Failed to load world
|
||||
world.locked=In use
|
||||
world.locked.failed=The world is currently in use. Please close the game and try again.
|
||||
world.manage=Worlds
|
||||
@@ -1436,13 +1457,17 @@ system.architecture=Architecture
|
||||
system.operating_system=Operating System
|
||||
|
||||
terracotta=Multiplayer
|
||||
terracotta.easytier=About EasyTier
|
||||
terracotta.terracotta=Terracotta | Multiplayer
|
||||
terracotta.status=Lobby
|
||||
terracotta.back=Exit
|
||||
terracotta.feedback.title=Fill Out Feedback Form
|
||||
terracotta.feedback.desc=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
|
||||
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
|
||||
terracotta.difficulty.easiest=Excellent network: almost connected!
|
||||
terracotta.difficulty.simple=Good network: connection may take some time
|
||||
terracotta.difficulty.medium=Average network: enabling fallback routes, though connection may fail
|
||||
terracotta.difficulty.tough=Poor network: enabling fallback routes, though connection may fail
|
||||
terracotta.difficulty.estimate_only=Success rate is an estimate based on host and client NAT types, for reference only!
|
||||
terracotta.from_local.title=Third-party download channels for Multiplayer Core
|
||||
terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable.
|
||||
terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it.
|
||||
@@ -1610,4 +1635,4 @@ wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s
|
||||
wizard.prev=< Prev
|
||||
wizard.failed=Failed
|
||||
wizard.finish=Finish
|
||||
wizard.next=Next >
|
||||
wizard.next=Next >
|
||||
@@ -74,6 +74,7 @@ account.failed.server_disconnected=فشل الاتصال بخادم المصاد
|
||||
إذا استمرت المشكلة بعد محاولات متعددة، يرجى محاولة تسجيل الدخول إلى الحساب مرة أخرى.
|
||||
account.failed.server_response_malformed=استجابة خادم غير صالحة. قد يكون خادم المصادقة معطلاً.
|
||||
account.failed.ssl=حدث خطأ SSL أثناء الاتصال بالخادم. يرجى محاولة تحديث Java الخاص بك.
|
||||
account.failed.dns=حدث خطأ SSL عند الاتصال بالخادم. قد تكون هناك مشكلة في تحليل أسماء النطاقات (DNS). يرجى محاولة تغيير خادم DNS أو استخدام خدمة وكيل.
|
||||
account.failed.wrong_account=لقد قمت بتسجيل الدخول إلى حساب خاطئ.
|
||||
account.hmcl.hint=يجب عليك النقر على "تسجيل الدخول" وإكمال العملية في نافذة المتصفح المفتوحة.
|
||||
account.injector.add=خادم مصادقة جديد
|
||||
@@ -359,6 +360,7 @@ exception.access_denied=HMCL غير قادر على الوصول إلى المل
|
||||
إذا لم يكن الأمر كذلك، يرجى التحقق مما إذا كان حساب المستخدم الخاص بك لديه أذونات كافية للوصول إليه.
|
||||
exception.artifact_malformed=لا يمكن التحقق من سلامة الملفات المحملة.
|
||||
exception.ssl_handshake=فشل إنشاء اتصال SSL لأن شهادة SSL مفقودة من تثبيت Java الحالي. يمكنك محاولة فتح HMCL باستخدام تثبيت Java آخر والمحاولة مرة أخرى.
|
||||
exception.dns.pollution=فشل إنشاء اتصال SSL. قد تكون هناك مشكلة في تحليل أسماء النطاقات (DNS). يرجى محاولة تغيير خادم DNS أو استخدام خدمة وكيل.
|
||||
|
||||
extension.bat=ملف Batch لـ Windows
|
||||
extension.mod=ملف التعديل
|
||||
@@ -1400,7 +1402,6 @@ system.architecture=البنية
|
||||
system.operating_system=نظام التشغيل
|
||||
|
||||
terracotta=اللعب الجماعي
|
||||
terracotta.easytier=حول EasyTier
|
||||
terracotta.terracotta=Terracotta | اللعب الجماعي
|
||||
terracotta.status=الردهة
|
||||
terracotta.back=خروج
|
||||
|
||||
@@ -74,6 +74,7 @@ account.failed.server_disconnected=No se ha podido conectar con el servidor de a
|
||||
Si lo intentas varias veces y sigue fallando, vuelve a intentar iniciar sesión en la cuenta.
|
||||
account.failed.server_response_malformed=Respuesta del servidor no válida, el servidor de autenticación puede no estar funcionando.
|
||||
account.failed.ssl=Se ha producido un error SSL al conectar con el servidor. Por favor, intente actualizar su Java.
|
||||
account.failed.dns=Se produjo un error SSL al conectar con el servidor. Es posible que la resolución DNS sea incorrecta. Intente cambiar el servidor DNS o usar un servicio proxy.
|
||||
account.failed.wrong_account=Ha iniciado sesión en la cuenta equivocada.
|
||||
account.hmcl.hint=Debe hacer clic en «Iniciar sesión» y completar el proceso en la ventana abierta del navegador.
|
||||
account.injector.add=Nuevo servidor Auth
|
||||
@@ -359,6 +360,7 @@ exception.access_denied=HMCL no puede acceder al archivo %s. Puede estar bloquea
|
||||
Si no es así, comprueba si tu cuenta tiene permisos suficientes para acceder a ella.
|
||||
exception.artifact_malformed=No se puede verificar la integridad de los archivos descargados.
|
||||
exception.ssl_handshake=No se pudo establecer una conexión SSL porque falta el certificado SSL en la instalación actual de Java. Puede intentar abrir HMCL con otro Java y volver a intentarlo.
|
||||
exception.dns.pollution=No se pudo establecer una conexión SSL. Es posible que la resolución DNS sea incorrecta. Por favor, intente cambiar el servidor DNS o usar un servicio proxy.
|
||||
|
||||
extension.bat=Archivo por lotes de Windows
|
||||
extension.mod=Archivo mod
|
||||
@@ -1111,6 +1113,9 @@ world.chunkbase.end_city=Ciudad del End
|
||||
world.chunkbase.seed_map=Vista previa de la generación mundial
|
||||
world.chunkbase.stronghold=Fortaleza
|
||||
world.chunkbase.nether_fortress=Fortaleza del Nether
|
||||
world.duplicate.failed.already_exists=El directorio ya existe
|
||||
world.duplicate.failed.empty_name=El nombre no puede estar vacío
|
||||
world.duplicate.failed.invalid_name=El nombre contiene caracteres no válidos
|
||||
world.datapack=Paquetes de datos
|
||||
world.datetime=Jugado por última vez en %s
|
||||
world.delete=Eliminar este mundo
|
||||
|
||||
@@ -310,6 +310,7 @@ exception.access_denied=ファイル %s にアクセスできないので、HMCL
|
||||
Windowsユーザーの場合、リソースモニターでプログラムがファイルを占有しているかどうかを確認し、もしそうなら、このファイルを占有している関連プログラムを閉じるか、コンピュータを再起動して、もう一度試してみることも可能です。
|
||||
exception.artifact_malformed=ダウンロードしたファイルがチェックサムを通過していない。
|
||||
exception.ssl_handshake=現在のJava仮想マシンに該当するSSL証明書がないため、SSL接続を確立できませんでした。別のJava仮想マシンでHMCLを起動して、もう一度試してみてください。
|
||||
exception.dns.pollution=SSL 接続を確立できませんでした。DNS 解決が正しくない可能性があります。DNS サーバーを変更するか、プロキシサービスを使用してみてください。
|
||||
|
||||
extension.bat=WindowsBatファイル
|
||||
extension.mod=Modファイル
|
||||
|
||||
@@ -78,6 +78,7 @@ account.failed.server_disconnected=不可訪所入伺服器。戶簿訊更敗矣
|
||||
君可求助於右上之鈕。
|
||||
account.failed.server_response_malformed=鑒權伺服器之訊有謬。伺服器或壞。
|
||||
account.failed.ssl=將訪伺服器而 SSL 有謬。站證或舊,抑爪哇之版舊。宜新爪哇,抑廢代而再試之。\n君可求助於右上之鈕。
|
||||
account.failed.dns=將訪伺服器而 SSL 有謬。或由 DNS 解析有誤也。宜更 DNS 伺服,抑用代理以訪。\n君可求助於右上之鈕。
|
||||
account.failed.wrong_account=入謬戶簿
|
||||
account.hmcl.hint=子須擊「登戶簿」之紐,並登簿於所見之頁。
|
||||
account.injector.add=增鑒權伺服器
|
||||
@@ -352,6 +353,7 @@ exception.access_denied=不能訪案「%s」。HMCL 或無權以訪之,抑既
|
||||
凡有謬,遽求助於右上之鈕。
|
||||
exception.artifact_malformed=所引之案未能經校。\n君可求助於右上之鈕
|
||||
exception.ssl_handshake=無築 SSL 鏈。爪哇缺證。宜改爪哇,抑制廢爾代。\n君可求助於右上之鈕。
|
||||
exception.dns.pollution=無築 SSL 鏈。或由 DNS 污染、解析有誤也。宜更 DNS 伺服,抑用代理以訪。\n君可求助於右上之鈕。
|
||||
|
||||
extension.bat=視窗角本
|
||||
extension.mod=改囊案
|
||||
|
||||
@@ -77,6 +77,7 @@ account.failed.server_disconnected=Невозможно получить дос
|
||||
аккаунта.
|
||||
account.failed.server_response_malformed=Неверный ответ сервера, видимо сервер авторизации не работает.
|
||||
account.failed.ssl=При подключении к серверу произошла ошибка SSL. Пожалуйста, попробуйте обновить Java.
|
||||
account.failed.dns=При подключении к серверу произошла ошибка SSL. Возможно, проблемы с разрешением DNS. Попробуйте сменить DNS‑сервер или воспользоваться прокси-службой.
|
||||
account.failed.wrong_account=Вы вошли в неверный аккаунт.
|
||||
account.hmcl.hint=Необходимо нажать на «Войти» и завершить процесс в открывшейся вкладке вашего браузера.
|
||||
account.injector.add=Новый сервер авторизации
|
||||
@@ -359,6 +360,7 @@ exception.access_denied=Лаунчер не может получить дост
|
||||
Если нет, проверьте, достаточно ли привилегий у вашего аккаунта для доступа к нему.
|
||||
exception.artifact_malformed=Не удалось проверить целостность скачаных файлов.
|
||||
exception.ssl_handshake=Не удалось установить SSL-соединение из-за отсутствия SSL-сертификатов в текущей установке Java. Вы можете попробовать запустить лаунчер в другой версии Java, а затем повторить попытку.
|
||||
exception.dns.pollution=Не удалось установить SSL‑соединение. Возможно, некорректно разрешаются DNS‑имена. Попробуйте сменить DNS‑сервер или воспользоваться прокси-службой.
|
||||
|
||||
extension.bat=Пакетный файл Windows
|
||||
extension.mod=Файл мода
|
||||
@@ -1104,6 +1106,9 @@ world.chunkbase.end_city=Город Края
|
||||
world.chunkbase.seed_map=Предпросмотр генерации мира
|
||||
world.chunkbase.stronghold=Крепость
|
||||
world.chunkbase.nether_fortress=Крепость Нижнего мира
|
||||
world.duplicate.failed.already_exists=Папка уже существует
|
||||
world.duplicate.failed.empty_name=Название не может быть пустым
|
||||
world.duplicate.failed.invalid_name=Название содержит недопустимые символы
|
||||
world.delete=Удалить этот мир
|
||||
world.delete.failed=Не удалось удалить мир.\n%s
|
||||
world.datapack=Наборы данных
|
||||
|
||||
@@ -73,6 +73,7 @@ account.failed.server_disconnected=Не вдалося підключитися
|
||||
Якщо проблема не зникає після кількох спроб, спробуйте знову увійти до облікового запису.
|
||||
account.failed.server_response_malformed=Недійсна відповідь сервера. Можливо, сервер автентифікації не працює.
|
||||
account.failed.ssl=Під час підключення до сервера сталася помилка SSL. Спробуйте оновити Java.
|
||||
account.failed.dns=Під час підключення до сервера сталася помилка SSL. Можливо, DNS неправильно розв'язується. Спробуйте змінити DNS-сервер або використовувати проксі-сервіс.
|
||||
account.failed.wrong_account=Ви увійшли до неправильного облікового запису.
|
||||
account.hmcl.hint=Вам потрібно натиснути "Увійти" і завершити процес у відкритому вікні браузера.
|
||||
account.injector.add=Новий сервер автентифікації
|
||||
@@ -357,6 +358,7 @@ exception.access_denied=HMCL не може отримати доступ до ф
|
||||
Якщо ні, перевірте, чи має ваш обліковий запис достатні дозволи для доступу до нього.
|
||||
exception.artifact_malformed=Не вдається перевірити цілісність завантажених файлів.
|
||||
exception.ssl_handshake=Не вдалося встановити SSL-з'єднання, оскільки SSL-сертифікат відсутній у поточній інсталяції Java. Ви можете спробувати відкрити HMCL іншою інсталяцією Java і спробувати знову.
|
||||
exception.dns.pollution=Не вдалося встановити SSL-з'єднання. Можливо, DNS неправильно розв'язується. Спробуйте змінити DNS-сервер або використовувати проксі-сервіс.
|
||||
|
||||
extension.bat=Пакетний файл Windows
|
||||
extension.mod=Файл мода
|
||||
@@ -1048,6 +1050,9 @@ world.chunkbase.end_city=Кінцеве місто
|
||||
world.chunkbase.seed_map=Карта насіння
|
||||
world.chunkbase.stronghold=Фортеця
|
||||
world.chunkbase.nether_fortress=Форт Незеру
|
||||
world.duplicate.failed.already_exists=Каталог вже існує
|
||||
world.duplicate.failed.empty_name=Назва не може бути порожньою
|
||||
world.duplicate.failed.invalid_name=Назва містить недійсні символи
|
||||
world.datapack=Datapacks
|
||||
world.datetime=Останній раз грали %s
|
||||
world.delete=Видалити цей світ
|
||||
|
||||
@@ -78,6 +78,7 @@ account.failed.server_disconnected=無法訪問登入伺服器。帳戶資訊重
|
||||
若嘗試多次無法重新整理,可嘗試重新增加該帳戶,或許可以解決該問題。
|
||||
account.failed.server_response_malformed=無法解析認證伺服器回應,可能是伺服器故障。
|
||||
account.failed.ssl=連線伺服器時發生了 SSL 錯誤。可能網站證書已過期或你使用的 Java 版本過低。請嘗試更新 Java。
|
||||
account.failed.dns=連線伺服器時發生了 SSL 錯誤。可能是 DNS 解析有誤。請嘗試更換 DNS 伺服器或使用代理服務。
|
||||
account.failed.wrong_account=登入了錯誤的帳戶
|
||||
account.hmcl.hint=你需要點擊「登入」按鈕,並在開啟的網頁中完成登入
|
||||
account.injector.add=新增認證伺服器
|
||||
@@ -124,6 +125,7 @@ account.methods.microsoft.purchase=購買 Minecraft
|
||||
account.methods.forgot_password=忘記密碼
|
||||
account.methods.ban_query=查詢帳戶是否被封禁
|
||||
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。請下載 <a href="https://hmcl.huangyuhui.net/download">官方版本</a> 進行登入。
|
||||
account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。請下載官方版本來重新整理帳戶。
|
||||
account.methods.microsoft.snapshot.website=官方網站
|
||||
account.methods.offline=離線模式
|
||||
account.methods.offline.name.special_characters=建議使用英文字母、數字以及底線命名,且長度不超過 16 個字元
|
||||
@@ -370,6 +372,7 @@ exception.access_denied=無法存取檔案「%s」。因為 HMCL 沒有對該檔
|
||||
對於 Windows 使用者,你還可以嘗試透過資源監視器查看是否有程式占用了該檔案。如果是,你可以關閉占用此檔案的程式,或者重啟電腦再試。
|
||||
exception.artifact_malformed=下載的檔案正確,但無法透過校驗。
|
||||
exception.ssl_handshake=無法建立 SSL 連線。目前 Java 缺少相關的 SSL 證書。你可以嘗試使用其他 Java 或關閉網路代理開啟 HMCL 再試。
|
||||
exception.dns.pollution=無法建立 SSL 連線。可能是 DNS 解析有誤。請嘗試更換 DNS 伺服器或使用代理服務。
|
||||
|
||||
extension.bat=Windows 批次檔
|
||||
extension.mod=模組檔案
|
||||
@@ -919,6 +922,13 @@ world.chunkbase.end_city=終界城地圖
|
||||
world.chunkbase.seed_map=種子地圖
|
||||
world.chunkbase.stronghold=要塞地圖
|
||||
world.chunkbase.nether_fortress=地獄要塞地圖
|
||||
world.duplicate=複製此世界
|
||||
world.duplicate.prompt=輸入複製後的世界名稱
|
||||
world.duplicate.failed.already_exists=目錄已存在
|
||||
world.duplicate.failed.empty_name=名稱不能為空
|
||||
world.duplicate.failed.invalid_name=名稱中包含無效字元
|
||||
world.duplicate.failed=複製世界失敗
|
||||
world.duplicate.success.toast=複製世界成功
|
||||
world.datapack=資料包管理
|
||||
world.datetime=上一次遊戲時間: %s
|
||||
world.delete=刪除此世界
|
||||
@@ -930,6 +940,15 @@ world.export.title=選取該世界的儲存位置
|
||||
world.export.location=儲存到
|
||||
world.export.wizard=匯出世界「%s」
|
||||
world.extension=世界壓縮檔
|
||||
world.icon=世界圖示
|
||||
world.icon.change=修改世界圖示
|
||||
world.icon.change.fail.load.title=圖片解析失敗
|
||||
world.icon.change.fail.load.text=該圖片似乎已損毀,HMCL 無法解析它
|
||||
world.icon.change.fail.not_64x64.title=圖片大小錯誤
|
||||
world.icon.change.fail.not_64x64.text=該圖片的解析度為 %d×%d,而不是 64×64,請提供一張 64×64 解析度的圖片再次嘗試
|
||||
world.icon.change.succeed.toast=世界圖示修改成功
|
||||
world.icon.change.tip=請提供一張 64×64 PNG 格式的圖片。錯誤解析度的圖片將無法被 Minecraft 解析。
|
||||
world.icon.choose.title=選擇世界圖示
|
||||
world.import.already_exists=此世界已經存在
|
||||
world.import.choose=選取要匯入的世界壓縮檔
|
||||
world.import.failed=無法匯入此世界: %s
|
||||
@@ -944,6 +963,7 @@ world.info.difficulty.peaceful=和平
|
||||
world.info.difficulty.easy=簡單
|
||||
world.info.difficulty.normal=普通
|
||||
world.info.difficulty.hard=困難
|
||||
world.info.difficulty_lock=鎖定難易度
|
||||
world.info.failed=讀取世界資訊失敗
|
||||
world.info.game_version=遊戲版本
|
||||
world.info.last_played=上一次遊戲時間
|
||||
@@ -964,6 +984,7 @@ world.info.player.xp_level=經驗等級
|
||||
world.info.random_seed=種子碼
|
||||
world.info.time=遊戲內時間
|
||||
world.info.time.format=%s 天
|
||||
world.load.fail=世界載入失敗
|
||||
world.locked=使用中
|
||||
world.locked.failed=該世界正在使用中,請關閉遊戲後重試。
|
||||
world.game_version=遊戲版本
|
||||
@@ -1215,13 +1236,17 @@ system.architecture=架構
|
||||
system.operating_system=作業系統
|
||||
|
||||
terracotta=多人遊戲
|
||||
terracotta.easytier=關於 EasyTier
|
||||
terracotta.terracotta=Terracotta | 陶瓦聯機
|
||||
terracotta.status=聯機大廳
|
||||
terracotta.back=退出
|
||||
terracotta.feedback.title=填寫回饋表
|
||||
terracotta.feedback.desc=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
|
||||
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
|
||||
terracotta.difficulty.easiest=當前網路狀況極佳:稍等一下就成功!
|
||||
terracotta.difficulty.simple=目前網路狀況較好:建立連線需要一段時間…
|
||||
terracotta.difficulty.medium=目前網路狀態中等:已啟用抗干擾備用線路,連線可能失敗
|
||||
terracotta.difficulty.tough=目前網路狀態極差:已啟用抗干擾備用線路,連線可能失敗
|
||||
terracotta.difficulty.estimate_only=連接成功率由房主和房客的 NAT 類型推算得到,僅供參考。
|
||||
terracotta.from_local.title=線上核心第三方下載管道
|
||||
terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
|
||||
terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。
|
||||
|
||||
@@ -79,6 +79,7 @@ account.failed.server_disconnected=无法访问登录服务器。账户信息刷
|
||||
你可以点击右上角帮助按钮进行求助。
|
||||
account.failed.server_response_malformed=无法解析认证服务器响应。可能是服务器故障。
|
||||
account.failed.ssl=连接服务器时发生了 SSL 错误。可能网站证书已过期或你使用的 Java 版本过低。请尝试更新 Java,或关闭网络代理后再试。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.failed.dns=连接服务器时发生了 SSL 错误。可能是 DNS 解析有误。请尝试更换 DNS 服务器或使用代理服务。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.failed.wrong_account=登录了错误的账户
|
||||
account.hmcl.hint=你需要点击“登录”按钮,并在弹出的网页中完成登录。
|
||||
account.injector.add=添加认证服务器
|
||||
@@ -126,6 +127,7 @@ account.methods.microsoft.purchase=购买 Minecraft
|
||||
account.methods.forgot_password=忘记密码
|
||||
account.methods.ban_query=检测账户是否被封禁
|
||||
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。请下载 <a href="https://hmcl.huangyuhui.net/download">官方版本</a> 来登录微软账户。
|
||||
account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。请下载官方版本来刷新账户。
|
||||
account.methods.microsoft.snapshot.website=官方网站
|
||||
account.methods.offline=离线模式
|
||||
account.methods.offline.name.special_characters=建议使用英文字符、数字以及下划线命名,且长度不超过 16 个字符
|
||||
@@ -373,6 +375,7 @@ exception.access_denied=无法访问文件“%s”。HMCL 没有对该文件的
|
||||
如遇到问题,你可以点击右上角帮助按钮进行求助。
|
||||
exception.artifact_malformed=下载的文件无法通过校验。\n你可以点击右上角帮助按钮进行求助。
|
||||
exception.ssl_handshake=无法建立 SSL 连接。当前 Java 缺少相关的 SSL 证书。你可以尝试使用其他 Java 启动 HMCL 再试。\n你可以点击右上角帮助按钮进行求助。
|
||||
exception.dns.pollution=无法建立 SSL 连接。可能是 DNS 解析有误。请尝试更换 DNS 服务器或使用代理服务。\n你可以点击右上角帮助按钮进行求助。
|
||||
|
||||
extension.bat=Windows 脚本
|
||||
extension.mod=模组文件
|
||||
@@ -923,6 +926,13 @@ world.chunkbase.end_city=末地城地图
|
||||
world.chunkbase.seed_map=种子地图
|
||||
world.chunkbase.stronghold=要塞地图
|
||||
world.chunkbase.nether_fortress=下界要塞地图
|
||||
world.duplicate=复制此世界
|
||||
world.duplicate.prompt=输入复制后的世界名称
|
||||
world.duplicate.failed.already_exists=文件夹已存在
|
||||
world.duplicate.failed.empty_name=名称不能为空
|
||||
world.duplicate.failed.invalid_name=名称中包含非法字符
|
||||
world.duplicate.failed=复制世界失败
|
||||
world.duplicate.success.toast=复制世界成功
|
||||
world.datapack=数据包管理
|
||||
world.datetime=上一次游戏时间: %s
|
||||
world.delete=删除此世界
|
||||
@@ -935,6 +945,15 @@ world.export.location=保存到
|
||||
world.export.wizard=导出世界“%s”
|
||||
world.extension=世界压缩包
|
||||
world.game_version=游戏版本
|
||||
world.icon=世界图标
|
||||
world.icon.change=修改世界图标
|
||||
world.icon.change.fail.load.title=图片解析出错
|
||||
world.icon.change.fail.load.text=该图片似乎已损坏,HMCL 无法解析它
|
||||
world.icon.change.fail.not_64x64.title=图片大小错误
|
||||
world.icon.change.fail.not_64x64.text=该图片的分辨率为 %d×%d,而不是 64×64,请提供一张 64×64 分辨率的图片再次尝试
|
||||
world.icon.change.succeed.toast=世界图标修改成功
|
||||
world.icon.change.tip=请提供一张 64×64 PNG 格式的图片。错误分辨率的图片将无法被 Minecraft 解析。
|
||||
world.icon.choose.title=选择世界图标
|
||||
world.import.already_exists=此世界已经存在
|
||||
world.import.choose=选择要导入的世界压缩包
|
||||
world.import.failed=无法导入此世界:%s
|
||||
@@ -949,6 +968,7 @@ world.info.difficulty.peaceful=和平
|
||||
world.info.difficulty.easy=简单
|
||||
world.info.difficulty.normal=普通
|
||||
world.info.difficulty.hard=困难
|
||||
world.info.difficulty_lock=锁定难度
|
||||
world.info.failed=读取世界信息失败
|
||||
world.info.game_version=游戏版本
|
||||
world.info.last_played=上一次游戏时间
|
||||
@@ -969,6 +989,7 @@ world.info.player.xp_level=经验等级
|
||||
world.info.random_seed=种子
|
||||
world.info.time=游戏内时间
|
||||
world.info.time.format=%s 天
|
||||
world.load.fail=世界加载失败
|
||||
world.locked=使用中
|
||||
world.locked.failed=该世界正在使用中,请关闭游戏后重试。
|
||||
world.manage=世界管理
|
||||
@@ -1219,13 +1240,17 @@ system.architecture=架构
|
||||
system.operating_system=操作系统
|
||||
|
||||
terracotta=多人联机
|
||||
terracotta.easytier=关于 EasyTier
|
||||
terracotta.terracotta=Terracotta | 陶瓦联机
|
||||
terracotta.status=联机大厅
|
||||
terracotta.back=退出
|
||||
terracotta.feedback.title=填写反馈表
|
||||
terracotta.feedback.desc=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
|
||||
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
|
||||
terracotta.difficulty.easiest=当前网络状态极好:稍等一下就成功!
|
||||
terracotta.difficulty.simple=当前网络状态较好:建立连接需要一段时间……
|
||||
terracotta.difficulty.medium=当前网络状态中等:已启用抗干扰备用线路,连接可能失败
|
||||
terracotta.difficulty.tough=当前网络状态极差:已启用抗干扰备用线路,连接可能失败
|
||||
terracotta.difficulty.estimate_only=连接成功率由房主和房客的 NAT 类型推算得到,仅供参考。
|
||||
terracotta.from_local.title=联机核心第三方下载渠道
|
||||
terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢
|
||||
terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。
|
||||
|
||||
@@ -1,29 +1,73 @@
|
||||
{
|
||||
"version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|(1[0-3]))",
|
||||
"version_recent": [
|
||||
"0.3.13"
|
||||
],
|
||||
"version_latest": "0.3.14",
|
||||
"classifiers": {
|
||||
"freebsd-x86_64": "sha256:ff8c50b701e55fde9701a1f7fbe9d7317b24087c28ca6337ba9d695c53ad499a",
|
||||
"linux-arm64": "sha256:6912a14255cdeedee512244c686dd7f80dcc45d8dfbe129ea4ea3f1b22d3de60",
|
||||
"linux-x86_64": "sha256:9b13f51dc41c5112f2ab163df5c0dccdff3d22deb793a6698c013e9c09badd00",
|
||||
"linux-loongarch64": "sha256:729ca24ef643547b6a3917520ec3335b92e226ad1884c85d6bb6c2cfa917cb03",
|
||||
"linux-riscv64": "sha256:afbd8cffeacc47431f18c3ae659f7a308e1d3f2efab0ab9c5ece5176d068bf30",
|
||||
"macos-arm64": "sha256:9ed9c7a35b01dce440135c91e4a5e617d84f6c8ba13a117d13a2796dc69584f5",
|
||||
"macos-arm64.pkg": "sha256:49dbebd608c4036c260f09d4005dedaacb26fc1582b10479ed51476baa48f4a9",
|
||||
"macos-x86_64": "sha256:40a44703237b0a194b0fb18075e3b2361ace8f4319b60269cb56c5548d2afb3a",
|
||||
"macos-x86_64.pkg": "sha256:95a9de782b9c3a352ae6ec702c2fc4ca34ba807e8e4a768e61902a0c78391332",
|
||||
"windows-arm64.exe": "sha256:8428c6bc6228213c5f645ab3dd0f3b684da515be103ee53d5bdc4adf59fc8a5f",
|
||||
"windows-x86_64.exe": "sha256:942b91888264e1c96a10b7c94bd134f4b10d3e352e3c731def0ff6a3069c8221"
|
||||
"__comment__": "THIS FILE IS MACHINE GENERATED! DO NOT EDIT!",
|
||||
"version_latest": "0.4.2",
|
||||
"packages": {
|
||||
"windows-x86_64": {
|
||||
"hash": "6a98f524d4f00373696517306af8aa50d01d55ce4eadb27e9e4bc2f882707a0b5f20d5d4c33371d1459dcf5bf144ffed9beb414202d9ccf32b11dbbfcf19d650",
|
||||
"files": {
|
||||
"VCRUNTIME140.DLL": "3d4b24061f72c0e957c7b04a0c4098c94c8f1afb4a7e159850b9939c7210d73398be6f27b5ab85073b4e8c999816e7804fef0f6115c39cd061f4aaeb4dcda8cf",
|
||||
"terracotta-0.4.2-windows-x86_64.exe": "6e98d1f2380ed22fb5a2dd4aafce6c773e9cf69100c8bb8e49e7d6983756bdb9a31f80e06bcfbe5a2742144fe806d3d687dec54d8f09d87c659341f99dd9fd80"
|
||||
}
|
||||
},
|
||||
"windows-arm64": {
|
||||
"hash": "fc1077247014ac0c712469498bde2ef7f6d881d5fcb7bdd5e11ebe20218fed365be19afdb8d453a79d77b729f866058522b910741767f4df947faa891434b463",
|
||||
"files": {
|
||||
"VCRUNTIME140.DLL": "5cb5ce114614101d260f4754c09e8a0dd57e4da885ebb96b91e274326f3e1dd95ed0ade9f542f1922fad0ed025e88a1f368e791e1d01fae69718f0ec3c7b98c8",
|
||||
"terracotta-0.4.2-windows-arm64.exe": "30a15c5c53e5817c5a3634532172559327474741d3b2c7ef4e8a30acc6f59cdcf3570bf5f583e3cbe9e2abc8253e977c1abda1e9f36c88c4e99240da257347d0"
|
||||
}
|
||||
},
|
||||
"macos-x86_64": {
|
||||
"hash": "a762e4b2d6f84e899292b9e3856d009411a516d3c47f54575f843ce082f63dff2baa68ba0faa844b8b64fb12e91017386f15f5e7f975f8ee605bf8d4217cb091",
|
||||
"files": {
|
||||
"terracotta-0.4.2-macos-x86_64": "24efb85390eff88a538ed7e503fb1488e5e622730ca30c741a0e8b4c8f4e8d4868a2f9f38da8de540aeb535af2fd1e41c7081dae9c700e8a1a03b6c540218164",
|
||||
"terracotta-0.4.2-macos-x86_64.pkg": "0f80437061231018ec0f860f875aba02cae9e0b36d21f2db8e99d25948806e1119f7844a4d4acca01d6124d7079718762cdebe3756b005a55632de7029fe0d24"
|
||||
}
|
||||
},
|
||||
"macos-arm64": {
|
||||
"hash": "09e444fea2d9fd19f3e5cb62e29055228345be163924cbd408d947646fafed1012cf48508ee6a155ede3d571e2ffaa72d09ceeb1493c8a60feb05e0699f19ba3",
|
||||
"files": {
|
||||
"terracotta-0.4.2-macos-arm64": "8e59a9d78acd57702dc044d6f2799c6af586b075a262f7d4dbbf0876e1af8d8271e04783c24ff820b801e3b14cd0190ab8403d097f3f2d98b6d911f95ed1e972",
|
||||
"terracotta-0.4.2-macos-arm64.pkg": "b0b72f8883767c359f0700e958bd859dd5932c4674fbdf2a32f7de189fb9f54822d4825a754dc856416c8760338eb8ffca80b40c0a57c608fbe6ae9928888993"
|
||||
}
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"hash": "d326ad95815d04568d485b5038e40ffc47ca54292fa0925eee6f5cea014024f901d661708aac2a743037b990882ad82b4d0b7bb03dc3b2fe720dbf0f3efe1c98",
|
||||
"files": {
|
||||
"terracotta-0.4.2-linux-x86_64": "fac328ba8957a711b03557bb913940f22d61b76608cd203fdf51024b6f94b19f5bc91c9b8a9fa80baf6968e1e6873c1880fd4cf54a2f8e3c6cf1e6ac161f8d0c"
|
||||
}
|
||||
},
|
||||
"linux-arm64": {
|
||||
"hash": "57c08f48d9535e93ad547d2dfc852d267992cc164a7208b42a2da0a6cbc2f21862f610e02a746b4b67150f4dec26b86a4f96eb9bd2f58d124d5b40ba50c6d55e",
|
||||
"files": {
|
||||
"terracotta-0.4.2-linux-arm64": "d807744c2041c98686e4b505324713badea7a0f31e8810be49ae053a63fb6dfc474ac58d678fb93eea0dd5cccff7372d9ec6135a1046f4b306cad35cd90ecacd"
|
||||
}
|
||||
},
|
||||
"linux-loongarch64": {
|
||||
"hash": "a19f4ad4fa1fd2f3c1c867f8743178ab0524d1634d63e122ffc080623e4f14207ecab58830fa0d888696ef28ce4cfa189e6ffbe92c6e1aa1836652617a96a801",
|
||||
"files": {
|
||||
"terracotta-0.4.2-linux-loongarch64": "7d4896b275207def0861e544cf2afc9501c1946f1bc8bb2bad9ef4510dd915eabb63eeedff725e69565cdf5c27c146b52dd75d24115d5141909c0865f89cd7b9"
|
||||
}
|
||||
},
|
||||
"linux-riscv64": {
|
||||
"hash": "ef3dfcf3c64dfcaf94adc518f0184d017a32a924f67ef33ffb8128f20669cf177cd345df3be8c00225a275bd44f70657b94fb43c60afeb21238000ac7a7f1721",
|
||||
"files": {
|
||||
"terracotta-0.4.2-linux-riscv64": "afddc55f16f5cf3103548d13f7a3238964c8899dba8eb0e1fdc9e354da65e91fa4486de4b8680a7a3d9326eacf18c1ddb6b5b1f9459908f35f77b36f769172ea"
|
||||
}
|
||||
},
|
||||
"freebsd-x86_64": {
|
||||
"hash": "a4d02ebbb1419f4e9265f5b24060432799151a27e0d1871690d2f3a51f40bcfd6d92c176001a15a62caa9bf31ea1b67a7b2da10e3351bfcdd36bfac1377d08d9",
|
||||
"files": {
|
||||
"terracotta-0.4.2-freebsd-x86_64": "a50da02752cef73d21804b26eb02261568ed3053c8fef1074014d50226843810f71e6eda39a53af3bad8e173cec40826631a4c8877af460d14a307f1b0a62519"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads": [
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
|
||||
],
|
||||
"downloads_CN": [
|
||||
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}"
|
||||
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
|
||||
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
|
||||
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
@@ -40,7 +84,7 @@
|
||||
"zh": "QQ 群",
|
||||
"zh-Hant": "QQ 群"
|
||||
},
|
||||
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
|
||||
"link": "https://qm.qq.com/cgi-bin/qm/qr?k\u003dnIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5\u0026jump_from\u003dwebapi\u0026authKey\u003dsXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"__comment__": "Run upgradeTerracottaConfig task with Gradle to resolve this template.",
|
||||
|
||||
"version_latest": "@script_generated",
|
||||
|
||||
"packages": "@script_generated",
|
||||
|
||||
"downloads": [
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
|
||||
],
|
||||
"downloads_CN": [
|
||||
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
|
||||
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
|
||||
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
|
||||
],
|
||||
|
||||
"links": [
|
||||
{
|
||||
"desc": {
|
||||
"default": "GitHub Release",
|
||||
"zh": "GitHub 发布页",
|
||||
"zh-Hant": "GitHub 發布頁"
|
||||
},
|
||||
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
|
||||
},
|
||||
{
|
||||
"desc": {
|
||||
"default": "Tencent QQ Group",
|
||||
"zh": "QQ 群",
|
||||
"zh-Hant": "QQ 群"
|
||||
},
|
||||
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.download;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -47,25 +49,58 @@ public class MultipleSourceVersionList extends VersionList<RemoteVersion> {
|
||||
|
||||
private Task<?> refreshAsync(String gameVersion, int sourceIndex) {
|
||||
VersionList<?> versionList = backends[sourceIndex];
|
||||
return versionList.refreshAsync(gameVersion)
|
||||
.thenComposeAsync(() -> {
|
||||
Task<?> refreshTask = versionList.refreshAsync(gameVersion);
|
||||
|
||||
return new Task<Object>() {
|
||||
private Task<?> nextTask = null;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
setName("MultipleSourceVersionList.refreshAsync(" + sourceIndex + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependents() {
|
||||
return List.of(refreshTask);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends Task<?>> getDependencies() {
|
||||
return nextTask != null ? List.of(nextTask) : List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (isDependentsSucceeded()) {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
versions.putAll(gameVersion, versionList.getVersions(gameVersion));
|
||||
} catch (Exception e) {
|
||||
if (sourceIndex == backends.length - 1) {
|
||||
LOG.warning("Failed to fetch versions list from all sources", e);
|
||||
throw e;
|
||||
} else {
|
||||
LOG.warning("Failed to fetch versions list and try to fetch from other source", e);
|
||||
return refreshAsync(gameVersion, sourceIndex + 1);
|
||||
}
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
setResult(refreshTask.getResult());
|
||||
} else {
|
||||
Exception exception = refreshTask.getException();
|
||||
assert exception != null;
|
||||
|
||||
if (sourceIndex == backends.length - 1) {
|
||||
LOG.warning("Failed to fetch versions list from all sources", exception);
|
||||
setSignificance(TaskSignificance.MINOR);
|
||||
throw exception;
|
||||
} else {
|
||||
LOG.warning("Failed to fetch versions list and try to fetch from other source", exception);
|
||||
nextTask = refreshAsync(gameVersion, sourceIndex + 1);
|
||||
nextTask.storeTo(this::setResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+4
-1
@@ -92,7 +92,10 @@ public final class CleanroomInstallTask extends Task<Version> {
|
||||
|
||||
@Override
|
||||
public void postExecute() throws Exception {
|
||||
Files.deleteIfExists(installer);
|
||||
if (remote != null) {
|
||||
Files.deleteIfExists(installer);
|
||||
}
|
||||
|
||||
setResult(task.getResult());
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.github.steveice10.opennbt.NBTIO;
|
||||
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
|
||||
import com.github.steveice10.opennbt.tag.builtin.LongTag;
|
||||
import com.github.steveice10.opennbt.tag.builtin.StringTag;
|
||||
import com.github.steveice10.opennbt.tag.builtin.Tag;
|
||||
import com.github.steveice10.opennbt.tag.builtin.*;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.util.io.*;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
@@ -48,13 +45,10 @@ public final class World {
|
||||
|
||||
private final Path file;
|
||||
private String fileName;
|
||||
private String worldName;
|
||||
private GameVersionNumber gameVersion;
|
||||
private long lastPlayed;
|
||||
private CompoundTag levelData;
|
||||
private Image icon;
|
||||
private Long seed;
|
||||
private boolean largeBiomes;
|
||||
private boolean isLocked;
|
||||
private Path levelDataPath;
|
||||
|
||||
public World(Path file) throws IOException {
|
||||
this.file = file;
|
||||
@@ -67,10 +61,100 @@ public final class World {
|
||||
throw new IOException("Path " + file + " cannot be recognized as a Minecraft world");
|
||||
}
|
||||
|
||||
public Path getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getWorldName() {
|
||||
CompoundTag data = levelData.get("Data");
|
||||
StringTag levelNameTag = data.get("LevelName");
|
||||
return levelNameTag.getValue();
|
||||
}
|
||||
|
||||
public void setWorldName(String worldName) throws IOException {
|
||||
if (levelData.get("Data") instanceof CompoundTag data && data.get("LevelName") instanceof StringTag levelNameTag) {
|
||||
levelNameTag.setValue(worldName);
|
||||
writeLevelDat(levelData);
|
||||
}
|
||||
}
|
||||
|
||||
public Path getLevelDatFile() {
|
||||
return file.resolve("level.dat");
|
||||
}
|
||||
|
||||
public Path getSessionLockFile() {
|
||||
return file.resolve("session.lock");
|
||||
}
|
||||
|
||||
public CompoundTag getLevelData() {
|
||||
return levelData;
|
||||
}
|
||||
|
||||
public long getLastPlayed() {
|
||||
CompoundTag data = levelData.get("Data");
|
||||
LongTag lastPlayedTag = data.get("LastPlayed");
|
||||
return lastPlayedTag.getValue();
|
||||
}
|
||||
|
||||
public @Nullable GameVersionNumber getGameVersion() {
|
||||
if (levelData.get("Data") instanceof CompoundTag data &&
|
||||
data.get("Version") instanceof CompoundTag versionTag &&
|
||||
versionTag.get("Name") instanceof StringTag nameTag) {
|
||||
return GameVersionNumber.asGameVersion(nameTag.getValue());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public @Nullable Long getSeed() {
|
||||
CompoundTag data = levelData.get("Data");
|
||||
if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag && worldGenSettingsTag.get("seed") instanceof LongTag seedTag) { //Valid after 1.16
|
||||
return seedTag.getValue();
|
||||
} else if (data.get("RandomSeed") instanceof LongTag seedTag) { //Valid before 1.16
|
||||
return seedTag.getValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isLargeBiomes() {
|
||||
CompoundTag data = levelData.get("Data");
|
||||
if (data.get("generatorName") instanceof StringTag generatorNameTag) { //Valid before 1.16
|
||||
return "largeBiomes".equals(generatorNameTag.getValue());
|
||||
} else {
|
||||
if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag
|
||||
&& worldGenSettingsTag.get("dimensions") instanceof CompoundTag dimensionsTag
|
||||
&& dimensionsTag.get("minecraft:overworld") instanceof CompoundTag overworldTag
|
||||
&& overworldTag.get("generator") instanceof CompoundTag generatorTag) {
|
||||
if (generatorTag.get("biome_source") instanceof CompoundTag biomeSourceTag
|
||||
&& biomeSourceTag.get("large_biomes") instanceof ByteTag largeBiomesTag) { //Valid between 1.16 and 1.16.2
|
||||
return largeBiomesTag.getValue() == (byte) 1;
|
||||
} else if (generatorTag.get("settings") instanceof StringTag settingsTag) { //Valid after 1.16.2
|
||||
return "minecraft:large_biomes".equals(settingsTag.getValue());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Image getIcon() {
|
||||
return icon;
|
||||
}
|
||||
|
||||
public boolean isLocked() {
|
||||
return isLocked;
|
||||
}
|
||||
|
||||
private void loadFromDirectory() throws IOException {
|
||||
fileName = FileUtils.getName(file);
|
||||
Path levelDat = file.resolve("level.dat");
|
||||
loadWorldInfo(levelDat);
|
||||
if (!Files.exists(levelDat)) { // version 20w14infinite
|
||||
levelDat = file.resolve("special_level.dat");
|
||||
}
|
||||
loadAndCheckLevelDat(levelDat);
|
||||
this.levelDataPath = levelDat;
|
||||
isLocked = isLocked(getSessionLockFile());
|
||||
|
||||
Path iconFile = file.resolve("icon.png");
|
||||
@@ -85,56 +169,16 @@ public final class World {
|
||||
}
|
||||
}
|
||||
|
||||
public Path getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getWorldName() {
|
||||
return worldName;
|
||||
}
|
||||
|
||||
public Path getLevelDatFile() {
|
||||
return file.resolve("level.dat");
|
||||
}
|
||||
|
||||
public Path getSessionLockFile() {
|
||||
return file.resolve("session.lock");
|
||||
}
|
||||
|
||||
public long getLastPlayed() {
|
||||
return lastPlayed;
|
||||
}
|
||||
|
||||
public @Nullable GameVersionNumber getGameVersion() {
|
||||
return gameVersion;
|
||||
}
|
||||
|
||||
public @Nullable Long getSeed() {
|
||||
return seed;
|
||||
}
|
||||
|
||||
public boolean isLargeBiomes() {
|
||||
return largeBiomes;
|
||||
}
|
||||
|
||||
public Image getIcon() {
|
||||
return icon;
|
||||
}
|
||||
|
||||
public boolean isLocked() {
|
||||
return isLocked;
|
||||
}
|
||||
|
||||
private void loadFromZipImpl(Path root) throws IOException {
|
||||
Path levelDat = root.resolve("level.dat");
|
||||
if (!Files.exists(levelDat))
|
||||
throw new IOException("Not a valid world zip file since level.dat cannot be found.");
|
||||
if (!Files.exists(levelDat)) { //version 20w14infinite
|
||||
levelDat = root.resolve("special_level.dat");
|
||||
}
|
||||
if (!Files.exists(levelDat)) {
|
||||
throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found.");
|
||||
}
|
||||
|
||||
loadWorldInfo(levelDat);
|
||||
loadAndCheckLevelDat(levelDat);
|
||||
|
||||
Path iconFile = root.resolve("icon.png");
|
||||
if (Files.isRegularFile(iconFile)) {
|
||||
@@ -167,50 +211,22 @@ public final class World {
|
||||
}
|
||||
}
|
||||
|
||||
private void loadWorldInfo(Path levelDat) throws IOException {
|
||||
CompoundTag nbt = parseLevelDat(levelDat);
|
||||
|
||||
CompoundTag data = nbt.get("Data");
|
||||
private void loadAndCheckLevelDat(Path levelDat) throws IOException {
|
||||
this.levelData = parseLevelDat(levelDat);
|
||||
CompoundTag data = levelData.get("Data");
|
||||
if (data == null)
|
||||
throw new IOException("level.dat missing Data");
|
||||
|
||||
if (data.get("LevelName") instanceof StringTag)
|
||||
worldName = data.<StringTag>get("LevelName").getValue();
|
||||
else
|
||||
if (!(data.get("LevelName") instanceof StringTag))
|
||||
throw new IOException("level.dat missing LevelName");
|
||||
|
||||
if (data.get("LastPlayed") instanceof LongTag)
|
||||
lastPlayed = data.<LongTag>get("LastPlayed").getValue();
|
||||
else
|
||||
if (!(data.get("LastPlayed") instanceof LongTag))
|
||||
throw new IOException("level.dat missing LastPlayed");
|
||||
}
|
||||
|
||||
gameVersion = null;
|
||||
if (data.get("Version") instanceof CompoundTag) {
|
||||
CompoundTag version = data.get("Version");
|
||||
|
||||
if (version.get("Name") instanceof StringTag)
|
||||
gameVersion = GameVersionNumber.asGameVersion(version.<StringTag>get("Name").getValue());
|
||||
}
|
||||
|
||||
Tag worldGenSettings = data.get("WorldGenSettings");
|
||||
if (worldGenSettings instanceof CompoundTag) {
|
||||
Tag seedTag = ((CompoundTag) worldGenSettings).get("seed");
|
||||
if (seedTag instanceof LongTag) {
|
||||
seed = ((LongTag) seedTag).getValue();
|
||||
}
|
||||
}
|
||||
if (seed == null) {
|
||||
Tag seedTag = data.get("RandomSeed");
|
||||
if (seedTag instanceof LongTag) {
|
||||
seed = ((LongTag) seedTag).getValue();
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: Only work for 1.15 and below
|
||||
if (data.get("generatorName") instanceof StringTag) {
|
||||
largeBiomes = "largeBiomes".equals(data.<StringTag>get("generatorName").getValue());
|
||||
} else {
|
||||
largeBiomes = false;
|
||||
public void reloadLevelDat() throws IOException {
|
||||
if (levelDataPath != null) {
|
||||
loadAndCheckLevelDat(this.levelDataPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +235,9 @@ public final class World {
|
||||
throw new IOException("Not a valid world directory");
|
||||
|
||||
// Change the name recorded in level.dat
|
||||
CompoundTag nbt = readLevelDat();
|
||||
CompoundTag data = nbt.get("Data");
|
||||
CompoundTag data = levelData.get("Data");
|
||||
data.put(new StringTag("LevelName", newName));
|
||||
writeLevelDat(nbt);
|
||||
writeLevelDat(levelData);
|
||||
|
||||
// then change the folder's name
|
||||
Files.move(file, file.resolveSibling(newName));
|
||||
@@ -283,11 +298,19 @@ public final class World {
|
||||
FileUtils.forceDelete(file);
|
||||
}
|
||||
|
||||
public CompoundTag readLevelDat() throws IOException {
|
||||
if (!Files.isDirectory(file))
|
||||
public void copy(String newName) throws IOException {
|
||||
if (!Files.isDirectory(file)) {
|
||||
throw new IOException("Not a valid world directory");
|
||||
}
|
||||
|
||||
return parseLevelDat(getLevelDatFile());
|
||||
if (isLocked()) {
|
||||
throw new WorldLockedException("The world " + getFile() + " has been locked");
|
||||
}
|
||||
|
||||
Path newPath = file.resolveSibling(newName);
|
||||
FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock"));
|
||||
World newWorld = new World(newPath);
|
||||
newWorld.rename(newName);
|
||||
}
|
||||
|
||||
public FileChannel lock() throws WorldLockedException {
|
||||
|
||||
@@ -172,7 +172,7 @@ public class DefaultLauncher extends Launcher {
|
||||
|
||||
Proxy.Type proxyType = options.getProxyType();
|
||||
if (proxyType == null) {
|
||||
res.addDefault("-Djava.net.useSystemProxies", "true");
|
||||
res.addDefault("-Djava.net.useSystemProxies=", "true");
|
||||
} else {
|
||||
String proxyHost = options.getProxyHost();
|
||||
int proxyPort = options.getProxyPort();
|
||||
@@ -397,9 +397,12 @@ public class DefaultLauncher extends Launcher {
|
||||
for (Library library : version.getLibraries())
|
||||
if (library.isNative())
|
||||
new Unzipper(repository.getLibraryFile(version, library), destination)
|
||||
.setFilter((zipEntry, isDirectory, destFile, path) -> {
|
||||
if (!isDirectory && Files.isRegularFile(destFile) && Files.size(destFile) == Files.size(zipEntry))
|
||||
.setFilter((zipEntry, destFile, relativePath) -> {
|
||||
if (!zipEntry.isDirectory() && !zipEntry.isUnixSymlink()
|
||||
&& Files.isRegularFile(destFile)
|
||||
&& zipEntry.getSize() == Files.size(destFile)) {
|
||||
return false;
|
||||
}
|
||||
String ext = FileUtils.getExtension(destFile);
|
||||
if (ext.equals("sha1") || ext.equals("git"))
|
||||
return false;
|
||||
@@ -411,7 +414,7 @@ public class DefaultLauncher extends Launcher {
|
||||
return false;
|
||||
}
|
||||
|
||||
return library.getExtract().shouldExtract(path);
|
||||
return library.getExtract().shouldExtract(relativePath);
|
||||
})
|
||||
.setReplaceExistentFile(false).unzip();
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -17,19 +17,22 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.mod;
|
||||
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class MinecraftInstanceTask<T> extends Task<ModpackConfiguration<T>> {
|
||||
|
||||
@@ -53,26 +56,42 @@ public final class MinecraftInstanceTask<T> extends Task<ModpackConfiguration<T>
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
private static void getOverrides(List<ModpackConfiguration.FileInformation> overrides,
|
||||
ZipFileTree tree,
|
||||
ArchiveFileTree.Dir<ZipArchiveEntry> dir,
|
||||
List<String> names) throws IOException {
|
||||
String prefix = String.join("/", names);
|
||||
if (!prefix.isEmpty())
|
||||
prefix = prefix + "/";
|
||||
|
||||
for (Map.Entry<String, ZipArchiveEntry> entry : dir.getFiles().entrySet()) {
|
||||
String hash;
|
||||
try (InputStream input = tree.getInputStream(entry.getValue())) {
|
||||
hash = DigestUtils.digestToString("SHA-1", input);
|
||||
}
|
||||
overrides.add(new ModpackConfiguration.FileInformation(prefix + entry.getKey(), hash));
|
||||
}
|
||||
|
||||
for (ArchiveFileTree.Dir<ZipArchiveEntry> subDir : dir.getSubDirs().values()) {
|
||||
names.add(subDir.getName());
|
||||
getOverrides(overrides, tree, subDir, names);
|
||||
names.remove(names.size() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
List<ModpackConfiguration.FileInformation> overrides = new ArrayList<>();
|
||||
|
||||
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(encoding).build()) {
|
||||
try (var tree = new ZipFileTree(CompressingUtils.openZipFileWithPossibleEncoding(zipFile, encoding))) {
|
||||
for (String subDirectory : subDirectories) {
|
||||
Path root = fs.getPath(subDirectory);
|
||||
|
||||
if (Files.exists(root))
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||
String relativePath = root.relativize(file).normalize().toString().replace(File.separatorChar, '/');
|
||||
overrides.add(new ModpackConfiguration.FileInformation(relativePath, DigestUtils.digestToString("SHA-1", file)));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
ArchiveFileTree.Dir<ZipArchiveEntry> root = tree.getDirectory(subDirectory);
|
||||
if (root == null)
|
||||
continue;
|
||||
var names = new ArrayList<String>();
|
||||
getOverrides(overrides, tree, root, names);
|
||||
}
|
||||
}
|
||||
|
||||
ModpackConfiguration<T> configuration = new ModpackConfiguration<>(manifest, type, name, version, overrides);
|
||||
Files.createDirectories(jsonFile.getParent());
|
||||
JsonUtils.writeToJsonFile(jsonFile, configuration);
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
@@ -38,7 +39,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
public final class ModManager {
|
||||
@FunctionalInterface
|
||||
private interface ModMetadataReader {
|
||||
LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException;
|
||||
LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException;
|
||||
}
|
||||
|
||||
private static final Map<String, List<Pair<ModMetadataReader, ModLoaderType>>> READERS;
|
||||
@@ -125,10 +126,10 @@ public final class ModManager {
|
||||
LocalModFile modInfo = null;
|
||||
|
||||
List<Exception> exceptions = new ArrayList<>();
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file)) {
|
||||
try (ZipFileTree tree = CompressingUtils.openZipTree(file)) {
|
||||
for (ModMetadataReader reader : supportedReaders) {
|
||||
try {
|
||||
modInfo = reader.fromFile(this, file, fs);
|
||||
modInfo = reader.fromFile(this, file, tree);
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
exceptions.add(e);
|
||||
@@ -138,7 +139,7 @@ public final class ModManager {
|
||||
if (modInfo == null) {
|
||||
for (ModMetadataReader reader : unsupportedReaders) {
|
||||
try {
|
||||
modInfo = reader.fromFile(this, file, fs);
|
||||
modInfo = reader.fromFile(this, file, tree);
|
||||
break;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
@@ -73,22 +73,22 @@ public class ModpackInstallTask<T> extends Task<Void> {
|
||||
.setTerminateIfSubDirectoryNotExists()
|
||||
.setReplaceExistentFile(true)
|
||||
.setEncoding(charset)
|
||||
.setFilter((destPath, isDirectory, zipEntry, entryPath) -> {
|
||||
if (isDirectory) return true;
|
||||
if (!callback.test(entryPath)) return false;
|
||||
entries.add(entryPath);
|
||||
.setFilter((zipEntry, destFile, relativePath) -> {
|
||||
if (zipEntry.isDirectory()) return true;
|
||||
if (!callback.test(relativePath)) return false;
|
||||
entries.add(relativePath);
|
||||
|
||||
if (!files.containsKey(entryPath)) {
|
||||
if (!files.containsKey(relativePath)) {
|
||||
// If old modpack does not have this entry, add this entry or override the file that user added.
|
||||
return true;
|
||||
} else if (!Files.exists(destPath)) {
|
||||
} else if (!Files.exists(destFile)) {
|
||||
// If both old and new modpacks have this entry, but the file is deleted by user, leave it missing.
|
||||
return false;
|
||||
} else {
|
||||
// If both old and new modpacks have this entry, and user has modified this file,
|
||||
// we will not replace it since this modified file is what user expects.
|
||||
String fileHash = DigestUtils.digestToString("SHA-1", destPath);
|
||||
String oldHash = files.get(entryPath).getHash();
|
||||
String fileHash = DigestUtils.digestToString("SHA-1", destFile);
|
||||
String oldHash = files.get(relativePath).getHash();
|
||||
return Objects.equals(oldHash, fileHash);
|
||||
}
|
||||
}).unzip();
|
||||
|
||||
+94
-62
@@ -36,6 +36,7 @@ import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
@@ -46,6 +47,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
|
||||
|
||||
private static final String PREFIX = "https://api.curseforge.com";
|
||||
private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
|
||||
private static final Semaphore SEMAPHORE = new Semaphore(16);
|
||||
|
||||
private static final int WORD_PERFECT_MATCH_WEIGHT = 5;
|
||||
|
||||
@@ -110,46 +112,55 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
|
||||
int categoryId = 0;
|
||||
if (category != null && category.getSelf() instanceof CurseAddon.Category) {
|
||||
categoryId = ((CurseAddon.Category) category.getSelf()).getId();
|
||||
}
|
||||
Response<List<CurseAddon>> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", mapOf(
|
||||
pair("gameId", "432"),
|
||||
pair("classId", Integer.toString(section)),
|
||||
pair("categoryId", Integer.toString(categoryId)),
|
||||
pair("gameVersion", gameVersion),
|
||||
pair("searchFilter", searchFilter),
|
||||
pair("sortField", Integer.toString(toModsSearchSortField(sortType))),
|
||||
pair("sortOrder", toSortOrder(sortOrder)),
|
||||
pair("index", Integer.toString(pageOffset * pageSize)),
|
||||
pair("pageSize", Integer.toString(pageSize)))))))
|
||||
.getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
|
||||
if (searchFilter.isEmpty()) {
|
||||
return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
|
||||
}
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/1549
|
||||
String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT);
|
||||
Map<String, Integer> searchFilterWords = new HashMap<>();
|
||||
for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) {
|
||||
searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1);
|
||||
}
|
||||
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
|
||||
return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> {
|
||||
String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT);
|
||||
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
|
||||
|
||||
for (String s : StringUtils.tokenize(lowerCaseResult)) {
|
||||
if (searchFilterWords.containsKey(s)) {
|
||||
diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length();
|
||||
}
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
int categoryId = 0;
|
||||
if (category != null && category.getSelf() instanceof CurseAddon.Category) {
|
||||
categoryId = ((CurseAddon.Category) category.getSelf()).getId();
|
||||
}
|
||||
|
||||
return pair(remoteMod, diff);
|
||||
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
|
||||
var query = new LinkedHashMap<String, String>();
|
||||
query.put("gameId", "432");
|
||||
query.put("classId", Integer.toString(section));
|
||||
if (categoryId != 0)
|
||||
query.put("categoryId", Integer.toString(categoryId));
|
||||
query.put("gameVersion", gameVersion);
|
||||
query.put("searchFilter", searchFilter);
|
||||
query.put("sortField", Integer.toString(toModsSearchSortField(sortType)));
|
||||
query.put("sortOrder", toSortOrder(sortOrder));
|
||||
query.put("index", Integer.toString(pageOffset * pageSize));
|
||||
query.put("pageSize", Integer.toString(pageSize));
|
||||
|
||||
Response<List<CurseAddon>> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", query))))
|
||||
.getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
|
||||
if (searchFilter.isEmpty()) {
|
||||
return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
|
||||
}
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/1549
|
||||
String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT);
|
||||
Map<String, Integer> searchFilterWords = new HashMap<>();
|
||||
for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) {
|
||||
searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1);
|
||||
}
|
||||
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
|
||||
return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> {
|
||||
String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT);
|
||||
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
|
||||
|
||||
for (String s : StringUtils.tokenize(lowerCaseResult)) {
|
||||
if (searchFilterWords.containsKey(s)) {
|
||||
diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length();
|
||||
}
|
||||
}
|
||||
|
||||
return pair(remoteMod, diff);
|
||||
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -173,48 +184,69 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Response<FingerprintMatchesResult> response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432"))
|
||||
.json(mapOf(pair("fingerprints", Collections.singletonList(hash))))
|
||||
.getJson(Response.typeOf(FingerprintMatchesResult.class));
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
Response<FingerprintMatchesResult> response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432"))
|
||||
.json(mapOf(pair("fingerprints", Collections.singletonList(hash))))
|
||||
.getJson(Response.typeOf(FingerprintMatchesResult.class));
|
||||
|
||||
if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) {
|
||||
return Optional.empty();
|
||||
if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion());
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
|
||||
return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod getModById(String id) throws IOException {
|
||||
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
|
||||
.getJson(Response.typeOf(CurseAddon.class));
|
||||
return response.data.toMod();
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
|
||||
.getJson(Response.typeOf(CurseAddon.class));
|
||||
return response.data.toMod();
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
|
||||
Response<CurseAddon.LatestFile> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId)))
|
||||
.getJson(Response.typeOf(CurseAddon.LatestFile.class));
|
||||
return response.getData().toVersion().getFile();
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
Response<CurseAddon.LatestFile> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId)))
|
||||
.getJson(Response.typeOf(CurseAddon.LatestFile.class));
|
||||
return response.getData().toVersion().getFile();
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
|
||||
Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
|
||||
pair("pageSize", "10000")))
|
||||
.getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class)));
|
||||
return response.getData().stream().map(CurseAddon.LatestFile::toVersion);
|
||||
}
|
||||
|
||||
public List<CurseAddon.Category> getCategoriesImpl() throws IOException {
|
||||
Response<List<CurseAddon.Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
|
||||
.getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class)));
|
||||
return reorganizeCategories(categories.getData(), section);
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
|
||||
pair("pageSize", "10000")))
|
||||
.getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class)));
|
||||
return response.getData().stream().map(CurseAddon.LatestFile::toVersion);
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteModRepository.Category> getCategories() throws IOException {
|
||||
return getCategoriesImpl().stream().map(CurseAddon.Category::toCategory);
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
Response<List<CurseAddon.Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
|
||||
.getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class)));
|
||||
return reorganizeCategories(categories.getData(), section).stream().map(CurseAddon.Category::toCategory);
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
private List<CurseAddon.Category> reorganizeCategories(List<CurseAddon.Category> categories, int rootId) {
|
||||
|
||||
@@ -19,16 +19,16 @@ package org.jackhuang.hmcl.mod.modinfo;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -59,11 +59,11 @@ public final class FabricModMetadata {
|
||||
this.contact = contact;
|
||||
}
|
||||
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
|
||||
Path mcmod = fs.getPath("fabric.mod.json");
|
||||
if (Files.notExists(mcmod))
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
|
||||
ZipArchiveEntry mcmod = tree.getEntry("fabric.mod.json");
|
||||
if (mcmod == null)
|
||||
throw new IOException("File " + modFile + " is not a Fabric mod.");
|
||||
FabricModMetadata metadata = JsonUtils.fromNonNullJson(Files.readString(mcmod), FabricModMetadata.class);
|
||||
FabricModMetadata metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), FabricModMetadata.class);
|
||||
String authors = metadata.authors == null ? "" : metadata.authors.stream().map(author -> author.name).collect(Collectors.joining(", "));
|
||||
return new LocalModFile(modManager, modManager.getLocalMod(metadata.id, ModLoaderType.FABRIC), modFile, metadata.name, new LocalModFile.Description(metadata.description),
|
||||
authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon);
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.moandjiezana.toml.Toml;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
@@ -16,14 +17,13 @@ import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -149,33 +149,33 @@ public final class ForgeNewModMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException {
|
||||
return fromFile(modManager, modFile, fs, ModLoaderType.FORGE);
|
||||
public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException {
|
||||
return fromFile(modManager, modFile, tree, ModLoaderType.FORGE);
|
||||
}
|
||||
|
||||
public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException {
|
||||
return fromFile(modManager, modFile, fs, ModLoaderType.NEO_FORGED);
|
||||
public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException {
|
||||
return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGED);
|
||||
}
|
||||
|
||||
private static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException {
|
||||
private static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException {
|
||||
if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGED) {
|
||||
throw new IOException("Invalid mod loader: " + modLoaderType);
|
||||
}
|
||||
|
||||
if (modLoaderType == ModLoaderType.NEO_FORGED) {
|
||||
try {
|
||||
return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, fs);
|
||||
return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, tree);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, fs);
|
||||
return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, tree);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
return fromEmbeddedMod(modManager, modFile, fs, modLoaderType);
|
||||
return fromEmbeddedMod(modManager, modFile, tree, modLoaderType);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
@@ -187,19 +187,19 @@ public final class ForgeNewModMetadata {
|
||||
ModLoaderType modLoaderType,
|
||||
ModManager modManager,
|
||||
Path modFile,
|
||||
FileSystem fs) throws IOException, JsonParseException {
|
||||
Path modToml = fs.getPath(tomlPath);
|
||||
if (Files.notExists(modToml))
|
||||
ZipFileTree tree) throws IOException, JsonParseException {
|
||||
ZipArchiveEntry modToml = tree.getEntry(tomlPath);
|
||||
if (modToml == null)
|
||||
throw new IOException("File " + modFile + " is not a Forge 1.13+ or NeoForge mod.");
|
||||
Toml toml = new Toml().read(Files.readString(modToml));
|
||||
Toml toml = new Toml().read(tree.readTextEntry(modToml));
|
||||
ForgeNewModMetadata metadata = toml.to(ForgeNewModMetadata.class);
|
||||
if (metadata == null || metadata.getMods().isEmpty())
|
||||
throw new IOException("Mod " + modFile + " `mods.toml` is malformed..");
|
||||
Mod mod = metadata.getMods().get(0);
|
||||
Path manifestMF = fs.getPath("META-INF/MANIFEST.MF");
|
||||
ZipArchiveEntry manifestMF = tree.getEntry("META-INF/MANIFEST.MF");
|
||||
String jarVersion = "";
|
||||
if (Files.exists(manifestMF)) {
|
||||
try (InputStream is = Files.newInputStream(manifestMF)) {
|
||||
if (manifestMF != null) {
|
||||
try (InputStream is = tree.getInputStream(manifestMF)) {
|
||||
Manifest manifest = new Manifest(is);
|
||||
jarVersion = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
|
||||
} catch (IOException e) {
|
||||
@@ -215,30 +215,30 @@ public final class ForgeNewModMetadata {
|
||||
metadata.getLogoFile());
|
||||
}
|
||||
|
||||
private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException {
|
||||
Path manifestFile = fs.getPath("META-INF/MANIFEST.MF");
|
||||
if (!Files.isRegularFile(manifestFile))
|
||||
throw new IOException("Missing MANIFEST.MF in file " + manifestFile);
|
||||
private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException {
|
||||
ZipArchiveEntry manifestFile = tree.getEntry("META-INF/MANIFEST.MF");
|
||||
if (manifestFile == null)
|
||||
throw new IOException("Missing MANIFEST.MF in file " + modFile);
|
||||
|
||||
Manifest manifest;
|
||||
try (InputStream input = Files.newInputStream(manifestFile)) {
|
||||
try (InputStream input = tree.getInputStream(manifestFile)) {
|
||||
manifest = new Manifest(input);
|
||||
}
|
||||
|
||||
List<Path> embeddedModFiles = List.of();
|
||||
List<ZipArchiveEntry> embeddedModFiles = List.of();
|
||||
|
||||
String embeddedDependenciesMod = manifest.getMainAttributes().getValue("Embedded-Dependencies-Mod");
|
||||
if (embeddedDependenciesMod != null) {
|
||||
Path embeddedModFile = fs.getPath(embeddedDependenciesMod);
|
||||
if (!Files.isRegularFile(embeddedModFile)) {
|
||||
LOG.warning("Missing embedded-dependencies-mod: " + embeddedModFile);
|
||||
ZipArchiveEntry embeddedModFile = tree.getEntry(embeddedDependenciesMod);
|
||||
if (embeddedModFile == null) {
|
||||
LOG.warning("Missing embedded-dependencies-mod: " + embeddedDependenciesMod);
|
||||
throw new IOException();
|
||||
}
|
||||
embeddedModFiles = List.of(embeddedModFile);
|
||||
} else {
|
||||
Path jarInJarMetadata = fs.getPath("META-INF/jarjar/metadata.json");
|
||||
if (Files.isRegularFile(jarInJarMetadata)) {
|
||||
JarInJarMetadata metadata = JsonUtils.fromJsonFile(jarInJarMetadata, JarInJarMetadata.class);
|
||||
ZipArchiveEntry jarInJarMetadata = tree.getEntry("META-INF/jarjar/metadata.json");
|
||||
if (jarInJarMetadata != null) {
|
||||
JarInJarMetadata metadata = JsonUtils.fromJsonFully(tree.getInputStream(jarInJarMetadata), JarInJarMetadata.class);
|
||||
if (metadata == null)
|
||||
throw new IOException("Invalid metadata file: " + jarInJarMetadata);
|
||||
|
||||
@@ -246,11 +246,11 @@ public final class ForgeNewModMetadata {
|
||||
|
||||
embeddedModFiles = new ArrayList<>();
|
||||
for (EmbeddedJarMetadata jar : metadata.jars) {
|
||||
Path path = fs.getPath(jar.path);
|
||||
if (Files.isRegularFile(path)) {
|
||||
ZipArchiveEntry path = tree.getEntry(jar.path);
|
||||
if (path != null) {
|
||||
embeddedModFiles.add(path);
|
||||
} else {
|
||||
LOG.warning("Missing embedded-dependencies-mod: " + path);
|
||||
LOG.warning("Missing embedded-dependencies-mod: " + jar.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,10 +262,10 @@ public final class ForgeNewModMetadata {
|
||||
|
||||
Path tempFile = Files.createTempFile("hmcl-", ".zip");
|
||||
try {
|
||||
for (Path embeddedModFile : embeddedModFiles) {
|
||||
Files.copy(embeddedModFile, tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
try (FileSystem embeddedFs = CompressingUtils.createReadOnlyZipFileSystem(tempFile)) {
|
||||
return fromFile(modManager, modFile, embeddedFs, modLoaderType);
|
||||
for (ZipArchiveEntry embeddedModFile : embeddedModFiles) {
|
||||
tree.extractTo(embeddedModFile, tempFile);
|
||||
try (ZipFileTree embeddedTree = CompressingUtils.openZipTree(tempFile)) {
|
||||
return fromFile(modManager, modFile, embeddedTree, modLoaderType);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,16 @@ import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
@@ -123,14 +123,14 @@ public final class ForgeOldModMetadata {
|
||||
return authors;
|
||||
}
|
||||
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
|
||||
Path mcmod = fs.getPath("mcmod.info");
|
||||
if (Files.notExists(mcmod))
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
|
||||
ZipArchiveEntry mcmod = tree.getEntry("mcmod.info");
|
||||
if (mcmod == null)
|
||||
throw new IOException("File " + modFile + " is not a Forge mod.");
|
||||
|
||||
List<ForgeOldModMetadata> modList;
|
||||
|
||||
try (var reader = Files.newBufferedReader(mcmod);
|
||||
try (var reader = tree.getBufferedReader(mcmod);
|
||||
var jsonReader = new JsonReader(reader)) {
|
||||
JsonToken firstToken = jsonReader.peek();
|
||||
|
||||
|
||||
@@ -18,17 +18,16 @@
|
||||
package org.jackhuang.hmcl.mod.modinfo;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -109,18 +108,16 @@ public final class LiteModMetadata {
|
||||
public String getUpdateURI() {
|
||||
return updateURI;
|
||||
}
|
||||
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
|
||||
try (ZipFile zipFile = new ZipFile(modFile.toFile())) {
|
||||
ZipEntry entry = zipFile.getEntry("litemod.json");
|
||||
if (entry == null)
|
||||
throw new IOException("File " + modFile + "is not a LiteLoader mod.");
|
||||
LiteModMetadata metadata = JsonUtils.fromJsonFully(zipFile.getInputStream(entry), LiteModMetadata.class);
|
||||
if (metadata == null)
|
||||
throw new IOException("Mod " + modFile + " `litemod.json` is malformed.");
|
||||
return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalModFile.Description(metadata.getDescription()), metadata.getAuthor(),
|
||||
metadata.getVersion(), metadata.getGameVersion(), metadata.getUpdateURI(), "");
|
||||
}
|
||||
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
|
||||
ZipArchiveEntry entry = tree.getEntry("litemod.json");
|
||||
if (entry == null)
|
||||
throw new IOException("File " + modFile + " is not a LiteLoader mod.");
|
||||
LiteModMetadata metadata = JsonUtils.fromJsonFully(tree.getInputStream(entry), LiteModMetadata.class);
|
||||
if (metadata == null)
|
||||
throw new IOException("Mod " + modFile + " `litemod.json` is malformed.");
|
||||
return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalModFile.Description(metadata.getDescription()), metadata.getAuthor(),
|
||||
metadata.getVersion(), metadata.getGameVersion(), metadata.getUpdateURI(), "");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.mod.modinfo;
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
@@ -29,11 +30,10 @@ import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -183,11 +183,11 @@ public record PackMcMeta(@SerializedName("pack") PackInfo pack) implements Valid
|
||||
}
|
||||
}
|
||||
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
|
||||
Path mcmod = fs.getPath("pack.mcmeta");
|
||||
if (Files.notExists(mcmod))
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
|
||||
ZipArchiveEntry mcmod = tree.getEntry("pack.mcmeta");
|
||||
if (mcmod == null)
|
||||
throw new IOException("File " + modFile + " is not a resource pack.");
|
||||
PackMcMeta metadata = JsonUtils.fromNonNullJson(Files.readString(mcmod), PackMcMeta.class);
|
||||
PackMcMeta metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), PackMcMeta.class);
|
||||
return new LocalModFile(
|
||||
modManager,
|
||||
modManager.getLocalMod(FileUtils.getNameWithoutExtension(modFile), ModLoaderType.PACK),
|
||||
|
||||
@@ -2,15 +2,15 @@ package org.jackhuang.hmcl.mod.modinfo;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -53,13 +53,13 @@ public final class QuiltModMetadata {
|
||||
this.quilt_loader = quiltLoader;
|
||||
}
|
||||
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
|
||||
Path path = fs.getPath("quilt.mod.json");
|
||||
if (Files.notExists(path)) {
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
|
||||
ZipArchiveEntry path = tree.getEntry("quilt.mod.json");
|
||||
if (path == null) {
|
||||
throw new IOException("File " + modFile + " is not a Quilt mod.");
|
||||
}
|
||||
|
||||
QuiltModMetadata root = JsonUtils.fromNonNullJson(Files.readString(path), QuiltModMetadata.class);
|
||||
QuiltModMetadata root = JsonUtils.fromNonNullJsonFully(tree.getInputStream(path), QuiltModMetadata.class);
|
||||
if (root.schema_version != 1) {
|
||||
throw new IOException("File " + modFile + " is not a supported Quilt mod.");
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ public class ModrinthInstallTask extends Task<Void> {
|
||||
builder.version("forge", modLoader.getValue());
|
||||
break;
|
||||
case "neoforge":
|
||||
// https://github.com/HMCL-dev/HMCL/pull/5170
|
||||
case "neo-forge":
|
||||
builder.version("neoforge", modLoader.getValue());
|
||||
break;
|
||||
case "fabric-loader":
|
||||
|
||||
+77
-35
@@ -24,7 +24,11 @@ import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.RemoteMod;
|
||||
import org.jackhuang.hmcl.mod.RemoteModRepository;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
@@ -35,7 +39,14 @@ import java.io.IOException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -49,6 +60,8 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
public static final ModrinthRemoteModRepository RESOURCE_PACKS = new ModrinthRemoteModRepository("resourcepack");
|
||||
public static final ModrinthRemoteModRepository SHADER_PACKS = new ModrinthRemoteModRepository("shader");
|
||||
|
||||
private static final Semaphore SEMAPHORE = new Semaphore(16);
|
||||
|
||||
private static final String PREFIX = "https://api.modrinth.com";
|
||||
|
||||
private final String projectType;
|
||||
@@ -81,30 +94,36 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
List<List<String>> facets = new ArrayList<>();
|
||||
facets.add(Collections.singletonList("project_type:" + projectType));
|
||||
if (StringUtils.isNotBlank(gameVersion)) {
|
||||
facets.add(Collections.singletonList("versions:" + gameVersion));
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
List<List<String>> facets = new ArrayList<>();
|
||||
facets.add(Collections.singletonList("project_type:" + projectType));
|
||||
if (StringUtils.isNotBlank(gameVersion)) {
|
||||
facets.add(Collections.singletonList("versions:" + gameVersion));
|
||||
}
|
||||
if (category != null && StringUtils.isNotBlank(category.getId())) {
|
||||
facets.add(Collections.singletonList("categories:" + category.getId()));
|
||||
}
|
||||
Map<String, String> query = mapOf(
|
||||
pair("query", searchFilter),
|
||||
pair("facets", JsonUtils.UGLY_GSON.toJson(facets)),
|
||||
pair("offset", Integer.toString(pageOffset * pageSize)),
|
||||
pair("limit", Integer.toString(pageSize)),
|
||||
pair("index", convertSortType(sort))
|
||||
);
|
||||
Response<ProjectSearchResult> response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query)))
|
||||
.getJson(Response.typeOf(ProjectSearchResult.class));
|
||||
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
if (category != null && StringUtils.isNotBlank(category.getId())) {
|
||||
facets.add(Collections.singletonList("categories:" + category.getId()));
|
||||
}
|
||||
Map<String, String> query = mapOf(
|
||||
pair("query", searchFilter),
|
||||
pair("facets", JsonUtils.UGLY_GSON.toJson(facets)),
|
||||
pair("offset", Integer.toString(pageOffset * pageSize)),
|
||||
pair("limit", Integer.toString(pageSize)),
|
||||
pair("index", convertSortType(sort))
|
||||
);
|
||||
Response<ProjectSearchResult> response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query)))
|
||||
.getJson(Response.typeOf(ProjectSearchResult.class));
|
||||
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
|
||||
String sha1 = DigestUtils.digestToString("SHA-1", file);
|
||||
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
ProjectVersion mod = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1,
|
||||
pair("algorithm", "sha1"))
|
||||
@@ -118,14 +137,21 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
}
|
||||
} catch (NoSuchFileException e) {
|
||||
return Optional.empty();
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod getModById(String id) throws IOException {
|
||||
id = StringUtils.removePrefix(id, "local-");
|
||||
Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class);
|
||||
return project.toMod();
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
id = StringUtils.removePrefix(id, "local-");
|
||||
Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class);
|
||||
return project.toMod();
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -135,20 +161,28 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
|
||||
@Override
|
||||
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
|
||||
id = StringUtils.removePrefix(id, "local-");
|
||||
List<ProjectVersion> versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version")
|
||||
.getJson(listTypeOf(ProjectVersion.class));
|
||||
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream);
|
||||
}
|
||||
|
||||
public List<Category> getCategoriesImpl() throws IOException {
|
||||
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class));
|
||||
return categories.stream().filter(category -> category.getProjectType().equals(projectType)).collect(Collectors.toList());
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
id = StringUtils.removePrefix(id, "local-");
|
||||
List<ProjectVersion> versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version")
|
||||
.getJson(listTypeOf(ProjectVersion.class));
|
||||
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream);
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteModRepository.Category> getCategories() throws IOException {
|
||||
return getCategoriesImpl().stream().map(Category::toCategory);
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class));
|
||||
return categories.stream()
|
||||
.filter(category -> category.getProjectType().equals(projectType))
|
||||
.map(Category::toCategory);
|
||||
} finally {
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
}
|
||||
|
||||
public static class Category {
|
||||
@@ -160,7 +194,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
private final String projectType;
|
||||
|
||||
public Category() {
|
||||
this("","","");
|
||||
this("", "", "");
|
||||
}
|
||||
|
||||
public Category(String icon, String name, String projectType) {
|
||||
@@ -576,6 +610,9 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
|
||||
private final List<String> categories;
|
||||
|
||||
@SerializedName("display_categories")
|
||||
private final List<String> displayCategories;
|
||||
|
||||
@SerializedName("project_type")
|
||||
private final String projectType;
|
||||
|
||||
@@ -600,11 +637,12 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
@SerializedName("latest_version")
|
||||
private final String latestVersion;
|
||||
|
||||
public ProjectSearchResult(String slug, String title, String description, List<String> categories, String projectType, int downloads, String iconUrl, String projectId, String author, List<String> versions, Instant dateCreated, Instant dateModified, String latestVersion) {
|
||||
public ProjectSearchResult(String slug, String title, String description, List<String> categories, List<String> displayCategories, String projectType, int downloads, String iconUrl, String projectId, String author, List<String> versions, Instant dateCreated, Instant dateModified, String latestVersion) {
|
||||
this.slug = slug;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.categories = categories;
|
||||
this.displayCategories = displayCategories;
|
||||
this.projectType = projectType;
|
||||
this.downloads = downloads;
|
||||
this.iconUrl = iconUrl;
|
||||
@@ -632,6 +670,10 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
return categories;
|
||||
}
|
||||
|
||||
public List<String> getDisplayCategories() {
|
||||
return displayCategories;
|
||||
}
|
||||
|
||||
public String getProjectType() {
|
||||
return projectType;
|
||||
}
|
||||
@@ -691,7 +733,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
|
||||
author,
|
||||
title,
|
||||
description,
|
||||
categories,
|
||||
displayCategories,
|
||||
String.format("https://modrinth.com/%s/%s", projectType, projectId),
|
||||
iconUrl,
|
||||
this
|
||||
|
||||
@@ -185,7 +185,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
}
|
||||
}
|
||||
|
||||
ArrayList<IOException> exceptions = null;
|
||||
ArrayList<Exception> exceptions = null;
|
||||
|
||||
// If loading the cache fails, the cache should not be loaded again.
|
||||
boolean useCachedResult = true;
|
||||
@@ -212,7 +212,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
do {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
|
||||
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
|
||||
.header("User-Agent", Holder.USER_AGENT);
|
||||
.header("User-Agent", NetworkUtils.USER_AGENT);
|
||||
|
||||
headers.forEach(requestBuilder::header);
|
||||
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
|
||||
@@ -284,10 +284,12 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
contentLength,
|
||||
contentEncoding);
|
||||
return;
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
} catch (FileNotFoundException ex) {
|
||||
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
|
||||
} catch (IOException ex) {
|
||||
} catch (Exception ex) {
|
||||
if (exceptions == null)
|
||||
exceptions = new ArrayList<>();
|
||||
|
||||
@@ -301,7 +303,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
}
|
||||
|
||||
private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException {
|
||||
ArrayList<IOException> exceptions = null;
|
||||
ArrayList<Exception> exceptions = null;
|
||||
for (int retryTime = 0; retryTime < retry; retryTime++) {
|
||||
if (isCancelled()) {
|
||||
throw new InterruptedException();
|
||||
@@ -317,11 +319,13 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
conn.getContentLengthLong(),
|
||||
ContentEncoding.fromConnection(conn));
|
||||
return;
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
} catch (FileNotFoundException ex) {
|
||||
LOG.warning("Failed to download " + uri + ", not found", ex);
|
||||
|
||||
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
|
||||
} catch (IOException ex) {
|
||||
} catch (Exception ex) {
|
||||
if (exceptions == null)
|
||||
exceptions = new ArrayList<>();
|
||||
|
||||
@@ -333,7 +337,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
throw toDownloadException(uri, null, exceptions);
|
||||
}
|
||||
|
||||
private static DownloadException toDownloadException(URI uri, @Nullable IOException last, @Nullable ArrayList<IOException> exceptions) {
|
||||
private static DownloadException toDownloadException(URI uri, @Nullable Exception last, @Nullable ArrayList<Exception> exceptions) {
|
||||
if (exceptions == null || exceptions.isEmpty()) {
|
||||
return new DownloadException(uri, last != null
|
||||
? last
|
||||
@@ -342,7 +346,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
if (last == null)
|
||||
last = exceptions.remove(exceptions.size() - 1);
|
||||
|
||||
for (IOException e : exceptions) {
|
||||
for (Exception e : exceptions) {
|
||||
last.addSuppressed(e);
|
||||
}
|
||||
return new DownloadException(uri, last);
|
||||
@@ -505,12 +509,21 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
return downloadExecutorConcurrency;
|
||||
}
|
||||
|
||||
private static volatile boolean initialized = false;
|
||||
|
||||
public static void notifyInitialized() {
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized.
|
||||
private static final class Holder {
|
||||
private static final HttpClient HTTP_CLIENT;
|
||||
private static final String USER_AGENT = System.getProperty("http.agent", "HMCL");
|
||||
|
||||
static {
|
||||
if (!initialized) {
|
||||
throw new AssertionError("FetchTask.Holder accessed before ProxyManager initialization.");
|
||||
}
|
||||
|
||||
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
|
||||
|
||||
HTTP_CLIENT = HttpClient.newBuilder()
|
||||
|
||||
@@ -21,6 +21,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.tree.ZipFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
@@ -127,13 +128,33 @@ public final class CompressingUtils {
|
||||
throw new IOException("Cannot find suitable encoding for the zip.");
|
||||
}
|
||||
|
||||
public static ZipFileTree openZipTree(Path zipFile) throws IOException {
|
||||
return new ZipFileTree(openZipFile(zipFile));
|
||||
}
|
||||
|
||||
public static ZipArchiveReader openZipFile(Path zipFile) throws IOException {
|
||||
return openZipFileWithPossibleEncoding(zipFile, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static ZipArchiveReader openZipFile(Path zipFile, Charset charset) throws IOException {
|
||||
return new ZipArchiveReader(zipFile, charset);
|
||||
}
|
||||
|
||||
public static ZipArchiveReader openZipFileWithPossibleEncoding(Path zipFile, Charset possibleEncoding) throws IOException {
|
||||
if (possibleEncoding == null)
|
||||
possibleEncoding = StandardCharsets.UTF_8;
|
||||
|
||||
ZipArchiveReader zipReader = new ZipArchiveReader(Files.newByteChannel(zipFile));
|
||||
|
||||
Charset suitableEncoding;
|
||||
try {
|
||||
suitableEncoding = findSuitableEncoding(zipReader);
|
||||
if (suitableEncoding == StandardCharsets.UTF_8)
|
||||
return zipReader;
|
||||
if (possibleEncoding != StandardCharsets.UTF_8 && CompressingUtils.testEncoding(zipReader, possibleEncoding)) {
|
||||
suitableEncoding = possibleEncoding;
|
||||
} else {
|
||||
suitableEncoding = CompressingUtils.findSuitableEncoding(zipReader);
|
||||
if (suitableEncoding == StandardCharsets.UTF_8)
|
||||
return zipReader;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IOUtils.closeQuietly(zipReader, e);
|
||||
throw e;
|
||||
@@ -143,10 +164,6 @@ public final class CompressingUtils {
|
||||
return new ZipArchiveReader(Files.newByteChannel(zipFile), suitableEncoding);
|
||||
}
|
||||
|
||||
public static ZipArchiveReader openZipFile(Path zipFile, Charset charset) throws IOException {
|
||||
return new ZipArchiveReader(zipFile, charset);
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private boolean autoDetectEncoding = false;
|
||||
private Charset encoding = StandardCharsets.UTF_8;
|
||||
|
||||
@@ -527,4 +527,25 @@ public final class FileUtils {
|
||||
public static String printFileStructure(Path path, int maxDepth) throws IOException {
|
||||
return DirectoryStructurePrinter.list(path, maxDepth);
|
||||
}
|
||||
|
||||
public static EnumSet<PosixFilePermission> parsePosixFilePermission(int unixMode) {
|
||||
EnumSet<PosixFilePermission> permissions = EnumSet.noneOf(PosixFilePermission.class);
|
||||
|
||||
// Owner permissions
|
||||
if ((unixMode & 0400) != 0) permissions.add(PosixFilePermission.OWNER_READ);
|
||||
if ((unixMode & 0200) != 0) permissions.add(PosixFilePermission.OWNER_WRITE);
|
||||
if ((unixMode & 0100) != 0) permissions.add(PosixFilePermission.OWNER_EXECUTE);
|
||||
|
||||
// Group permissions
|
||||
if ((unixMode & 0040) != 0) permissions.add(PosixFilePermission.GROUP_READ);
|
||||
if ((unixMode & 0020) != 0) permissions.add(PosixFilePermission.GROUP_WRITE);
|
||||
if ((unixMode & 0010) != 0) permissions.add(PosixFilePermission.GROUP_EXECUTE);
|
||||
|
||||
// Others permissions
|
||||
if ((unixMode & 0004) != 0) permissions.add(PosixFilePermission.OTHERS_READ);
|
||||
if ((unixMode & 0002) != 0) permissions.add(PosixFilePermission.OTHERS_WRITE);
|
||||
if ((unixMode & 0001) != 0) permissions.add(PosixFilePermission.OTHERS_EXECUTE);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
* @author huangyuhui
|
||||
*/
|
||||
public final class NetworkUtils {
|
||||
public static final String USER_AGENT = System.getProperty("http.agent", "HMCL");
|
||||
|
||||
public static final String PARAMETER_SEPARATOR = "&";
|
||||
public static final String NAME_VALUE_SEPARATOR = "=";
|
||||
public static final int TIME_OUT = 8000;
|
||||
@@ -95,6 +97,27 @@ public final class NetworkUtils {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String withQuery(String baseUrl, List<Pair<String, String>> params) {
|
||||
StringBuilder sb = new StringBuilder(baseUrl);
|
||||
boolean first = true;
|
||||
for (Pair<String, String> param : params) {
|
||||
if (param.getValue() == null)
|
||||
continue;
|
||||
if (first) {
|
||||
if (!baseUrl.isEmpty()) {
|
||||
sb.append('?');
|
||||
}
|
||||
first = false;
|
||||
} else {
|
||||
sb.append(PARAMETER_SEPARATOR);
|
||||
}
|
||||
sb.append(encodeURL(param.getKey()));
|
||||
sb.append(NAME_VALUE_SEPARATOR);
|
||||
sb.append(encodeURL(param.getValue()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static List<URI> withQuery(List<URI> list, Map<String, String> params) {
|
||||
return list.stream().map(uri -> URI.create(withQuery(uri.toString(), params))).collect(Collectors.toList());
|
||||
}
|
||||
@@ -147,6 +170,7 @@ public final class NetworkUtils {
|
||||
connection.setReadTimeout(TIME_OUT);
|
||||
if (connection instanceof HttpURLConnection httpConnection) {
|
||||
httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag());
|
||||
httpConnection.setRequestProperty("User-Agent", USER_AGENT);
|
||||
httpConnection.setInstanceFollowRedirects(false);
|
||||
}
|
||||
return connection;
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user