Compare commits

...
13 Commits
52 changed files with 890 additions and 564 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: QQ 群
url: https://docs.hmcl.net/groups.html
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
+11 -14
View File
@@ -12,9 +12,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
@@ -31,23 +31,20 @@ jobs:
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload JAR
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-jar
path: |
HMCL/build/libs/HMCL-*.jar
HMCL/build/libs/HMCL-*.jar.sha256
path: HMCL/build/libs/HMCL-*.jar
archive: false
- name: Upload EXE
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: |
HMCL/build/libs/HMCL-*.exe
HMCL/build/libs/HMCL-*.exe.sha256
path: HMCL/build/libs/HMCL-*.exe
archive: false
- name: Upload SH
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
path: HMCL/build/libs/HMCL-*.sh
archive: false
@@ -20,8 +20,6 @@
package com.jfoenix.controls;
import com.jfoenix.skins.JFXProgressBarSkin;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.Skin;
@@ -51,26 +49,18 @@ public class JFXProgressBar extends ProgressBar {
return new JFXProgressBarSkin(this);
}
private boolean smoothProgress = true;
public boolean isSmoothProgress() {
return smoothProgress;
}
public void setSmoothProgress(boolean smoothProgress) {
this.smoothProgress = smoothProgress;
}
private void initialize() {
setPrefWidth(200);
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
private DoubleProperty secondaryProgress;
public DoubleProperty secondaryProgressProperty() {
if (secondaryProgress == null) {
secondaryProgress = new SimpleDoubleProperty(this, "secondaryProgress", INDETERMINATE_PROGRESS);
}
return secondaryProgress;
}
public double getSecondaryProgress() {
return secondaryProgress == null ? INDETERMINATE_PROGRESS : secondaryProgress.get();
}
public void setSecondaryProgress(double secondaryProgress) {
secondaryProgressProperty().set(secondaryProgress);
}
}
@@ -31,6 +31,7 @@ import javafx.scene.control.Skin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/// JFXSpinner is the material design implementation of a loading spinner.
///
@@ -71,10 +72,10 @@ public class JFXSpinner extends ProgressIndicator {
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-spinner";
private static final double DEFAULT_RADIUS = 12.0;
private static final double DEFAULT_RADIUS = 16.0;
/**
* specifies the radius of the spinner node, by default it's set to `12.0`
* specifies the radius of the spinner node, by default it's set to `16.0`
*/
private StyleableDoubleProperty radius;
@@ -96,25 +97,8 @@ public class JFXSpinner extends ProgressIndicator {
this.radiusProperty().set(radius);
}
/// specifies from which angle the spinner should start spinning
private StyleableDoubleProperty startingAngle;
public final StyleableDoubleProperty startingAngleProperty() {
if (this.startingAngle == null) {
startingAngle = new SimpleStyleableDoubleProperty(StyleableProperties.STARTING_ANGLE,
JFXSpinner.this,
"startingAngle",
0.0);
}
return this.startingAngle;
}
public final double getStartingAngle() {
return startingAngle != null ? startingAngle.get() : 0.0;
}
public final void setStartingAngle(final double startingAngle) {
this.startingAngleProperty().set(startingAngle);
public double getStartingAngle() {
return 360 - ThreadLocalRandom.current().nextDouble() * 720;
}
private static final class StyleableProperties {
@@ -132,26 +116,12 @@ public class JFXSpinner extends ProgressIndicator {
}
};
private static final CssMetaData<JFXSpinner, Number> STARTING_ANGLE =
new CssMetaData<>("-jfx-starting-angle",
SizeConverter.getInstance(), 0.0) {
@Override
public boolean isSettable(JFXSpinner control) {
return control.startingAngle == null || !control.startingAngle.isBound();
}
@Override
public StyleableDoubleProperty getStyleableProperty(JFXSpinner control) {
return control.startingAngleProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ProgressIndicator.getClassCssMetaData());
Collections.addAll(styleables, RADIUS, STARTING_ANGLE);
Collections.addAll(styleables, RADIUS);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
@@ -20,61 +20,38 @@
package com.jfoenix.skins;
import com.jfoenix.controls.JFXProgressBar;
import com.jfoenix.utils.JFXNodeUtils;
import com.jfoenix.utils.TreeShowingProperty;
import javafx.animation.*;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.skin.ProgressIndicatorSkin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
/// # Material Design ProgressBar Skin
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-10-06
public class JFXProgressBarSkin extends ProgressIndicatorSkin {
public class JFXProgressBarSkin extends SkinBase<JFXProgressBar> {
private StackPane track;
private StackPane secondaryBar;
private StackPane bar;
private double barWidth = 0;
private double secondaryBarWidth = 0;
private Timeline indeterminateTransition;
private Region clip;
private static final double DEFAULT_HEIGHT = 4;
private final StackPane track;
private final StackPane bar;
private final Rectangle clip;
private Animation transition;
private final TreeShowingProperty treeShowingProperty;
private double fullWidth;
public JFXProgressBarSkin(JFXProgressBar bar) {
super(bar);
public JFXProgressBarSkin(JFXProgressBar control) {
super(control);
this.treeShowingProperty = new TreeShowingProperty(bar);
this.treeShowingProperty = new TreeShowingProperty(control);
bar.widthProperty().addListener(observable -> {
updateProgress();
updateSecondaryProgress();
});
registerChangeListener(bar.progressProperty(), (obs) -> updateProgress());
registerChangeListener(bar.secondaryProgressProperty(), obs -> updateSecondaryProgress());
registerChangeListener(bar.visibleProperty(), obs -> updateAnimation());
registerChangeListener(bar.parentProperty(), obs -> updateAnimation());
registerChangeListener(bar.sceneProperty(), obs -> updateAnimation());
unregisterChangeListeners(treeShowingProperty);
unregisterChangeListeners(bar.indeterminateProperty());
registerChangeListener(treeShowingProperty, obs -> this.updateAnimation());
registerChangeListener(bar.indeterminateProperty(), obs -> initialize());
initialize();
getSkinnable().requestLayout();
}
protected void initialize() {
registerChangeListener(treeShowingProperty, obs -> updateProgress(false));
registerChangeListener(control.progressProperty(), obs -> updateProgress(true));
track = new StackPane();
track.getStyleClass().setAll("track");
@@ -82,14 +59,14 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
bar = new StackPane();
bar.getStyleClass().setAll("bar");
secondaryBar = new StackPane();
secondaryBar.getStyleClass().setAll("secondary-bar");
clip = new Rectangle();
clip.setArcWidth(DEFAULT_HEIGHT);
clip.setArcHeight(DEFAULT_HEIGHT);
bar.setClip(clip);
clip = new Region();
clip.setBackground(new Background(new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY)));
bar.backgroundProperty().addListener(observable -> JFXNodeUtils.updateBackground(bar.getBackground(), clip));
getChildren().setAll(track, bar);
getChildren().setAll(track, secondaryBar, bar);
getSkinnable().requestLayout();
}
@Override
@@ -104,7 +81,7 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
return topInset + bar.prefHeight(width) + bottomInset;
return topInset + DEFAULT_HEIGHT + bottomInset;
}
@Override
@@ -120,105 +97,129 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
@Override
protected void layoutChildren(double x, double y, double w, double h) {
track.resizeRelocate(x, y, w, h);
secondaryBar.resizeRelocate(x, y, secondaryBarWidth, h);
bar.resizeRelocate(x, y, getSkinnable().isIndeterminate() ? w : barWidth, h);
clip.resizeRelocate(0, 0, w, h);
bar.resizeRelocate(x, y, w, h);
if (getSkinnable().isIndeterminate()) {
createIndeterminateTimeline();
if (JFXNodeUtils.isTreeShowing(getSkinnable())) {
indeterminateTransition.play();
}
// apply clip
bar.setClip(clip);
} else if (indeterminateTransition != null) {
clip.relocate(0, 0);
clip.setWidth(0);
clip.setHeight(h);
clip.setTranslateX(0);
fullWidth = w;
clearAnimation();
updateProgress(false);
}
private boolean wasIndeterminate = false;
private void updateProgress(boolean playProgressAnimation) {
double progress = Math.min(getSkinnable().getProgress(), 1.0);
boolean isIndeterminate = progress < 0.0;
boolean isTreeShowing = treeShowingProperty.get();
if (isIndeterminate != wasIndeterminate) {
wasIndeterminate = isIndeterminate;
clearAnimation();
// remove clip
bar.setClip(null);
clip.setTranslateX(0);
}
}
protected void updateSecondaryProgress() {
final JFXProgressBar control = (JFXProgressBar) getSkinnable();
secondaryBarWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2
* Math.min(1, Math.max(0, control.getSecondaryProgress()))) / 2.0F;
control.requestLayout();
}
boolean wasIndeterminate = false;
protected void pauseTimeline(boolean pause) {
if (getSkinnable().isIndeterminate()) {
if (indeterminateTransition == null) {
createIndeterminateTimeline();
if (isIndeterminate) { // indeterminate
if (isTreeShowing) {
if (transition == null) {
transition = createIndeterminateTransition();
transition.playFromStart();
} else {
transition.play();
}
} else if (transition != null) {
transition.pause();
}
if (pause) {
indeterminateTransition.pause();
} else { // determinate
clearAnimation();
if (isTreeShowing && playProgressAnimation
&& AnimationUtils.isAnimationEnabled()
&& getSkinnable().isSmoothProgress()) {
transition = createDeterminateTransition(progress);
transition.playFromStart();
} else {
indeterminateTransition.play();
clip.setWidth(computeBarWidth(progress));
}
}
}
private void updateAnimation() {
final boolean isTreeShowing = treeShowingProperty.get();
if (indeterminateTransition != null) {
pauseTimeline(!isTreeShowing);
} else if (isTreeShowing) {
createIndeterminateTimeline();
}
private static final Duration INDETERMINATE_DURATION = Duration.seconds(1);
private Transition createIndeterminateTransition() {
double minWidth = 0;
double maxWidth = fullWidth * 0.4;
Transition transition = new Transition() {
{
setInterpolator(Interpolator.LINEAR);
setCycleDuration(INDETERMINATE_DURATION);
}
@Override
protected void interpolate(double frac) {
double currentWidth;
if (frac <= 0.5) {
currentWidth = Interpolator.EASE_IN.interpolate(minWidth, maxWidth, frac / 0.5);
} else {
currentWidth = Interpolator.EASE_OUT.interpolate(maxWidth, minWidth, (frac - 0.5) / 0.5);
}
double targetCenter;
if (frac <= 0.1) {
targetCenter = 0.0;
} else if (frac >= 0.9) {
targetCenter = fullWidth;
} else {
targetCenter = ((frac - 0.1) / 0.8) * fullWidth;
}
clip.setWidth(currentWidth);
clip.setTranslateX(targetCenter - currentWidth / 2.0);
}
};
transition.setCycleCount(Timeline.INDEFINITE);
return transition;
}
private void updateProgress() {
final ProgressIndicator control = getSkinnable();
final boolean isIndeterminate = control.isIndeterminate();
if (!(isIndeterminate && wasIndeterminate)) {
barWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2
* Math.min(1, Math.max(0, control.getProgress()))) / 2.0F;
control.requestLayout();
}
wasIndeterminate = isIndeterminate;
}
private static final Duration DETERMINATE_DURATION = Duration.seconds(0.2);
private void createIndeterminateTimeline() {
if (indeterminateTransition != null) {
clearAnimation();
}
double dur = 1;
ProgressIndicator control = getSkinnable();
final double w = control.getWidth() - (snappedLeftInset() + snappedRightInset());
indeterminateTransition = new Timeline(new KeyFrame(
Duration.ZERO,
new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_IN),
new KeyValue(clip.translateXProperty(), -w / 2, Interpolator.LINEAR)
),
new KeyFrame(
Duration.seconds(0.5 * dur),
new KeyValue(clip.scaleXProperty(), 0.4, Interpolator.LINEAR)
),
new KeyFrame(
Duration.seconds(0.9 * dur),
new KeyValue(clip.translateXProperty(), w / 2, Interpolator.LINEAR)
),
new KeyFrame(
Duration.seconds(1 * dur),
new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_OUT)
));
indeterminateTransition.setCycleCount(Timeline.INDEFINITE);
private Timeline createDeterminateTransition(double targetProgress) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(clip.widthProperty(), clip.getWidth(), Interpolator.EASE_OUT)),
new KeyFrame(DETERMINATE_DURATION,
new KeyValue(clip.widthProperty(), computeBarWidth(targetProgress), Interpolator.EASE_OUT))
);
timeline.setOnFinished(e -> {
if (transition == timeline) {
transition = null;
}
});
return timeline;
}
private void clearAnimation() {
indeterminateTransition.stop();
indeterminateTransition.getKeyFrames().clear();
indeterminateTransition = null;
if (transition != null) {
transition.stop();
transition = null;
}
}
@Override
public void dispose() {
super.dispose();
treeShowingProperty.dispose();
if (indeterminateTransition != null) {
clearAnimation();
}
clearAnimation();
}
private double computeBarWidth(double progress) {
assert progress >= 0 && progress <= 1;
double barWidth = ((int) fullWidth * 2 * progress) / 2.0;
return progress > 0 ? Math.max(barWidth, DEFAULT_HEIGHT) : barWidth;
}
}
@@ -20,25 +20,24 @@
package com.jfoenix.skins;
import com.jfoenix.controls.JFXSpinner;
import com.jfoenix.utils.JFXNodeUtils;
import com.jfoenix.utils.TreeShowingProperty;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.scene.Group;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Arc;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.StrokeLineCap;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import java.util.ArrayList;
import java.util.List;
/// JFXSpinner material design skin
///
@@ -47,10 +46,7 @@ import javafx.util.Duration;
/// @since 2017-09-25
public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
private static final Color GREEN_COLOR = Color.valueOf("#0F9D58");
private static final Color RED_COLOR = Color.valueOf("#db4437");
private static final Color YELLOW_COLOR = Color.valueOf("#f4b400");
private static final Color BLUE_COLOR = Color.valueOf("#4285f4");
private static final double DEFAULT_STROKE_WIDTH = 4;
private JFXSpinner control;
private final TreeShowingProperty treeShowingProperty;
@@ -61,110 +57,62 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
private Arc track;
private final StackPane arcPane;
private final Rectangle fillRect;
private double arcLength = -1;
private final Text text;
private final double startingAngle;
public JFXSpinnerSkin(JFXSpinner control) {
super(control);
this.control = control;
this.treeShowingProperty = new TreeShowingProperty(control);
this.startingAngle = control.getStartingAngle();
arc = new Arc();
arc.setManaged(false);
arc.setStartAngle(0);
arc.setLength(180);
arc.getStyleClass().setAll("arc");
arc.setFill(Color.TRANSPARENT);
arc.setStrokeWidth(3);
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
arc.setStrokeLineCap(StrokeLineCap.ROUND);
track = new Arc();
track.setManaged(false);
track.setStartAngle(0);
track.setLength(360);
track.setStrokeWidth(3);
track.setStrokeWidth(DEFAULT_STROKE_WIDTH);
track.getStyleClass().setAll("track");
track.setFill(Color.TRANSPARENT);
fillRect = new Rectangle();
fillRect.setFill(Color.TRANSPARENT);
text = new Text();
text.setStyle("-fx-font-size:null");
text.getStyleClass().setAll("text", "percentage");
final Group group = new Group(fillRect, track, arc, text);
final Group group = new Group(fillRect, track, arc);
group.setManaged(false);
arcPane = new StackPane(group);
arcPane.setPrefSize(50, 50);
getChildren().setAll(arcPane);
// register listeners
registerChangeListener(control.indeterminateProperty(), obs -> initialize());
registerChangeListener(control.progressProperty(), obs -> updateProgress());
registerChangeListener(treeShowingProperty, obs -> updateAnimation());
registerChangeListener(control.sceneProperty(), obs -> updateAnimation());
registerChangeListener(treeShowingProperty, obs -> updateProgress());
}
private void initialize() {
if (getSkinnable().isIndeterminate()) {
if (timeline == null) {
createTransition();
if (JFXNodeUtils.isTreeShowing(getSkinnable())) {
private void updateProgress() {
double progress = Double.min(getSkinnable().getProgress(), 1.0);
if (progress < 0) { // indeterminate
boolean treeShowing = treeShowingProperty.get();
if (treeShowing) {
if (timeline == null) {
timeline = createTransition();
timeline.playFromStart();
} else {
timeline.play();
}
} else if (timeline != null) {
timeline.pause();
}
} else {
} else { // determinate
clearAnimation();
arc.setStartAngle(90);
updateProgress();
}
}
private KeyFrame[] getKeyFrames(double angle, double duration, Paint color) {
KeyFrame[] frames = new KeyFrame[4];
frames[0] = new KeyFrame(Duration.seconds(duration),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 45 + control.getStartingAngle(),
Interpolator.LINEAR));
frames[1] = new KeyFrame(Duration.seconds(duration + 0.4),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 90 + control.getStartingAngle(),
Interpolator.LINEAR));
frames[2] = new KeyFrame(Duration.seconds(duration + 0.7),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 135 + control.getStartingAngle(),
Interpolator.LINEAR));
frames[3] = new KeyFrame(Duration.seconds(duration + 1.1),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 435 + control.getStartingAngle(),
Interpolator.LINEAR),
new KeyValue(arc.strokeProperty(), color, Interpolator.EASE_BOTH));
return frames;
}
private void pauseTimeline(boolean pause) {
if (getSkinnable().isIndeterminate()) {
if (timeline == null) {
createTransition();
}
if (pause) {
timeline.pause();
} else {
timeline.play();
}
}
}
private void updateAnimation() {
final boolean isTreeShowing = treeShowingProperty.get();
if (timeline != null) {
pauseTimeline(!isTreeShowing);
} else if (isTreeShowing) {
createTransition();
arc.setLength(-360 * progress);
}
}
@@ -224,21 +172,9 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
fillRect.setHeight(arcSize);
if (!isValid) {
initialize();
updateProgress();
isValid = true;
}
if (!getSkinnable().isIndeterminate()) {
arc.setLength(arcLength);
if (text.isVisible()) {
final double progress = control.getProgress();
int intProgress = (int) Math.round(progress * 100.0);
Font font = text.getFont();
text.setFont(Font.font(font.getFamily(), radius / 1.7));
text.setText((progress > 1 ? 100 : intProgress) + "%");
text.relocate((arcSize - text.getLayoutBounds().getWidth()) / 2, (arcSize - text.getLayoutBounds().getHeight()) / 2);
}
}
}
private void updateArcLayout(double radius, double arcSize) {
@@ -254,66 +190,64 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
track.setStrokeWidth(arc.getStrokeWidth());
}
boolean wasIndeterminate = false;
protected void updateProgress() {
final ProgressIndicator control = getSkinnable();
final boolean isIndeterminate = control.isIndeterminate();
if (!(isIndeterminate && wasIndeterminate)) {
arcLength = -360 * control.getProgress();
control.requestLayout();
}
wasIndeterminate = isIndeterminate;
}
private void createTransition() {
if (!getSkinnable().isIndeterminate()) return;
final Paint initialColor = arc.getStroke();
if (initialColor == null) {
arc.setStroke(BLUE_COLOR);
}
KeyFrame[] blueFrame = getKeyFrames(0, 0, initialColor == null ? BLUE_COLOR : initialColor);
KeyFrame[] redFrame = getKeyFrames(450, 1.4, initialColor == null ? RED_COLOR : initialColor);
KeyFrame[] yellowFrame = getKeyFrames(900, 2.8, initialColor == null ? YELLOW_COLOR : initialColor);
KeyFrame[] greenFrame = getKeyFrames(1350, 4.2, initialColor == null ? GREEN_COLOR : initialColor);
KeyFrame endingFrame = new KeyFrame(Duration.seconds(5.6),
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
frames.add(new KeyFrame(Duration.seconds(duration),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
1845 + control.getStartingAngle(),
Interpolator.LINEAR));
angle + 45 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 90 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 135 + startingAngle,
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 435 + startingAngle,
Interpolator.LINEAR)));
}
if (timeline != null) {
timeline.stop();
timeline.getKeyFrames().clear();
private Timeline createTransition() {
Timeline timeline;
if (AnimationUtils.isAnimationEnabled()) {
var keyFrames = new ArrayList<KeyFrame>(17);
addKeyFrames(keyFrames, 0, 0);
addKeyFrames(keyFrames, 450, 1.4);
addKeyFrames(keyFrames, 900, 2.8);
addKeyFrames(keyFrames, 1350, 4.2);
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
1845 + startingAngle,
Interpolator.LINEAR)));
timeline = new Timeline();
timeline.getKeyFrames().setAll(keyFrames);
} else {
final double arcLength = 250;
timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
new KeyFrame(Duration.seconds(1.2),
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
);
}
timeline = new Timeline(blueFrame[0],
blueFrame[1],
blueFrame[2],
blueFrame[3],
redFrame[0],
redFrame[1],
redFrame[2],
redFrame[3],
yellowFrame[0],
yellowFrame[1],
yellowFrame[2],
yellowFrame[3],
greenFrame[0],
greenFrame[1],
greenFrame[2],
greenFrame[3],
endingFrame);
timeline.setCycleCount(Timeline.INDEFINITE);
timeline.setDelay(Duration.ZERO);
timeline.playFromStart();
return timeline;
}
private void clearAnimation() {
if (timeline != null) {
timeline.stop();
timeline.getKeyFrames().clear();
timeline = null;
}
}
@@ -387,7 +387,16 @@ public final class LauncherHelper {
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
try {
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
GameJavaVersion minimumJavaVersion = null;
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
}
}
if (minimumJavaVersion == null)
minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) {
Controllers.dialog(
@@ -402,8 +411,17 @@ public final class LauncherHelper {
targetJavaVersion = GameJavaVersion.get(targetJavaVersionMajor);
} catch (NumberFormatException ignored) {
}
} else
targetJavaVersion = version.getJavaVersion();
} else {
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
}
}
if (targetJavaVersion == null)
targetJavaVersion = version.getJavaVersion();
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
downloadJava(targetJavaVersion, profile)
@@ -433,7 +451,7 @@ public final class LauncherHelper {
if (java != null) {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.isMandatory()) {
violatedMandatoryConstraints.add(constraint);
} else {
@@ -468,9 +486,14 @@ public final class LauncherHelper {
return result;
} else {
GameJavaVersion gameJavaVersion;
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM_JAVA_21))
gameJavaVersion = GameJavaVersion.JAVA_21;
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
.orElse("");
gameJavaVersion = !cleanroomVersion.isEmpty()
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
@@ -560,9 +583,14 @@ public final class LauncherHelper {
case MODDED_JAVA_21:
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
break;
case CLEANROOM_JAVA_21:
suggestions.add(i18n("launch.advice.cleanroom"));
case CLEANROOM: {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
if (!cleanroomVersion.isEmpty())
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
else
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
break;
}
case VANILLA_JAVA_8_51:
suggestions.add(i18n("launch.advice.java8_51_1_13"));
break;
@@ -115,8 +115,8 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
}
@Override
public RemoteMod getModById(String id) throws IOException {
return getBackedRemoteModRepository().getModById(id);
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
}
@Override
@@ -125,7 +125,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(id);
public Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
}
}
@@ -304,7 +304,7 @@ public final class JavaManager {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.isMandatory()) {
violationMandatory = true;
} else {
@@ -237,6 +237,21 @@ public final class Config extends ObservableSetting {
this.disableAutoShowUpdateDialog.set(disableAutoShowUpdateDialog);
}
@SerializedName("disableAprilFools")
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
public BooleanProperty disableAprilFoolsProperty() {
return disableAprilFools;
}
public boolean isDisableAprilFools() {
return disableAprilFools.get();
}
public void setDisableAprilFools(boolean disableAprilFools) {
this.disableAprilFools.set(disableAprilFools);
}
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
@@ -23,6 +23,7 @@ import com.jfoenix.validation.base.ValidatorBase;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.DoubleProperty;
@@ -64,12 +65,16 @@ import org.jackhuang.hmcl.ui.versions.GameListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -82,6 +87,7 @@ public final class Controllers {
public static final String JAVA_VERSION_TIP = "javaVersion";
public static final String JAVA_INTERPRETED_MODE_TIP = "javaInterpretedMode";
public static final String SOFTWARE_RENDERING = "softwareRendering";
public static final String APRIL_FOOLS = "aprilFools";
public static final int MIN_WIDTH = 800 + 2 + 16; // bg width + border width*2 + shadow width*2
public static final int MIN_HEIGHT = 450 + 2 + 40 + 16; // bg height + border width*2 + toolbar height + shadow width*2
@@ -225,6 +231,8 @@ public final class Controllers {
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
LOG.info("April Fools: " + AprilFools.isEnabled());
if (System.getProperty("prism.lcdtext") == null) {
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
@@ -432,6 +440,53 @@ public final class Controllers {
agreementPane.setActions(agreementLink, yesButton, noButton);
Controllers.dialog(agreementPane);
}
aprilFools:
if (AprilFools.isEnabled()) {
int currentYear = LocalDate.now().getYear();
if (config().getShownTips().get(APRIL_FOOLS) instanceof Number year && year.intValue() >= currentYear)
break aprilFools;
if (!I18n.getLocale().getLocale().getLanguage().equals("zh"))
break aprilFools;
SupportedLocale lzh = SupportedLocale.getSupportedLocales().stream()
.filter(locale -> "lzh".equals(locale.getName()))
.findFirst().orElse(null);
if (lzh == null) {
LOG.warning("No supported locale found for lzh");
break aprilFools;
}
Runnable updateShowTips = () -> config().getShownTips().put(APRIL_FOOLS, currentYear);
Controllers.confirmWithCountdown(i18n("launcher.april_fools.switch_lzh"), null, 10,
MessageType.QUESTION, () -> {
Controllers.confirm(i18n("launcher.april_fools.switch_lzh.confirm"), null, MessageType.QUESTION, () -> {
LOG.info("Switching locale to " + lzh);
updateShowTips.run();
config().setLocalization(lzh);
Controllers.onApplicationStop();
try {
FileSaver.waitForAllSaves();
} catch (InterruptedException ignored) {
// Ignore
}
try {
Restarter.restartSelf();
} catch (IOException e) {
LOG.warning("Failed to restart self", e);
}
Platform.exit();
}, updateShowTips);
}, updateShowTips);
}
}
public static void dialog(Region content) {
@@ -1241,8 +1241,8 @@ public final class FXUtils {
.setSignificance(Task.TaskSignificance.MINOR);
}
public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uri)
public static Task<Image> getRemoteImageTask(List<URI> uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uris)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
@@ -83,8 +83,8 @@ import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class TaskListPane extends StackPane {
private static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 0);
private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 26);
private static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 4, 0);
private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 4, 26);
private TaskExecutor executor;
private final JFXListView<Node> listView = new JFXListView<>();
@@ -279,6 +279,7 @@ public final class TaskListPane extends StackPane {
private final class Cell extends ListCell<Node> {
private static final double STATUS_ICON_SIZE = 14;
private static final Insets PROGRESS_BAR_MARGIN = new Insets(2, 0, 0, 0);
private final BorderPane pane = new BorderPane();
private final StackPane left = new StackPane();
@@ -310,6 +311,7 @@ public final class TaskListPane extends StackPane {
bar.minWidthProperty().bind(barWidth);
bar.prefWidthProperty().bind(barWidth);
bar.maxWidthProperty().bind(barWidth);
BorderPane.setMargin(bar, PROGRESS_BAR_MARGIN);
setGraphic(pane);
}
@@ -325,8 +327,9 @@ public final class TaskListPane extends StackPane {
pane.paddingProperty().unbind();
title.textProperty().unbind();
message.textProperty().unbind();
bar.progressProperty().unbind();
bar.setSmoothProgress(false);
bar.progressProperty().unbind();
StageNode prevStageNode;
if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null)
prevStageNode.status.removeListener(statusChangeListener);
@@ -364,6 +367,8 @@ public final class TaskListPane extends StackPane {
pane.setRight(null);
pane.setBottom(null);
}
bar.setSmoothProgress(true);
}
}
@@ -84,8 +84,8 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
"game", versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((profile, __, mod, file) -> {
Versions.downloadModpackImpl(profile, uploadVersion, mod, file);
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, profile, __, mod, file) -> {
Versions.downloadModpackImpl(downloadProvider, profile, uploadVersion, mod, file);
}, false);
JFXButton installLocalModpackButton = FXUtils.newRaisedButton(i18n("install.modpack"));
@@ -94,9 +94,9 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
page.getActions().add(installLocalModpackButton);
return page;
}));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((profile, version, mod, file) -> download(profile, version, file, "mods"), true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((profile, version, mod, file) -> download(profile, version, file, "resourcepacks"), true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((profile, version, mod, file) -> download(profile, version, file, "shaderpacks"), true)));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "mods"), true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "resourcepacks"), true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "shaderpacks"), true)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteModRepository.WORLDS)));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
@@ -129,7 +129,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
};
}
public static void download(Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) {
public static void download(DownloadProvider downloadProvider, Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) {
if (version == null) version = profile.getSelectedVersion();
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory();
@@ -138,7 +138,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(file.getFile().getUrl(), dest);
var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.getFile().getUrl()), dest);
task.setName(file.getName());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
@@ -45,6 +45,7 @@ import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.AprilFools;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
@@ -224,6 +225,14 @@ public final class SettingsPage extends ScrollPane {
settingsPane.getContent().add(disableAutoShowUpdateDialogPane);
}
if (AprilFools.isShowAprilFoolsSettings()) {
LineToggleButton disableAprilFools = new LineToggleButton();
disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools"));
disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart"));
disableAprilFools.selectedProperty().bindBidirectional(config().disableAprilFoolsProperty());
settingsPane.getContent().add(disableAprilFools);
}
{
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
fileCommonLocation.loadChildren(Arrays.asList(
@@ -101,6 +101,10 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
this.downloadProvider = DownloadProviders.getDownloadProvider();
}
public DownloadProvider getDownloadProvider() {
return downloadProvider;
}
public ObservableList<Node> getActions() {
return actions;
}
@@ -233,18 +237,20 @@ 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);
}
};
private final RemoteImageLoader iconLoader;
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
listView.getStyleClass().add("no-horizontal-scrollbar");
iconLoader = new RemoteImageLoader(control.downloadProvider) {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris) {
return FXUtils.getRemoteImageTask(uris, 80, 80, true, true);
}
};
BorderPane pane = new BorderPane();
GridPane searchPane = new GridPane();
@@ -38,6 +38,7 @@ import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.Version;
@@ -112,7 +113,7 @@ public class DownloadPage extends Control implements DecoratorPage {
setFailed(false);
Task.supplyAsync(() -> {
Stream<RemoteMod.Version> versions = addon.getData().loadVersions(repository);
Stream<RemoteMod.Version> versions = addon.getData().loadVersions(repository, page.getDownloadProvider());
return sortVersions(versions);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
@@ -179,7 +180,7 @@ public class DownloadPage extends Control implements DecoratorPage {
if (this.callback == null) {
saveAs(mod, file);
} else {
this.callback.download(version.getProfile(), version.getVersion(), mod, file);
this.callback.download(page.getDownloadProvider(), version.getProfile(), version.getVersion(), mod, file);
}
}
@@ -548,7 +549,7 @@ public class DownloadPage extends Control implements DecoratorPage {
dependencies.put(dependency.getType(), list);
}
queue.add(Task.supplyAsync(Schedulers.io(), dependency::load)
queue.add(Task.supplyAsync(Schedulers.io(), () -> dependency.load(selfPage.page.getDownloadProvider()))
.setSignificance(Task.TaskSignificance.MINOR)
.thenAcceptAsync(Schedulers.javafx(), dep -> {
if (dep == RemoteMod.BROKEN) {
@@ -576,7 +577,8 @@ public class DownloadPage extends Control implements DecoratorPage {
}
}
@FunctionalInterface
public interface DownloadCallback {
void download(Profile profile, @Nullable String version, RemoteMod mod, RemoteMod.Version file);
void download(DownloadProvider downloadProvider, Profile profile, @Nullable String version, RemoteMod mod, RemoteMod.Version file);
}
}
@@ -50,7 +50,7 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.SHADER_PACK, null, ModrinthRemoteModRepository.SHADER_PACKS);
var page = new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.SHADER_PACK, CurseForgeRemoteModRepository.SHADERS, ModrinthRemoteModRepository.SHADER_PACKS);
page.supportChinese.set(false);
return page;
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.ui.versions;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.task.Schedulers;
@@ -30,16 +31,18 @@ import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class ModCheckUpdatesTask extends Task<List<LocalModFile.ModUpdate>> {
private final DownloadProvider downloadProvider;
private final List<Task<LocalModFile.ModUpdate>> dependents;
public ModCheckUpdatesTask(String gameVersion, Collection<LocalModFile> mods) {
public ModCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<LocalModFile> mods) {
this.downloadProvider = downloadProvider;
dependents = mods.stream().map(mod ->
Task.supplyAsync(Schedulers.io(), () -> {
LocalModFile.ModUpdate candidate = null;
for (RemoteMod.Type type : RemoteMod.Type.values()) {
LocalModFile.ModUpdate update = null;
try {
update = mod.checkUpdates(gameVersion, type.getRemoteModRepository());
update = mod.checkUpdates(downloadProvider, gameVersion, type.getRemoteModRepository());
} catch (IOException e) {
LOG.warning(String.format("Cannot check update for mod %s.", mod.getFileName()), e);
}
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -240,7 +241,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
.composeAsync(() -> {
Optional<String> gameVersion = profile.getRepository().getGameVersion(instanceId);
if (gameVersion.isPresent()) {
return new ModCheckUpdatesTask(gameVersion.get(), mods);
return new ModCheckUpdatesTask(DownloadProviders.getDownloadProvider(), gameVersion.get(), mods);
}
return null;
})
@@ -43,6 +43,7 @@ import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.task.Schedulers;
@@ -465,7 +466,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
Task.runAsync(() -> {
Optional<RemoteMod.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo(), modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteMod remoteMod = repository.getModById(versionOptional.get().getModid());
RemoteMod remoteMod = repository.getModById(DownloadProviders.getDownloadProvider(), versionOptional.get().getModid());
FXUtils.runInFX(() -> {
for (ModLoaderType modLoaderType : versionOptional.get().getLoaders()) {
String loaderName = switch (modLoaderType) {
@@ -493,7 +494,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
repository instanceof CurseForgeRemoteModRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false),
remoteMod,
new Profile.ProfileVersion(ModListPageSkin.this.getSkinnable().getProfile(), ModListPageSkin.this.getSkinnable().getInstanceId()),
(profile, version, mod, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(profile, version, file, "mods")
(downloadProvider, profile, version, mod, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(downloadProvider, profile, version, file, "mods")
));
});
button.setDisable(false);
@@ -22,6 +22,7 @@ import javafx.application.Platform;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.mod.RemoteMod;
@@ -42,13 +43,13 @@ import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -72,11 +73,11 @@ public final class Versions {
}
}
public static void downloadModpackImpl(Profile profile, String version, RemoteMod mod, RemoteMod.Version file) {
public static void downloadModpackImpl(DownloadProvider downloadProvider, Profile profile, String version, RemoteMod mod, RemoteMod.Version file) {
Path modpack;
URI downloadURL;
List<URI> downloadURLs;
try {
downloadURL = NetworkUtils.toURI(file.getFile().getUrl());
downloadURLs = downloadProvider.injectURLWithCandidates(file.getFile().getUrl());
modpack = Files.createTempFile("modpack", ".zip");
} catch (IOException | IllegalArgumentException e) {
Controllers.dialog(
@@ -85,7 +86,7 @@ public final class Versions {
return;
}
Controllers.taskDialog(
new FileDownloadTask(downloadURL, modpack)
new FileDownloadTask(downloadURLs, modpack)
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
ModpackInstallWizardProvider installWizardProvider;
@@ -37,6 +37,7 @@ import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -174,15 +175,25 @@ public final class UpdateHandler {
startJava(updateTo, "--apply-to", self.toString());
}
private static void startJava(Path jar, String... appArgs) throws IOException {
public static void startJava(Path jar, String... appArgs) throws IOException {
List<String> commandline = new ArrayList<>();
commandline.add(JavaRuntime.getDefault().getBinary().toString());
for (Map.Entry<Object, Object> entry : System.getProperties().entrySet()) {
Object key = entry.getKey();
if (key instanceof String && ((String) key).startsWith("hmcl.")) {
commandline.add("-D" + key + "=" + entry.getValue());
try {
for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) {
if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) {
commandline.add(inputArgument);
}
}
} catch (Throwable ignored) {
// ManagementFactory not available
for (Map.Entry<Object, Object> entry : System.getProperties().entrySet()) {
if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) {
commandline.add("-D" + key + "=" + entry.getValue());
}
}
}
commandline.add("-jar");
commandline.add(jar.toAbsolutePath().toString());
commandline.addAll(Arrays.asList(appArgs));
@@ -0,0 +1,80 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import java.time.LocalDate;
import java.time.Month;
import java.util.List;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
/// April Fools' Day utilities.
///
/// This class provides methods to check if it is April Fools' Day or near April Fools' Day.
/// It also provides a method to check if April Fools is enabled.
///
/// @author Glavo
public final class AprilFools {
private static final boolean ENABLED;
private static final boolean SHOW_APRIL_FOOLS_SETTINGS;
static {
var date = LocalDate.now();
// Some countries/regions may oppose April Fools' Day for various reasons.
// Therefore, we use a regional whitelist to avoid risks.
// Currently, we have only listed a limited set of countries/regions for testing.
// We will investigate more countries/regions in the future to expand this list.
boolean supportedRegion = List.of(
"CN", "TW", "HK", "MO", "JP", "KR", "VN", "SG", "MY",
"ES", "DE", "FR", "GB", "RU", "UA", "US"
).contains(LocaleUtils.SYSTEM_DEFAULT.getCountry());
boolean aprilFoolsMode;
String value = System.getProperty("hmcl.april_fools", System.getenv("HMCL_APRIL_FOOLS"));
if ("true".equalsIgnoreCase(value))
aprilFoolsMode = true;
else if ("false".equalsIgnoreCase(value) || !supportedRegion)
aprilFoolsMode = false;
else
aprilFoolsMode = date.getMonth() == Month.APRIL && date.getDayOfMonth() == 1;
ENABLED = aprilFoolsMode && !config().isDisableAprilFools();
SHOW_APRIL_FOOLS_SETTINGS = aprilFoolsMode || supportedRegion && date.getMonth() == Month.MARCH && date.getDayOfMonth() > 30;
}
/// Whether April Fools settings should be shown.
///
/// This method returns true if April Fools settings should be shown.
public static boolean isShowAprilFoolsSettings() {
return SHOW_APRIL_FOOLS_SETTINGS;
}
/// Whether April Fools is enabled.
///
/// This method returns true if April Fools is enabled.
public static boolean isEnabled() {
return ENABLED;
}
private AprilFools() {
}
}
@@ -22,6 +22,7 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -29,18 +30,21 @@ import java.util.concurrent.locks.ReentrantLock;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
/// @author Glavo
public final class FileSaver extends Thread {
private static final Pair<Path, String> SHUTDOWN = Pair.pair(null, null);
private static final BlockingQueue<Pair<Path, String>> queue = new LinkedBlockingQueue<>();
private static final BlockingQueue<Action> queue = new LinkedBlockingQueue<>();
private static final AtomicBoolean running = new AtomicBoolean(false);
private static final ReentrantLock runningLock = new ReentrantLock();
private static volatile boolean shutdown = false;
private static void addAction(Action action) {
queue.add(action);
if (running.compareAndSet(false, true)) {
new FileSaver().start();
}
}
private static void doSave(Map<Path, String> map) {
for (Map.Entry<Path, String> entry : map.entrySet()) {
saveSync(entry.getKey(), entry.getValue());
@@ -53,10 +57,7 @@ public final class FileSaver extends Thread {
ShutdownHook.ensureInstalled();
queue.add(Pair.pair(file, content));
if (running.compareAndSet(false, true)) {
new FileSaver().start();
}
addAction(new DoSave(file, content));
}
public static void saveSync(Path file, String content) {
@@ -70,7 +71,17 @@ public final class FileSaver extends Thread {
public static void shutdown() {
shutdown = true;
queue.add(SHUTDOWN);
queue.add(Shutdown.INSTANCE);
}
/// Wait for all saves to complete.
///
/// This method should be called before the [#shutdown()] method.
public static void waitForAllSaves() throws InterruptedException {
assert !shutdown;
Wait wait = new Wait();
addAction(wait);
wait.await();
}
private FileSaver() {
@@ -92,34 +103,46 @@ public final class FileSaver extends Thread {
runningLock.lock();
try {
HashMap<Path, String> map = new HashMap<>();
ArrayList<Pair<Path, String>> buffer = new ArrayList<>();
ArrayList<Action> buffer = new ArrayList<>();
ArrayList<Wait> waits = new ArrayList<>();
while (!stopped) {
if (shutdown) {
stopCurrentSaver();
} else {
Pair<Path, String> head = queue.poll(30, TimeUnit.SECONDS);
if (head == null || head == SHUTDOWN) {
stopCurrentSaver();
} else {
map.put(head.getKey(), head.getValue());
Action head = queue.poll(30, TimeUnit.SECONDS);
if (head instanceof DoSave save) {
map.put(save.file(), save.content());
//noinspection BusyWait
Thread.sleep(200); // Waiting for more changes
} else if (head instanceof Wait wait) {
waits.add(wait);
} else if (head == null || head instanceof Shutdown) {
// Shutdown or timeout
stopCurrentSaver();
}
}
while (queue.drainTo(buffer) > 0) {
for (Pair<Path, String> pair : buffer) {
if (pair == SHUTDOWN)
for (Action action : buffer) {
if (action instanceof DoSave save) {
map.put(save.file(), save.content());
} else if (action instanceof Wait wait) {
waits.add(wait);
} else if (action instanceof Shutdown) {
stopCurrentSaver();
else
map.put(pair.getKey(), pair.getValue());
}
}
buffer.clear();
}
doSave(map);
map.clear();
for (Wait wait : waits) {
wait.countDown();
}
waits.clear();
}
} catch (InterruptedException e) {
throw new AssertionError("This thread cannot be interrupted", e);
@@ -128,6 +151,28 @@ public final class FileSaver extends Thread {
}
}
private sealed interface Action {
}
private record DoSave(Path file, String content) implements Action {
}
private static final class Wait implements Action {
private final CountDownLatch latch = new CountDownLatch(1);
public void await() throws InterruptedException {
latch.await();
}
public void countDown() {
latch.countDown();
}
}
private enum Shutdown implements Action {
INSTANCE
}
private static final class ShutdownHook extends Thread {
static {
@@ -144,9 +189,10 @@ public final class FileSaver extends Thread {
runningLock.lock();
try {
HashMap<Path, String> map = new HashMap<>();
for (Pair<Path, String> pair : queue) {
if (pair != SHUTDOWN)
map.put(pair.getKey(), pair.getValue());
for (Action action : queue) {
if (action instanceof DoSave save) {
map.put(save.file(), save.content());
}
}
doSave(map);
} finally {
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.util;
import javafx.beans.value.WritableValue;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -33,18 +34,20 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public abstract class RemoteImageLoader {
private final DownloadProvider downloadProvider;
private final Map<URI, WeakReference<Image>> cache = new HashMap<>();
private final Map<URI, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
private final WeakHashMap<WritableValue<Image>, URI> reverseLookup = new WeakHashMap<>();
public RemoteImageLoader() {
public RemoteImageLoader(DownloadProvider downloadProvider) {
this.downloadProvider = downloadProvider;
}
protected @Nullable Image getPlaceholder() {
return null;
}
protected abstract @NotNull Task<Image> createLoadTask(@NotNull URI uri);
protected abstract @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris);
@FXThread
public void load(@NotNull WritableValue<Image> writableValue, String url) {
@@ -82,7 +85,7 @@ public abstract class RemoteImageLoader {
}
}
createLoadTask(uri).whenComplete(Schedulers.javafx(), (result, exception) -> {
createLoadTask(downloadProvider.injectURLWithCandidates(url)).whenComplete(Schedulers.javafx(), (result, exception) -> {
Image image;
if (exception == null) {
image = result;
@@ -0,0 +1,45 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.io.JarUtils;
import java.io.IOException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class Restarter {
/// Restart the current application.
public static void restartSelf() throws IOException {
LOG.info("Restarting HMCL");
Path thisJar = JarUtils.thisJarPath();
if (thisJar == null) {
throw new IOException("Failed to find current HMCL location");
}
UpdateHandler.startJava(thisJar);
}
private Restarter() {
}
}
+6 -76
View File
@@ -913,28 +913,22 @@
* *
*******************************************************************************/
.jfx-progress-bar > .track {
-fx-background-color: -monet-secondary-container;
}
.jfx-progress-bar > .bar,
.jfx-progress-bar:indeterminate > .bar{
.jfx-progress-bar > .bar {
-fx-background-color: -monet-primary;
-fx-padding: 1.5;
}
.jfx-progress-bar > .secondary-bar,
.jfx-progress-bar:indeterminate > .secondary-bar {
-fx-background-color: -monet-secondary;
-fx-padding: 2;
}
.jfx-progress-bar > .track,
.jfx-progress-bar > .bar {
-fx-background-radius: 0;
-fx-background-radius: 2;
-fx-background-insets: 0;
}
/*******************************************************************************
* *
* JFX Textfield *
@@ -1457,20 +1451,8 @@
-fx-stroke: -monet-primary-container;
}
.jfx-spinner:determinate .percentage {
-fx-fill: -monet-on-surface-variant;
}
.first-spinner {
-jfx-radius: 20;
}
.first-spinner .arc {
-fx-stroke-width: 5.0;
}
.small-spinner {
-jfx-radius: 10;
-jfx-radius: 9;
}
.small-spinner .arc {
@@ -1478,59 +1460,7 @@
}
.small-spinner-pane .jfx-spinner {
-jfx-radius: 10;
}
.small-spinner-pane .jfx-spinner .arc {
-fx-stroke-width: 3.0;
}
.second-spinner {
-jfx-radius: 30;
}
.second-spinner .arc {
-fx-stroke-width: 5.0;
}
.third-spinner {
-jfx-radius: 40;
}
.third-spinner .arc {
-fx-stroke-width: 5.0;
}
.fourth-spinner {
-jfx-radius: 50;
}
.fourth-spinner .arc {
-fx-stroke-width: 5.0;
}
.fifth-spinner {
-jfx-radius: 60;
}
.fifth-spinner .arc {
-fx-stroke-width: 5.0;
}
.sixth-spinner {
-jfx-radius: 70;
}
.sixth-spinner .arc {
-fx-stroke-width: 5.0;
}
.seventh-spinner {
-jfx-radius: 80;
}
.seventh-spinner .arc {
-fx-stroke-width: 5.0;
-jfx-radius: 9;
}
/*******************************************************************************
@@ -334,6 +334,10 @@ curse.category.4550=Quests
curse.category.4555=World Gen
curse.category.4552=Scripts
curse.category.6553=Realistic
curse.category.6554=Fantasy
curse.category.6555=Vanilla
curse.sort.author=Author
curse.sort.date_created=Date Created
curse.sort.last_updated=Last Updated
@@ -794,7 +798,7 @@ launch.advice=%s Do you still want to continue to launch?
launch.advice.multi=The following problems were detected:\n\n%s\n\nThese problems may prevent you from launching the game or affect gaming experience.\nDo you still want to continue to launch?
launch.advice.java.auto=The current Java version is not compatible with the instance.\n\nClick "Yes" to automatically choose the most compatible Java version. Or, you can navigate to "Global/Instance-specific Settings → Java" to choose one yourself.
launch.advice.java.modded_java_7=Minecraft 1.7.2 and previous versions require Java 7 or earlier.
launch.advice.cleanroom=Cleanroom can only be run on Java 21 or later. Please use Java 21 or later versions.
launch.advice.cleanroom=Cleanroom %2$s can only be run on Java %1$s or later. Please use Java %1$s or later versions.
launch.advice.corrected=We have resolved the Java problem. If you still want to use your choice of Java version, you can disable "Do not check JVM compatibility" in "Global/Instance-specific Settings → Advanced Settings".
launch.advice.uncorrected=If you still want to use your choice of Java version, you can disable "Do not check JVM compatibility" in "Global/Instance-specific Settings → Advanced Settings".
launch.advice.different_platform=The 64-bit Java version is recommended for your device, but you have installed a 32-bit one.
@@ -840,6 +844,8 @@ launcher.agreement=ToS and EULA
launcher.agreement.accept=Accept
launcher.agreement.decline=Decline
launcher.agreement.hint=You must agree to the EULA to use this software.
launcher.april_fools.switch_lzh=HMCL Launcher now supports Classical Chinese, do you want to switch to Classical Chinese mode?\nYou can switch back to English in "置設 → 貫用 → 文".
launcher.april_fools.switch_lzh.confirm=Confirm switching to Classical Chinese mode?\nClick "OK" to exit HMCL and restart it in Classical Chinese mode; click "Cancel" to keep the current language and enter HMCL homepage.
launcher.background=Background Image
launcher.background.choose=Choose background image
launcher.background.classic=Classic
@@ -1417,6 +1423,7 @@ settings.launcher.brightness.dark=Dark Mode
settings.launcher.brightness.light=Light Mode
settings.launcher.common_path.tooltip=HMCL will put all game assets and dependencies here. If there are existing libraries in the game directory, then HMCL will prefer to use them first.
settings.launcher.debug=Debug
settings.launcher.disable_april_fools=Do not enable April Fools features
settings.launcher.disable_auto_game_options=Do not switch game language
settings.launcher.download=Download
settings.launcher.download.threads=Threads
@@ -1140,7 +1140,7 @@ settings.launcher.font.anti_aliasing.auto=自調
settings.launcher.font.anti_aliasing.gray=灰階
settings.launcher.font.anti_aliasing.lcd=子像素
settings.launcher.general=貫用
settings.launcher.language=文 (複啟而效)
settings.launcher.language=文
settings.launcher.launcher_log.export=錄出啟者之誌
settings.launcher.launcher_log.export.failed=錄誌出而未成
settings.launcher.launcher_log.export.success=誌既存於「%s」
@@ -330,6 +330,10 @@ curse.category.4550=任務
curse.category.4555=世界生成
curse.category.4552=指令碼
curse.category.6553=寫實
curse.category.6554=幻想
curse.category.6555=原生
curse.sort.author=作者
curse.sort.date_created=建立日期
curse.sort.last_updated=最近更新
@@ -598,7 +602,7 @@ launch.advice=%s 是否繼續啟動?
launch.advice.multi=檢測到以下問題:\n\n%s\n\n這些問題可能導致遊戲無法正常啟動或影響遊戲體驗,是否繼續啟動?
launch.advice.java.auto=目前選取的 Java 版本不滿足遊戲要求,是否自動選取合適的 Java 版本?\n或者你可以到「(全域/實例特定) 遊戲設定 → 遊戲 Java」中選取一個合適的 Java 版本。
launch.advice.java.modded_java_7=Minecraft 1.7.2 及更低版本需要 Java 7 及更低版本。
launch.advice.cleanroom=Cleanroom 僅支援 Java 21 或更高版本。請使用 Java 21 或最新版本。
launch.advice.cleanroom=Cleanroom %2$s 只能在 Java %1$s 或更高版本上運行。請使用 Java %1$s 或最新版本。
launch.advice.corrected=我們已經修正了 Java 版本問題。如果你確實希望使用你自訂的 Java,你可以在「(全域/實例特定) 遊戲設定 → 進階設定」中往下滑,開啟「不檢查 Java 虛擬機與遊戲的相容性」。
launch.advice.uncorrected=如果你確實希望使用你自訂的 Java,你可以在「(全域/實例特定) 遊戲設定 → 進階設定」中往下滑,開啟「不檢查 Java 虛擬機與遊戲的相容性」。
launch.advice.different_platform=你正在使用 32 位元 Java 啟動遊戲。建議更換至 64 位元 Java。
@@ -644,6 +648,8 @@ launcher.agreement=使用者協議與免責宣告
launcher.agreement.accept=同意
launcher.agreement.decline=拒絕
launcher.agreement.hint=同意本軟體的使用者協議與免責宣告以使用本軟體。
launcher.april_fools.switch_lzh=HMCL 啟動器現已支援文言文,我們誠邀你參與體驗。是否要將介面語言切換至中文 (文言)?\n在切換語言後,你可以在「置設 → 貫用 → 文」中將語言切換回中文 (繁體)。
launcher.april_fools.switch_lzh.confirm=確認切換語言?\n點擊「確定」後 HMCL 將自動重新啟動並將介面語言切換至中文 (文言);點擊「取消」將繼續使用中文 (繁體) 進入 HMCL 主頁。
launcher.background=背景圖片
launcher.background.choose=選取背景圖片
launcher.background.classic=經典
@@ -1202,6 +1208,7 @@ settings.launcher.brightness.dark=深色模式
settings.launcher.brightness.light=淺色模式
settings.launcher.common_path.tooltip=啟動器將所有遊戲資源及相依元件庫檔案放於此集中管理。如果遊戲目錄內有現成的將不會使用公共庫檔案。
settings.launcher.debug=除錯
settings.launcher.disable_april_fools=不啟用愚人節功能
settings.launcher.disable_auto_game_options=不自動切換遊戲語言
settings.launcher.download=下載
settings.launcher.download.threads=執行緒數
@@ -332,6 +332,10 @@ curse.category.4550=任务
curse.category.4555=世界生成
curse.category.4552=脚本
curse.category.6553=写实
curse.category.6554=幻想
curse.category.6555=原生
curse.sort.author=作者
curse.sort.date_created=创建日期
curse.sort.last_updated=最近更新
@@ -603,7 +607,7 @@ launch.advice=%s 是否继续启动?
launch.advice.multi=检测到以下问题:\n\n%s\n\n这些问题可能导致游戏无法正常启动或影响游戏体验,是否继续启动?\n你可以点击右上角帮助按钮进行求助。
launch.advice.java.auto=当前选择的 Java 版本不满足游戏要求。\n点击“是”即可由 HMCL 来自动选取合适的 Java 版本。\n或者你可以在“(全局/实例特定) 游戏设置 → 游戏 Java”中选择一个合适的 Java 版本。
launch.advice.java.modded_java_7=Minecraft 1.7.2 及更低版本需要 Java 7 及更低版本。
launch.advice.cleanroom=Cleanroom 只能在 Java 21 或更高版本上运行。请使用 Java 21 或最新版本。
launch.advice.cleanroom=Cleanroom %2$s 只能在 Java %1$s 或更高版本上运行。请使用 Java %1$s 或最新版本。
launch.advice.corrected=我们已经修复了 Java 版本问题。如果你确实希望使用你自定义的 Java,你可以在“(全局/实例特定) 游戏设置 → 高级设置”中往下滑,启用“不检查 Java 虚拟机与游戏的兼容性”。
launch.advice.uncorrected=如果你确实希望使用你自定义的 Java,你可以在“(全局/实例特定) 游戏设置 → 高级设置”中往下滑,启用“不检查 Java 虚拟机与游戏的兼容性”。
launch.advice.different_platform=你正在使用 32 位 Java 启动游戏。建议更换至 64 位 Java。
@@ -649,6 +653,8 @@ launcher.agreement=用户协议与免责声明
launcher.agreement.accept=同意
launcher.agreement.decline=拒绝
launcher.agreement.hint=同意本软件的用户协议与免责声明以使用本软件。
launcher.april_fools.switch_lzh=HMCL 启动器现已支持文言文,我们诚邀你参与体验。是否要将界面语言切换至中文 (文言)?\n在切换语言后,你可以在“置設 → 貫用 → 文”中将语言切换回中文 (简体)。
launcher.april_fools.switch_lzh.confirm=确认切换语言?\n点击“确定”后 HMCL 将自动重启并将界面语言切换至中文 (文言);点击“取消”将继续使用中文 (简体) 进入 HMCL 主页。
launcher.background=背景图片
launcher.background.choose=选择背景图片
launcher.background.classic=经典
@@ -1207,6 +1213,7 @@ settings.launcher.brightness.dark=深色模式
settings.launcher.brightness.light=浅色模式
settings.launcher.common_path.tooltip=启动器将所有游戏资源及依赖库文件存放于此集中管理。如果游戏文件夹内有现成的将不会使用公共库文件。
settings.launcher.debug=调试
settings.launcher.disable_april_fools=不启用愚人节功能
settings.launcher.disable_auto_game_options=不自动切换游戏语言
settings.launcher.download=下载
settings.launcher.download.threads=线程数
@@ -33,7 +33,6 @@ import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import static org.jackhuang.hmcl.util.Pair.pair;
@@ -57,6 +56,7 @@ public final class BMCLAPIDownloadProvider implements DownloadProvider {
private final QuiltVersionList quilt;
private final QuiltAPIVersionList quiltApi;
private final List<Pair<String, String>> replacement;
private final List<Pair<String, String>> fallbackReplacement;
public BMCLAPIDownloadProvider(String apiRoot) {
this.apiRoot = apiRoot;
@@ -73,7 +73,7 @@ public final class BMCLAPIDownloadProvider implements DownloadProvider {
this.legacyFabric = new LegacyFabricVersionList(this);
this.legacyFabricApi = new LegacyFabricAPIVersionList(this);
this.replacement = Arrays.asList(
this.replacement = List.of(
pair("https://bmclapi2.bangbang93.com", apiRoot),
pair("https://launchermeta.mojang.com", apiRoot),
pair("https://piston-meta.mojang.com", apiRoot),
@@ -93,7 +93,10 @@ public final class BMCLAPIDownloadProvider implements DownloadProvider {
pair("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
pair("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
pair("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
pair("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
pair("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto")
);
this.fallbackReplacement = List.of(
// https://github.com/mcmod-info-mirror/mcim-rust-api
pair("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth"),
pair("https://cdn.modrinth.com", "https://mod.mcimirror.top"),
@@ -135,17 +138,38 @@ public final class BMCLAPIDownloadProvider implements DownloadProvider {
};
}
@Override
public String injectURL(String baseURL) {
private static String injectURL(List<Pair<String, String>> replacement, String baseURL) {
for (Pair<String, String> pair : replacement) {
if (baseURL.startsWith(pair.getKey())) {
return pair.getValue() + baseURL.substring(pair.getKey().length());
}
}
return baseURL;
}
@Override
public String injectURL(String baseURL) {
return injectURL(replacement, baseURL);
}
@Override
public List<URI> injectURLWithCandidates(String baseURL) {
String injected = injectURL(replacement, baseURL);
if (injected.equals(baseURL)) {
String fallbackInjected = injectURL(fallbackReplacement, baseURL);
if (fallbackInjected.equals(baseURL)) {
return List.of(NetworkUtils.toURI(baseURL));
} else {
return List.of(
NetworkUtils.toURI(baseURL),
NetworkUtils.toURI(fallbackInjected)
);
}
} else {
return List.of(NetworkUtils.toURI(injected));
}
}
@Override
public int getConcurrency() {
return Math.max(Runtime.getRuntime().availableProcessors() * 2, 6);
@@ -42,7 +42,7 @@ public class FabricAPIVersionList extends VersionList<FabricAPIRemoteVersion> {
@Override
public Task<?> refreshAsync() {
return Task.runAsync(() -> {
for (RemoteMod.Version modVersion : Lang.toIterable(ModrinthRemoteModRepository.MODS.getRemoteVersionsById("P7dR8mSH"))) {
for (RemoteMod.Version modVersion : Lang.toIterable(ModrinthRemoteModRepository.MODS.getRemoteVersionsById(downloadProvider, "P7dR8mSH"))) {
for (String gameVersion : modVersion.getGameVersions()) {
versions.put(gameVersion, new FabricAPIRemoteVersion(gameVersion, modVersion.getVersion(), modVersion.getName(), modVersion.getDatePublished(), modVersion,
Collections.singletonList(modVersion.getFile().getUrl())));
@@ -42,7 +42,7 @@ public class LegacyFabricAPIVersionList extends VersionList<LegacyFabricAPIRemot
@Override
public Task<?> refreshAsync() {
return Task.runAsync(() -> {
for (RemoteMod.Version modVersion : Lang.toIterable(ModrinthRemoteModRepository.MODS.getRemoteVersionsById("legacy-fabric-api"))) {
for (RemoteMod.Version modVersion : Lang.toIterable(ModrinthRemoteModRepository.MODS.getRemoteVersionsById(downloadProvider, "legacy-fabric-api"))) {
for (String gameVersion : modVersion.getGameVersions()) {
versions.put(gameVersion, new LegacyFabricAPIRemoteVersion(gameVersion, modVersion.getVersion(), modVersion.getName(), modVersion.getDatePublished(), modVersion,
Collections.singletonList(modVersion.getFile().getUrl())));
@@ -42,7 +42,7 @@ public class QuiltAPIVersionList extends VersionList<QuiltAPIRemoteVersion> {
@Override
public Task<?> refreshAsync() {
return Task.runAsync(() -> {
for (RemoteMod.Version modVersion : Lang.toIterable(ModrinthRemoteModRepository.MODS.getRemoteVersionsById("qsl"))) {
for (RemoteMod.Version modVersion : Lang.toIterable(ModrinthRemoteModRepository.MODS.getRemoteVersionsById(downloadProvider, "qsl"))) {
for (String gameVersion : modVersion.getGameVersions()) {
versions.put(gameVersion, new QuiltAPIRemoteVersion(gameVersion, modVersion.getVersion(), modVersion.getName(), modVersion.getDatePublished(), modVersion,
Collections.singletonList(modVersion.getFile().getUrl())));
@@ -17,11 +17,13 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.util.Arrays;
import java.util.Collections;
@@ -51,6 +53,14 @@ public record GameJavaVersion(String component, int majorVersion) {
return null;
}
public static GameJavaVersion getCleanroomJavaVersion(String cleanroomVersion) {
VersionNumber versionNumber = VersionNumber.asVersion(StringUtils.removeSuffix(cleanroomVersion, "-alpha"));
if (versionNumber.compareTo("0.5.0") >= 0)
return JAVA_25;
else
return JAVA_21;
}
public static GameJavaVersion get(int major) {
return switch (major) {
case 8 -> JAVA_8;
@@ -41,7 +41,7 @@ public enum JavaVersionConstraint {
}
@Override
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java) {
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java, LibraryAnalyzer analyzer) {
GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersionNumber);
return minimumJavaVersion == null || java.getParsedVersion() >= minimumJavaVersion.majorVersion();
}
@@ -57,7 +57,7 @@ public enum JavaVersionConstraint {
}
@Override
public VersionRange<VersionNumber> getJavaVersionRange(Version version) {
public VersionRange<VersionNumber> getJavaVersionRange(Version version, LibraryAnalyzer analyzer) {
String javaVersion;
if (Objects.requireNonNull(version.getJavaVersion()).majorVersion() >= 9) {
javaVersion = "" + version.getJavaVersion().majorVersion();
@@ -108,13 +108,26 @@ public enum JavaVersionConstraint {
&& super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer);
}
},
CLEANROOM_JAVA_21(true, GameVersionNumber.between("1.12.2", "1.12.999"), VersionNumber.atLeast("21")) {
CLEANROOM(true, GameVersionNumber.between("1.12.2", "1.12.999"), VersionRange.all()) {
@Override
protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable Version version,
@Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) {
protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable Version version, @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) {
return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)
&& super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer);
}
@Override
public VersionRange<VersionNumber> getJavaVersionRange(Version version, LibraryAnalyzer analyzer) {
if (analyzer == null || !analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
return VersionRange.all();
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
if (cleanroomVersion.isEmpty())
return VersionRange.all();
else
return VersionNumber.atLeast(
String.valueOf(GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion())
);
}
},
// LaunchWrapper<=1.12 will crash because LaunchWrapper assumes the system class loader is an instance of URLClassLoader (Java 8)
LAUNCH_WRAPPER(true, GameVersionNumber.atMost("1.12.999"), VersionNumber.atMost("1.8.999")) {
@@ -143,8 +156,8 @@ public enum JavaVersionConstraint {
}
@Override
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java) {
return java.getArchitecture() != Architecture.X86_64 || super.checkJava(gameVersionNumber, version, java);
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java, LibraryAnalyzer analyzer) {
return java.getArchitecture() != Architecture.X86_64 || super.checkJava(gameVersionNumber, version, java, analyzer);
}
},
// Minecraft currently does not provide official support for architectures other than x86 and x86-64.
@@ -162,7 +175,7 @@ public enum JavaVersionConstraint {
}
@Override
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java) {
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java, LibraryAnalyzer analyzer) {
return java.getArchitecture().isX86();
}
},
@@ -194,7 +207,7 @@ public enum JavaVersionConstraint {
}
@Override
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java) {
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java, LibraryAnalyzer analyzer) {
int parsedJavaVersion = java.getParsedVersion();
if (parsedJavaVersion > 17) {
return false;
@@ -230,7 +243,7 @@ public enum JavaVersionConstraint {
return gameVersionRange;
}
public VersionRange<VersionNumber> getJavaVersionRange(Version version) {
public VersionRange<VersionNumber> getJavaVersionRange(Version version, LibraryAnalyzer analyzer) {
return javaVersionRange;
}
@@ -251,15 +264,15 @@ public enum JavaVersionConstraint {
? String.valueOf(gameJavaVersion.majorVersion())
: "1." + gameJavaVersion.majorVersion();
VersionRange<VersionNumber> range = getJavaVersionRange(version);
VersionRange<VersionNumber> range = getJavaVersionRange(version, analyzer);
VersionNumber maximum = range.getMaximum();
return maximum == null || maximum.compareTo(versionNumber) >= 0;
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java) {
return getJavaVersionRange(version).contains(java.getVersionNumber());
public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java, LibraryAnalyzer analyzer) {
return getJavaVersionRange(version, analyzer).contains(java.getVersionNumber());
}
public static final List<JavaVersionConstraint> ALL = Lang.immutableListOf(values());
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.mod;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
@@ -177,10 +178,10 @@ public final class LocalModFile implements Comparable<LocalModFile> {
file = modManager.disableMod(file);
}
public ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository) throws IOException {
public ModUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteModRepository repository) throws IOException {
Optional<RemoteMod.Version> currentVersion = repository.getRemoteVersionByLocalFile(this, file);
if (!currentVersion.isPresent()) return null;
List<RemoteMod.Version> remoteVersions = repository.getRemoteVersionsById(currentVersion.get().getModid())
List<RemoteMod.Version> remoteVersions = repository.getRemoteVersionsById(downloadProvider, currentVersion.get().getModid())
.filter(version -> version.getGameVersions().contains(gameVersion))
.filter(version -> version.getLoaders().contains(getModLoaderType()))
.filter(version -> version.getDatePublished().compareTo(currentVersion.get().getDatePublished()) > 0)
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.mod;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
@@ -32,12 +33,12 @@ public final class RemoteMod {
public static final RemoteMod BROKEN = new RemoteMod("", "", "RemoteMod.BROKEN", "", Collections.emptyList(), "", "", new RemoteMod.IMod() {
@Override
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository) throws IOException {
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
throw new IOException();
}
@Override
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository) throws IOException {
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
throw new IOException();
}
});
@@ -154,12 +155,12 @@ public final class RemoteMod {
return this.id;
}
public RemoteMod load() throws IOException {
public RemoteMod load(DownloadProvider downloadProvider) throws IOException {
if (this.remoteMod == null) {
if (this.type == DependencyType.BROKEN) {
this.remoteMod = RemoteMod.BROKEN;
} else {
this.remoteMod = this.remoteModRepository.resolveDependency(this.id);
this.remoteMod = this.remoteModRepository.resolveDependency(downloadProvider, this.id);
}
}
return this.remoteMod;
@@ -202,9 +203,9 @@ public final class RemoteMod {
}
public interface IMod {
List<RemoteMod> loadDependencies(RemoteModRepository modRepository) throws IOException;
List<RemoteMod> loadDependencies(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException;
Stream<Version> loadVersions(RemoteModRepository modRepository) throws IOException;
Stream<Version> loadVersions(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException;
}
public interface IVersion {
@@ -90,15 +90,15 @@ public interface RemoteModRepository {
Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException;
RemoteMod getModById(String id) throws IOException;
RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException;
default RemoteMod resolveDependency(String id) throws IOException {
return getModById(id);
default RemoteMod resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
return getModById(downloadProvider, id);
}
RemoteMod.File getModFile(String modId, String fileId) throws IOException;
Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException;
Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException;
Stream<Category> getCategories() throws IOException;
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.mod.curse;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
@@ -191,7 +192,7 @@ public class CurseAddon implements RemoteMod.IMod {
}
@Override
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository) throws IOException {
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
Set<Integer> dependencies = latestFiles.stream()
.flatMap(latestFile -> latestFile.getDependencies().stream())
.filter(dep -> dep.getRelationType() == 3)
@@ -199,14 +200,14 @@ public class CurseAddon implements RemoteMod.IMod {
.collect(Collectors.toSet());
List<RemoteMod> mods = new ArrayList<>();
for (int dependencyId : dependencies) {
mods.add(modRepository.getModById(Integer.toString(dependencyId)));
mods.add(modRepository.getModById(downloadProvider, Integer.toString(dependencyId)));
}
return mods;
}
@Override
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository) throws IOException {
return modRepository.getRemoteVersionsById(Integer.toString(id));
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
return modRepository.getRemoteVersionsById(downloadProvider, Integer.toString(id));
}
public RemoteMod toMod() {
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.mod.curse;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.DefaultGameRepository;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.mod.ModpackCompletionException;
@@ -147,7 +148,7 @@ public final class CurseCompletionTask extends Task<Void> {
.filter(f -> f.fileName() != null)
.flatMap(f -> {
try {
Path path = guessFilePath(f, resourcePacksRoot, shaderPacksRoot);
Path path = guessFilePath(f, dependency.getDownloadProvider(), resourcePacksRoot, shaderPacksRoot);
if (path == null) {
return Stream.empty();
}
@@ -175,13 +176,14 @@ public final class CurseCompletionTask extends Task<Void> {
* Guess where to store the file.
*
* @param file The file.
* @param downloadProvider
* @param resourcePacksRoot ./resourcepacks.
* @param shaderPacksRoot ./shaderpacks.
* @return ./resourcepacks/$filename or ./shaderpacks/$filename or ./mods/$filename if the file doesn't exist. null if the file existed.
* @throws IOException If IOException was encountered during getting data from CurseForge.
*/
private Path guessFilePath(CurseManifestFile file, Path resourcePacksRoot, Path shaderPacksRoot) throws IOException {
RemoteMod mod = CurseForgeRemoteModRepository.MODS.getModById(Integer.toString(file.projectID()));
private Path guessFilePath(CurseManifestFile file, DownloadProvider downloadProvider, Path resourcePacksRoot, Path shaderPacksRoot) throws IOException {
RemoteMod mod = CurseForgeRemoteModRepository.MODS.getModById(downloadProvider, Integer.toString(file.projectID()));
int classID = ((CurseAddon) mod.getData()).getClassId();
String fileName = file.fileName();
return switch (classID) {
@@ -227,7 +227,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
}
@Override
public RemoteMod getModById(String id) throws IOException {
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
@@ -251,7 +251,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
public Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
@@ -302,6 +302,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
public static final int SECTION_RESOURCE_PACK = 12;
public static final int SECTION_WORLD = 17;
public static final int SECTION_MODPACK = 4471;
public static final int SECTION_SHADER = 6552;
public static final int SECTION_CUSTOMIZATION = 4546;
public static final int SECTION_ADDONS = 4559; // For Pocket Edition
public static final int SECTION_UNKNOWN1 = 4944;
@@ -313,6 +314,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
public static final CurseForgeRemoteModRepository RESOURCE_PACKS = new CurseForgeRemoteModRepository(RemoteModRepository.Type.RESOURCE_PACK, SECTION_RESOURCE_PACK);
public static final CurseForgeRemoteModRepository WORLDS = new CurseForgeRemoteModRepository(RemoteModRepository.Type.WORLD, SECTION_WORLD);
public static final CurseForgeRemoteModRepository CUSTOMIZATIONS = new CurseForgeRemoteModRepository(RemoteModRepository.Type.CUSTOMIZATION, SECTION_CUSTOMIZATION);
public static final CurseForgeRemoteModRepository SHADERS = new CurseForgeRemoteModRepository(RemoteModRepository.Type.SHADER_PACK, SECTION_SHADER);
public static class Pagination {
private final int index;
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -36,7 +35,6 @@ import java.util.Collection;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -123,7 +121,7 @@ public class ModrinthCompletionTask extends Task<Void> {
continue;
var task = new FileDownloadTask(
file.getDownloads().stream().map(NetworkUtils::toURI).collect(Collectors.toList()),
dependency.getDownloadProvider().injectURLsWithCandidates(file.getDownloads()),
filePath);
task.setCacheRepository(dependency.getCacheRepository());
task.setCaching(true);
@@ -123,7 +123,8 @@ public class ModrinthInstallTask extends Task<Void> {
String ext = FileUtils.getExtension(StringUtils.substringAfter(iconUri.getPath(), '/')).toLowerCase(Locale.ROOT);
if (SUPPORTED_ICON_EXTS.contains(ext)) {
iconExt = ext;
dependents.add(downloadIconTask = new CacheFileTask(iconUrl));
dependents.add(downloadIconTask = new CacheFileTask(dependencyManager.getDownloadProvider().injectURLWithCandidates(iconUrl)));
}
}
dependencies.add(new ModrinthCompletionTask(dependencyManager, name, manifest));
@@ -115,7 +115,6 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
pair("index", convertSortType(sort))
);
List<URI> candidates = downloadProvider.injectURLWithCandidates(NetworkUtils.withQuery(PREFIX + "/v2/search", query));
IOException exception = null;
for (URI candidate : candidates) {
@@ -126,13 +125,15 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
} catch (IOException e) {
LOG.warning("Failed to search addons: " + candidate, e);
IOException wrapper = new IOException("Failed to search addons: " + candidate, e);
if (candidates.size() == 1) {
exception = e;
exception = wrapper;
} else {
if (exception == null) {
exception = new IOException("Failed to search addons");
}
exception.addSuppressed(e);
exception.addSuppressed(wrapper);
}
}
}
@@ -167,21 +168,40 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
}
@Override
public RemoteMod getModById(String id) throws IOException {
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
id = StringUtils.removePrefix(id, "local-");
Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class);
return project.toMod();
List<URI> candidates = downloadProvider.injectURLWithCandidates(PREFIX + "/v2/project/" + id);
IOException exception = null;
for (URI candidate : candidates) {
try {
Project project = HttpRequest.GET(candidate.toString()).getJson(Project.class);
return project.toMod();
} catch (IOException e) {
IOException wrapper = new IOException("Failed to get mod: " + candidate, e);
if (candidates.size() == 1) {
exception = wrapper;
} else {
if (exception == null) {
exception = new IOException("Failed to get mod");
}
exception.addSuppressed(wrapper);
}
}
}
throw exception != null ? exception : new IOException("No candidates found");
} finally {
SEMAPHORE.release();
}
}
@Override
public RemoteMod resolveDependency(String id) throws IOException {
public RemoteMod resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
try {
return getModById(id);
return getModById(downloadProvider, id);
} catch (ResponseCodeException e) {
if (e.getResponseCode() == 502 || e.getResponseCode() == 404) {
return RemoteMod.BROKEN;
@@ -198,13 +218,33 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
public Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
id = StringUtils.removePrefix(id, "local-");
List<ProjectVersion> versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version?include_changelog=false")
.getJson(listTypeOf(ProjectVersion.class));
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream);
List<URI> candidates = downloadProvider.injectURLWithCandidates(PREFIX + "/v2/project/" + id + "/version?include_changelog=false");
IOException exception = null;
for (URI candidate : candidates) {
try {
List<ProjectVersion> versions = HttpRequest.GET(candidate.toString())
.getJson(listTypeOf(ProjectVersion.class));
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream);
} catch (IOException e) {
IOException wrapper = new IOException("Failed to get remote versions: " + candidate, e);
if (candidates.size() == 1) {
exception = wrapper;
} else {
if (exception == null) {
exception = new IOException("Failed to get remote versions");
}
exception.addSuppressed(wrapper);
}
}
}
throw exception != null ? exception : new IOException("No candidates found");
} finally {
SEMAPHORE.release();
}
@@ -362,20 +402,20 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
}
@Override
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository) throws IOException {
Set<RemoteMod.Dependency> dependencies = modRepository.getRemoteVersionsById(getId())
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
Set<RemoteMod.Dependency> dependencies = modRepository.getRemoteVersionsById(downloadProvider, getId())
.flatMap(version -> version.getDependencies().stream())
.collect(Collectors.toSet());
List<RemoteMod> mods = new ArrayList<>();
for (RemoteMod.Dependency dependency : dependencies) {
mods.add(dependency.load());
mods.add(dependency.load(downloadProvider));
}
return mods;
}
@Override
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository) throws IOException {
return modRepository.getRemoteVersionsById(getId());
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
return modRepository.getRemoteVersionsById(downloadProvider, getId());
}
public RemoteMod toMod() {
@@ -749,20 +789,20 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
}
@Override
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository) throws IOException {
Set<RemoteMod.Dependency> dependencies = modRepository.getRemoteVersionsById(getProjectId())
public List<RemoteMod> loadDependencies(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
Set<RemoteMod.Dependency> dependencies = modRepository.getRemoteVersionsById(downloadProvider, getProjectId())
.flatMap(version -> version.getDependencies().stream())
.collect(Collectors.toSet());
List<RemoteMod> mods = new ArrayList<>();
for (RemoteMod.Dependency dependency : dependencies) {
mods.add(dependency.load());
mods.add(dependency.load(downloadProvider));
}
return mods;
}
@Override
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository) throws IOException {
return modRepository.getRemoteVersionsById(getProjectId());
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
return modRepository.getRemoteVersionsById(downloadProvider, getProjectId());
}
public RemoteMod toMod() {
@@ -50,6 +50,14 @@ public final class CacheFileTask extends FetchTask<Path> {
throw new IllegalArgumentException(uri.toString());
}
public CacheFileTask(@NotNull List<@NotNull URI> uris) {
super(uris);
setName(uris.get(0).toString());
if (!uris.stream().allMatch(NetworkUtils::isHttpUri))
throw new IllegalArgumentException(uris.toString());
}
@Override
protected EnumCheckETag shouldCheckETag() {
// Check cache
@@ -34,6 +34,8 @@ import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -149,4 +151,63 @@ public final class MacOSHardwareDetector extends HardwareDetector {
return Collections.emptyList();
}
private static final Pattern PAGE_SIZE_PATTERN = Pattern.compile("\\(page size of (?<size>\\d+) bytes\\)");
@Override
public long getFreeMemorySize() {
vmStat:
try {
Map<String, String> stats = SystemUtils.run(List.of("/usr/bin/vm_stat"),
inputStream -> KeyValuePairUtils.loadPairs(
new BufferedReader(new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET))));
String statistics = stats.get("Mach Virtual Memory Statistics");
long pageSize;
long pagesFree;
long pagesInactive;
long pagesSpeculative;
if (statistics != null) {
Matcher matcher = PAGE_SIZE_PATTERN.matcher(statistics);
if (matcher.find()) {
pageSize = Long.parseLong(matcher.group("size"));
} else {
break vmStat;
}
} else {
break vmStat;
}
String pagesFreeStr = stats.get("Pages free");
if (pagesFreeStr != null && pagesFreeStr.endsWith(".")) {
pagesFree = Long.parseUnsignedLong(pagesFreeStr, 0, pagesFreeStr.length() - 1, 10);
} else {
break vmStat;
}
String pagesInactiveStr = stats.get("Pages inactive");
if (pagesInactiveStr != null && pagesInactiveStr.endsWith(".")) {
pagesInactive = Long.parseUnsignedLong(pagesInactiveStr, 0, pagesInactiveStr.length() - 1, 10);
} else {
break vmStat;
}
String pagesSpeculativeStr = stats.get("Pages speculative");
if (pagesSpeculativeStr != null && pagesSpeculativeStr.endsWith(".")) {
pagesSpeculative = Long.parseUnsignedLong(pagesSpeculativeStr, 0, pagesSpeculativeStr.length() - 1, 10);
} else {
break vmStat;
}
long available = (pagesFree + pagesSpeculative + pagesInactive) * pageSize;
if (available > 0) {
return available;
}
} catch (Throwable e) {
LOG.warning("Failed to parse vm_stat output", e);
}
return super.getFreeMemorySize();
}
}
@@ -357,6 +357,10 @@ public abstract sealed class GameVersionNumber implements Comparable<GameVersion
needNormalize = true;
releaseType = ReleaseType.PRE_RELEASE;
eaVersion = VersionNumber.asVersion(suffix.substring(" Pre-release ".length()));
} else if (suffix.startsWith("-rc-")) {
needNormalize = true;
releaseType = ReleaseType.RELEASE_CANDIDATE;
eaVersion = VersionNumber.asVersion(suffix.substring("-rc-".length()));
} else if (suffix.startsWith("-rc")) {
releaseType = ReleaseType.RELEASE_CANDIDATE;
eaVersion = VersionNumber.asVersion(suffix.substring("-rc".length()));
@@ -439,6 +439,7 @@ public final class GameVersionNumberTest {
assertNormalized("26.1-pre1", "26.1-pre-1");
assertNormalized("1.21.11-rc1", "1.21.11 Release Candidate 1");
assertNormalized("1.21.11-rc1_unobfuscated", "1.21.11 Release Candidate 1 Unobfuscated");
assertNormalized("26.1-rc1", "26.1-rc-1");
assertNormalized("1.14_combat-212796", "1.14.3 - Combat Test");
assertNormalized("1.14_combat-0", "Combat Test 2");
assertNormalized("1.14_combat-3", "Combat Test 3");