Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
583e004d71 | ||
|
|
63207affc6 | ||
|
|
8b147bf20a | ||
|
|
15754119d5 | ||
|
|
8119324a92 | ||
|
|
c0ad7764b0 | ||
|
|
3ea054ae31 | ||
|
|
d03ad38e24 | ||
|
|
93eaee317c | ||
|
|
68b3a9f076 | ||
|
|
c943e4c74f | ||
|
|
4dba96e635 | ||
|
|
e521a1b91a | ||
|
|
a7e0fe7499 | ||
|
|
4e2d5cac9e | ||
|
|
452d030817 | ||
|
|
da9ec2c1e1 | ||
|
|
4ebbb400ab | ||
|
|
0cfc2471a0 | ||
|
|
413a7291ec | ||
|
|
e4893a6df9 | ||
|
|
bb16a0b6c0 |
No files matched your search
@@ -6,3 +6,6 @@ contact_links:
|
||||
- name: Discord 服务器
|
||||
url: https://discord.gg/jVvC7HfM6U
|
||||
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
|
||||
- name: GitHub Issue
|
||||
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: QQ 群
|
||||
url: https://docs.hmcl.net/groups.html
|
||||
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
|
||||
- name: GitHub Issue
|
||||
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
distribution: 'liberica'
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Setup Gradle
|
||||
|
||||
+24
-12
@@ -1,4 +1,6 @@
|
||||
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
|
||||
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
|
||||
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
|
||||
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
||||
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
@@ -6,7 +8,6 @@ import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
||||
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
|
||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
||||
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
||||
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
||||
@@ -462,21 +463,32 @@ val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList"
|
||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||
}
|
||||
|
||||
val createLocaleNamesResourceBundle = tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
dependsOn(createLanguageList)
|
||||
val createLocaleNamesResourceBundle =
|
||||
tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
|
||||
// mcmod data
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModData") {
|
||||
inputFile.set(layout.projectDirectory.file("mod.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt"))
|
||||
tasks.register<FormatJsonFile>("formatModJson") {
|
||||
file.set(layout.projectDirectory.file("mod.json"))
|
||||
}
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModPackData") {
|
||||
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt"))
|
||||
tasks.register<FormatJsonFile>("formatModpackJson") {
|
||||
file.set(layout.projectDirectory.file("modpack.json"))
|
||||
}
|
||||
|
||||
tasks.register<UpdateAddonProjectInfos>("updateModProjectInfos") {
|
||||
inputFile.set(layout.projectDirectory.file("mod.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/mod.csv"))
|
||||
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MOD)
|
||||
}
|
||||
|
||||
tasks.register<UpdateAddonProjectInfos>("updateModpackProjectInfos") {
|
||||
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/modpack.csv"))
|
||||
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MODPACK)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final StackPane box = new StackPane();
|
||||
private final StackPane mark = new StackPane();
|
||||
private final SVGPath markShape = new SVGPath();
|
||||
private final double lineThick = 2.0;
|
||||
private final double padding = 10.0;
|
||||
private final JFXRippler rippler;
|
||||
@@ -37,7 +38,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final double labelOffset = -8.0;
|
||||
private final Transition transition;
|
||||
private boolean invalid = true;
|
||||
private JFXFillTransition select;
|
||||
private boolean selected;
|
||||
private boolean indeterminate;
|
||||
|
||||
/// The checkbox fill animation, initialized with the configured checked color.
|
||||
private JFXFillTransition fillTransition;
|
||||
|
||||
public JFXCheckBoxSkin(JFXCheckBox control) {
|
||||
super(control);
|
||||
@@ -50,22 +55,17 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
boxContainer.getChildren().add(this.box);
|
||||
boxContainer.setPadding(new Insets(this.padding));
|
||||
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
|
||||
this.updateRippleColor();
|
||||
SVGPath shape = new SVGPath();
|
||||
shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
||||
this.mark.setShape(shape);
|
||||
this.mark.setMaxSize(15.0, 12.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
||||
this.updateRippleColor(control.isSelected(), control.isIndeterminate());
|
||||
this.mark.setShape(this.markShape);
|
||||
this.updateMarkShape(true, false);
|
||||
this.mark.setVisible(false);
|
||||
this.mark.setScaleX(0.0);
|
||||
this.mark.setScaleY(0.0);
|
||||
boxContainer.getChildren().add(this.mark);
|
||||
this.container.getChildren().add(this.rippler);
|
||||
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
|
||||
control.selectedProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.updateRippleColor();
|
||||
this.playSelectAnimation(newVal);
|
||||
});
|
||||
this.registerChangeListener(control.selectedProperty(), ignored -> requestSelectionUpdate());
|
||||
this.registerChangeListener(control.indeterminateProperty(), ignored -> requestSelectionUpdate());
|
||||
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||
if (focusVisibleProperty == null)
|
||||
@@ -82,37 +82,63 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
|
||||
this.updateChildren();
|
||||
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
|
||||
if (select != null) {
|
||||
select.stop();
|
||||
if (fillTransition != null) {
|
||||
fillTransition.stop();
|
||||
}
|
||||
this.createFillTransition();
|
||||
updateColors();
|
||||
boolean selected = control.isSelected();
|
||||
boolean indeterminate = control.isIndeterminate();
|
||||
JFXFillTransition fillTransition = this.createFillTransition();
|
||||
this.fillTransition = fillTransition;
|
||||
fillTransition.jumpTo(selected || indeterminate ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||
updateColors(selected, indeterminate);
|
||||
});
|
||||
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors());
|
||||
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors(control.isSelected(), control.isIndeterminate()));
|
||||
this.transition = new CheckBoxTransition();
|
||||
this.createFillTransition();
|
||||
this.fillTransition = this.createFillTransition();
|
||||
}
|
||||
|
||||
private void updateRippleColor() {
|
||||
var control = (JFXCheckBox) this.getSkinnable();
|
||||
this.rippler.setRipplerFill(control.isSelected()
|
||||
private void requestSelectionUpdate() {
|
||||
this.invalid = true;
|
||||
this.getSkinnable().requestLayout();
|
||||
}
|
||||
|
||||
private void updateRippleColor(boolean selected, boolean indeterminate) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
this.rippler.setRipplerFill(selected || indeterminate
|
||||
? control.getCheckedColor()
|
||||
: control.getUnCheckedColor());
|
||||
}
|
||||
|
||||
private void updateColors() {
|
||||
private void updateColors(boolean selected, boolean indeterminate) {
|
||||
var control = (JFXCheckBox) getSkinnable();
|
||||
boolean isSelected = control.isSelected();
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT);
|
||||
rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor());
|
||||
boolean marked = selected || indeterminate;
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, marked ? control.getCheckedColor() : Color.TRANSPARENT);
|
||||
rippler.setRipplerFill(marked ? control.getCheckedColor() : control.getUnCheckedColor());
|
||||
this.updateBorder(selected, indeterminate);
|
||||
}
|
||||
|
||||
private void updateBorder(boolean selected, boolean indeterminate) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
|
||||
box.setBorder(new Border(new BorderStroke(
|
||||
isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
selected || indeterminate ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
borderStroke.getTopStyle(),
|
||||
borderStroke.getRadii(),
|
||||
borderStroke.getWidths())));
|
||||
}
|
||||
|
||||
private void updateMarkShape(boolean selected, boolean indeterminate) {
|
||||
if (indeterminate) {
|
||||
this.markShape.setContent("M1 0 H9 A1 1 0 0 1 10 1 A1 1 0 0 1 9 2 H1 A1 1 0 0 1 0 1 A1 1 0 0 1 1 0 Z");
|
||||
this.mark.setMaxSize(10.0, 2.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary;");
|
||||
} else if (selected) {
|
||||
this.markShape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
||||
this.mark.setMaxSize(15.0, 12.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
||||
}
|
||||
}
|
||||
|
||||
protected void updateChildren() {
|
||||
super.updateChildren();
|
||||
if (this.container != null) {
|
||||
@@ -140,9 +166,10 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
|
||||
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
|
||||
if (this.invalid) {
|
||||
if (this.getSkinnable().isSelected()) {
|
||||
this.playSelectAnimation(true);
|
||||
}
|
||||
boolean selected = checkBox.isSelected();
|
||||
boolean indeterminate = checkBox.isIndeterminate();
|
||||
this.updateRippleColor(selected, indeterminate);
|
||||
this.playSelectAnimation(selected, indeterminate);
|
||||
|
||||
this.invalid = false;
|
||||
}
|
||||
@@ -169,47 +196,85 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
};
|
||||
}
|
||||
|
||||
/// Animates a selection change, or applies it immediately when animations are disabled.
|
||||
private void playSelectAnimation(boolean selection) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
private void playSelectAnimation(boolean selected, boolean indeterminate) {
|
||||
boolean previousSelected = this.selected;
|
||||
boolean previousIndeterminate = this.indeterminate;
|
||||
this.selected = selected;
|
||||
this.indeterminate = indeterminate;
|
||||
boolean previousMarked = previousSelected || previousIndeterminate;
|
||||
boolean marked = selected || indeterminate;
|
||||
boolean sameVisualState = previousIndeterminate == indeterminate
|
||||
&& (indeterminate || previousSelected == selected);
|
||||
this.updateBorder(selected, indeterminate);
|
||||
|
||||
this.box.setBorder(new Border(new BorderStroke(
|
||||
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
BorderStrokeStyle.SOLID,
|
||||
new CornerRadii(2.0),
|
||||
new BorderWidths(this.lineThick))));
|
||||
if (sameVisualState) {
|
||||
return;
|
||||
}
|
||||
this.invalid = false;
|
||||
|
||||
if (!AnimationUtils.isAnimationEnabled()) {
|
||||
finishSelectionAnimation();
|
||||
finishSelectionAnimation(selected, indeterminate);
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
if (previousMarked && marked) {
|
||||
this.transition.stop();
|
||||
this.transition.setOnFinished(null);
|
||||
this.transition.jumpTo(this.transition.getCycleDuration());
|
||||
this.transition.setRate(-1.0);
|
||||
this.transition.setOnFinished(event -> {
|
||||
this.transition.setOnFinished(null);
|
||||
this.updateMarkShape(selected, indeterminate);
|
||||
this.transition.jumpTo(Duration.ZERO);
|
||||
this.transition.setRate(1.0);
|
||||
this.transition.play();
|
||||
});
|
||||
this.transition.play();
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setOnFinished(null);
|
||||
if (marked) {
|
||||
this.updateMarkShape(selected, indeterminate);
|
||||
}
|
||||
this.transition.setRate(marked ? 1.0 : -1.0);
|
||||
this.fillTransition.setRate(marked ? 1.0 : -1.0);
|
||||
this.fillTransition.play();
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
}
|
||||
|
||||
/// Stops selection animations and immediately displays the checkbox's current selected state.
|
||||
/// Stops selection animations and immediately displays the checkbox's current state.
|
||||
///
|
||||
/// The check mark, fill, and border are updated even if no animation is running.
|
||||
/// This also suppresses the initial selection animation on the first layout.
|
||||
/// The state indicator, fill, and border are updated even if no animation is running.
|
||||
/// This also suppresses pending selection updates and the initial selection animation.
|
||||
/// The skin must not have been disposed.
|
||||
public void finishSelectionAnimation() {
|
||||
transition.stop();
|
||||
select.stop();
|
||||
invalid = false;
|
||||
boolean selected = getSkinnable().isSelected();
|
||||
mark.setVisible(selected);
|
||||
mark.setScaleX(selected ? 1.0 : 0.0);
|
||||
mark.setScaleY(selected ? 1.0 : 0.0);
|
||||
updateColors();
|
||||
CheckBox checkBox = this.getSkinnable();
|
||||
this.finishSelectionAnimation(checkBox.isSelected(), checkBox.isIndeterminate());
|
||||
}
|
||||
|
||||
private void createFillTransition() {
|
||||
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
||||
private void finishSelectionAnimation(boolean selected, boolean indeterminate) {
|
||||
this.selected = selected;
|
||||
this.indeterminate = indeterminate;
|
||||
boolean marked = selected || indeterminate;
|
||||
transition.stop();
|
||||
transition.setOnFinished(null);
|
||||
transition.jumpTo(marked ? transition.getCycleDuration() : Duration.ZERO);
|
||||
fillTransition.stop();
|
||||
fillTransition.jumpTo(marked ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||
invalid = false;
|
||||
updateMarkShape(selected, indeterminate);
|
||||
mark.setVisible(marked);
|
||||
mark.setScaleX(marked ? 1.0 : 0.0);
|
||||
mark.setScaleY(marked ? 1.0 : 0.0);
|
||||
updateColors(selected, indeterminate);
|
||||
}
|
||||
|
||||
private JFXFillTransition createFillTransition() {
|
||||
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
||||
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
|
||||
this.select.setInterpolator(Interpolator.EASE_OUT);
|
||||
fillTransition.setInterpolator(Interpolator.EASE_OUT);
|
||||
return fillTransition;
|
||||
}
|
||||
|
||||
private final class CheckBoxTransition extends CachedTransition {
|
||||
|
||||
@@ -69,7 +69,7 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
arc = new Arc();
|
||||
arc.setManaged(false);
|
||||
arc.setLength(180);
|
||||
arc.setLength(-180);
|
||||
arc.getStyleClass().setAll("arc");
|
||||
arc.setFill(Color.TRANSPARENT);
|
||||
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||
@@ -192,24 +192,24 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
|
||||
frames.add(new KeyFrame(Duration.seconds(duration),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 45 + startingAngle,
|
||||
-(angle + 45 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 90 + startingAngle,
|
||||
-(angle + 90 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 135 + startingAngle,
|
||||
-(angle + 135 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 435 + startingAngle,
|
||||
-(angle + 435 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
}
|
||||
|
||||
@@ -222,21 +222,21 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
addKeyFrames(keyFrames, 900, 2.8);
|
||||
addKeyFrames(keyFrames, 1350, 4.2);
|
||||
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
1845 + startingAngle,
|
||||
-(1845 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
|
||||
timeline = new Timeline();
|
||||
timeline.getKeyFrames().setAll(keyFrames);
|
||||
} else {
|
||||
final double arcLength = 250;
|
||||
final double arcLength = -250;
|
||||
timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(), -(45 + startingAngle), Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
|
||||
new KeyFrame(Duration.seconds(1.2),
|
||||
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(), -(45 + 360 + startingAngle), Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ public final class Launcher extends Application {
|
||||
Platform.setImplicitExit(false);
|
||||
Controllers.initialize(primaryStage);
|
||||
|
||||
RegionUtils.init();
|
||||
UpdateChecker.init();
|
||||
|
||||
primaryStage.show();
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon;
|
||||
|
||||
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProjectInfo;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
|
||||
public static final AddonProjectInfoRegistry<ModProjectInfo> MOD = new AddonProjectInfoRegistry<>(
|
||||
"/assets/addon/mod.csv",
|
||||
32768
|
||||
) {
|
||||
@Override
|
||||
protected int getFieldsCount() {
|
||||
return MOD_IDS_INDEX + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModProjectInfo parseAddonProjectInfo(String[] fields) throws Exception {
|
||||
return new ModProjectInfo(
|
||||
Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||
fields[CURSEFORGE_ID_INDEX],
|
||||
fields[MODRINTH_ID_INDEX],
|
||||
fields[MAIN_NAME_INDEX],
|
||||
fields[SUB_NAME_INDEX],
|
||||
fields[ABBR_NAME_INDEX],
|
||||
fields[MOD_IDS_INDEX]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/// Provides metadata for modpacks.
|
||||
public static final AddonProjectInfoRegistry<ModpackProjectInfo> MODPACK = new AddonProjectInfoRegistry<>(
|
||||
"/assets/addon/modpack.csv", 2048
|
||||
) {
|
||||
@Override
|
||||
protected int getFieldsCount() {
|
||||
return ABBR_NAME_INDEX + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModpackProjectInfo parseAddonProjectInfo(String[] fields) {
|
||||
return new ModpackProjectInfo(Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||
fields[CURSEFORGE_ID_INDEX], fields[MODRINTH_ID_INDEX],
|
||||
fields[MAIN_NAME_INDEX], fields[SUB_NAME_INDEX], fields[ABBR_NAME_INDEX]);
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns the registry for the addon type, or `null` for null or unsupported types.
|
||||
public static @Nullable AddonProjectInfoRegistry<?> getByAddonType(@Nullable RemoteAddon.Type type) {
|
||||
if (type == null) return null;
|
||||
return switch (type) {
|
||||
case MOD -> MOD;
|
||||
case MODPACK -> MODPACK;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
protected static final int MCMOD_ID_INDEX = 0;
|
||||
protected static final int CURSEFORGE_ID_INDEX = 1;
|
||||
protected static final int MODRINTH_ID_INDEX = 2;
|
||||
protected static final int MAIN_NAME_INDEX = 3;
|
||||
protected static final int SUB_NAME_INDEX = 4;
|
||||
protected static final int ABBR_NAME_INDEX = 5;
|
||||
protected static final int MOD_IDS_INDEX = 6;
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final String assetsPath;
|
||||
private final int initialCapacity;
|
||||
|
||||
private volatile @Nullable SoftReference<@Unmodifiable List<I>> infosRef;
|
||||
|
||||
/// Softly caches projects indexed by mod ID.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> modIdMapRef;
|
||||
|
||||
/// Softly caches projects indexed by cleaned secondary name.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> subnameMapRef;
|
||||
|
||||
/// Softly caches projects indexed by CurseForge identifier.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> curseForgeMapRef;
|
||||
|
||||
/// Softly caches searchable names in resource and field order.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> keywordsRef;
|
||||
|
||||
/// Creates a registry reading records from the given classpath resource.
|
||||
AddonProjectInfoRegistry(String assetsPath, int initialCapacity) {
|
||||
this.assetsPath = assetsPath;
|
||||
this.initialCapacity = initialCapacity;
|
||||
}
|
||||
|
||||
protected abstract int getFieldsCount();
|
||||
|
||||
protected abstract I parseAddonProjectInfo(String[] fields) throws Exception;
|
||||
|
||||
private @Unmodifiable List<I> loadInfos() throws IOException {
|
||||
@Nullable InputStream inputStream = AddonProjectInfoRegistry.class.getResourceAsStream(assetsPath);
|
||||
if (inputStream == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||
ArrayList<I> infos = new ArrayList<>(initialCapacity);
|
||||
@Unmodifiable List<I> result = Collections.unmodifiableList(infos);
|
||||
|
||||
int fieldsCount = getFieldsCount();
|
||||
String[] fields = new String[fieldsCount];
|
||||
|
||||
@Nullable StringBuilder fieldBuilder = null;
|
||||
|
||||
@Nullable String line;
|
||||
loop:
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
for (int fieldIndex = 0; fieldIndex < fieldsCount; fieldIndex++) {
|
||||
if (offset > line.length()) {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
|
||||
int end = line.indexOf(',', offset);
|
||||
if (end < 0)
|
||||
end = line.length();
|
||||
|
||||
|
||||
String field;
|
||||
if (offset == end) {
|
||||
field = "";
|
||||
} else {
|
||||
int cursor = offset;
|
||||
while (cursor < end) {
|
||||
if (line.charAt(cursor) == '\\') {
|
||||
break;
|
||||
} else {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cursor == end) {
|
||||
field = line.substring(offset, end);
|
||||
} else {
|
||||
if (fieldBuilder == null) {
|
||||
fieldBuilder = new StringBuilder(32);
|
||||
} else {
|
||||
fieldBuilder.setLength(0);
|
||||
}
|
||||
|
||||
fieldBuilder.append(line, offset, cursor);
|
||||
while (cursor < end) {
|
||||
char c = line.charAt(cursor);
|
||||
if (c == '\\') {
|
||||
if (cursor < end - 1) {
|
||||
char next = line.charAt(cursor + 1);
|
||||
switch (next) {
|
||||
case '0' -> {
|
||||
fieldBuilder.append('\0');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'n' -> {
|
||||
fieldBuilder.append('\n');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'r' -> {
|
||||
fieldBuilder.append('\r');
|
||||
cursor += 2;
|
||||
}
|
||||
case 't' -> {
|
||||
fieldBuilder.append('\t');
|
||||
cursor += 2;
|
||||
}
|
||||
case '\\' -> {
|
||||
fieldBuilder.append('\\');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'c' -> {
|
||||
fieldBuilder.append(',');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'u' -> {
|
||||
if (cursor < end - 5) {
|
||||
try {
|
||||
int codePoint = Integer.parseInt(line, cursor + 2, cursor + 6, 16);
|
||||
fieldBuilder.append((char) codePoint);
|
||||
cursor += 6;
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
field = fieldBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
fields[fieldIndex] = field;
|
||||
offset = end + 1;
|
||||
}
|
||||
|
||||
try {
|
||||
I info = parseAddonProjectInfo(fields);
|
||||
info.setKeepAlive(result);
|
||||
infos.add(info);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to parse line in " + assetsPath + ": " + line, e);
|
||||
}
|
||||
}
|
||||
|
||||
infos.trimToSize();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getModIdMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.modIdMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.modIdMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
if (info instanceof ModProjectInfo mod) {
|
||||
for (String id : mod.getModIds()) {
|
||||
if (!id.isEmpty())
|
||||
map.putIfAbsent(id, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.modIdMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getSubnameMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.subnameMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.subnameMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
String name = cleanSubname(info.subName());
|
||||
if (!name.isEmpty())
|
||||
map.putIfAbsent(name, info);
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.subnameMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getCurseForgeMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.curseForgeMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.curseForgeMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
String id = info.curseforgeId();
|
||||
if (StringUtils.isNotBlank(id))
|
||||
map.putIfAbsent(id, info);
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.curseForgeMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the first project with the exact CurseForge identifier, or `null` if the
|
||||
/// identifier is null, blank or unknown.
|
||||
public @Nullable I getInfoByCurseForgeId(@Nullable String id) {
|
||||
return StringUtils.isBlank(id) ? null : getCurseForgeMap().get(id);
|
||||
}
|
||||
|
||||
/// Finds a project by its cleaned secondary name, falling back to its exact mod ID.
|
||||
/// A name match is accepted only when the ID is blank or belongs to that mod project.
|
||||
/// Null and blank inputs are absent; returns `null` if neither lookup matches.
|
||||
/// Duplicate keys resolve to the first project in resource order.
|
||||
public @Nullable I getInfo(@Nullable String id, @Nullable String subname) {
|
||||
String name = cleanSubname(subname);
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
@Nullable I info = getSubnameMap().get(name);
|
||||
if (info != null && (StringUtils.isBlank(id)
|
||||
|| info instanceof ModProjectInfo mod && mod.getModIds().contains(id)))
|
||||
return info;
|
||||
}
|
||||
return StringUtils.isBlank(id) ? null : getModIdMap().get(id);
|
||||
}
|
||||
|
||||
private static boolean isSupportedSubnameChar(int ch) {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||
|| ".+\\".indexOf(ch) >= 0;
|
||||
}
|
||||
|
||||
/// Removes supported separators and pictographs from a secondary name.
|
||||
/// Returns an empty string for null, blank or unsupported names; matching is case-sensitive.
|
||||
private static String cleanSubname(@Nullable String subname) {
|
||||
if (StringUtils.isBlank(subname))
|
||||
return "";
|
||||
|
||||
int offset = 0;
|
||||
while (offset < subname.length()) {
|
||||
int ch = subname.charAt(offset);
|
||||
if (!isSupportedSubnameChar(ch))
|
||||
break;
|
||||
offset++;
|
||||
}
|
||||
|
||||
if (offset == subname.length())
|
||||
return subname;
|
||||
|
||||
StringBuilder builder = new StringBuilder(subname.length());
|
||||
builder.append(subname, 0, offset);
|
||||
|
||||
while (offset < subname.length()) {
|
||||
int ch = subname.codePointAt(offset);
|
||||
if (isSupportedSubnameChar(ch)) {
|
||||
builder.appendCodePoint(ch);
|
||||
} else if (!(Character.isWhitespace(ch)
|
||||
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0
|
||||
|| ch >= 0x1F300 && ch <= 0x1FAFF)) {
|
||||
return "";
|
||||
}
|
||||
offset += Character.charCount(ch);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/// Returns searchable names, rebuilding the index after its soft reference is cleared.
|
||||
private @Unmodifiable List<Pair<String, I>> getKeywords() {
|
||||
@Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> reference = keywordsRef;
|
||||
@Unmodifiable List<Pair<String, I>> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = keywordsRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
List<Pair<String, I>> keywords = new ArrayList<>();
|
||||
for (I info : getInfos()) {
|
||||
if (!info.mainName().isEmpty())
|
||||
keywords.add(Pair.pair(info.mainName(), info));
|
||||
if (!info.subName().isEmpty())
|
||||
keywords.add(Pair.pair(info.subName(), info));
|
||||
if (!info.abbrName().isEmpty())
|
||||
keywords.add(Pair.pair(info.abbrName(), info));
|
||||
}
|
||||
cached = List.copyOf(keywords);
|
||||
keywordsRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns projects ranked by descending longest common subsequence length after removing
|
||||
/// space characters from the query. Comparison is case-sensitive and uses UTF-16 units.
|
||||
/// Matches require at least one unit and at least query length minus three units.
|
||||
/// Each matching name contributes a result, so projects may occur more than once.
|
||||
/// Ties preserve resource order, then primary name, secondary name and abbreviation order.
|
||||
public @Unmodifiable List<I> search(String query) {
|
||||
query = query.chars().filter(ch -> !Character.isSpaceChar(ch))
|
||||
.collect(StringBuilder::new, (sb, ch) -> sb.append((char) ch), StringBuilder::append).toString();
|
||||
@Unmodifiable List<Pair<String, I>> keywords = getKeywords();
|
||||
int maxLength = keywords.stream().mapToInt(keyword -> keyword.getKey().length()).max().orElse(0);
|
||||
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxLength);
|
||||
List<Pair<Integer, I>> matches = new ArrayList<>();
|
||||
for (Pair<String, I> keyword : keywords) {
|
||||
int score = lcs.calc(query, keyword.getKey());
|
||||
if (score >= Math.max(1, query.length() - 3))
|
||||
matches.add(Pair.pair(score, keyword.getValue()));
|
||||
}
|
||||
return matches.stream().sorted((a, b) -> Integer.compare(b.getKey(), a.getKey()))
|
||||
.map(Pair::getValue).toList();
|
||||
}
|
||||
|
||||
/// Returns cached project metadata, loading it on demand. Missing resources and I/O failures
|
||||
/// produce an empty list; malformed records are skipped. The cache is softly referenced.
|
||||
public @Unmodifiable List<I> getInfos() {
|
||||
@Nullable SoftReference<@Unmodifiable List<I>> infosRef = this.infosRef;
|
||||
@Unmodifiable List<I> infos = infosRef != null ? infosRef.get() : null;
|
||||
if (infos != null)
|
||||
return infos;
|
||||
|
||||
lock.lock();
|
||||
try {
|
||||
infosRef = this.infosRef;
|
||||
infos = infosRef != null ? infosRef.get() : null;
|
||||
if (infos != null)
|
||||
return infos;
|
||||
|
||||
try {
|
||||
infos = loadInfos();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load addon project info from " + assetsPath, e);
|
||||
infos = List.of();
|
||||
}
|
||||
this.infosRef = new SoftReference<>(infos);
|
||||
return infos;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
|
||||
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
|
||||
|
||||
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile> addons) {
|
||||
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile<?>> addons) {
|
||||
dependents = addons.stream().map(addon ->
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
AddonUpdate candidate = null;
|
||||
|
||||
@@ -37,14 +37,14 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonUpdateTask extends Task<Void> {
|
||||
private final Collection<Task<?>> dependents = new ArrayList<>();
|
||||
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
|
||||
private final List<LocalAddonFile<?>> failedAddons = new ArrayList<>();
|
||||
|
||||
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
|
||||
setStage("addon.check_update.confirm");
|
||||
getProperties().put("total", addons.size());
|
||||
|
||||
for (AddonUpdate addon : addons) {
|
||||
LocalAddonFile local = addon.localAddonFile();
|
||||
LocalAddonFile<?> local = addon.localAddonFile();
|
||||
RemoteAddon.Version remote = addon.targetVersion();
|
||||
boolean isDisabled = local.isDisabled();
|
||||
String fileName = remote.file().filename();
|
||||
@@ -91,7 +91,7 @@ public class AddonUpdateTask extends Task<Void> {
|
||||
}
|
||||
}
|
||||
|
||||
public List<LocalAddonFile> getFailedAddons() {
|
||||
public List<LocalAddonFile<?>> getFailedAddons() {
|
||||
return failedAddons;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadSource;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -46,7 +46,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
DownloadSource source,
|
||||
WebURL defaultUrl, WebURL bmclapiUrl
|
||||
) {
|
||||
if (LocaleUtils.IS_CHINA_MAINLAND) {
|
||||
if (RegionUtils.isIsChinaMainlandMachine()) {
|
||||
return switch (source) {
|
||||
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
|
||||
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
|
||||
@@ -66,7 +66,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
|
||||
// true means prefer mirror, false means prefer official, null means do not use mirror
|
||||
@Nullable Boolean mirrorFirst;
|
||||
if (LocaleUtils.IS_CHINA_MAINLAND) {
|
||||
if (RegionUtils.isIsChinaMainlandMachine()) {
|
||||
mirrorFirst = switch (source) {
|
||||
case DEFAULT, MIRROR -> true;
|
||||
case OFFICIAL -> false;
|
||||
@@ -158,7 +158,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
|
||||
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
|
||||
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
|
||||
@@ -224,7 +224,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getCleanroomVersionListCandidates() {
|
||||
return LocaleUtils.IS_CHINA_MAINLAND
|
||||
return RegionUtils.isIsChinaMainlandMachine()
|
||||
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
|
||||
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
|
||||
}
|
||||
|
||||
@@ -565,7 +565,13 @@ public class HMCLGameInstance extends DefaultGameInstance {
|
||||
return getLayout().getInstanceGameSettingsFile(id);
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
|
||||
public LaunchOptions.Builder getLaunchOptions(
|
||||
JavaRuntime javaRuntime,
|
||||
Path javaExecutable,
|
||||
Path gameDir,
|
||||
List<String> javaAgents,
|
||||
List<String> javaArguments,
|
||||
boolean makeLaunchScript) {
|
||||
GameSettings.Effective vs = getEffectiveSettings();
|
||||
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
|
||||
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
|
||||
@@ -577,7 +583,7 @@ public class HMCLGameInstance extends DefaultGameInstance {
|
||||
? null
|
||||
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
|
||||
SystemInfo.getPhysicalMemoryStatus().available(),
|
||||
javaVersion.getPlatform()) / 1024L / 1024L);
|
||||
javaRuntime.getPlatform()) / 1024L / 1024L);
|
||||
} else {
|
||||
maxMemory = vs.getMaxMemory();
|
||||
}
|
||||
@@ -585,7 +591,8 @@ public class HMCLGameInstance extends DefaultGameInstance {
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setInstanceId(getId())
|
||||
.setGameDir(gameDir)
|
||||
.setJava(javaVersion)
|
||||
.setJava(javaRuntime)
|
||||
.setJavaExecutable(javaExecutable)
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(getId().id())
|
||||
.setProfileName(Metadata.TITLE)
|
||||
|
||||
@@ -57,11 +57,11 @@ import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -148,29 +148,46 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
/// Builds and executes the launch pipeline for the captured game instance.
|
||||
@SuppressWarnings("NonAtomicOperationOnVolatileField")
|
||||
private void launch0() {
|
||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||
PROCESSES.removeIf(it -> it.get() == null);
|
||||
|
||||
final var context = new Object() {
|
||||
volatile GameInstanceManifest launchManifest;
|
||||
volatile JavaRuntime javaRuntime;
|
||||
volatile Path javaExecutable;
|
||||
};
|
||||
|
||||
HMCLGameRepository repository = repository();
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||
var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest()));
|
||||
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
|
||||
CountDownLatch launchingLatch = new CountDownLatch(1);
|
||||
List<String> javaAgents = new ArrayList<>(0);
|
||||
List<String> javaArguments = new ArrayList<>(0);
|
||||
|
||||
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
|
||||
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||
context.launchManifest = LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest());
|
||||
|
||||
TaskExecutor executor = checkGameState(gameInstance, setting, launchManifest.get())
|
||||
TaskExecutor executor = checkGameState(gameInstance, setting, context.launchManifest)
|
||||
.thenComposeAsync(java -> {
|
||||
javaVersionRef.set(Objects.requireNonNull(java));
|
||||
launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments));
|
||||
context.javaRuntime = Objects.requireNonNull(java);
|
||||
context.javaExecutable = java.getBinary();
|
||||
if (java.getPlatform().os() == OperatingSystem.WINDOWS
|
||||
&& scriptFile == null
|
||||
&& !setting.getInheritable(GameSettings::notUseJavawProperty)
|
||||
&& FileUtils.getName(java.getBinary()).equalsIgnoreCase("java.exe")) {
|
||||
Path javawBinary = java.getBinary().resolveSibling("javaw.exe");
|
||||
if (Files.isRegularFile(javawBinary)) {
|
||||
context.javaExecutable = javawBinary;
|
||||
}
|
||||
}
|
||||
|
||||
context.launchManifest = NativePatcher.patchNative(gameInstance, context.launchManifest, java, setting, javaArguments);
|
||||
if (setting.getInheritable(GameSettings::notCheckGameProperty))
|
||||
return null;
|
||||
return Task.allOf(
|
||||
dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.get(), integrityCheck),
|
||||
dependencyManager.checkGameCompletionAsync(gameInstance, context.launchManifest, integrityCheck),
|
||||
Task.composeAsync(() -> {
|
||||
try {
|
||||
@Nullable ModpackConfiguration<?> configuration =
|
||||
@@ -203,7 +220,7 @@ public final class LauncherHelper {
|
||||
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) {
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, context.launchManifest, lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
@@ -213,13 +230,13 @@ public final class LauncherHelper {
|
||||
})
|
||||
);
|
||||
}).withStage("launch.state.dependencies")
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get()))
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), context.launchManifest))
|
||||
.thenComposeAsync(() -> {
|
||||
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
|| !NativePatcher.needPatchMemoryUtil(context.launchManifest, context.javaRuntime.getParsedVersion())) {
|
||||
return Task.completed(null);
|
||||
} else {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
@@ -239,7 +256,12 @@ public final class LauncherHelper {
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
|
||||
javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
context.javaRuntime,
|
||||
context.javaExecutable,
|
||||
repository.getBaseDirectory(),
|
||||
javaAgents,
|
||||
javaArguments,
|
||||
scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
@@ -248,7 +270,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(javaVersionRef.get().getBinary());
|
||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
|
||||
try {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg != null) {
|
||||
@@ -283,7 +305,7 @@ public final class LauncherHelper {
|
||||
|
||||
return new HMCLGameLauncher(
|
||||
gameInstance,
|
||||
launchManifest.get(),
|
||||
context.launchManifest,
|
||||
authInfo,
|
||||
launchOptions,
|
||||
launcherVisibility == LauncherVisibility.CLOSE
|
||||
@@ -305,10 +327,10 @@ public final class LauncherHelper {
|
||||
if (launcherVisibility == LauncherVisibility.CLOSE)
|
||||
Launcher.stopApplication();
|
||||
else
|
||||
launchingStepsPane.setCancel(new TaskCancellationAction(it -> {
|
||||
launchingStepsPane.setCancel(it -> {
|
||||
process.stop();
|
||||
it.fireEvent(new DialogCloseEvent());
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
runLater(() -> {
|
||||
launchingStepsPane.fireEvent(new DialogCloseEvent());
|
||||
@@ -746,7 +768,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
future.completeExceptionally(new CancellationException());
|
||||
}
|
||||
}), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())));
|
||||
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()));
|
||||
}, () -> future.completeExceptionally(new CancellationException())).build());
|
||||
|
||||
return future;
|
||||
|
||||
@@ -20,10 +20,12 @@ package org.jackhuang.hmcl.game;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.ui.instances.ModTranslations;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
@@ -51,15 +53,16 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
if (!StringUtils.containsChinese(searchFilter)) {
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
|
||||
if (registry == null || !StringUtils.containsChinese(searchFilter)) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
||||
|
||||
int count = 0;
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(type).searchMod(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
||||
for (AddonProjectInfo mod : registry.search(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.subName()) ? mod.subName() : mod.mainName()));
|
||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||
englishSearchFiltersSet.add(englishSearchFilter);
|
||||
}
|
||||
@@ -91,8 +94,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
continue;
|
||||
}
|
||||
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
||||
@Nullable AddonProjectInfo chineseTranslation = registry.getInfoByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.mainName()) && StringUtils.containsChinese(chineseTranslation.mainName())) {
|
||||
searchResultArray[chineseIndex++] = remoteAddon;
|
||||
} else {
|
||||
searchResultArray[englishIndex--] = remoteAddon;
|
||||
@@ -103,11 +106,11 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
||||
@Nullable AddonProjectInfo chineseRemoteMod = registry.getInfoByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.mainName()) || !StringUtils.containsChinese(chineseRemoteMod.mainName())) {
|
||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||
}
|
||||
String chineseRemoteModName = chineseRemoteMod.getName();
|
||||
String chineseRemoteModName = chineseRemoteMod.mainName();
|
||||
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
|
||||
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
|
||||
public static final class Instance extends GameSettings implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by instance-specific game settings.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 0));
|
||||
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 1));
|
||||
|
||||
/// Creates an empty instance setting.
|
||||
public Instance() {
|
||||
@@ -388,6 +388,19 @@ public sealed abstract class GameSettings extends ObservableSetting {
|
||||
return detectedJava;
|
||||
}
|
||||
|
||||
/// Property name for disabling automatic replacement with `javaw.exe` on Windows.
|
||||
public static final String PROPERTY_NOT_USE_JAVAW = "notUseJavaw";
|
||||
|
||||
/// Whether to keep the selected Java executable instead of replacing it with `javaw.exe`; defaults to `false`.
|
||||
@SerializedName(PROPERTY_NOT_USE_JAVAW)
|
||||
private final InheritableProperty<Boolean> notUseJavaw = newInheritableProperty(PROPERTY_NOT_USE_JAVAW, false);
|
||||
|
||||
/// Returns the property disabling automatic replacement of a Windows Java executable with a sibling `javaw.exe` file.
|
||||
/// If `true`, the selected executable is used unchanged. This property has no effect on other runtime platforms.
|
||||
public InheritableProperty<Boolean> notUseJavawProperty() {
|
||||
return notUseJavaw;
|
||||
}
|
||||
|
||||
/// Property name for customized JVM options.
|
||||
public static final String PROPERTY_JVM_OPTIONS = "jvmOptions";
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import java.util.Objects;
|
||||
public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game settings preset store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0));
|
||||
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 1));
|
||||
|
||||
/// Creates an empty game settings preset store.
|
||||
public GameSettingsPresets() {
|
||||
|
||||
@@ -24,9 +24,9 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
@@ -91,7 +91,7 @@ public final class TerracottaMetadata {
|
||||
}
|
||||
|
||||
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
|
||||
@Unmodifiable List<WebURL> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
|
||||
@Unmodifiable List<WebURL> links = (RegionUtils.isIsChinaMainlandMachine() ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
|
||||
.map(link -> WebURL.parse(options.replace(link)))
|
||||
.toList();
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
@@ -84,7 +84,7 @@ public final class TerracottaNodeList {
|
||||
return false;
|
||||
}
|
||||
|
||||
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
|
||||
return StringUtils.isBlank(node.region) || RegionUtils.isIsChinaMainlandMachine() == "CN".equalsIgnoreCase(node.region);
|
||||
})
|
||||
.map(it -> WebURL.parse(it.url()))
|
||||
.toList();
|
||||
|
||||
@@ -1574,7 +1574,9 @@ public final class FXUtils {
|
||||
|
||||
setHeading(new Label(i18n("button.select_date")));
|
||||
|
||||
GridPane body = new GridPane(4, 8);
|
||||
GridPane body = new GridPane();
|
||||
body.setHgap(4);
|
||||
body.setVgap(8);
|
||||
{
|
||||
Label fromLabel = new Label(i18n("button.select_date.from"));
|
||||
this.fromPicker = new JFXDatePicker(LocalDate.now());
|
||||
|
||||
@@ -40,7 +40,7 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.ClassTitle;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
|
||||
@@ -57,7 +57,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
|
||||
String property = System.getProperty("hmcl.offline.auth.restricted", "auto");
|
||||
|
||||
if ("false".equals(property)
|
||||
|| "auto".equals(property) && LocaleUtils.IS_CHINA_MAINLAND
|
||||
|| "auto".equals(property) && RegionUtils.isIsChinaMainlandMachine()
|
||||
|| SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
RESTRICTED.set(false);
|
||||
else
|
||||
|
||||
@@ -85,11 +85,11 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
|
||||
setCancel(cancel);
|
||||
|
||||
btnCancel.setDisable(onCancel.getCancellationAction() == null);
|
||||
btnCancel.setDisable(!onCancel.isEnabled());
|
||||
btnCancel.setOnAction(e -> {
|
||||
if (executor != null)
|
||||
executor.cancel();
|
||||
onCancel.getCancellationAction().accept(this);
|
||||
onCancel.accept(this);
|
||||
});
|
||||
|
||||
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
|
||||
|
||||
@@ -149,7 +149,6 @@ final class MainWindowPane extends StackPane {
|
||||
frame.setTop(titleBar);
|
||||
} else {
|
||||
decoration.setContent(titleBar);
|
||||
decoration.headerBar.backgroundProperty().bind(titleBar.backgroundProperty());
|
||||
frame.setTop(decoration.headerBar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,14 +52,12 @@ import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.ui.wizard.Refreshable;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardPage;
|
||||
import org.jackhuang.hmcl.util.NativePatcher;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
@@ -69,7 +67,6 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.*;
|
||||
@@ -406,6 +403,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
VBox.setVgrow(list, Priority.ALWAYS);
|
||||
|
||||
control.versions.addListener((InvalidationListener) o -> updateList());
|
||||
if (!control.versions.isEmpty())
|
||||
updateList();
|
||||
|
||||
list.setCellFactory(listView -> new RemoteVersionListCell(control));
|
||||
|
||||
@@ -454,7 +453,23 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
root.setCenter(transitionPane);
|
||||
}
|
||||
|
||||
this.getChildren().setAll(root);
|
||||
if (RegionUtils.isIsChinaMainlandNetwork()) {
|
||||
BorderPane wrapper = new BorderPane();
|
||||
|
||||
HintPane hint = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
hint.setSegment(i18n("download.china"));
|
||||
|
||||
BorderPane.setMargin(hint, new Insets(10, 10, 0, 10));
|
||||
|
||||
wrapper.setTop(hint);
|
||||
|
||||
root.setDisable(true);
|
||||
wrapper.setCenter(root);
|
||||
|
||||
this.getChildren().setAll(wrapper);
|
||||
} else {
|
||||
this.getChildren().setAll(root);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateList() {
|
||||
@@ -493,7 +508,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
list.getItems().setAll(versions.collect(Collectors.toList()));
|
||||
list.getItems().setAll(versions.toList());
|
||||
list.scrollTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,6 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
}
|
||||
Path file = Controllers.showSaveDialog(fileChooser);
|
||||
if (file == null) {
|
||||
controller.onEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -643,6 +643,11 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
jvmSettings
|
||||
);
|
||||
{
|
||||
var notUseJavawPane = createInheritableBooleanButton(GameSettings::notUseJavawProperty);
|
||||
jvmSettings.getContent().add(notUseJavawPane);
|
||||
notUseJavawPane.setTitle(i18n("settings.advanced.not_use_javaw"));
|
||||
notUseJavawPane.setSubtitle(i18n("settings.advanced.windows_only"));
|
||||
|
||||
var noJVMArgsPane = createInheritableBooleanButton(GameSettings::noJVMOptionsProperty);
|
||||
jvmSettings.getContent().add(noJVMArgsPane);
|
||||
noJVMArgsPane.setTitle(i18n("settings.advanced.no_jvm_args"));
|
||||
|
||||
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
@@ -27,28 +29,41 @@ import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdate;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdateTask;
|
||||
import org.jackhuang.hmcl.addon.update.AddonUpdates;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.CSVTable;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import org.jackhuang.hmcl.util.javafx.Subscription;
|
||||
|
||||
import java.nio.file.Path;
|
||||
@@ -70,101 +85,89 @@ public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
|
||||
private final Path localAddonDirectory;
|
||||
private final ObservableList<AddonUpdateObject> objects;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private final JFXListView<AddonUpdateObject> listView;
|
||||
|
||||
public AddonUpdatesPage(DownloadProvider downloadProvider, Path localAddonDirectory, AddonUpdates updates) {
|
||||
this.downloadProvider = downloadProvider;
|
||||
this.localAddonDirectory = localAddonDirectory;
|
||||
|
||||
getStyleClass().add("gray-background");
|
||||
|
||||
TableColumn<AddonUpdateObject, Boolean> enabledColumn = new TableColumn<>();
|
||||
var allEnabledBox = new JFXCheckBox();
|
||||
enabledColumn.setStyle("-fx-alignment: CENTER;");
|
||||
enabledColumn.setGraphic(allEnabledBox);
|
||||
enabledColumn.setCellFactory(JFXCheckBoxTableCell.forTableColumn(enabledColumn));
|
||||
setupCellValueFactory(enabledColumn, AddonUpdateObject::enabledProperty);
|
||||
enabledColumn.setEditable(true);
|
||||
enabledColumn.setMaxWidth(40);
|
||||
enabledColumn.setMinWidth(40);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> fileNameColumn = new TableColumn<>(i18n("addon.check_update.file"));
|
||||
fileNameColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(fileNameColumn, AddonUpdateObject::fileNameProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> currentVersionColumn = new TableColumn<>(i18n("addon.check_update.current_version"));
|
||||
currentVersionColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(currentVersionColumn, AddonUpdateObject::currentVersionProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> targetVersionColumn = new TableColumn<>(i18n("addon.check_update.target_version"));
|
||||
targetVersionColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> targetVersionTypeColumn = new TableColumn<>(i18n("addon.version_type"));
|
||||
setupCellValueFactory(targetVersionTypeColumn, AddonUpdateObject::targetVersionTypeProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> sourceColumn = new TableColumn<>(i18n("addon.check_update.source"));
|
||||
setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> changelogColumn = new TableColumn<>(i18n("addon.changelog"));
|
||||
{
|
||||
var oldCellFactory = changelogColumn.getCellFactory();
|
||||
changelogColumn.setCellFactory(param -> {
|
||||
TableCell<AddonUpdateObject, String> cell = oldCellFactory.call(param);
|
||||
cell.getStyleClass().add("addon-changelog-table-cell");
|
||||
cell.setOnMouseClicked(event -> {
|
||||
AddonUpdateObject object;
|
||||
if (cell.isEmpty() || cell.getTableRow() == null || (object = cell.getTableRow().getItem()) == null) return;
|
||||
Controllers.dialog(new AddonChangelog(object));
|
||||
});
|
||||
return cell;
|
||||
});
|
||||
changelogColumn.setCellValueFactory(__ -> new SimpleStringProperty(i18n("button.view")));
|
||||
}
|
||||
|
||||
var common = new Lazy<>(() -> updates.commonUpdates().stream().map(AddonUpdateObject::new).toList());
|
||||
var release = new Lazy<>(() -> updates.releaseUpdates().stream().map(AddonUpdateObject::new).toList());
|
||||
|
||||
objects = FXCollections.observableArrayList();
|
||||
|
||||
BooleanProperty showPreview = new SimpleBooleanProperty();
|
||||
{
|
||||
List<Subscription> subscriptions = new ArrayList<>();
|
||||
FXUtils.onChangeAndOperate(showPreview, preview -> {
|
||||
if (preview) objects.setAll(common.get());
|
||||
else objects.setAll(release.get());
|
||||
subscriptions.forEach(Subscription::unsubscribe);
|
||||
subscriptions.clear();
|
||||
subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new)));
|
||||
});
|
||||
StackPane centerWrapper = new StackPane();
|
||||
centerWrapper.setStyle("-fx-padding: 10;");
|
||||
|
||||
ComponentList centrePane = new ComponentList();
|
||||
centrePane.getStyleClass().add("no-padding");
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("addon.check_update.only_preview"));
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
|
||||
listView = new JFXListView<>();
|
||||
listView.setItems(objects);
|
||||
listView.getStyleClass().add("jfx-list-view-float");
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
setMargin(listView, new Insets(0, 10, 0, 10));
|
||||
VBox.setVgrow(listView, Priority.ALWAYS);
|
||||
|
||||
listView.setCellFactory(listView -> new AddonUpdateListCell());
|
||||
|
||||
ComponentList.setVgrow(listView, Priority.ALWAYS);
|
||||
|
||||
centrePane.getContent().setAll(listView);
|
||||
|
||||
centerWrapper.getChildren().setAll(centrePane);
|
||||
|
||||
setCenter(centerWrapper);
|
||||
}
|
||||
|
||||
TableView<AddonUpdateObject> table = new TableView<>(objects);
|
||||
table.setEditable(true);
|
||||
table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, targetVersionTypeColumn, sourceColumn, changelogColumn);
|
||||
setMargin(table, new Insets(10, 10, 5, 10));
|
||||
{
|
||||
HBox actions = new HBox(8);
|
||||
actions.setPadding(new Insets(8));
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
setCenter(table);
|
||||
JFXCheckBox allEnabledBox = new JFXCheckBox();
|
||||
allEnabledBox.setText(i18n("button.select_all"));
|
||||
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
|
||||
|
||||
HBox actions = new HBox(8);
|
||||
actions.setPadding(new Insets(8));
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
|
||||
{
|
||||
List<Subscription> subscriptions = new ArrayList<>();
|
||||
FXUtils.onChangeAndOperate(showPreviewCheck.selectedProperty(), preview -> {
|
||||
if (preview) objects.setAll(common.get());
|
||||
else objects.setAll(release.get());
|
||||
subscriptions.forEach(Subscription::unsubscribe);
|
||||
subscriptions.clear();
|
||||
subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new)));
|
||||
});
|
||||
}
|
||||
|
||||
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
|
||||
showPreviewCheck.selectedProperty().bindBidirectional(showPreview);
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
|
||||
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
|
||||
exportListButton.setOnAction(e -> exportList());
|
||||
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
|
||||
exportListButton.setOnAction(e -> exportList());
|
||||
|
||||
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
|
||||
nextButton.setOnAction(e -> updateFiles());
|
||||
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
|
||||
nextButton.setOnAction(e -> updateFiles());
|
||||
|
||||
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
onEscPressed(table, cancelButton::fire);
|
||||
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
onEscPressed(listView, cancelButton::fire);
|
||||
|
||||
actions.getChildren().setAll(showPreviewCheck, exportListButton, nextButton, cancelButton);
|
||||
setBottom(actions);
|
||||
actions.getChildren().setAll(allEnabledBox, showPreviewCheck, spacer, exportListButton, nextButton, cancelButton);
|
||||
|
||||
setBottom(actions);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> void setupCellValueFactory(TableColumn<AddonUpdateObject, T> column, Function<AddonUpdateObject, ObservableValue<T>> mapper) {
|
||||
@@ -243,6 +246,8 @@ public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
|
||||
final StringProperty source = new SimpleStringProperty();
|
||||
String changelog = null;
|
||||
|
||||
final ItemPropertyAsyncCache<Image, AddonUpdateObject> iconCache;
|
||||
|
||||
public AddonUpdateObject(AddonUpdate data) {
|
||||
this.data = data;
|
||||
|
||||
@@ -255,6 +260,12 @@ public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
|
||||
case CURSEFORGE -> i18n("addon.curseforge");
|
||||
case MODRINTH -> i18n("addon.modrinth");
|
||||
});
|
||||
|
||||
iconCache = new ItemPropertyAsyncCache.Soft<>(
|
||||
this,
|
||||
this::loadIcon,
|
||||
this::getDefaultIcon
|
||||
);
|
||||
}
|
||||
|
||||
public AddonUpdate getData() {
|
||||
@@ -273,6 +284,18 @@ public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName.set(fileName);
|
||||
}
|
||||
|
||||
public LocalAddonFile getLocalAddonFile() {
|
||||
return data.localAddonFile();
|
||||
}
|
||||
|
||||
public String getCurrentVersion() {
|
||||
return currentVersion.get();
|
||||
}
|
||||
|
||||
public StringProperty currentVersionProperty() {
|
||||
return currentVersion;
|
||||
}
|
||||
@@ -289,6 +312,163 @@ public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
|
||||
return source;
|
||||
}
|
||||
|
||||
public void setSource(String source) {
|
||||
this.source.set(source);
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
if (data.localAddonFile() instanceof LocalModFile localModFile) {
|
||||
return GameInstanceIconType
|
||||
.getIconType(localModFile.getModLoaderType())
|
||||
.getIcon();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Image loadIcon() {
|
||||
if (data.localAddonFile() instanceof LocalModFile localModFile) {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(localModFile.getLogoPath())) {
|
||||
iconPaths.add(localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(localModFile.getFile())) {
|
||||
|
||||
for (String path : iconPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(
|
||||
iconPath,
|
||||
80,
|
||||
80,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (!image.isError()
|
||||
&& image.getWidth() > 0
|
||||
&& image.getHeight() > 0
|
||||
&& Math.abs(image.getWidth() - image.getHeight()) < 1
|
||||
) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load addon icons", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.localAddonFile() instanceof ResourcePackFile resourcePackFile) {
|
||||
Image image = resourcePackFile.loadIcon();
|
||||
|
||||
if (image != null
|
||||
&& !image.isError()
|
||||
&& image.getWidth() > 0
|
||||
&& image.getHeight() > 0
|
||||
&& Math.abs(image.getWidth() - image.getHeight()) < 1
|
||||
) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultIcon();
|
||||
}
|
||||
}
|
||||
|
||||
private static class AddonUpdateListCell extends ListCell<AddonUpdateObject> {
|
||||
private final StackPane pane = new StackPane();
|
||||
private final RipplerContainer ripplerContainer;
|
||||
|
||||
private final JFXCheckBox enabledBox = new JFXCheckBox();
|
||||
private final ImageContainer imageContainer = new ImageContainer(40);
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton changelogButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
|
||||
AddonUpdateListCell() {
|
||||
HBox hbox = new HBox(16);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
content.setAlignment(Pos.CENTER);
|
||||
hbox.setAlignment(Pos.CENTER);
|
||||
|
||||
hbox.getChildren().setAll(enabledBox, imageContainer, content, changelogButton);
|
||||
|
||||
pane.getStyleClass().add("md-list-cell");
|
||||
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
|
||||
|
||||
ripplerContainer = new RipplerContainer(hbox);
|
||||
pane.getChildren().setAll(ripplerContainer);
|
||||
|
||||
enabledBox.setMouseTransparent(false);
|
||||
imageContainer.setMouseTransparent(true);
|
||||
|
||||
FXUtils.installFastTooltip(changelogButton, i18n("addon.changelog"));
|
||||
changelogButton.setOnAction(event -> {
|
||||
AddonUpdateObject item = getItem();
|
||||
if (item != null) {
|
||||
Controllers.dialog(new AddonChangelog(item));
|
||||
}
|
||||
});
|
||||
|
||||
FXUtils.onClicked(this, () -> {
|
||||
AddonUpdateObject item = getItem();
|
||||
if (item != null) {
|
||||
item.enabled.set(!item.isEnabled());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateItem(AddonUpdateObject item, boolean empty) {
|
||||
AddonUpdateObject oldItem = getItem();
|
||||
|
||||
if (oldItem != null) {
|
||||
enabledBox.selectedProperty().unbindBidirectional(oldItem.enabledProperty());
|
||||
}
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
|
||||
enabledBox.selectedProperty().bindBidirectional(item.enabledProperty());
|
||||
|
||||
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
|
||||
LocalAddonFile addonFile = item.getLocalAddonFile();
|
||||
|
||||
if (oldItem != item) {
|
||||
content.getTags().clear();
|
||||
|
||||
if (addonFile instanceof LocalModFile modFile) {
|
||||
content.addTag(modFile.getId());
|
||||
content.addTag(item.source.get());
|
||||
} else if (addonFile instanceof ResourcePackFile) {
|
||||
content.addTag(item.source.get());
|
||||
}
|
||||
}
|
||||
|
||||
if (addonFile instanceof LocalModFile modFile) {
|
||||
content.setTitle(modFile.getName());
|
||||
} else if (addonFile instanceof ResourcePackFile resourcePackFile) {
|
||||
content.setTitle(resourcePackFile.getFileName());
|
||||
}
|
||||
|
||||
content.setSubtitle(
|
||||
item.getCurrentVersion()
|
||||
+ " → "
|
||||
+ item.targetVersion.get()
|
||||
+ " | "
|
||||
+ item.targetVersionType.get()
|
||||
);
|
||||
|
||||
setGraphic(pane);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonChangelog extends JFXDialogLayout {
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.ListPageBase;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
@@ -35,6 +36,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -100,28 +102,37 @@ public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.Da
|
||||
}
|
||||
|
||||
void removeSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> {
|
||||
try {
|
||||
dataPack.deletePack(pack);
|
||||
} catch (IOException e) {
|
||||
// Fail to remove mods if the game is running or the datapack is absent.
|
||||
failed.add(pack.getPath().toString());
|
||||
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("datapack.delete.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void enableSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
|
||||
void setSelectedEnabled(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems, boolean enabled) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> pack.setActive(true));
|
||||
}
|
||||
|
||||
void disableSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
|
||||
.forEach(pack -> pack.setActive(false));
|
||||
.forEach(pack -> {
|
||||
try {
|
||||
pack.setActive(enabled);
|
||||
} catch (IOException e) {
|
||||
failed.add(pack.getPath().toString());
|
||||
LOG.warning("Failed to enable or disable datapack \"" + pack.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("datapack.toggle.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void openDataPackFolder() {
|
||||
|
||||
@@ -25,8 +25,10 @@ import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
@@ -55,7 +57,9 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
@@ -131,9 +135,9 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
}, null);
|
||||
});
|
||||
JFXButton enableButton = createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems()));
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true));
|
||||
JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems()));
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false));
|
||||
removeButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
enableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
disableButton.disableProperty().bind(getSkinnable().readOnly);
|
||||
@@ -256,15 +260,11 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
}
|
||||
|
||||
static class DataPackInfoObject extends RecursiveTreeObject<DataPackInfoObject> {
|
||||
private final BooleanProperty activeProperty;
|
||||
private final DataPack.Pack packInfo;
|
||||
|
||||
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
|
||||
|
||||
DataPackInfoObject(DataPack.Pack packInfo) {
|
||||
this.packInfo = packInfo;
|
||||
this.activeProperty = packInfo.activeProperty();
|
||||
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
|
||||
@@ -318,7 +318,10 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
ImageContainer imageContainer = new ImageContainer(32);
|
||||
final TwoLineListItem content = new TwoLineListItem();
|
||||
BooleanProperty booleanProperty;
|
||||
|
||||
private final InvalidationListener activeListener = obs -> checkBox.setSelected(getItem().getPackInfo().isActive());
|
||||
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
|
||||
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
|
||||
|
||||
DataPackInfoListCell(JFXListView<DataPackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
|
||||
super(listView);
|
||||
@@ -343,13 +346,25 @@ final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
|
||||
|
||||
@Override
|
||||
protected void updateControl(DataPackInfoObject dataItem, boolean empty) {
|
||||
if (observedActiveProperty != null)
|
||||
observedActiveProperty.removeListener(weakActiveListener);
|
||||
if (empty) return;
|
||||
content.setTitle(dataItem.getTitle());
|
||||
content.setSubtitle(dataItem.getSubtitle());
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.activeProperty);
|
||||
|
||||
DataPack.Pack packInfo = dataItem.getPackInfo();
|
||||
(observedActiveProperty = packInfo.activeProperty()).addListener(weakActiveListener);
|
||||
checkBox.setSelected(packInfo.isActive());
|
||||
checkBox.setOnAction(event -> {
|
||||
try {
|
||||
packInfo.setActive(checkBox.isSelected());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to change state of datapack " + packInfo.getPath(), e);
|
||||
Controllers.showToast(i18n("datapack.toggle.failed"));
|
||||
} finally {
|
||||
checkBox.setSelected(packInfo.isActive());
|
||||
}
|
||||
});
|
||||
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.download.DownloadCandidates;
|
||||
@@ -606,8 +608,9 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
: PADDING
|
||||
);
|
||||
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().type).getModByCurseForgeId(item.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.title());
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(getSkinnable().type);
|
||||
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(item.slug()) : null;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : item.title());
|
||||
String description = item.description();
|
||||
if (description != null) {
|
||||
description = description.replaceAll("\\R", " ");
|
||||
@@ -625,7 +628,23 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
});
|
||||
}
|
||||
|
||||
getChildren().setAll(pane);
|
||||
if (RegionUtils.isIsChinaMainlandNetwork()) {
|
||||
BorderPane wrapper = new BorderPane();
|
||||
|
||||
HintPane hint = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
hint.setSegment(i18n("download.china"));
|
||||
|
||||
BorderPane.setMargin(hint, new Insets(10, 10, 0, 10));
|
||||
|
||||
wrapper.setTop(hint);
|
||||
|
||||
pane.setDisable(true);
|
||||
wrapper.setCenter(pane);
|
||||
|
||||
this.getChildren().setAll(wrapper);
|
||||
} else {
|
||||
this.getChildren().setAll(pane);
|
||||
}
|
||||
}
|
||||
|
||||
private record CategoryIndented(int indent, RemoteAddonRepository.Category category) {
|
||||
|
||||
@@ -30,6 +30,8 @@ import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
@@ -65,9 +67,9 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty failed = new SimpleBooleanProperty(false);
|
||||
private final RemoteAddonRepository repository;
|
||||
private final ModTranslations translations;
|
||||
private final RemoteAddon addon;
|
||||
private final ModTranslations.Mod mod;
|
||||
/// Localized project metadata, or null when no registry or matching project is available.
|
||||
private final @Nullable AddonProjectInfo mod;
|
||||
private final HMCLGameInstance.Optional instanceReference;
|
||||
private final DownloadCallback callback;
|
||||
private final DownloadListPage page;
|
||||
@@ -80,8 +82,8 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
this.repository = page.repository;
|
||||
this.addon = addon;
|
||||
this.type = Objects.requireNonNullElse(addon.type(), page.type);
|
||||
this.translations = ModTranslations.getTranslationsByAddonType(this.type);
|
||||
this.mod = translations.getModByCurseForgeId(addon.slug());
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(this.type);
|
||||
this.mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
|
||||
this.instanceReference = instanceReference;
|
||||
this.callback = callback;
|
||||
loadAddonVersions();
|
||||
@@ -220,8 +222,8 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
ModTranslations.Mod mod = getSkinnable().translations.getModByCurseForgeId(getSkinnable().addon.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : getSkinnable().addon.title());
|
||||
@Nullable AddonProjectInfo mod = getSkinnable().mod;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : getSkinnable().addon.title());
|
||||
content.setSubtitle(getSkinnable().addon.description());
|
||||
content.getSubtitleLabel().setWrapText(true);
|
||||
getSkinnable().addon.categories().stream()
|
||||
@@ -231,9 +233,10 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
content.getFirstLine().setMinWidth(0);
|
||||
descriptionPane.getChildren().add(content);
|
||||
|
||||
if (getSkinnable().mod != null) {
|
||||
@Nullable String mcmodUrl = mod != null ? mod.mcmodUrl() : null;
|
||||
if (mcmodUrl != null) {
|
||||
JFXHyperlink openMcmodButton = new JFXHyperlink(i18n("mods.mcmod"));
|
||||
openMcmodButton.setExternalLink(getSkinnable().translations.getMcmodUrl(getSkinnable().mod));
|
||||
openMcmodButton.setExternalLink(mcmodUrl);
|
||||
descriptionPane.getChildren().add(openMcmodButton);
|
||||
openMcmodButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||
}
|
||||
@@ -414,8 +417,9 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
});
|
||||
setNode(IDX_LEADING, pane);
|
||||
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(addon.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title());
|
||||
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
|
||||
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : addon.title());
|
||||
content.setSubtitle(addon.description());
|
||||
for (String category : addon.categories()) {
|
||||
if (page.shouldDisplayCategory(category))
|
||||
|
||||
@@ -20,9 +20,12 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
@@ -41,6 +44,8 @@ import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
@@ -211,29 +216,39 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
}
|
||||
|
||||
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
try {
|
||||
modManager.removeMods(selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.toArray(LocalModFile[]::new));
|
||||
loadMods(modManager);
|
||||
} catch (IOException ignore) {
|
||||
// Fail to remove mods if the game is running or the mod is absent.
|
||||
}
|
||||
}
|
||||
|
||||
void enableSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> info.setActive(true));
|
||||
.forEach(info -> {
|
||||
try {
|
||||
info.delete();
|
||||
} catch (IOException e) {
|
||||
failed.add(info.getFile().toString());
|
||||
LOG.warning("Failed to delete mod \"" + info.getId() + "\"", e);
|
||||
}
|
||||
});
|
||||
loadMods(modManager);
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("mods.delete.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
void disableSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
void setSelectedEnabled(ObservableList<ModInfoObject> selectedItems, boolean enabled) {
|
||||
List<String> failed = new ArrayList<>();
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> info.setActive(false));
|
||||
.forEach(info -> {
|
||||
try {
|
||||
info.setActive(enabled);
|
||||
} catch (IOException e) {
|
||||
failed.add(info.getFile().toString());
|
||||
}
|
||||
});
|
||||
if (!failed.isEmpty())
|
||||
Controllers.dialog(i18n("mods.toggle.failed") + "\n" + String.join(",\n", failed),
|
||||
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
|
||||
public void openModFolder() {
|
||||
@@ -415,9 +430,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
}, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
|
||||
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getSelectionModel().getSelectedItems().stream()
|
||||
@@ -541,7 +556,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|| predicate.test(modInfo.getGameVersion())
|
||||
|| predicate.test(modInfo.getId())
|
||||
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|
||||
|| predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) {
|
||||
|| predicate.test((item.getProjectInfo() != null ? item.getProjectInfo().displayName() : null))) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
@@ -551,17 +566,15 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
}
|
||||
|
||||
public static final class ModInfoObject {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
private final @Nullable ModTranslations.Mod modTranslations;
|
||||
/// Matching project metadata, or null if the local mod cannot be identified.
|
||||
private final @Nullable ModProjectInfo projectInfo;
|
||||
|
||||
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
|
||||
this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName());
|
||||
|
||||
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
|
||||
}
|
||||
@@ -570,8 +583,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
public @Nullable ModTranslations.Mod getModTranslations() {
|
||||
return modTranslations;
|
||||
/// Returns matching project metadata, or null if no project was found.
|
||||
public @Nullable ModProjectInfo getProjectInfo() {
|
||||
return projectInfo;
|
||||
}
|
||||
|
||||
private Image getDefaultIcon() {
|
||||
@@ -621,8 +635,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.getTitleLabel().setWrapText(true);
|
||||
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getModTranslations().getDisplayName());
|
||||
if (modInfo.getProjectInfo() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getProjectInfo().displayName());
|
||||
else
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
|
||||
@@ -694,7 +708,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
getActions().add(officialPageButton);
|
||||
}
|
||||
|
||||
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
|
||||
@Nullable String mcmodUrl = modInfo.getProjectInfo() != null ? modInfo.getProjectInfo().mcmodUrl() : null;
|
||||
if (mcmodUrl == null) {
|
||||
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
|
||||
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
|
||||
pair("key", modInfo.getModInfo().getName()),
|
||||
@@ -704,7 +719,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
getActions().add(searchButton);
|
||||
} else {
|
||||
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
|
||||
mcmodButton.setExternalLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
|
||||
mcmodButton.setExternalLink(mcmodUrl);
|
||||
getActions().add(mcmodButton);
|
||||
}
|
||||
|
||||
@@ -733,7 +748,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
|
||||
private BooleanProperty booleanProperty;
|
||||
private final InvalidationListener activeListener = obs -> checkBox.setSelected(getItem().getModInfo().isActive());
|
||||
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
|
||||
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
|
||||
|
||||
private Tooltip warningTooltip;
|
||||
|
||||
@@ -762,6 +779,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|
||||
@Override
|
||||
protected void updateControl(ModInfoObject dataItem, boolean empty) {
|
||||
if (observedActiveProperty != null)
|
||||
observedActiveProperty.removeListener(weakActiveListener);
|
||||
|
||||
pseudoClassStateChanged(WARNING, false);
|
||||
if (warningTooltip != null) {
|
||||
Tooltip.uninstall(this, warningTooltip);
|
||||
@@ -775,15 +795,15 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
content.getTags().clear();
|
||||
|
||||
LocalModFile modInfo = dataItem.getModInfo();
|
||||
ModTranslations.Mod modTranslations = dataItem.getModTranslations();
|
||||
@Nullable ModProjectInfo projectInfo = dataItem.getProjectInfo();
|
||||
|
||||
ModLoaderType modLoaderType = modInfo.getModLoaderType();
|
||||
|
||||
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
|
||||
|
||||
String displayName = modInfo.getName();
|
||||
if (modTranslations != null && I18n.isUseChinese()) {
|
||||
String chineseName = modTranslations.getName();
|
||||
if (projectInfo != null && I18n.isUseChinese()) {
|
||||
String chineseName = projectInfo.mainName();
|
||||
if (StringUtils.containsChinese(chineseName)) {
|
||||
if (StringUtils.containsEmoji(chineseName)) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
@@ -823,10 +843,18 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
content.addTag(modVersion);
|
||||
}
|
||||
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
|
||||
(observedActiveProperty = modInfo.activeProperty()).addListener(weakActiveListener);
|
||||
checkBox.setSelected(modInfo.isActive());
|
||||
checkBox.setOnAction(event -> {
|
||||
try {
|
||||
modInfo.setActive(checkBox.isSelected());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to change state of mod file " + modInfo.getFile(), e);
|
||||
Controllers.showToast(i18n("mods.toggle.failed"));
|
||||
} finally {
|
||||
checkBox.setSelected(modInfo.isActive());
|
||||
}
|
||||
});
|
||||
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
|
||||
restoreButton.setOnAction(e -> {
|
||||
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
/**
|
||||
* Parser for mod_data.txt
|
||||
*
|
||||
* @see <a href="https://www.mcmod.cn">mcmod.cn</a>
|
||||
*/
|
||||
public enum ModTranslations {
|
||||
MOD("/assets/mod_data.txt") {
|
||||
@Override
|
||||
public String getMcmodUrl(Mod mod) {
|
||||
return String.format("https://www.mcmod.cn/class/%s.html", mod.getMcmod());
|
||||
}
|
||||
},
|
||||
MODPACK("/assets/modpack_data.txt") {
|
||||
@Override
|
||||
public String getMcmodUrl(Mod mod) {
|
||||
return String.format("https://www.mcmod.cn/modpack/%s.html", mod.getMcmod());
|
||||
}
|
||||
},
|
||||
EMPTY("") {
|
||||
@Override
|
||||
public String getMcmodUrl(Mod mod) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
public static ModTranslations getTranslationsByAddonType(@Nullable RemoteAddon.Type type) {
|
||||
if (type == null) return EMPTY;
|
||||
return switch (type) {
|
||||
case MOD -> MOD;
|
||||
case MODPACK -> MODPACK;
|
||||
default -> EMPTY;
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("StatementWithEmptyBody")
|
||||
private static String cleanSubname(String subname) {
|
||||
if (StringUtils.isBlank(subname))
|
||||
return "";
|
||||
|
||||
StringBuilder builder = new StringBuilder(subname.length());
|
||||
for (int i = 0; i < subname.length(); ) {
|
||||
int ch = subname.codePointAt(i);
|
||||
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||
|| ".+\\".indexOf(ch) >= 0) {
|
||||
builder.appendCodePoint(ch);
|
||||
} else if (Character.isWhitespace(ch)
|
||||
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0
|
||||
|| ch >= 0x1F300 && ch <= 0x1FAFF) {
|
||||
// Remove these unnecessary characters from subname
|
||||
} else {
|
||||
// The subname contains unsupported characters, so we do not use this subname to match the mod
|
||||
return "";
|
||||
}
|
||||
i += Character.charCount(ch);
|
||||
}
|
||||
return builder.length() == subname.length() ? subname : builder.toString();
|
||||
}
|
||||
|
||||
private final String resourceName;
|
||||
private volatile List<Mod> mods;
|
||||
private volatile Map<String, Mod> modIdMap; // mod id -> mod
|
||||
private volatile Map<String, Mod> subnameMap;
|
||||
private volatile Map<String, Mod> curseForgeMap; // curseforge id -> mod
|
||||
private volatile List<Pair<String, Mod>> keywords;
|
||||
private volatile int maxKeywordLength = -1;
|
||||
|
||||
ModTranslations(String resourceName) {
|
||||
this.resourceName = resourceName;
|
||||
}
|
||||
|
||||
public @NotNull List<Mod> getMods() {
|
||||
List<Mod> mods = this.mods;
|
||||
if (mods != null)
|
||||
return mods;
|
||||
|
||||
synchronized (this) {
|
||||
mods = this.mods;
|
||||
if (mods != null)
|
||||
return mods;
|
||||
|
||||
if (StringUtils.isBlank(resourceName)) {
|
||||
return this.mods = List.of();
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
ModTranslations.class.getResourceAsStream(resourceName), StandardCharsets.UTF_8))) {
|
||||
return this.mods = reader.lines().filter(line -> !line.startsWith("#")).map(Mod::new).toList();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load " + resourceName, e);
|
||||
return this.mods = List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getModIdMap() {
|
||||
Map<String, Mod> modIdMap = this.modIdMap;
|
||||
if (modIdMap != null)
|
||||
return modIdMap;
|
||||
synchronized (this) {
|
||||
modIdMap = this.modIdMap;
|
||||
if (modIdMap != null)
|
||||
return modIdMap;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
modIdMap = new HashMap<>(mods.size());
|
||||
for (Mod mod : mods) {
|
||||
for (String id : mod.getModIds()) {
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
modIdMap.putIfAbsent(id, mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.modIdMap = modIdMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getSubnameMap() {
|
||||
Map<String, Mod> subnameMap = this.subnameMap;
|
||||
if (subnameMap != null)
|
||||
return subnameMap;
|
||||
synchronized (this) {
|
||||
subnameMap = this.subnameMap;
|
||||
if (subnameMap != null)
|
||||
return subnameMap;
|
||||
|
||||
subnameMap = new HashMap<>();
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
for (Mod mod : mods) {
|
||||
String subname = cleanSubname(mod.getSubname());
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
subnameMap.putIfAbsent(subname, mod);
|
||||
}
|
||||
}
|
||||
|
||||
return this.subnameMap = subnameMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getCurseForgeMap() {
|
||||
Map<String, Mod> curseForgeMap = this.curseForgeMap;
|
||||
if (curseForgeMap != null)
|
||||
return curseForgeMap;
|
||||
|
||||
synchronized (this) {
|
||||
curseForgeMap = this.curseForgeMap;
|
||||
if (curseForgeMap != null)
|
||||
return curseForgeMap;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
curseForgeMap = new HashMap<>(mods.size());
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getCurseforge())) {
|
||||
curseForgeMap.putIfAbsent(mod.getCurseforge(), mod);
|
||||
}
|
||||
}
|
||||
|
||||
return this.curseForgeMap = curseForgeMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull List<Pair<String, Mod>> getKeywords() {
|
||||
List<Pair<String, Mod>> keywords = this.keywords;
|
||||
if (keywords != null)
|
||||
return keywords;
|
||||
|
||||
synchronized (this) {
|
||||
keywords = this.keywords;
|
||||
if (keywords != null)
|
||||
return keywords;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
|
||||
keywords = new ArrayList<>();
|
||||
int maxKeywordLength = -1;
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getName())) {
|
||||
keywords.add(pair(mod.getName(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getName().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getSubname())) {
|
||||
keywords.add(pair(mod.getSubname(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getSubname().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getAbbr())) {
|
||||
keywords.add(pair(mod.getAbbr(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getAbbr().length());
|
||||
}
|
||||
}
|
||||
|
||||
this.maxKeywordLength = maxKeywordLength;
|
||||
return this.keywords = keywords;
|
||||
}
|
||||
}
|
||||
|
||||
private int getMaxKeywordLength() {
|
||||
int maxKeywordLength = this.maxKeywordLength;
|
||||
if (maxKeywordLength >= 0)
|
||||
return maxKeywordLength;
|
||||
|
||||
// Ensure maxKeywordLength is initialized
|
||||
getKeywords();
|
||||
return this.maxKeywordLength;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Mod getModByCurseForgeId(String id) {
|
||||
if (StringUtils.isBlank(id)) return null;
|
||||
|
||||
return getCurseForgeMap().get(id);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Mod getMod(String id, String subname) {
|
||||
subname = cleanSubname(subname);
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
Mod mod = getSubnameMap().get(subname);
|
||||
if (mod != null && (StringUtils.isBlank(id) || mod.getModIds().contains(id)))
|
||||
return mod;
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(id))
|
||||
return getModIdMap().get(id);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public abstract String getMcmodUrl(Mod mod);
|
||||
|
||||
public List<Mod> searchMod(String query) {
|
||||
StringBuilder newQuery = query.chars()
|
||||
.filter(ch -> !Character.isSpaceChar(ch))
|
||||
.collect(StringBuilder::new, (sb, value) -> sb.append((char) value), StringBuilder::append);
|
||||
query = newQuery.toString();
|
||||
|
||||
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), getMaxKeywordLength());
|
||||
List<Pair<Integer, Mod>> modList = new ArrayList<>();
|
||||
for (Pair<String, Mod> keyword : getKeywords()) {
|
||||
int value = lcs.calc(query, keyword.getKey());
|
||||
if (value >= Math.max(1, query.length() - 3)) {
|
||||
modList.add(pair(value, keyword.getValue()));
|
||||
}
|
||||
}
|
||||
return modList.stream()
|
||||
.sorted((a, b) -> -a.getKey().compareTo(b.getKey()))
|
||||
.map(Pair::getValue)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static final class Mod {
|
||||
private final String curseforge;
|
||||
private final String mcmod;
|
||||
private final List<String> modIds;
|
||||
private final String name;
|
||||
private final String subname;
|
||||
private final String abbr;
|
||||
|
||||
public Mod(String line) {
|
||||
String[] items = line.split(";", -1);
|
||||
if (items.length != 6) {
|
||||
throw new IllegalArgumentException("Illegal mod data line, 6 items expected " + line);
|
||||
}
|
||||
|
||||
curseforge = items[0];
|
||||
mcmod = items[1];
|
||||
modIds = List.of(items[2].split(","));
|
||||
name = items[3];
|
||||
subname = items[4];
|
||||
abbr = items[5];
|
||||
}
|
||||
|
||||
public Mod(String curseforge, String mcmod, List<String> modIds, String name, String subname, String abbr) {
|
||||
this.curseforge = curseforge;
|
||||
this.mcmod = mcmod;
|
||||
this.modIds = modIds;
|
||||
this.name = name;
|
||||
this.subname = subname;
|
||||
this.abbr = abbr;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (StringUtils.isNotBlank(abbr)) {
|
||||
builder.append("[").append(abbr.trim()).append("] ");
|
||||
}
|
||||
builder.append(name);
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
builder.append(" (").append(subname).append(")");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public String getCurseforge() {
|
||||
return curseforge;
|
||||
}
|
||||
|
||||
public String getMcmod() {
|
||||
return mcmod;
|
||||
}
|
||||
|
||||
public List<String> getModIds() {
|
||||
return modIds;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getSubname() {
|
||||
return subname;
|
||||
}
|
||||
|
||||
public String getAbbr() {
|
||||
return abbr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,8 @@ import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
@@ -540,7 +540,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
|
||||
VBox content = new VBox(10);
|
||||
content.getChildren().add(ComponentList.createComponentListTitle(i18n("terracotta.status")));
|
||||
if (!LocaleUtils.IS_CHINA_MAINLAND) {
|
||||
if (!RegionUtils.isIsChinaMainlandMachine()) {
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
hintPane.setText(i18n("terracotta.unsupported.region"));
|
||||
content.getChildren().add(hintPane);
|
||||
|
||||
+2
-3
@@ -27,7 +27,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Queue;
|
||||
@@ -46,7 +45,7 @@ public abstract class TaskExecutorDialogWizardDisplayer extends AbstractWizardDi
|
||||
@Override
|
||||
public void handleTask(SettingsMap settings, Task<?> task) {
|
||||
@Nullable WizardProvider.TaskCleanup cleanup = settings.remove(WizardProvider.TaskCleanup.KEY);
|
||||
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(new TaskCancellationAction(it -> {
|
||||
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(it -> {
|
||||
if (cleanup != null) {
|
||||
// Keep the installation modal until onStop has released its repository draft.
|
||||
it.setCancel(null);
|
||||
@@ -54,7 +53,7 @@ public abstract class TaskExecutorDialogWizardDisplayer extends AbstractWizardDi
|
||||
}
|
||||
it.fireEvent(new DialogCloseEvent());
|
||||
onEnd();
|
||||
}));
|
||||
});
|
||||
|
||||
pane.setTitle(i18n("message.doing"));
|
||||
if (settings.containsKey("title")) {
|
||||
|
||||
@@ -67,6 +67,11 @@ public final class CrashReporter implements Thread.UncaughtExceptionHandler {
|
||||
alert.setHeaderText(i18n("message.info"));
|
||||
alert.showAndWait();
|
||||
} catch (Throwable t) {
|
||||
try {
|
||||
SwingUtils.showErrorDialog(info);
|
||||
} catch (Throwable t2) {
|
||||
t.addSuppressed(t2);
|
||||
}
|
||||
LOG.error("Unable to show message", t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@NotNullByDefault
|
||||
public final class RegionUtils {
|
||||
|
||||
/// Pre-check if the network is in China Mainland to avoid blocking the UI thread.
|
||||
public static void init() {
|
||||
if (IS_CHINA_MAINLAND_MACHINE) {
|
||||
Schedulers.io().execute(RegionUtils::isIsChinaMainlandNetwork);
|
||||
}
|
||||
}
|
||||
|
||||
private static final boolean IS_CHINA_MAINLAND_MACHINE = checkMachineIsChinaMainland();
|
||||
|
||||
private static boolean checkMachineIsChinaMainland() {
|
||||
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
|
||||
return true;
|
||||
|
||||
// Check if the time zone is UTC+8
|
||||
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
|
||||
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
|
||||
return true;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
|
||||
Kernel32 kernel32 = Kernel32.INSTANCE;
|
||||
|
||||
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
|
||||
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isIsChinaMainlandMachine() {
|
||||
return IS_CHINA_MAINLAND_MACHINE;
|
||||
}
|
||||
|
||||
private static volatile @Nullable Boolean isChinaMainlandNetwork;
|
||||
|
||||
private static boolean checkChinaMainlandNetwork() {
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
record IpWhoIsResponse(boolean success, String country_code) {
|
||||
}
|
||||
|
||||
try {
|
||||
LOG.info("Fetching network information");
|
||||
IpWhoIsResponse result = JsonUtils.fromNonNullJson(NetworkUtils.doGet("https://ipwho.is"), IpWhoIsResponse.class);
|
||||
return !result.success || "CN".equalsIgnoreCase(result.country_code);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get the network information", e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isIsChinaMainlandNetwork() {
|
||||
if (!IS_CHINA_MAINLAND_MACHINE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Boolean value = isChinaMainlandNetwork;
|
||||
if (value != null)
|
||||
return value;
|
||||
|
||||
synchronized (RegionUtils.class) {
|
||||
value = isChinaMainlandNetwork;
|
||||
|
||||
if (value != null)
|
||||
return value;
|
||||
|
||||
boolean result = checkChinaMainlandNetwork();
|
||||
isChinaMainlandNetwork = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private RegionUtils() {
|
||||
}
|
||||
}
|
||||
@@ -18,25 +18,30 @@
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class TaskCancellationAction {
|
||||
public static TaskCancellationAction NO_CANCEL = new TaskCancellationAction((Consumer<TaskExecutorDialogPane>) null);
|
||||
public static TaskCancellationAction NORMAL = new TaskCancellationAction(() -> {
|
||||
});
|
||||
@NotNullByDefault
|
||||
@FunctionalInterface
|
||||
public interface TaskCancellationAction extends Consumer<TaskExecutorDialogPane> {
|
||||
|
||||
private final Consumer<TaskExecutorDialogPane> cancellationAction;
|
||||
TaskCancellationAction NO_CANCEL = new TaskCancellationAction() {
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public TaskCancellationAction(Runnable cancellationAction) {
|
||||
this.cancellationAction = it -> cancellationAction.run();
|
||||
@Override
|
||||
public void accept(TaskExecutorDialogPane taskExecutorDialogPane) {
|
||||
}
|
||||
};
|
||||
|
||||
TaskCancellationAction NORMAL = ignored -> {
|
||||
};
|
||||
|
||||
default boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public TaskCancellationAction(Consumer<TaskExecutorDialogPane> cancellationAction) {
|
||||
this.cancellationAction = cancellationAction;
|
||||
}
|
||||
|
||||
public Consumer<TaskExecutorDialogPane> getCancellationAction() {
|
||||
return cancellationAction;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -106,7 +106,7 @@ account.methods.microsoft.close_page=Microsoft account authorization is now comp
|
||||
There are some extra work for us, but you can safely close this tab for now.
|
||||
account.methods.microsoft.error.add_family=Please click <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">here</a> to change your account birth date to be over 18 years old, or add your account to a family.
|
||||
account.methods.microsoft.error.country_unavailable=XBOX Live is not available in your current country/region.
|
||||
account.methods.microsoft.error.missing_xbox_account=Please confirm that you have purchased Minecraft: Java Edition. If you have, please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create your game profile.
|
||||
account.methods.microsoft.error.missing_xbox_account=Please confirm that you have purchased Minecraft. If you have, please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create your game profile.
|
||||
account.methods.microsoft.error.no_license=No Minecraft purchase record found. Please click <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">here</a> to get the game.\nIf you are using Game Pass, please create your game profile <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a>. If you have already purchased the game, please check if you are logged into the correct account.
|
||||
account.methods.microsoft.error.no_character=You have not created a game profile yet. Please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create one.
|
||||
account.methods.microsoft.error.banned=Your account may have been banned by XBOX Live.\n\
|
||||
@@ -175,6 +175,7 @@ addon.check_update.empty=All files are up-to-date
|
||||
addon.check_update.failed_check=Failed to check for updates.
|
||||
addon.check_update.failed_download=Failed to download some files.
|
||||
addon.check_update.file=File
|
||||
addon.check_update.only_preview=Updates are available only for preview versions.\nEnable "Show preview versions" below to view them.
|
||||
addon.check_update.preview=Show preview versions
|
||||
addon.check_update.source=Source
|
||||
addon.check_update.target_version=Target Version
|
||||
@@ -381,6 +382,7 @@ curse.category.6555=Vanilla
|
||||
datetime.format=MMM d, yyyy, h\:mm\:ss a
|
||||
|
||||
download=Download
|
||||
download.china=Due to relevant laws and regulations, HMCL does not currently support downloading game-related content in Chinese Mainland. Please visit the <a href="https://mc.163.com/">official Minecraft website</a> to download Minecraft China Edition.
|
||||
download.code.404=File "%s" not found on the remote server.
|
||||
download.content=Addons
|
||||
download.shader=Shaders
|
||||
@@ -993,7 +995,7 @@ message.question=Question
|
||||
modpack=Modpacks
|
||||
modpack.choose=Choose Modpack
|
||||
modpack.choose.local=Import from Local File
|
||||
modpack.choose.local.detail=You can drag the modpack file here.
|
||||
modpack.choose.local.detail=You can drag the modpack file here to install it.
|
||||
modpack.choose.remote=Download from URL
|
||||
modpack.choose.remote.detail=A direct download link to the remote modpack file is required.
|
||||
modpack.choose.repository=Download Modpack from CurseForge or Modrinth
|
||||
@@ -1171,6 +1173,7 @@ mods.add=Add
|
||||
mods.add.failed=Failed to add mod %s.
|
||||
mods.add.success=%s was successfully added.
|
||||
mods.add.title=Choose mod file you want to add
|
||||
mods.delete.failed=Failed to delete mod(s). The mod file(s) may be in use by another program.
|
||||
mods.disable=Disable
|
||||
mods.download=Download Mod
|
||||
mods.download.title=Download Mod - %1s
|
||||
@@ -1184,6 +1187,7 @@ mods.mcmod.page=MCMod Page
|
||||
mods.mcmod.search=Search in MCMod
|
||||
mods.name=Name
|
||||
mods.restore=Restore
|
||||
mods.toggle.failed=Failed to enable or disable mod(s). The mod file(s) may be in use by another program.
|
||||
mods.url=Official Page
|
||||
mods.update_modpack_mod.warning=Updating mods in a modpack can lead to irreparable results, possibly corrupting the modpack so that it cannot launch. Are you sure you want to update?
|
||||
mods.warning.loader_mismatch=Mod loader mismatch
|
||||
@@ -1207,9 +1211,11 @@ nbt.title=View File - %s
|
||||
datapack=Data Packs
|
||||
datapack.add=Add
|
||||
datapack.add.title=Choose data pack archive you want to add
|
||||
datapack.delete.failed=Failed to delete data pack(s). The data pack file(s) may be in use by another program.
|
||||
datapack.empty=No Data Packs
|
||||
datapack.reload.toast=Minecraft is running. Use the /reload command to reload data packs
|
||||
datapack.title=World [%s] - Data Packs
|
||||
datapack.toggle.failed=Failed to enable or disable data pack(s). The data pack file(s) may be in use by another program.
|
||||
|
||||
web.failed=Failed to load page
|
||||
web.open_in_browser=Do you want to open this address in a browser:\n%s
|
||||
@@ -1428,6 +1434,7 @@ settings.advanced.natives_directory=Native Library Path
|
||||
settings.advanced.natives_directory.custom.enabled=Use Custom Native Libraries
|
||||
settings.advanced.no_jvm_args=Do not add default JVM arguments
|
||||
settings.advanced.no_optimizing_jvm_args=Do not add default JVM optimization arguments
|
||||
settings.advanced.not_use_javaw=Do not use javaw.exe
|
||||
settings.advanced.precall_command=Pre-launch Command
|
||||
settings.advanced.precall_command.prompt=Commands to execute before the game launches
|
||||
settings.advanced.process_priority=Process Priority
|
||||
|
||||
@@ -176,6 +176,7 @@ addon.check_update.empty=جميع المودات محدّثة
|
||||
addon.check_update.failed_check=فشل التحقق من التحديثات.
|
||||
addon.check_update.failed_download=فشل تنزيل بعض الملفات.
|
||||
addon.check_update.file=الملف
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=المصدر
|
||||
addon.check_update.target_version=الإصدار المستهدف
|
||||
@@ -383,6 +384,7 @@ curse.category.4552=سكريبتات
|
||||
datetime.format=MMM d, yyyy, h\:mm\:ss a
|
||||
|
||||
download=تنزيل
|
||||
# download.china=
|
||||
download.code.404=الملف "%s" غير موجود على الخادم البعيد.
|
||||
download.content=الإضافات
|
||||
download.shader=الظلال
|
||||
@@ -860,6 +862,7 @@ java.download.prompt=يُرجى اختيار إصدار Java الذي تريد
|
||||
java.download.distribution=التوزيع
|
||||
java.download.version=الإصدار
|
||||
java.download.packageType=نوع الحزمة
|
||||
# java.empty=
|
||||
java.management=إدارة Java
|
||||
java.info.architecture=المعمارية
|
||||
java.info.vendor=المزوّد
|
||||
@@ -1170,6 +1173,7 @@ mods.add=إضافة
|
||||
mods.add.failed=فشل إضافة المود %s.
|
||||
mods.add.success=تمت إضافة %s بنجاح.
|
||||
mods.add.title=اختر ملف المود الذي تريد إضافته
|
||||
# mods.delete.failed=
|
||||
mods.disable=تعطيل
|
||||
mods.download=تنزيل مود
|
||||
mods.download.title=تنزيل مود - %1s
|
||||
@@ -1183,6 +1187,7 @@ mods.mcmod.page=صفحة MCMod
|
||||
mods.mcmod.search=البحث في MCMod
|
||||
mods.name=الاسم
|
||||
mods.restore=استعادة
|
||||
# mods.toggle.failed=
|
||||
mods.url=الصفحة الرسمية
|
||||
mods.update_modpack_mod.warning=تحديث المودات في حزمة مودات قد يؤدي إلى نتائج لا يمكن إصلاحها، وقد يتسبب في تلف الحزمة بحيث لا تعمل. هل أنت متأكد من التحديث؟
|
||||
mods.warning.loader_mismatch=عدم تطابق محمّل المودات
|
||||
@@ -1206,9 +1211,11 @@ nbt.title=عرض الملف - %s
|
||||
datapack=حزم البيانات
|
||||
datapack.add=إضافة
|
||||
datapack.add.title=اختر أرشيف حزمة البيانات التي تريد إضافتها
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
datapack.reload.toast=اللعبة تعمل حالياً، يُرجى استخدام الأمر /reload لإعادة تحميل حزمة البيانات
|
||||
datapack.title=العالم [%s] - حزم البيانات
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=فشل تحميل الصفحة
|
||||
web.open_in_browser=هل تريد فتح هذا العنوان في المتصفح:\n%s
|
||||
@@ -1427,6 +1434,7 @@ settings.advanced.natives_directory=مسار المكتبات الأصلية
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
settings.advanced.no_jvm_args=عدم إضافة وسيطات JVM الافتراضية
|
||||
settings.advanced.no_optimizing_jvm_args=عدم إضافة وسيطات تحسين JVM الافتراضية
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=أمر ما قبل التشغيل
|
||||
settings.advanced.precall_command.prompt=أوامر تُنفَّذ قبل تشغيل اللعبة
|
||||
settings.advanced.process_priority=أولوية العملية
|
||||
|
||||
@@ -105,7 +105,7 @@ account.methods.microsoft.close_page=Die Microsoft-Kontoautorisierung ist nun ab
|
||||
Es gibt noch einige zusätzliche Arbeiten für uns, aber Sie können diesen Tab vorerst sicher schließen.
|
||||
account.methods.microsoft.error.add_family=Bitte klicken Sie <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">hier</a>, um das Geburtsdatum Ihres Kontos auf über 18 Jahre zu ändern, oder fügen Sie Ihr Konto zu einer Familie hinzu.
|
||||
account.methods.microsoft.error.country_unavailable=XBOX Live ist in Ihrem aktuellen Land/Ihrer aktuellen Region nicht verfügbar.
|
||||
account.methods.microsoft.error.missing_xbox_account=Bitte bestätigen Sie, dass Sie Minecraft: Java Edition gekauft haben. Wenn Sie dies getan haben, klicken Sie bitte <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a>, um Ihr Spielprofil zu erstellen.
|
||||
account.methods.microsoft.error.missing_xbox_account=Bitte bestätigen Sie, dass Sie Minecraft gekauft haben. Wenn Sie dies getan haben, klicken Sie bitte <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a>, um Ihr Spielprofil zu erstellen.
|
||||
account.methods.microsoft.error.no_license=Kein Kaufbeleg für Minecraft gefunden. Bitte klicken Sie <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">hier</a>, um das Spiel zu erhalten.\nWenn Sie den Game Pass verwenden, erstellen Sie bitte <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a> Ihr Spielprofil. Wenn Sie das Spiel bereits gekauft haben, überprüfen Sie bitte, ob Sie mit dem richtigen Konto angemeldet sind.
|
||||
account.methods.microsoft.error.no_character=Sie haben noch kein Spielprofil erstellt. Bitte klicken Sie <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">hier</a>, um eines zu erstellen.
|
||||
account.methods.microsoft.error.banned=Ihr Konto wurde möglicherweise von XBOX Live gesperrt.\n\
|
||||
@@ -174,6 +174,7 @@ addon.check_update.empty=Alle Dateien sind auf dem neuesten Stand
|
||||
addon.check_update.failed_check=Fehler beim Suchen nach Aktualisierungen.
|
||||
addon.check_update.failed_download=Fehler beim Herunterladen einiger Dateien.
|
||||
addon.check_update.file=Datei
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Quelle
|
||||
addon.check_update.target_version=Zielversion
|
||||
@@ -380,6 +381,7 @@ curse.category.6555=Vanilla
|
||||
datetime.format=dd.MM.yyyy, HH\:mm\:ss
|
||||
|
||||
download=Herunterladen
|
||||
# download.china=
|
||||
download.code.404=Datei "%s" wurde nicht auf dem Remote-Server gefunden.
|
||||
download.content=Addons
|
||||
download.shader=Shaderpakete
|
||||
@@ -858,6 +860,7 @@ java.download.prompt=Bitte wählen Sie die Java-Version, die Sie herunterladen m
|
||||
java.download.distribution=Distribution
|
||||
java.download.version=Version
|
||||
java.download.packageType=Pakettyp
|
||||
# java.empty=
|
||||
java.management=Java-Verwaltung
|
||||
java.info.architecture=Architektur
|
||||
java.info.vendor=Anbieter
|
||||
@@ -1169,6 +1172,7 @@ mods.add=Hinzufügen
|
||||
mods.add.failed=Mod %s konnte nicht hinzugefügt werden.
|
||||
mods.add.success=%s wurde erfolgreich hinzugefügt.
|
||||
mods.add.title=Wählen Sie die Mod-Datei, die Sie hinzufügen möchten
|
||||
# mods.delete.failed=
|
||||
mods.disable=Deaktivieren
|
||||
mods.download=Mod herunterladen
|
||||
mods.download.title=Mod herunterladen - %1s
|
||||
@@ -1182,6 +1186,7 @@ mods.mcmod.page=MCMod-Seite
|
||||
mods.mcmod.search=In MCMod suchen
|
||||
mods.name=Name
|
||||
mods.restore=Wiederherstellen
|
||||
# mods.toggle.failed=
|
||||
mods.url=Offizielle Seite
|
||||
mods.update_modpack_mod.warning=Das Aktualisieren von Mods in einem Modpack kann zu irreparablen Ergebnissen führen, wodurch das Modpack möglicherweise beschädigt wird, sodass es nicht mehr gestartet werden kann. Sind Sie sicher, dass Sie aktualisieren möchten?
|
||||
mods.warning.loader_mismatch=Modloader stimmt nicht überein
|
||||
@@ -1205,9 +1210,11 @@ nbt.title=Datei ansehen - %s
|
||||
datapack=Datenpakete
|
||||
datapack.add=Hinzufügen
|
||||
datapack.add.title=Wählen Sie das Datenpaket-Archiv, das Sie hinzufügen möchten
|
||||
# datapack.delete.failed=
|
||||
datapack.empty=Keine Datenpakete
|
||||
datapack.reload.toast=Minecraft läuft. Verwenden Sie den Befehl /reload, um Datenpakete neu zu laden
|
||||
datapack.title=Welt [%s] - Datenpakete
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=Seite konnte nicht geladen werden
|
||||
web.open_in_browser=Möchten Sie diese Adresse in einem Browser öffnen:\n%s
|
||||
@@ -1426,6 +1433,7 @@ settings.advanced.natives_directory=Pfad der nativen Bibliotheken
|
||||
settings.advanced.natives_directory.custom.enabled=Benutzerdefinierte native Bibliotheken verwenden
|
||||
settings.advanced.no_jvm_args=Standard-JVM-Argumente nicht hinzufügen
|
||||
settings.advanced.no_optimizing_jvm_args=Standard-JVM-Optimierungsargumente nicht hinzufügen
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=Pre-Launch-Befehl
|
||||
settings.advanced.precall_command.prompt=Befehle, die vor dem Spielstart ausgeführt werden sollen
|
||||
settings.advanced.process_priority=Prozesspriorität
|
||||
|
||||
@@ -174,6 +174,7 @@ addon.check_update.empty=Todos los mods están actualizados
|
||||
addon.check_update.failed_check=No se ha podido comprobar si hay actualizaciones.
|
||||
addon.check_update.failed_download=No se han podido descargar algunos de los archivos.
|
||||
addon.check_update.file=Archivo
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Fuente
|
||||
addon.check_update.target_version=Versión de destino
|
||||
@@ -381,6 +382,7 @@ curse.category.4552=Scripts
|
||||
datetime.format=d MMM yyyy, H\:mm\:ss
|
||||
|
||||
download=Descargar
|
||||
# download.china=
|
||||
download.code.404=Archivo no encontrado en el servidor remoto: %s
|
||||
download.content=Complementos
|
||||
download.shader=Sombreadores
|
||||
@@ -852,6 +854,7 @@ java.download.prompt=Elija la versión de Java que desea descargar:
|
||||
java.download.distribution=Distribución
|
||||
java.download.version=Versión
|
||||
java.download.packageType=Tipo de paquete
|
||||
# java.empty=
|
||||
java.management=Gestión Java
|
||||
java.info.architecture=Arquitectura
|
||||
java.info.vendor=Proveedor
|
||||
@@ -1162,6 +1165,7 @@ mods=Mods
|
||||
mods.add.failed=No se ha podido añadir el mod %s.
|
||||
mods.add.success=%s se ha añadido correctamente.
|
||||
# mods.add.title=
|
||||
# mods.delete.failed=
|
||||
mods.disable=Desactivar
|
||||
mods.download=Descargar mod
|
||||
mods.download.title=Descargar mod - %1s
|
||||
@@ -1175,6 +1179,7 @@ mods.mcmod.page=Página de MCMod
|
||||
mods.mcmod.search=Búsqueda en MCMod
|
||||
mods.name=Nombre
|
||||
mods.restore=Restaurar
|
||||
# mods.toggle.failed=
|
||||
mods.url=Página oficial
|
||||
mods.update_modpack_mod.warning=Actualizar mods en un modpack puede generar resultados irreparables, posiblemente corrompiendo el modpack para que no pueda iniciarse. ¿Seguro que quieres actualizar?
|
||||
# mods.warning.loader_mismatch=
|
||||
@@ -1198,9 +1203,11 @@ nbt.title=Ver archivo - %s
|
||||
datapack=Paquetes de datos
|
||||
# datapack.add=
|
||||
# datapack.add.title=
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
# datapack.reload.toast=
|
||||
datapack.title=Mundo [%s] - Paquetes de datos
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=No se ha podido cargar la página
|
||||
web.open_in_browser=Desea abrir esta dirección en un navegador:\n%s
|
||||
@@ -1419,6 +1426,7 @@ settings.advanced.natives_directory=Ruta de la biblioteca nativa
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
settings.advanced.no_jvm_args=No añadir argumentos JVM por defecto
|
||||
settings.advanced.no_optimizing_jvm_args=No añadir argumentos de optimización de JVM
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=Comando pre-lanzamiento:
|
||||
settings.advanced.precall_command.prompt=El comando se ejecuta antes de que los juegos se lance
|
||||
settings.advanced.process_priority=Prioridad del proceso
|
||||
|
||||
@@ -163,6 +163,7 @@ addon.check_update.current_version=Current
|
||||
addon.check_update.failed_check=更新のチェックに失敗しました
|
||||
addon.check_update.failed_download=一部のファイルのダウンロードに失敗しました
|
||||
addon.check_update.file=ファイル
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Source
|
||||
addon.check_update.target_version=Target
|
||||
@@ -369,6 +370,7 @@ curse.category.4552=スクリプト
|
||||
datetime.format=yyyy/MM/dd H:mm:ss
|
||||
|
||||
download=ダウンロード
|
||||
# download.china=
|
||||
download.code.404=リモートサーバーにファイルが見つかりません:%s
|
||||
download.content=ゲームコンテンツ
|
||||
download.shader=シェーダー
|
||||
@@ -662,6 +664,7 @@ java.download.prompt=ダウンロードしたいJavaのバージョンを選択
|
||||
java.download.distribution=配布の種類
|
||||
java.download.version=バージョン
|
||||
java.download.packageType=パッケージの種類
|
||||
# java.empty=
|
||||
java.management=Javaの管理
|
||||
java.info.architecture=アーキテクチャ
|
||||
java.info.vendor=発行元
|
||||
@@ -970,6 +973,7 @@ mods=Mods
|
||||
mods.add.failed=mods %s の追加に失敗しました。
|
||||
mods.add.success=mods %s が正常に追加されました。
|
||||
# mods.add.title=
|
||||
# mods.delete.failed=
|
||||
mods.disable=無効にする
|
||||
mods.download=Modのダウンロード
|
||||
mods.download.title=Modダウンロード- %1s
|
||||
@@ -983,6 +987,7 @@ mods.mcmod.page=MCMOD
|
||||
mods.mcmod.search=MCMODで検索
|
||||
mods.name=Name
|
||||
mods.restore=Restore
|
||||
# mods.toggle.failed=
|
||||
mods.url=公式ページ
|
||||
mods.update_modpack_mod.warning=modpack 内の mod を更新すると、修復不可能な結果につながる可能性があり、modpack が破損して起動できなくなる可能性があります。更新してもよろしいですか?
|
||||
# mods.warning.loader_mismatch=
|
||||
@@ -1006,9 +1011,11 @@ mods.update_modpack_mod.warning=modpack 内の mod を更新すると、修復
|
||||
datapack=Datapacks
|
||||
# datapack.add=
|
||||
# datapack.add.title=
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
# datapack.reload.toast=
|
||||
datapack.title=World %s -データパック
|
||||
# datapack.toggle.failed=
|
||||
|
||||
# web.failed=
|
||||
# web.open_in_browser=
|
||||
@@ -1223,6 +1230,7 @@ settings.advanced.natives_directory=ネイティブライブラリパス(例
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
settings.advanced.no_jvm_args=デフォルトのJVM引数なし
|
||||
# settings.advanced.no_optimizing_jvm_args=
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=起動前コマンド
|
||||
settings.advanced.precall_command.prompt=ゲーム開始前に実行されます
|
||||
settings.advanced.process_priority=プロセスの優先度
|
||||
|
||||
@@ -174,6 +174,7 @@ addon.check_update.empty=無改囊可迭更
|
||||
addon.check_update.failed_check=檢囊迭更未成
|
||||
addon.check_update.failed_download=有引案未成
|
||||
addon.check_update.file=案
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=源
|
||||
addon.check_update.target_version=將至之版
|
||||
@@ -380,6 +381,7 @@ curse.category.4552=角本
|
||||
datetime.format=yyyy 年 MM 月 dd 日 HH:mm:ss
|
||||
|
||||
download=引
|
||||
# download.china=
|
||||
download.code.404=伺服器無案曰:%s\n君可求助於右上之鈕。
|
||||
download.content=戲案
|
||||
download.shader=光影
|
||||
@@ -674,6 +676,7 @@ java.download.prompt=須擇一爪哇版以引:
|
||||
java.download.distribution=廣布版
|
||||
java.download.version=版
|
||||
java.download.packageType=裝包類
|
||||
# java.empty=
|
||||
java.management=司爪哇
|
||||
java.info.architecture=大構
|
||||
java.info.vendor=供者
|
||||
@@ -982,6 +985,7 @@ mods.add=增改囊
|
||||
mods.add.failed=增改囊「%s」未成。\n君可求助於右上之鈕。
|
||||
mods.add.success=增改囊 %s 畢。
|
||||
mods.add.title=擇改囊
|
||||
# mods.delete.failed=
|
||||
mods.disable=禁此改囊
|
||||
mods.download=引改囊
|
||||
mods.download.title=引改囊 - %1s
|
||||
@@ -995,6 +999,7 @@ mods.mcmod.page=礦藝改囊典頁
|
||||
mods.mcmod.search=礦藝改囊典查
|
||||
mods.name=名
|
||||
mods.restore=復
|
||||
# mods.toggle.failed=
|
||||
mods.url=改囊官網
|
||||
mods.update_modpack_mod.warning=更迭改囊集之改囊或損之,以不能啟。行而不復,誠更迭乎?
|
||||
# mods.warning.loader_mismatch=
|
||||
@@ -1018,9 +1023,11 @@ nbt.title=覽案 - %s
|
||||
datapack=錄囊
|
||||
datapack.add=增錄囊
|
||||
datapack.add.title=擇須導入之錄囊緊囊
|
||||
# datapack.delete.failed=
|
||||
# datapack.empty=
|
||||
# datapack.reload.toast=
|
||||
datapack.title=生界 [%s] - 錄囊
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=載網葉未成
|
||||
web.open_in_browser=誠啟此鏈於瀏覽器乎:\n%s
|
||||
@@ -1235,6 +1242,7 @@ settings.advanced.natives_directory=府庫之徑 (LWJGL)
|
||||
# settings.advanced.natives_directory.custom.enabled=
|
||||
settings.advanced.no_jvm_args=無添爪哇虛機之本通弦
|
||||
# settings.advanced.no_optimizing_jvm_args=
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=令於戲前
|
||||
settings.advanced.precall_command.prompt=將前戲啟而行
|
||||
settings.advanced.process_priority=進程之次
|
||||
|
||||
@@ -103,7 +103,7 @@ account.methods.microsoft.code=Код (скопирован в буфер обм
|
||||
account.methods.microsoft.close_page=Авторизация аккаунта Microsoft завершена. Осталось выполнить некоторые шаги, которые необходимо завершить позже. Вы можете закрыть эту страницу.
|
||||
account.methods.microsoft.error.add_family=Пожалуйста, нажмите <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">здесь</a>, чтобы изменить дату рождения вашего аккаунта так, чтобы вам было больше 18 лет, или добавить ваш аккаунт в семейную группу.
|
||||
account.methods.microsoft.error.country_unavailable=XBOX Live недоступен в вашей текущей стране/регионе.
|
||||
account.methods.microsoft.error.missing_xbox_account=Пожалуйста, подтвердите, что вы приобрели Minecraft: Java Edition. Если да, нажмите <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">здесь</a>, чтобы создать свой игровой профиль.
|
||||
account.methods.microsoft.error.missing_xbox_account=Пожалуйста, подтвердите, что вы приобрели Minecraft. Если да, нажмите <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">здесь</a>, чтобы создать свой игровой профиль.
|
||||
account.methods.microsoft.error.no_license=Не найдено записей о покупке Minecraft. Пожалуйста, нажмите <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">здесь</a>, чтобы скачать игру.\n\
|
||||
Если вы пользуетесь Game Pass, создайте игровой профиль <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">здесь</a>. Если вы уже приобрели игру, проверьте, в тот ли аккаунт вы вошли.
|
||||
account.methods.microsoft.error.no_character=Вы еще не создали игровой профиль. Пожалуйста, нажмите <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">здесь</a>, чтобы создать его.
|
||||
@@ -170,6 +170,7 @@ addon.check_update.empty=Все файлы актуальны
|
||||
addon.check_update.failed_check=Не удалось проверить обновления.
|
||||
addon.check_update.failed_download=Не удалось скачать некоторые файлы.
|
||||
addon.check_update.file=Файл
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Источник
|
||||
addon.check_update.target_version=Целевая версия
|
||||
@@ -377,6 +378,7 @@ curse.category.6555=Ванильный
|
||||
datetime.format=d MMM yyyy г., HH\:mm\:ss
|
||||
|
||||
download=Скачать
|
||||
# download.china=
|
||||
download.code.404=Файл «%s» не найден на удаленном сервере.
|
||||
download.content=Аддоны
|
||||
download.shader=Шейдеры
|
||||
@@ -789,6 +791,7 @@ java.download.prompt=Выберите версию Java, которую вы х
|
||||
java.download.distribution=Дистрибутив
|
||||
java.download.version=Версия
|
||||
java.download.packageType=Тип пакета
|
||||
# java.empty=
|
||||
java.management=Управление Java
|
||||
java.info.architecture=Архитектура
|
||||
java.info.vendor=Поставщик
|
||||
@@ -1102,6 +1105,7 @@ mods.add=Добавить
|
||||
mods.add.failed=Не удалось установить мод %s.
|
||||
mods.add.success=%s был успешно добавлен.
|
||||
mods.add.title=Выберите файл мода, который хотите добавить
|
||||
# mods.delete.failed=
|
||||
mods.disable=Отключить
|
||||
mods.download=Скачать мод
|
||||
mods.download.title=Скачать мод - %1s
|
||||
@@ -1115,6 +1119,7 @@ mods.mcmod.page=Страница MCMod
|
||||
mods.mcmod.search=Искать на MCMod
|
||||
mods.name=Название
|
||||
mods.restore=Восстановить
|
||||
# mods.toggle.failed=
|
||||
mods.url=Официальная страница
|
||||
mods.update_modpack_mod.warning=Обновление модов в модпаке может привести к непоправимым последствиям: модпак может повредиться и перестать запускаться. Вы уверены, что хотите обновить?
|
||||
mods.warning.loader_mismatch=Не тот загрузчик модов
|
||||
@@ -1138,9 +1143,11 @@ nbt.title=Просмотр файла - %s
|
||||
datapack=Наборы данных
|
||||
datapack.add=Добавить
|
||||
datapack.add.title=Выберите архив набора данных, который хотите добавить
|
||||
# datapack.delete.failed=
|
||||
datapack.empty=Нет наборов данных
|
||||
datapack.reload.toast=Minecraft запущен. Чтобы перезагрузить наборы данных, используйте команду /reload
|
||||
datapack.title=Мир [%s] - Наборы данных
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=Не удалось загрузить страницу
|
||||
web.open_in_browser=Хотите ли вы открыть этот адрес в браузере?\n\n%s
|
||||
@@ -1359,6 +1366,7 @@ settings.advanced.natives_directory=Папка с нативными библи
|
||||
settings.advanced.natives_directory.custom.enabled=Использовать свои нативные библиотеки
|
||||
settings.advanced.no_jvm_args=Не добавлять аргументы JVM по умолчанию
|
||||
settings.advanced.no_optimizing_jvm_args=Не добавлять аргументы оптимизации JVM по умолчанию
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=Команда перед запуском
|
||||
settings.advanced.precall_command.prompt=Команды, которые необходимо выполнить перед запуском игры
|
||||
settings.advanced.process_priority=Приоритет процесса
|
||||
|
||||
@@ -106,7 +106,7 @@ account.methods.microsoft.close_page=Авторизація облікового
|
||||
Нам залишилося виконати ще трохи роботи, але ви можете безпечно закрити цю вкладку зараз.
|
||||
account.methods.microsoft.error.add_family=Будь ласка, натисніть <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">тут</a>, щоб змінити дату народження вашого акаунта на старше 18 років, або додайте свій акаунт до сім'ї.
|
||||
account.methods.microsoft.error.country_unavailable=XBOX Live недоступний у вашій поточній країні/регіоні.
|
||||
account.methods.microsoft.error.missing_xbox_account=Будь ласка, підтвердіть, що ви придбали Minecraft: Java Edition. Якщо так, натисніть <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">тут</a>, щоб створити свій ігровий профіль.
|
||||
account.methods.microsoft.error.missing_xbox_account=Будь ласка, підтвердіть, що ви придбали Minecraft. Якщо так, натисніть <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">тут</a>, щоб створити свій ігровий профіль.
|
||||
account.methods.microsoft.error.no_license=Не знайдено запису про покупку Minecraft. Будь ласка, натисніть <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">тут</a>, щоб отримати гру.\nЯкщо ви використовуєте Game Pass, будь ласка, створіть свій ігровий профіль <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">тут</a>. Якщо ви вже придбали гру, перевірте, чи увійшли ви в правильний акаунт.
|
||||
account.methods.microsoft.error.no_character=Ви ще не створили ігровий профіль. Будь ласка, натисніть <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">тут</a>, щоб створити його.
|
||||
account.methods.microsoft.error.banned=Ваш акаунт міг бути заблокований XBOX Live.\n\
|
||||
@@ -176,6 +176,7 @@ addon.check_update.empty=Усі моди оновлені
|
||||
addon.check_update.failed_check=Не вдалося перевірити оновлення.
|
||||
addon.check_update.failed_download=Не вдалося завантажити деякі файли.
|
||||
addon.check_update.file=Файл
|
||||
# addon.check_update.only_preview=
|
||||
# addon.check_update.preview=
|
||||
addon.check_update.source=Джерело
|
||||
addon.check_update.target_version=Цільова версія
|
||||
@@ -377,6 +378,7 @@ curse.category.6555=Ваніла
|
||||
datetime.format=dd.MM.yyyy, HH:mm:ss
|
||||
|
||||
download=Завантажити
|
||||
# download.china=
|
||||
download.code.404=Файл "%s" не знайдено на віддаленому сервері.
|
||||
download.content=Додатки
|
||||
download.shader=Шейдери
|
||||
@@ -813,6 +815,7 @@ java.download.prompt=Будь ласка, виберіть версію Java, я
|
||||
java.download.distribution=Дистрибутив
|
||||
java.download.version=Версія
|
||||
java.download.packageType=Тип пакета
|
||||
# java.empty=
|
||||
java.management=Керування Java
|
||||
java.info.architecture=Архітектура
|
||||
java.info.vendor=Постачальник
|
||||
@@ -1125,6 +1128,7 @@ mods.add=Додати
|
||||
mods.add.failed=Не вдалося додати мод %s.
|
||||
mods.add.success=%s успішно додано.
|
||||
mods.add.title=Виберіть файл мода, який ви хочете додати
|
||||
# mods.delete.failed=
|
||||
mods.disable=Вимкнути
|
||||
mods.download=Завантажити мод
|
||||
mods.download.title=Завантажити мод - %1s
|
||||
@@ -1138,6 +1142,7 @@ mods.mcmod.page=Сторінка MCMod
|
||||
mods.mcmod.search=Пошук в MCMod
|
||||
mods.name=Назва
|
||||
mods.restore=Відновити
|
||||
# mods.toggle.failed=
|
||||
mods.url=Офіційна сторінка
|
||||
mods.update_modpack_mod.warning=Оновлення модів у модпаку може призвести до непоправних наслідків, можливо, пошкодивши модпак так, що він не зможе запуститися. Ви впевнені, що хочете оновити?
|
||||
mods.warning.loader_mismatch=Невідповідність завантажувача модів
|
||||
@@ -1161,9 +1166,11 @@ nbt.title=Перегляд файлу - %s
|
||||
datapack=Datapacks
|
||||
datapack.add=Додати
|
||||
datapack.add.title=Виберіть архів пакета даних, який ви хочете додати
|
||||
# datapack.delete.failed=
|
||||
datapack.empty=Немає пакетів даних
|
||||
datapack.reload.toast=Minecraft запущено. Використовуйте команду /reload, щоб перезавантажити пакети даних
|
||||
datapack.title=Світ [%s] - Datapacks
|
||||
# datapack.toggle.failed=
|
||||
|
||||
web.failed=Не вдалося завантажити сторінку
|
||||
web.open_in_browser=Бажаєте відкрити цю адресу в браузері: \n%s
|
||||
@@ -1382,6 +1389,7 @@ settings.advanced.natives_directory=Шлях до нативних бібліо
|
||||
settings.advanced.natives_directory.custom.enabled=Використовувати власні нативні бібліотеки
|
||||
settings.advanced.no_jvm_args=Не додавати типові аргументи JVM
|
||||
settings.advanced.no_optimizing_jvm_args=Не додавати типові аргументи оптимізації JVM
|
||||
# settings.advanced.not_use_javaw=
|
||||
settings.advanced.precall_command=Команда перед запуском
|
||||
settings.advanced.precall_command.prompt=Команди для виконання перед запуском гри
|
||||
settings.advanced.process_priority=Пріоритет процесу
|
||||
|
||||
@@ -108,7 +108,7 @@ account.methods.microsoft.code=代码 (已自动复制)
|
||||
account.methods.microsoft.close_page=已完成微软账户授权。其余登录步骤将由启动器自动执行。你现在可以关闭本页面了。
|
||||
account.methods.microsoft.error.add_family=请点击 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此处</a> 更改你的账户出生日期,使年龄满 18 岁以上,或将账户加入到家庭中。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 XBOX Live 的支持。
|
||||
account.methods.microsoft.error.missing_xbox_account=请确认你已经购买了 Minecraft: Java 版。若已购买,请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.missing_xbox_account=请确认你已经购买了 Minecraft。若已购买,请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.no_license=未找到 Minecraft 购买记录。请点击 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此处</a> 购买游戏。\n若你使用 Game Pass,请在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。若已购买,请检查登录的账户是否正确。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.no_character=你尚未创建游戏档案。请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
|
||||
account.methods.microsoft.error.banned=你的账户可能被 XBOX Live 封禁。\n你可以点击 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此处</a> 按钮查询账户封禁状态。
|
||||
@@ -175,6 +175,7 @@ addon.check_update.empty=没有需要更新的文件
|
||||
addon.check_update.failed_check=检查更新失败
|
||||
addon.check_update.failed_download=部分文件下载失败
|
||||
addon.check_update.file=文件
|
||||
addon.check_update.only_preview=仅找到测试版本的更新\n你可以启用下方的“显示测试版本”来查看
|
||||
addon.check_update.preview=显示测试版本
|
||||
addon.check_update.source=来源
|
||||
addon.check_update.target_version=目标版本
|
||||
@@ -381,6 +382,7 @@ curse.category.6555=原生
|
||||
datetime.format=yyyy 年 MM 月 dd 日 HH:mm:ss
|
||||
|
||||
download=下载
|
||||
download.china=根据相关法律法规,HMCL 在中国大陆地区暂不支持下载游戏相关内容。请前往<a href="https://mc.163.com/">我的世界官方网站</a>下载我的世界中国版。
|
||||
download.code.404=远程服务器不包含需要下载的文件: %s\n你可以点击右上角帮助按钮进行求助。
|
||||
download.content=游戏内容
|
||||
download.shader=光影
|
||||
@@ -811,7 +813,7 @@ message.question=确认
|
||||
modpack=整合包
|
||||
modpack.choose=选择要安装的游戏整合包文件
|
||||
modpack.choose.local=导入本地整合包文件
|
||||
modpack.choose.local.detail=你可以直接将整合包文件拖入本页面以安装
|
||||
modpack.choose.local.detail=你可以将整合包文件拖到此处以安装
|
||||
modpack.choose.remote=从互联网下载整合包
|
||||
modpack.choose.remote.detail=需要提供整合包的下载链接
|
||||
modpack.choose.repository=从 CurseForge/Modrinth 下载整合包
|
||||
@@ -987,6 +989,7 @@ mods.add=添加模组
|
||||
mods.add.failed=添加模组“%s”失败。\n如遇到问题,你可以点击右上角帮助按钮进行求助。
|
||||
mods.add.success=成功添加模组 %s。
|
||||
mods.add.title=选择要添加的模组文件
|
||||
mods.delete.failed=删除模组失败,模组文件可能已被其他程序占用。
|
||||
mods.disable=禁用
|
||||
mods.download=下载模组
|
||||
mods.download.title=模组下载 - %1s
|
||||
@@ -1000,6 +1003,7 @@ mods.mcmod.page=MC 百科页面
|
||||
mods.mcmod.search=MC 百科搜索
|
||||
mods.name=名称
|
||||
mods.restore=回退
|
||||
mods.toggle.failed=无法启用或禁用模组,模组文件可能已被其他程序占用
|
||||
mods.url=官方页面
|
||||
mods.update_modpack_mod.warning=更新整合包中的模组可能导致整合包损坏,使整合包无法正常启动。该操作不可逆,确定要更新吗?
|
||||
mods.warning.loader_mismatch=模组加载器不匹配
|
||||
@@ -1023,9 +1027,11 @@ nbt.title=查看文件 - %s
|
||||
datapack=数据包
|
||||
datapack.add=添加数据包
|
||||
datapack.add.title=选择要添加的数据包压缩包
|
||||
datapack.delete.failed=删除数据包失败,数据包文件可能已被其他程序占用。
|
||||
datapack.empty=没有数据包
|
||||
datapack.reload.toast=Minecraft 正在运行,请使用 /reload 命令重新加载数据包
|
||||
datapack.title=世界 [%s] - 数据包
|
||||
datapack.toggle.failed=无法启用或禁用数据包,数据包文件可能已被其他程序占用
|
||||
|
||||
web.failed=加载页面失败
|
||||
web.open_in_browser=是否要在浏览器中打开此链接:\n%s
|
||||
@@ -1240,6 +1246,7 @@ settings.advanced.natives_directory=本地库路径 (LWJGL)
|
||||
settings.advanced.natives_directory.custom.enabled=使用自定义本地库
|
||||
settings.advanced.no_jvm_args=不添加默认的 Java 虚拟机参数
|
||||
settings.advanced.no_optimizing_jvm_args=不自动添加 Java 虚拟机优化参数
|
||||
settings.advanced.not_use_javaw=不使用 javaw.exe
|
||||
settings.advanced.precall_command=游戏启动前执行命令
|
||||
settings.advanced.precall_command.prompt=将在游戏启动前调用
|
||||
settings.advanced.process_priority=进程优先级
|
||||
|
||||
@@ -107,7 +107,7 @@ account.methods.microsoft.code=程式碼 (已自動複製)
|
||||
account.methods.microsoft.close_page=已完成 Microsoft 帳戶授權。啟動器將自動執行後續步驟。你現在可以關閉本頁面了。
|
||||
account.methods.microsoft.error.add_family=請點擊 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此處</a> 更改你的帳戶出生日期,使年齡滿 18 歲以上,或將帳戶加入到家庭中。
|
||||
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 XBOX Live 的支援。
|
||||
account.methods.microsoft.error.missing_xbox_account=請確認你已經購買了 Minecraft: Java 版。若已購買,請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
|
||||
account.methods.microsoft.error.missing_xbox_account=請確認你已經購買了 Minecraft。若已購買,請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
|
||||
account.methods.microsoft.error.no_license=未找到 Minecraft 購買記錄。請點擊 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此處</a> 購買遊戲。\n若你使用 Game Pass,請在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。若已購買,請檢查登入的帳戶是否正確。
|
||||
account.methods.microsoft.error.no_character=你可能未建立遊戲檔案。請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
|
||||
account.methods.microsoft.error.banned=你的帳戶可能被 XBOX Live 封禁。\n你可以點擊 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此處</a> 按鈕查詢帳戶封禁狀態。
|
||||
@@ -173,6 +173,7 @@ addon.check_update.empty=沒有需要更新的檔案
|
||||
addon.check_update.failed_check=檢查更新失敗
|
||||
addon.check_update.failed_download=部分檔案下載失敗
|
||||
addon.check_update.file=檔案
|
||||
addon.check_update.only_preview=僅找到測試版本的更新\n你可以啟用下方的“顯示 Alpha/Beta 版本”來查看
|
||||
addon.check_update.preview=顯示 Alpha/Beta 版本
|
||||
addon.check_update.source=來源
|
||||
addon.check_update.target_version=目標版本
|
||||
@@ -379,6 +380,7 @@ curse.category.6555=原生
|
||||
datetime.format=yyyy 年 MM 月 dd 日 HH:mm:ss
|
||||
|
||||
download=下載
|
||||
download.china=根據相關法律法規,HMCL 在中國大陸地區暫不支援下載遊戲相關內容。請前往<a href="https://mc.163.com/">我的世界官方網站</a>下載我的世界中國版。
|
||||
download.code.404=遠端伺服器沒有需要下載的檔案:%s
|
||||
download.content=遊戲內容
|
||||
download.shader=光影
|
||||
@@ -805,7 +807,7 @@ message.question=確認
|
||||
modpack=模組包
|
||||
modpack.choose=選取要安裝的遊戲模組包檔案
|
||||
modpack.choose.local=匯入本機模組包檔案
|
||||
modpack.choose.local.detail=你可以直接將模組包檔案拖入本頁面以安裝
|
||||
modpack.choose.local.detail=你可以將整合包檔案拖到此處以安裝
|
||||
modpack.choose.remote=從網路下載模組包
|
||||
modpack.choose.remote.detail=需要提供模組包的下載連結
|
||||
modpack.choose.repository=從 CurseForge/Modrinth 下載模組包
|
||||
@@ -981,6 +983,7 @@ mods.add=新增模組
|
||||
mods.add.failed=新增模組「%s」失敗。
|
||||
mods.add.success=成功新增模組「%s」。
|
||||
mods.add.title=選取要新增的模組檔案
|
||||
mods.delete.failed=刪除模組失敗,模組檔案可能已被其他程式佔用。
|
||||
mods.disable=停用
|
||||
mods.download=下載模組
|
||||
mods.download.title=模組下載 - %1s
|
||||
@@ -994,6 +997,7 @@ mods.mcmod.page=MC 百科頁面
|
||||
mods.mcmod.search=MC 百科檢索
|
||||
mods.name=名稱
|
||||
mods.restore=回退
|
||||
mods.toggle.failed=無法啟用或停用模組,模組檔案可能已被其他程式佔用
|
||||
mods.url=官方頁面
|
||||
mods.update_modpack_mod.warning=更新模組包中的模組可能導致模組包損壞,使模組包無法正常啟動。該操作不可逆,確定要更新嗎?
|
||||
mods.warning.loader_mismatch=模組載入器不匹配
|
||||
@@ -1017,9 +1021,11 @@ nbt.title=查看檔案 - %s
|
||||
datapack=資料包
|
||||
datapack.add=新增資料包
|
||||
datapack.add.title=選取要新增的資料包壓縮檔
|
||||
datapack.delete.failed=刪除資料包失敗,資料包檔案可能已被其他程式佔用。
|
||||
datapack.empty=沒有資料包
|
||||
datapack.reload.toast=Minecraft 正在執行,請使用 /reload 指令重新載入資料包
|
||||
datapack.title=世界 [%s] - 資料包
|
||||
datapack.toggle.failed=無法啟用或停用資料包,資料包檔案可能已被其他程式佔用
|
||||
|
||||
web.failed=載入頁面失敗
|
||||
web.open_in_browser=是否要在瀏覽器中開啟此連結:\n%s
|
||||
@@ -1234,6 +1240,7 @@ settings.advanced.natives_directory=本機庫路徑 (LWJGL)
|
||||
settings.advanced.natives_directory.custom.enabled=使用自訂本機庫
|
||||
settings.advanced.no_jvm_args=不新增預設的 Java 虛擬機參數
|
||||
settings.advanced.no_optimizing_jvm_args=不自動新增 Java 虛擬機最佳化參數
|
||||
settings.advanced.not_use_javaw=不使用 javaw.exe
|
||||
settings.advanced.precall_command=遊戲啟動前執行指令
|
||||
settings.advanced.precall_command.prompt=將在遊戲啟動前呼叫使用
|
||||
settings.advanced.process_priority=處理程式優先度
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon;
|
||||
|
||||
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.ref.Reference;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/// Verifies project lookup, nullable registry selection and reclaimable indices.
|
||||
@NotNullByDefault
|
||||
class AddonProjectInfoRegistryTest {
|
||||
/// Creates an isolated registry backed by deterministic test data.
|
||||
private static AddonProjectInfoRegistry<ModProjectInfo> registry() {
|
||||
return new AddonProjectInfoRegistry<>("/addon/project-info-test.csv", 3) {
|
||||
@Override
|
||||
protected int getFieldsCount() {
|
||||
return 7;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModProjectInfo parseAddonProjectInfo(String[] fields) {
|
||||
return new ModProjectInfo(Integer.parseInt(fields[0]), fields[1], fields[2],
|
||||
fields[3], fields[4], fields[5], fields[6]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Unsupported types have no registry rather than an empty implementation.
|
||||
@Test
|
||||
void selectsOnlySupportedRegistries() {
|
||||
assertSame(AddonProjectInfoRegistry.MOD, AddonProjectInfoRegistry.getByAddonType(RemoteAddon.Type.MOD));
|
||||
assertSame(AddonProjectInfoRegistry.MODPACK, AddonProjectInfoRegistry.getByAddonType(RemoteAddon.Type.MODPACK));
|
||||
assertNull(AddonProjectInfoRegistry.getByAddonType(null));
|
||||
assertNull(AddonProjectInfoRegistry.getByAddonType(RemoteAddon.Type.RESOURCE_PACK));
|
||||
}
|
||||
|
||||
/// Name matches require a compatible ID; duplicate IDs keep the first entry.
|
||||
@Test
|
||||
void preservesLookupPrecedence() {
|
||||
var registry = registry();
|
||||
var first = registry.getInfos().get(0);
|
||||
var second = registry.getInfos().get(1);
|
||||
assertSame(first, registry.getInfoByCurseForgeId("shared"));
|
||||
assertSame(first, registry.getInfo("shared-id", null));
|
||||
assertSame(second, registry.getInfo(null, "Second_Name!"));
|
||||
assertSame(first, registry.getInfo("first", "Second Name"));
|
||||
assertNull(registry.getInfo("missing", "Second Name"));
|
||||
assertNull(registry.getInfo(null, null));
|
||||
assertNull(registry.getInfoByCurseForgeId(" "));
|
||||
}
|
||||
|
||||
/// Clearing each index reference rebuilds lookups without losing results.
|
||||
@Test
|
||||
void rebuildsClearedIndices() throws ReflectiveOperationException {
|
||||
var registry = registry();
|
||||
var first = registry.getInfos().get(0);
|
||||
assertSame(first, registry.getInfo("first", "First Name"));
|
||||
assertSame(first, registry.getInfo("first", null));
|
||||
assertSame(first, registry.getInfoByCurseForgeId("shared"));
|
||||
var matches = registry.search("First");
|
||||
for (String name : List.of("modIdMapRef", "subnameMapRef", "curseForgeMapRef", "keywordsRef")) {
|
||||
var field = AddonProjectInfoRegistry.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
@Nullable Reference<?> reference = (Reference<?>) field.get(registry);
|
||||
assertNotNull(reference);
|
||||
reference.clear();
|
||||
}
|
||||
assertSame(first, registry.getInfo("first", "First Name"));
|
||||
assertSame(first, registry.getInfo("first", null));
|
||||
assertSame(first, registry.getInfoByCurseForgeId("shared"));
|
||||
assertEquals(matches, registry.search("First"));
|
||||
}
|
||||
|
||||
/// UTF-8 names and escaped commas survive loading; empty searches return no matches.
|
||||
@Test
|
||||
void readsAndDisplaysNames() {
|
||||
var registry = registry();
|
||||
var third = registry.getInfos().get(2);
|
||||
assertEquals("\u673a\u68b0\u52a8\u529b", third.mainName());
|
||||
assertEquals("[EX] \u673a\u68b0\u52a8\u529b (Example, Name)", third.displayName());
|
||||
assertEquals(List.of(third), registry.search("\u673a\u68b0\u52a8\u529b"));
|
||||
assertTrue(registry.search(" ").isEmpty());
|
||||
assertThrows(UnsupportedOperationException.class, () -> registry.search("First").clear());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
1,shared,,First,First Name,FST,first;shared-id
|
||||
2,shared,,Second,Second Name,SND,second;shared-id
|
||||
3,example,,机械动力,Example\c Name, EX ,example
|
||||
|
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Range;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public abstract class AddonProjectInfo {
|
||||
|
||||
@Range(from = 0, to = Integer.MAX_VALUE)
|
||||
private final int mcmodId;
|
||||
private final String curseforgeId;
|
||||
private final String modrinthId;
|
||||
|
||||
private final String mainName;
|
||||
private final String subName;
|
||||
private final String abbrName;
|
||||
|
||||
/// This field is typically used to keep a reference to the `List<AddonProjectInfo>` that stores this object,
|
||||
/// so that the cached `List<AddonProjectInfo>` is not collected while the `AddonProjectInfo` object itself
|
||||
/// has not yet been collected, which could result in multiple identical `AddonProjectInfo` objects on the heap.
|
||||
@SuppressWarnings({"unused", "FieldCanBeLocal"})
|
||||
private @Nullable Object keepAlive;
|
||||
|
||||
protected AddonProjectInfo(
|
||||
int mcmodId, String curseforgeId, String modrinthId,
|
||||
String mainName, String subName, String abbrName) {
|
||||
if (mainName.isEmpty()) {
|
||||
throw new IllegalArgumentException("main name must not be empty");
|
||||
}
|
||||
|
||||
this.mcmodId = mcmodId;
|
||||
this.curseforgeId = curseforgeId;
|
||||
this.modrinthId = modrinthId;
|
||||
this.mainName = mainName;
|
||||
this.subName = subName;
|
||||
this.abbrName = abbrName;
|
||||
}
|
||||
|
||||
/// Returns the recorded CurseForge identifier, possibly empty.
|
||||
public String curseforgeId() {
|
||||
return curseforgeId;
|
||||
}
|
||||
|
||||
/// Returns the recorded primary name.
|
||||
public String mainName() {
|
||||
return mainName;
|
||||
}
|
||||
|
||||
/// Returns the recorded secondary name, possibly empty.
|
||||
public String subName() {
|
||||
return subName;
|
||||
}
|
||||
|
||||
/// Returns the recorded abbreviation, possibly empty.
|
||||
public String abbrName() {
|
||||
return abbrName;
|
||||
}
|
||||
|
||||
/// Returns the primary name with a nonblank abbreviation in brackets and a nonblank
|
||||
/// secondary name in parentheses. Only the abbreviation is trimmed.
|
||||
public String displayName() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (!abbrName.isBlank())
|
||||
builder.append('[').append(abbrName.trim()).append("] ");
|
||||
builder.append(mainName);
|
||||
if (!subName.isBlank())
|
||||
builder.append(" (").append(subName).append(')');
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public void setKeepAlive(@Nullable Object keepAlive) {
|
||||
this.keepAlive = keepAlive;
|
||||
}
|
||||
|
||||
protected abstract String mcmodClass();
|
||||
|
||||
protected abstract String curseforgeClass();
|
||||
|
||||
protected abstract String modrinthClass();
|
||||
|
||||
public @Nullable String mcmodUrl() {
|
||||
return mcmodId > 0
|
||||
? "https://www.mcmod.cn/" + mcmodClass() + "/" + mcmodId + ".html"
|
||||
: null;
|
||||
}
|
||||
|
||||
public @Nullable String curseforgeUrl() {
|
||||
return !curseforgeId.isEmpty()
|
||||
? "https://www.curseforge.com/minecraft/" + curseforgeClass() + "/" + curseforgeId
|
||||
: null;
|
||||
}
|
||||
|
||||
public @Nullable String modrinthUrl() {
|
||||
return !modrinthId.isEmpty()
|
||||
? "https://modrinth.com/" + modrinthClass() + "/" + modrinthId
|
||||
: null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -35,8 +35,8 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/// Sub-classes should implement `Comparable`
|
||||
public abstract class LocalAddonFile {
|
||||
/// @param <T> this type of addon
|
||||
public abstract class LocalAddonFile<T> implements Comparable<T> {
|
||||
|
||||
protected LocalAddonFile() {
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import java.util.concurrent.locks.ReentrantLock;
|
||||
/// copies. Callers obtain a manager from the current instance after a refresh or COW publish.
|
||||
///
|
||||
/// @param <T> the local addon file type managed by this manager
|
||||
public abstract class LocalAddonManager<T extends LocalAddonFile> {
|
||||
public abstract class LocalAddonManager<T extends LocalAddonFile<T>> {
|
||||
|
||||
/// File-name suffix used for disabled addon files.
|
||||
public static final String DISABLED_EXTENSION = ".disabled";
|
||||
|
||||
@@ -19,8 +19,8 @@ package org.jackhuang.hmcl.addon.datapack;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
@@ -246,7 +246,7 @@ public class DataPack {
|
||||
private Path path;
|
||||
private final boolean isDirectory;
|
||||
private Path statusFile;
|
||||
private final BooleanProperty activeProperty;
|
||||
private final ReadOnlyBooleanWrapper activeProperty;
|
||||
private final String id;
|
||||
private final LocalAddonFile.Description description;
|
||||
private final DataPack parentDataPack;
|
||||
@@ -259,7 +259,7 @@ public class DataPack {
|
||||
this.parentDataPack = parentDataPack;
|
||||
|
||||
this.statusFile = initializeStatusFile(path, isDirectory);
|
||||
this.activeProperty = initializeActiveProperty();
|
||||
this.activeProperty = new ReadOnlyBooleanWrapper(this, "active", !FileUtils.getExtension(this.statusFile).equals(DISABLED_EXT));
|
||||
}
|
||||
|
||||
private Path initializeStatusFile(Path path, boolean isDirectory) {
|
||||
@@ -270,42 +270,6 @@ public class DataPack {
|
||||
return path;
|
||||
}
|
||||
|
||||
private BooleanProperty initializeActiveProperty() {
|
||||
BooleanProperty property = new SimpleBooleanProperty(this, "active", !FileUtils.getExtension(this.statusFile).equals(DISABLED_EXT));
|
||||
property.addListener((obs, wasActive, isNowActive) -> {
|
||||
if (wasActive != isNowActive) {
|
||||
handleFileRename(isNowActive);
|
||||
}
|
||||
});
|
||||
return property;
|
||||
}
|
||||
|
||||
private void handleFileRename(boolean isNowActive) {
|
||||
Path newStatusFile = calculateNewStatusFilePath(isNowActive);
|
||||
if (statusFile.equals(newStatusFile)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.move(this.statusFile, newStatusFile);
|
||||
this.statusFile = newStatusFile;
|
||||
if (!this.isDirectory) {
|
||||
this.path = newStatusFile;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to rename file from " + this.statusFile + " to " + newStatusFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Path calculateNewStatusFilePath(boolean isActive) {
|
||||
boolean isFileDisabled = DISABLED_EXT.equals(FileUtils.getExtension(this.statusFile));
|
||||
if (isActive && isFileDisabled) {
|
||||
return this.statusFile.getParent().resolve(FileUtils.getNameWithoutExtension(this.statusFile));
|
||||
} else if (!isActive && !isFileDisabled) {
|
||||
return this.statusFile.getParent().resolve(FileUtils.getName(this.statusFile) + "." + DISABLED_EXT);
|
||||
}
|
||||
return this.statusFile;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -318,16 +282,28 @@ public class DataPack {
|
||||
return parentDataPack;
|
||||
}
|
||||
|
||||
public BooleanProperty activeProperty() {
|
||||
return activeProperty;
|
||||
public ReadOnlyBooleanProperty activeProperty() {
|
||||
return activeProperty.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return activeProperty.get();
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.activeProperty.set(active);
|
||||
public void setActive(boolean active) throws IOException {
|
||||
if (active == isActive())
|
||||
return;
|
||||
|
||||
Path newStatusFile = active
|
||||
? statusFile.getParent().resolve(FileUtils.getNameWithoutExtension(statusFile))
|
||||
: statusFile.getParent().resolve(FileUtils.getName(statusFile) + "." + DISABLED_EXT);
|
||||
|
||||
Files.move(statusFile, newStatusFile);
|
||||
statusFile = newStatusFile;
|
||||
if (!isDirectory)
|
||||
path = newStatusFile;
|
||||
|
||||
activeProperty.set(active);
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon.mod;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||
import org.jackhuang.hmcl.addon.AddonLoader;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonManager;
|
||||
@@ -31,13 +31,11 @@ import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
public final class LocalModFile extends LocalAddonFile implements Comparable<LocalModFile> {
|
||||
public final class LocalModFile extends LocalAddonFile<LocalModFile> {
|
||||
|
||||
private Path file;
|
||||
private final ModManager modManager;
|
||||
@@ -50,7 +48,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
|
||||
private final String url;
|
||||
private final String fileName;
|
||||
private final String logoPath;
|
||||
private final BooleanProperty activeProperty;
|
||||
private final ReadOnlyBooleanWrapper activeProperty;
|
||||
|
||||
public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description) {
|
||||
this(modManager, mod, file, name, description, "", "", "", "", "");
|
||||
@@ -69,23 +67,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
|
||||
this.url = url;
|
||||
this.logoPath = logoPath;
|
||||
|
||||
activeProperty = new SimpleBooleanProperty(this, "active", !modManager.isDisabled(file)) {
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
if (isOld()) return;
|
||||
|
||||
Path path = LocalModFile.this.file.toAbsolutePath();
|
||||
|
||||
try {
|
||||
if (get())
|
||||
LocalModFile.this.file = modManager.enableMod(path);
|
||||
else
|
||||
LocalModFile.this.file = modManager.disableMod(path);
|
||||
} catch (IOException e) {
|
||||
LOG.error("Unable to invert state of mod file " + path, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
activeProperty = new ReadOnlyBooleanWrapper(this, "active", !modManager.isDisabled(file));
|
||||
|
||||
fileName = FileUtils.getNameWithoutExtension(LocalAddonManager.getLocalAddonName(file));
|
||||
|
||||
@@ -145,15 +127,21 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
|
||||
return logoPath;
|
||||
}
|
||||
|
||||
public BooleanProperty activeProperty() {
|
||||
return activeProperty;
|
||||
public ReadOnlyBooleanProperty activeProperty() {
|
||||
return activeProperty.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return activeProperty.get();
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
public void setActive(boolean active) throws IOException {
|
||||
if (isOld() || active == isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path path = file.toAbsolutePath();
|
||||
file = active ? modManager.enableMod(path) : modManager.disableMod(path);
|
||||
activeProperty.set(active);
|
||||
}
|
||||
|
||||
|
||||
@@ -289,12 +289,6 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
}
|
||||
}
|
||||
|
||||
public void removeMods(LocalModFile... localModFiles) throws IOException {
|
||||
for (LocalModFile localModFile : localModFiles) {
|
||||
localModFile.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSupportedLoaders() {
|
||||
supportedLoaders.clear();
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon.mod;
|
||||
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class ModProjectInfo extends AddonProjectInfo {
|
||||
|
||||
private final @Nullable Object modIds;
|
||||
|
||||
public ModProjectInfo(
|
||||
int mcmodId, String curseforgeId, String modrinthId,
|
||||
String mainName, String subName, String abbrName,
|
||||
String modIds) {
|
||||
super(mcmodId, curseforgeId, modrinthId, mainName, subName, abbrName);
|
||||
|
||||
if (modIds.isEmpty()) {
|
||||
this.modIds = null;
|
||||
} else if (modIds.indexOf(';') < 0) {
|
||||
this.modIds = modIds;
|
||||
} else {
|
||||
this.modIds = List.<String>of(modIds.split(";"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable list of recorded mod IDs, or an empty list if none are recorded.
|
||||
@SuppressWarnings("unchecked")
|
||||
public @Unmodifiable List<String> getModIds() {
|
||||
if (modIds == null)
|
||||
return List.of();
|
||||
if (modIds instanceof String id)
|
||||
return List.of(id);
|
||||
return (List<String>) modIds;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String mcmodClass() {
|
||||
return "class";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String curseforgeClass() {
|
||||
return "mc-mods";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String modrinthClass() {
|
||||
return "mod";
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public sealed abstract class ResourcePackFile extends LocalAddonFile implements Comparable<ResourcePackFile> permits ResourcePackFolder, ResourcePackZipFile {
|
||||
public sealed abstract class ResourcePackFile extends LocalAddonFile<ResourcePackFile> permits ResourcePackFolder, ResourcePackZipFile {
|
||||
static ResourcePackFile fromFile(ResourcePackManager manager, Path path) throws IOException {
|
||||
if (isFileResourcePack(path)) {
|
||||
return Files.isRegularFile(path) ? new ResourcePackZipFile(manager, path) : new ResourcePackFolder(manager, path);
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
@NotNullByDefault
|
||||
public record AddonUpdate(
|
||||
LocalAddonFile localAddonFile,
|
||||
LocalAddonFile<?> localAddonFile,
|
||||
RemoteAddon.Version currentVersion,
|
||||
RemoteAddon.Version targetVersion
|
||||
) {
|
||||
|
||||
@@ -20,9 +20,11 @@ package org.jackhuang.hmcl.auth.microsoft;
|
||||
import com.google.gson.JsonObject;
|
||||
import javafx.beans.binding.ObjectBinding;
|
||||
import org.jackhuang.hmcl.auth.*;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
|
||||
import java.nio.file.Path;
|
||||
@@ -37,7 +39,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
public final class MicrosoftAccount extends OAuthAccount {
|
||||
|
||||
protected final MicrosoftService service;
|
||||
protected UUID profileID;
|
||||
protected final UUID profileID;
|
||||
|
||||
private boolean authenticated = false;
|
||||
private MicrosoftSession session;
|
||||
@@ -47,6 +49,8 @@ public final class MicrosoftAccount extends OAuthAccount {
|
||||
this.service = requireNonNull(service);
|
||||
this.session = requireNonNull(session);
|
||||
this.profileID = requireNonNull(session.profile().id());
|
||||
|
||||
addProfilePropertiesListener();
|
||||
}
|
||||
|
||||
protected MicrosoftAccount(MicrosoftService service, OAuth.GrantFlow flow) throws AuthenticationException {
|
||||
@@ -62,6 +66,18 @@ public final class MicrosoftAccount extends OAuthAccount {
|
||||
|
||||
profileID = session.profile().id();
|
||||
authenticated = true;
|
||||
|
||||
addProfilePropertiesListener();
|
||||
}
|
||||
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private ObjectBinding<Optional<CompleteGameProfile>> profilePropertiesBinding;
|
||||
private void addProfilePropertiesListener() {
|
||||
// binding() is thread-safe
|
||||
// hold the binding so that it won't be garbage-collected
|
||||
profilePropertiesBinding = service.getProfileRepository().binding(profileID, true);
|
||||
// and it's safe to add a listener to an ObjectBinding which does not have any listener attached before (maybe tricky)
|
||||
profilePropertiesBinding.addListener((a, b, c) -> this.invalidate());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -146,6 +162,8 @@ public final class MicrosoftAccount extends OAuthAccount {
|
||||
public void writePrivateData(JsonObject privateData) {
|
||||
super.writePrivateData(privateData);
|
||||
session.writePrivateData(privateData);
|
||||
service.getProfileRepository().getImmediately(profileID).ifPresent(profile ->
|
||||
privateData.add("profileProperties", JsonUtils.GSON.toJsonTree(profile.getProperties())));
|
||||
}
|
||||
|
||||
public MicrosoftService getService() {
|
||||
|
||||
@@ -24,7 +24,11 @@ import org.jackhuang.hmcl.auth.AccountID;
|
||||
import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||
import org.jackhuang.hmcl.auth.CharacterSelector;
|
||||
import org.jackhuang.hmcl.auth.OAuth;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MicrosoftAccountFactory extends AccountFactory<MicrosoftAccount> {
|
||||
@@ -51,6 +55,17 @@ public class MicrosoftAccountFactory extends AccountFactory<MicrosoftAccount> {
|
||||
Objects.requireNonNull(privateData);
|
||||
AccountID accountID = Account.readAccountID(metadata);
|
||||
MicrosoftSession session = MicrosoftSession.fromStorage(metadata, privateData);
|
||||
|
||||
if (privateData.get("profileProperties") instanceof JsonObject profilePropertiesObject) {
|
||||
Map<String, String> properties = JsonUtils.GSON.fromJson(
|
||||
profilePropertiesObject,
|
||||
JsonUtils.mapTypeOf(String.class, String.class));
|
||||
MicrosoftSession.GameProfile profile = session.profile();
|
||||
var profileRepository = service.getProfileRepository();
|
||||
profileRepository.put(profile.id(), new CompleteGameProfile(new GameProfile(profile.id(), profile.name()), properties));
|
||||
profileRepository.invalidate(profile.id());
|
||||
}
|
||||
|
||||
return new MicrosoftAccount(accountID, service, session);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public class LaunchOptions implements Serializable {
|
||||
private GameInstanceID instanceId;
|
||||
private Path gameDir;
|
||||
private JavaRuntime java;
|
||||
private Path javaExecutable;
|
||||
private String versionName;
|
||||
private String versionType;
|
||||
private String profileName;
|
||||
@@ -85,6 +86,16 @@ public class LaunchOptions implements Serializable {
|
||||
return java;
|
||||
}
|
||||
|
||||
/// Returns the executable selected for launch, falling back to [JavaRuntime#getBinary()] if none is specified.
|
||||
/// The path is used without automatic executable substitution or file validation.
|
||||
public Path getJavaExecutable() {
|
||||
if (javaExecutable != null)
|
||||
return javaExecutable;
|
||||
if (java != null)
|
||||
return java.getBinary();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will shown in the left bottom corner of the main menu of Minecraft.
|
||||
* null if use the id of launch version.
|
||||
@@ -336,6 +347,11 @@ public class LaunchOptions implements Serializable {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setJavaExecutable(Path javaExecutable) {
|
||||
options.javaExecutable = javaExecutable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setVersionName(String versionName) {
|
||||
options.versionName = versionName;
|
||||
return this;
|
||||
|
||||
@@ -98,7 +98,7 @@ public class DefaultLauncher extends Launcher {
|
||||
if (StringUtils.isNotBlank(options.getWrapper()))
|
||||
res.addAllWithoutParsing(StringUtils.tokenize(options.getWrapper(), getEnvVars(nativeFolder)));
|
||||
|
||||
res.add(options.getJava().getBinary().toString());
|
||||
res.add(options.getJavaExecutable().toString());
|
||||
|
||||
res.addAllWithoutParsingAndReadExternal(options.getOverrideJavaArguments());
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.modpack;
|
||||
|
||||
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class ModpackProjectInfo extends AddonProjectInfo {
|
||||
public ModpackProjectInfo(
|
||||
int mcmodId, String curseforgeId, String modrinthId,
|
||||
String mainName, String subName, String abbrName
|
||||
) {
|
||||
super(mcmodId, curseforgeId, modrinthId, mainName, subName, abbrName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String mcmodClass() {
|
||||
return "modpack";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String curseforgeClass() {
|
||||
return "modpacks";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String modrinthClass() {
|
||||
return "modpack";
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,6 @@
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
@@ -31,9 +27,6 @@ import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
@@ -48,29 +41,6 @@ public final class LocaleUtils {
|
||||
|
||||
public static final Locale SYSTEM_DEFAULT = Locale.getDefault();
|
||||
|
||||
public static final boolean IS_CHINA_MAINLAND = isChinaMainland();
|
||||
|
||||
private static boolean isChinaMainland() {
|
||||
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
|
||||
return true;
|
||||
|
||||
// Check if the time zone is UTC+8
|
||||
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
|
||||
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
|
||||
return true;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
|
||||
Kernel32 kernel32 = Kernel32.INSTANCE;
|
||||
|
||||
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
|
||||
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final Locale LOCALE_ZH_HANS = Locale.forLanguageTag("zh-Hans");
|
||||
public static final Locale LOCALE_ZH_HANT = Locale.forLanguageTag("zh-Hant");
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies {
|
||||
implementation(libs.kala.compress.ar)
|
||||
implementation(libs.weburl)
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.icu4j)
|
||||
implementation("org.apache.maven:maven-artifact:3.9.16")
|
||||
|
||||
testImplementation(libs.junit.jupiter)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.gradle.addon;
|
||||
|
||||
import com.google.gson.*;
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
import org.gradle.api.tasks.UntrackedTask;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/// Rewrites a JSON source file in its canonical compact representation.
|
||||
@NotNullByDefault
|
||||
@UntrackedTask(because = "Formats a JSON source file in place")
|
||||
public abstract class FormatJsonFile extends DefaultTask {
|
||||
|
||||
/// Returns the JSON source file to rewrite.
|
||||
@InputFile
|
||||
public abstract RegularFileProperty getFile();
|
||||
|
||||
/// Parses and rewrites the configured JSON file if its representation changes.
|
||||
///
|
||||
/// @throws IOException if the file cannot be read or written
|
||||
/// @throws JsonParseException if the file does not contain valid JSON
|
||||
@TaskAction
|
||||
public void run() throws IOException {
|
||||
Path path = getFile().get().getAsFile().toPath();
|
||||
String original = Files.readString(path);
|
||||
|
||||
JsonElement json = JsonParser.parseString(original);
|
||||
String formatted = new GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.create()
|
||||
.toJson(json);
|
||||
|
||||
if (!formatted.equals(original)) {
|
||||
Files.writeString(path, formatted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
/*
|
||||
* 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.gradle.addon;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.ibm.icu.lang.UCharacter;
|
||||
import com.ibm.icu.lang.UCharacterCategory;
|
||||
import com.ibm.icu.util.ULocale;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.logging.Logger;
|
||||
import org.gradle.api.logging.Logging;
|
||||
import org.gradle.api.provider.Property;
|
||||
import org.gradle.api.tasks.Input;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.OutputFile;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/// Converts mcmod.cn JSON exports into UTF-8 metadata resources for the launcher's addon project registries.
|
||||
@NotNullByDefault
|
||||
public abstract class UpdateAddonProjectInfos extends DefaultTask {
|
||||
|
||||
/// Reports records and optional metadata that cannot be converted.
|
||||
private static final Logger LOGGER = Logging.getLogger(UpdateAddonProjectInfos.class);
|
||||
|
||||
/// Returns the input file property for a UTF-8 JSON array of project records.
|
||||
@InputFile
|
||||
public abstract RegularFileProperty getInputFile();
|
||||
|
||||
/// Returns the output file property. Existing contents are replaced during execution.
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getOutputFile();
|
||||
|
||||
/// Returns the type of projects to export.
|
||||
@Input
|
||||
public abstract Property<AddonProjectType> getProjectType();
|
||||
|
||||
/// Reads the export and writes the selected project type's metadata.
|
||||
/// Invalid records are skipped where possible, and invalid optional metadata is omitted.
|
||||
/// A failure may leave a partially written output file.
|
||||
///
|
||||
/// @throws Exception if the export cannot be read, converted or written
|
||||
@TaskAction
|
||||
public void run() throws Exception {
|
||||
Path inputFile = getInputFile().getAsFile().get().toPath();
|
||||
Path outputFile = getOutputFile().getAsFile().get().toPath();
|
||||
|
||||
AddonProjectType projectType = getProjectType().get();
|
||||
|
||||
List<AddonInfo> infos;
|
||||
try (var reader = Files.newBufferedReader(inputFile)) {
|
||||
Gson gson = new Gson();
|
||||
infos = gson.fromJson(reader, TypeToken.getParameterized(List.class, AddonInfo.class).getType());
|
||||
}
|
||||
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
try (var writer = new CSVWriter(Files.newBufferedWriter(outputFile))) {
|
||||
writer.writeComment("""
|
||||
|
||||
Hello Minecraft! Launcher
|
||||
Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
|
||||
mcmod.cn
|
||||
Copyright (C) 2026. All Rights Reserved.
|
||||
|
||||
""");
|
||||
|
||||
Map<String, TreeSet<Integer>> modIdTomcmodId = new HashMap<>();
|
||||
|
||||
for (AddonInfo info : infos) {
|
||||
int mcmodId;
|
||||
|
||||
try {
|
||||
mcmodId = projectType.getMcmodId(info);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to get the id of mcmod.cn for \"{}\"", info.name.main);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (projectType.shouldSkip(mcmodId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable String curseforgeId;
|
||||
try {
|
||||
curseforgeId = projectType.getCurseForgeId(info);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to get the id of CurseForge for \"{}\"", info.name.main);
|
||||
curseforgeId = null;
|
||||
}
|
||||
|
||||
@Nullable String modrinthId;
|
||||
try {
|
||||
modrinthId = projectType.getModrinthId(info);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to get the id of Modrinth for \"{}\"", info.name.main);
|
||||
modrinthId = null;
|
||||
}
|
||||
|
||||
String mainName = cleanChineseName(info.name.main);
|
||||
String subName = cleanName(info.name.sub);
|
||||
String abbr = cleanName(info.name.abbr);
|
||||
|
||||
if (mainName.isEmpty()) {
|
||||
LOGGER.warn("Mod \"{}\" has empty main name", info.name.main);
|
||||
continue;
|
||||
}
|
||||
|
||||
String modids;
|
||||
if (projectType == AddonProjectType.MOD) {
|
||||
try {
|
||||
Collection<String> idList = cleanModIds(info, mcmodId);
|
||||
|
||||
for (String modid : idList) {
|
||||
modIdTomcmodId.computeIfAbsent(modid, k -> new TreeSet<>()).add(mcmodId);
|
||||
}
|
||||
|
||||
modids = String.join(";", idList);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Mod \"{}\" has invalid modid list: {}", info.name.main, info.modid);
|
||||
modids = "";
|
||||
}
|
||||
} else {
|
||||
modids = null;
|
||||
}
|
||||
|
||||
// Keep this field order aligned with the runtime project registry parser.
|
||||
writer.writeField(String.valueOf(mcmodId));
|
||||
writer.writeField(curseforgeId);
|
||||
writer.writeField(modrinthId);
|
||||
writer.writeField(mainName);
|
||||
writer.writeField(subName);
|
||||
writer.writeField(abbr);
|
||||
|
||||
if (projectType == AddonProjectType.MOD) {
|
||||
writer.writeField(modids);
|
||||
}
|
||||
|
||||
writer.writeNewLine();
|
||||
}
|
||||
|
||||
modIdTomcmodId.forEach((modid, mcmodIds) -> {
|
||||
if (mcmodIds.size() > 1) {
|
||||
LOGGER.info("Mod id \"{}\" is used by multiple mods: {}", modid, mcmodIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes comma-delimited records using backslash escapes instead of quoted CSV fields.
|
||||
/// Closing this writer closes its underlying writer.
|
||||
private static final class CSVWriter implements Closeable {
|
||||
/// Whether the next field omits the leading comma.
|
||||
private boolean firstField = true;
|
||||
/// Destination owned by this writer and closed by [#close()].
|
||||
private final BufferedWriter writer;
|
||||
|
||||
/// Creates a record writer that takes ownership of the supplied destination.
|
||||
private CSVWriter(BufferedWriter writer) {
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
/// Closes the underlying writer, flushing its buffered output.
|
||||
///
|
||||
/// @throws IOException if flushing or closing the destination fails
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
writer.close();
|
||||
}
|
||||
|
||||
/// Writes comment lines prefixed with `# `. Must be called at a record boundary.
|
||||
///
|
||||
/// @throws IOException if writing to the destination fails
|
||||
public void writeComment(String comment) throws IOException {
|
||||
for (String line : comment.lines().toList()) {
|
||||
writer.write("# ");
|
||||
writer.write(line);
|
||||
writer.write('\n');
|
||||
}
|
||||
firstField = true;
|
||||
}
|
||||
|
||||
/// Appends a backslash-escaped field with a delimiter when needed.
|
||||
/// Null values produce empty fields. The record is not terminated.
|
||||
///
|
||||
/// @throws IOException if writing to the destination fails
|
||||
public void writeField(@Nullable String field) throws IOException {
|
||||
if (firstField) {
|
||||
firstField = false;
|
||||
} else {
|
||||
writer.write(',');
|
||||
}
|
||||
|
||||
if (field == null || field.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < field.length(); ) {
|
||||
int ch = field.codePointAt(i);
|
||||
|
||||
switch (ch) {
|
||||
case '\0' -> writer.write("\\0");
|
||||
case '\\' -> writer.write("\\\\");
|
||||
case '\n' -> writer.write("\\n");
|
||||
case '\r' -> writer.write("\\r");
|
||||
case '\t' -> writer.write("\\t");
|
||||
case ',' -> writer.write("\\c");
|
||||
default -> {
|
||||
|
||||
boolean needEscape = switch (UCharacter.getType(ch)) {
|
||||
case UCharacterCategory.CONTROL,
|
||||
UCharacterCategory.FORMAT,
|
||||
UCharacterCategory.UNASSIGNED,
|
||||
UCharacterCategory.SURROGATE,
|
||||
UCharacterCategory.PRIVATE_USE,
|
||||
UCharacterCategory.LINE_SEPARATOR,
|
||||
UCharacterCategory.PARAGRAPH_SEPARATOR -> true;
|
||||
default -> false;
|
||||
};
|
||||
|
||||
if (Character.isBmpCodePoint(ch)) {
|
||||
if (needEscape) {
|
||||
writer.write(String.format("\\u%04x", ch));
|
||||
} else {
|
||||
writer.write(ch);
|
||||
}
|
||||
} else {
|
||||
char ch0 = Character.highSurrogate(ch);
|
||||
char ch1 = Character.lowSurrogate(ch);
|
||||
if (needEscape) {
|
||||
// The reader decodes fixed-width UTF-16 escapes, so preserve both surrogate units.
|
||||
writer.write(String.format("\\u%04x\\u%04x", (int) ch0, (int) ch1));
|
||||
} else {
|
||||
writer.write(ch0);
|
||||
writer.write(ch1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
i += Character.charCount(ch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminates the current record with LF and starts a new record.
|
||||
///
|
||||
/// @throws IOException if writing the separator fails
|
||||
public void writeNewLine() throws IOException {
|
||||
writer.write('\n');
|
||||
firstField = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans an exported name by trimming, removing zero-width spaces and decoding selected HTML entities.
|
||||
private static String cleanName(String name) {
|
||||
// Remove zero-width space (U+200B) characters.
|
||||
name = name.replace("\u200b", "");
|
||||
|
||||
// Remove zero-width non-joiner (U+200C) characters.
|
||||
name = name.replace("\u200c", "");
|
||||
|
||||
// Remove soft hyphen (U+00AD) characters.
|
||||
// TODO: Temporary workaround for https://www.mcmod.cn/class/3084.html
|
||||
name = name.replace("\u00ad", "");
|
||||
|
||||
// Remove left-to-right mark (U+200E) and right-to-left mark (U+200F) characters.
|
||||
// TODO: Temporary workaround for https://www.mcmod.cn/class/7550.html
|
||||
name = name
|
||||
.replace("\u200e", "")
|
||||
.replace("\u200f", "");
|
||||
|
||||
if (name.indexOf('&') >= 0) {
|
||||
// Most string values in the original JSON data contain HTML-escaped characters, such as `&`, `<`, and `>`.
|
||||
name = name
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
}
|
||||
|
||||
name = name.trim();
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/// Cleans a primary name and normalizes fullwidth parentheses and their spacing.
|
||||
private static String cleanChineseName(String chineseName) {
|
||||
chineseName = cleanName(chineseName);
|
||||
|
||||
if (chineseName.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder(chineseName.length());
|
||||
int[] codePoints = chineseName.codePoints().toArray();
|
||||
for (int i = 0; i < codePoints.length; i++) {
|
||||
int ch = codePoints[i];
|
||||
int prev = i > 0 ? codePoints[i - 1] : 0;
|
||||
|
||||
switch (ch) {
|
||||
case '(' -> {
|
||||
if (Character.isWhitespace(prev) || prev == '!' || prev == '。')
|
||||
builder.append('(');
|
||||
else
|
||||
builder.append(" (");
|
||||
}
|
||||
case ')' -> builder.append(')');
|
||||
default -> builder.appendCodePoint(ch);
|
||||
}
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
/// Recognizes accepted mod ID syntax.
|
||||
private static final Pattern MODID_PATTERN = Pattern.compile("^[a-zA-Z0-9._|+' -]+$");
|
||||
/// Explicitly accepted IDs that do not match [#MODID_PATTERN].
|
||||
private static final Set<String> MODID_WHITELIST = Set.of(
|
||||
// https://www.mcmod.cn/class/866.html
|
||||
"<CoFH Tweak>",
|
||||
// https://www.mcmod.cn/class/1780.html
|
||||
"EFFLL (LiteLoader ObjectHolder fix)"
|
||||
);
|
||||
|
||||
/// Returns cleaned, validated mod IDs without duplicates, preserving their original order.
|
||||
///
|
||||
/// @throws GradleException if the mod ID list is null or a cleaned ID is not accepted
|
||||
private static Collection<String> cleanModIds(AddonInfo info, int mcmodId) {
|
||||
if (info.modid() == null) {
|
||||
throw new GradleException("Modid list is null");
|
||||
}
|
||||
|
||||
var result = new LinkedHashSet<String>(info.modid.size());
|
||||
|
||||
for (String modid : info.modid) {
|
||||
String cleanedModid = cleanName(modid.trim());
|
||||
|
||||
// TODO: Temporary workaround, remove this after the modid is fixed
|
||||
cleanedModid = switch (cleanedModid) {
|
||||
// replace non-breaking hyphen (U+2011) with normal hyphen
|
||||
// https://www.mcmod.cn/class/31211.html
|
||||
case "godfall‑trinkets" -> "godfall-trinkets";
|
||||
// https://www.mcmod.cn/class/30575.html
|
||||
case "enchantment-table\"" -> "enchantment-table";
|
||||
// https://www.mcmod.cn/class/28657.html
|
||||
case "斗蛐蛐" -> "mob_battle_plus";
|
||||
// https://www.mcmod.cn/class/4158.html
|
||||
case "morecuriostotemsofundying:" -> "mtca";
|
||||
// https://www.mcmod.cn/class/12820.html
|
||||
case "caves_&_canyons" -> "caves_canyons";
|
||||
// https://www.mcmod.cn/class/30957.html
|
||||
case "minecraft:" -> "moderncontentbackport";
|
||||
default -> cleanedModid;
|
||||
};
|
||||
|
||||
// https://www.mcmod.cn/class/31322.html
|
||||
if (cleanedModid.equals("minecraft")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// https://www.mcmod.cn/class/4.html
|
||||
if (mcmodId == 4 && cleanedModid.equals("kamenridercraft4th")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// https://www.mcmod.cn/class/15744.html
|
||||
if (mcmodId == 15744 && cleanedModid.equals("Fabric API")) {
|
||||
cleanedModid = "impropers3dminimap";
|
||||
}
|
||||
|
||||
// https://www.mcmod.cn/class/26546.html
|
||||
if (mcmodId == 26546 && cleanedModid.equals("swem")) {
|
||||
cleanedModid = "sweconm";
|
||||
}
|
||||
|
||||
if (!MODID_PATTERN.matcher(cleanedModid).matches() && !MODID_WHITELIST.contains(cleanedModid)) {
|
||||
throw new GradleException("Invalid modid: " + modid);
|
||||
}
|
||||
|
||||
result.add(cleanedModid);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Selects project-specific link parsing, exclusion rules and output fields.
|
||||
public enum AddonProjectType {
|
||||
/// Mod projects, excluding Minecraft itself.
|
||||
MOD("class", "mc-mods", "mod") {
|
||||
/// Returns whether the ID identifies Minecraft rather than a mod.
|
||||
@Override
|
||||
public boolean shouldSkip(int mcmodId) {
|
||||
if (mcmodId == 1) {
|
||||
// Should skip the "Minecraft" project, which is the Minecraft game itself, not a mod.
|
||||
// https://www.mcmod.cn/class/1.html
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
/// Modpack projects.
|
||||
MODPACK("modpack", "modpacks", "modpack");
|
||||
|
||||
/// Recognizes project URLs on mcmod.cn.
|
||||
private final Pattern mcmodUrlPattern;
|
||||
/// CurseForge category for this project type.
|
||||
private final String curseforgeType;
|
||||
|
||||
/// Modrinth category for this project type.
|
||||
private final String modrinthType;
|
||||
|
||||
/// Creates a project type for the specified mcmod.cn path and hosting-platform categories.
|
||||
///
|
||||
/// @param mcmodSlug mcmod.cn path segment used in the canonical URL pattern
|
||||
/// @param curseforgeType CurseForge category accepted during slug extraction
|
||||
/// @param modrinthType Modrinth category accepted during identifier extraction
|
||||
AddonProjectType(
|
||||
String mcmodSlug,
|
||||
String curseforgeType,
|
||||
String modrinthType
|
||||
) {
|
||||
this.mcmodUrlPattern = Pattern.compile("https://www\\.mcmod\\.cn/" + mcmodSlug + "/(?<id>\\d+)\\.html");
|
||||
this.curseforgeType = curseforgeType;
|
||||
this.modrinthType = modrinthType;
|
||||
}
|
||||
|
||||
/// Extracts the project ID from the first mcmod.cn link.
|
||||
///
|
||||
/// @throws GradleException if the URL is not valid for this project type
|
||||
int getMcmodId(AddonInfo info) {
|
||||
String url = info.links.list.get("mcmod").get(0).url();
|
||||
var matcher = mcmodUrlPattern.matcher(url);
|
||||
if (!matcher.matches()) {
|
||||
throw new GradleException("Invalid mcmod.cn URL: " + url);
|
||||
}
|
||||
|
||||
return Integer.parseInt(matcher.group("id"));
|
||||
}
|
||||
|
||||
/// Recognizes CurseForge project paths.
|
||||
private static final Pattern CURSEFORGE_PATH_PATTERNS = Pattern.compile(
|
||||
"^/(?<game>[a-zA-Z-]+)/(?<type>[a-zA-Z0-9-]+)/+(?<id>[\\w-]+)(/(.*?))?$"
|
||||
);
|
||||
|
||||
|
||||
/// Returns a CurseForge project slug for Minecraft and this project type,
|
||||
/// or null if no applicable link is present. No network requests are made.
|
||||
///
|
||||
/// @throws Exception if no suitable link is found and at least one link is invalid
|
||||
@Nullable String getCurseForgeId(AddonInfo info) throws Exception {
|
||||
var links = info.links.list.get("curseforge");
|
||||
|
||||
if (links == null || links.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Exception cause = null;
|
||||
|
||||
for (AddonInfo.Link link : links) {
|
||||
try {
|
||||
WebURL url = WebURL.parse(link.url());
|
||||
|
||||
if (!url.getScheme().equals("https") && !url.getScheme().equals("http")) {
|
||||
throw new GradleException("Invalid CurseForge URL: " + url);
|
||||
}
|
||||
|
||||
switch (Objects.requireNonNull(url.getHost(), "host")) {
|
||||
case "files.xmdhs.com",
|
||||
"edge.forgecdn.net",
|
||||
"minecraft.curseforge.com",
|
||||
"mediafilez.forgecdn.net",
|
||||
// Deprecated domain names
|
||||
"www.curse.com",
|
||||
"mods.curse.com" -> {
|
||||
continue;
|
||||
}
|
||||
case "curseforge.com",
|
||||
"www.curseforge.com",
|
||||
"legacy.curseforge.com",
|
||||
"beta.curseforge.com" -> {
|
||||
String path = url.getPath();
|
||||
|
||||
var matcher = CURSEFORGE_PATH_PATTERNS.matcher(path);
|
||||
if (!matcher.matches()) {
|
||||
throw new GradleException("Invalid CurseForge URL: " + url);
|
||||
}
|
||||
|
||||
String game = matcher.group("game");
|
||||
if (!game.equalsIgnoreCase("minecraft")) {
|
||||
// Ignore minecraft-bedrock and other non-minecraft projects
|
||||
continue;
|
||||
}
|
||||
|
||||
String type = matcher.group("type");
|
||||
if (type.equals(curseforgeType)) {
|
||||
return matcher.group("id");
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
default -> throw new GradleException("Invalid CurseForge URL: " + url);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (cause == null) {
|
||||
cause = e;
|
||||
} else {
|
||||
cause.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cause != null)
|
||||
throw cause;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Recognizes Modrinth project paths.
|
||||
private static final Pattern MODRINTH_PATH_PATTERNS = Pattern.compile(
|
||||
"^/(?<type>[a-zA-Z0-9-]+)/(?<id>[^/]+)(/(.*?))?$"
|
||||
);
|
||||
|
||||
/// Returns a Modrinth project identifier from a link for this project type,
|
||||
/// or null if no applicable link is present. The identifier may be a slug or project ID.
|
||||
/// No network requests are made.
|
||||
///
|
||||
/// @throws Exception if no suitable link is found and at least one link is invalid
|
||||
@Nullable String getModrinthId(AddonInfo info) throws Exception {
|
||||
var links = info.links.list.get("modrinth");
|
||||
|
||||
if (links == null || links.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Exception cause = null;
|
||||
|
||||
for (AddonInfo.Link link : links) {
|
||||
try {
|
||||
WebURL url = WebURL.parse(link.url());
|
||||
|
||||
if (!url.getScheme().equals("https") && !url.getScheme().equals("http")) {
|
||||
throw new GradleException("Invalid Modrinth URL: " + url);
|
||||
}
|
||||
|
||||
switch (Objects.requireNonNull(url.getHost(), "host")) {
|
||||
case "github.com",
|
||||
"cdn.modrinth.com" -> {
|
||||
continue;
|
||||
}
|
||||
case "modrinth.com",
|
||||
"www.modrinth.com" -> {
|
||||
String path = url.getPath();
|
||||
|
||||
var matcher = MODRINTH_PATH_PATTERNS.matcher(path);
|
||||
if (!matcher.matches()) {
|
||||
throw new GradleException("Invalid Modrinth URL: " + url);
|
||||
}
|
||||
|
||||
String type = matcher.group("type");
|
||||
if (!type.equals(modrinthType) && !(this == MOD && type.equals("project"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return matcher.group("id");
|
||||
}
|
||||
default -> throw new GradleException("Invalid Modrinth URL: " + url);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (cause == null) {
|
||||
cause = e;
|
||||
} else {
|
||||
cause.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cause != null)
|
||||
throw cause;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns whether the project identified by the mcmod.cn ID is excluded from output.
|
||||
///
|
||||
/// @implSpec The default implementation returns false.
|
||||
public boolean shouldSkip(int mcmodId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents one project in the JSON export.
|
||||
///
|
||||
/// @param name primary, secondary and abbreviated project names
|
||||
/// @param modid optional mod IDs decoded from an array or a wrapper object's `list` field
|
||||
/// @param links links grouped by source name
|
||||
private record AddonInfo(
|
||||
AddonInfo.Name name,
|
||||
@JsonAdapter(AddonInfo.ModIdDeserializer.class)
|
||||
@Nullable List<String> modid,
|
||||
Links links
|
||||
) {
|
||||
/// Contains the exported project names before cleanup.
|
||||
///
|
||||
/// @param main primary name
|
||||
/// @param sub secondary name
|
||||
/// @param abbr abbreviation
|
||||
record Name(String main, String sub, String abbr) {
|
||||
}
|
||||
|
||||
/// Contains a source URL and its accompanying text.
|
||||
///
|
||||
/// @param url exported URL, not yet validated
|
||||
/// @param content accompanying text, unused by metadata generation
|
||||
record Link(String url, String content) {
|
||||
}
|
||||
|
||||
/// Groups exported links by source.
|
||||
///
|
||||
/// @param list source names mapped to link lists in export order
|
||||
record Links(@Unmodifiable Map<String, @Unmodifiable List<Link>> list) {
|
||||
}
|
||||
|
||||
/// Decodes mod IDs from either a JSON array or an object's `list` member.
|
||||
public static final class ModIdDeserializer implements JsonDeserializer<List<String>> {
|
||||
/// Generic target type passed to Gson for mod ID arrays.
|
||||
private static final Type STRING_LIST = TypeToken.getParameterized(List.class, String.class).getType();
|
||||
|
||||
/// Decodes a mod ID list, returning null if the list is absent or JSON null.
|
||||
///
|
||||
/// @throws JsonParseException if Gson cannot decode the selected value as a string list
|
||||
/// @throws IllegalStateException if a non-null, non-array value is not a JSON object
|
||||
@Override
|
||||
public @Nullable List<String> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (json.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (json.isJsonArray()) {
|
||||
return context.deserialize(json, STRING_LIST);
|
||||
} else {
|
||||
JsonObject jsonObject = json.getAsJsonObject();
|
||||
JsonElement list = jsonObject.get("list");
|
||||
if (list == null) {
|
||||
return null;
|
||||
} else {
|
||||
return context.deserialize(list, STRING_LIST);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.gradle.mod;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.logging.Logger;
|
||||
import org.gradle.api.logging.Logging;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.OutputFile;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public abstract class ParseModDataTask extends DefaultTask {
|
||||
|
||||
@InputFile
|
||||
public abstract RegularFileProperty getInputFile();
|
||||
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getOutputFile();
|
||||
|
||||
private static final Logger LOGGER = Logging.getLogger(ParseModDataTask.class);
|
||||
|
||||
private static final String S = ";";
|
||||
private static final String MOD_SEPARATOR = ",";
|
||||
|
||||
private static final Pattern[] CURSEFORGE_PATTERNS = {
|
||||
Pattern.compile("^/(minecraft|Minecraft|minecraft-bedrock)/(mc-mods|data-packs|modpacks|customization|mc-addons|texture-packs|customization/configuration|addons|scripts|bukkit-plugins)/+(?<modid>[\\w-]+)(/(.*?))?$"),
|
||||
Pattern.compile("^/projects/(?<modid>[\\w-]+)(/(.*?))?$"),
|
||||
Pattern.compile("^/mc-mods/minecraft/(?<modid>[\\w-]+)(/(.*?))?$"),
|
||||
Pattern.compile("^/legacy/mc-mods/minecraft/(\\d+)-(?<modid>[\\w-]+)"),
|
||||
};
|
||||
|
||||
private static String parseName(String name) {
|
||||
return name.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
}
|
||||
|
||||
private static final Pattern MCMOD_PATTERN =
|
||||
Pattern.compile("^https://www\\.mcmod\\.cn/(class|modpack)/(?<modid>\\d+)\\.html$");
|
||||
|
||||
private static String parseMcMod(String url) {
|
||||
Matcher matcher = MCMOD_PATTERN.matcher(url);
|
||||
if (matcher.matches()) {
|
||||
return matcher.group("modid");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String cleanChineseName(String chineseName) {
|
||||
if (chineseName == null || chineseName.isBlank())
|
||||
return "";
|
||||
|
||||
chineseName = chineseName.trim();
|
||||
|
||||
StringBuilder builder = new StringBuilder(chineseName.length());
|
||||
int[] codePoints = chineseName.codePoints().toArray();
|
||||
for (int i = 0; i < codePoints.length; i++) {
|
||||
int ch = codePoints[i];
|
||||
int prev = i > 0 ? codePoints[i - 1] : 0;
|
||||
|
||||
switch (ch) {
|
||||
case '(' -> {
|
||||
if (Character.isWhitespace(prev) || prev == '!' || prev == '。')
|
||||
builder.append('(');
|
||||
else
|
||||
builder.append(" (");
|
||||
}
|
||||
case ')' -> builder.append(')');
|
||||
default -> builder.appendCodePoint(ch);
|
||||
}
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
private static final Set<String> SKIP = Set.of(
|
||||
"Minecraft",
|
||||
"The Building Game"
|
||||
);
|
||||
|
||||
@TaskAction
|
||||
public void run() throws IOException {
|
||||
Path inputFile = getInputFile().get().getAsFile().toPath().toAbsolutePath();
|
||||
Path outputFile = getOutputFile().get().getAsFile().toPath().toAbsolutePath();
|
||||
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
|
||||
List<ModData> modDatas;
|
||||
try (BufferedReader reader = Files.newBufferedReader(inputFile)) {
|
||||
modDatas = new Gson().fromJson(reader, TypeToken.getParameterized(List.class, ModData.class).getType());
|
||||
}
|
||||
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(outputFile, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)) {
|
||||
writer.write("#\n" +
|
||||
"# Hello Minecraft! Launcher\n" +
|
||||
"# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors\n" +
|
||||
"#\n" +
|
||||
"# mcmod.cn\n" +
|
||||
"# Copyright (C) 2025. All Rights Reserved.\n" +
|
||||
"#\n");
|
||||
for (ModData mod : modDatas) {
|
||||
String chineseName = parseName(mod.name.main);
|
||||
String subName = parseName(mod.name.sub);
|
||||
String abbr = parseName(mod.name.abbr);
|
||||
|
||||
chineseName = chineseName == null ? "" : cleanChineseName(chineseName);
|
||||
if (subName == null)
|
||||
subName = "";
|
||||
if (abbr == null)
|
||||
abbr = "";
|
||||
|
||||
if (SKIP.contains(subName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chineseName.contains(S) || subName.contains(S)) {
|
||||
LOGGER.warn("Error chinese name: {}", chineseName);
|
||||
continue;
|
||||
}
|
||||
|
||||
String curseforgeId = "";
|
||||
String mcmodId = "";
|
||||
|
||||
Map<String, List<ModData.Link>> links = mod.links.list;
|
||||
List<ModData.Link> curseforgeLinks = links.get("curseforge");
|
||||
List<ModData.Link> mcmodLinks = links.get("mcmod");
|
||||
|
||||
if (curseforgeLinks != null && !curseforgeLinks.isEmpty()) {
|
||||
boolean reportError = true;
|
||||
|
||||
for (ModData.Link link : curseforgeLinks) {
|
||||
WebURL res = WebURL.parse(link.url);
|
||||
|
||||
if (!"http".equals(res.getScheme()) && !"https".equals(res.getScheme())) {
|
||||
curseforgeId = "";
|
||||
break;
|
||||
}
|
||||
|
||||
if (Set.of(
|
||||
"files.xmdhs.com",
|
||||
"edge.forgecdn.net",
|
||||
"minecraft.curseforge.com",
|
||||
"mediafilez.forgecdn.net"
|
||||
).contains(res.getHost())) {
|
||||
reportError = false;
|
||||
curseforgeId = "";
|
||||
break;
|
||||
}
|
||||
|
||||
for (Pattern pattern : CURSEFORGE_PATTERNS) {
|
||||
Matcher matcher = pattern.matcher(res.getPath());
|
||||
if (matcher.matches()) {
|
||||
curseforgeId = matcher.group("modid");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!curseforgeId.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (curseforgeId.isEmpty() && reportError) {
|
||||
LOGGER.warn("Error curseforge: {}", chineseName);
|
||||
}
|
||||
}
|
||||
|
||||
if (mcmodLinks != null && !mcmodLinks.isEmpty()) {
|
||||
mcmodId = parseMcMod(mcmodLinks.get(0).url);
|
||||
if (mcmodId.isEmpty()) {
|
||||
throw new GradleException("Error mcmod: " + chineseName);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> modId = new ArrayList<>();
|
||||
if (mod.modid != null) {
|
||||
for (String id : mod.modid) {
|
||||
String cleanId = parseName(id.trim());
|
||||
|
||||
if (cleanId.contains(MOD_SEPARATOR) || cleanId.contains(S)) {
|
||||
throw new GradleException("Error modid: " + id);
|
||||
}
|
||||
|
||||
modId.add(cleanId);
|
||||
}
|
||||
}
|
||||
|
||||
String modIds = String.join(MOD_SEPARATOR, modId);
|
||||
|
||||
writer.write(curseforgeId + S + mcmodId + S + modIds + S + chineseName + S + subName + S + abbr + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ModData {
|
||||
|
||||
public Name name;
|
||||
|
||||
@JsonAdapter(ModIdDeserializer.class)
|
||||
public List<String> modid;
|
||||
|
||||
public Links links;
|
||||
|
||||
public static final class Name {
|
||||
public String main;
|
||||
public String sub;
|
||||
public String abbr;
|
||||
}
|
||||
|
||||
public static final class Link {
|
||||
public String url;
|
||||
public String content;
|
||||
}
|
||||
|
||||
public static final class Links {
|
||||
public Map<String, List<Link>> list;
|
||||
}
|
||||
|
||||
public static final class ModIdDeserializer implements JsonDeserializer<List<String>> {
|
||||
private static final Type STRING_LIST = TypeToken.getParameterized(List.class, String.class).getType();
|
||||
|
||||
@Override
|
||||
public List<String> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (json.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (json.isJsonArray()) {
|
||||
return context.deserialize(json, STRING_LIST);
|
||||
} else {
|
||||
JsonObject jsonObject = json.getAsJsonObject();
|
||||
JsonElement list = jsonObject.get("list");
|
||||
if (list == null) {
|
||||
return null;
|
||||
} else {
|
||||
return context.deserialize(list, STRING_LIST);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.glavo.site/hmcl/game-settings/1.0.1",
|
||||
"title": "HMCL Game Settings Presets",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"$schema"
|
||||
],
|
||||
"patternProperties": {
|
||||
"^\\$schema$": {
|
||||
"enum": [
|
||||
"https://schemas.glavo.site/hmcl/game-settings/1.0.1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"presets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/preset"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"preset": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/gameSettings"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/gameSettingsPresetID"
|
||||
},
|
||||
"name": {
|
||||
"$ref": "#/$defs/localizedText"
|
||||
},
|
||||
"autoNameNumber": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"defaultIsolationType": {
|
||||
"enum": [
|
||||
"NEVER",
|
||||
"ALWAYS",
|
||||
"MODDED"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"gameSettings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"javaType": {
|
||||
"enum": [
|
||||
"AUTO",
|
||||
"VERSION",
|
||||
"DETECTED",
|
||||
"CUSTOM"
|
||||
]
|
||||
},
|
||||
"customJavaVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"customJavaPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"detectedJava": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"pathHash": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notUseJavaw": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"jvmOptions": {
|
||||
"type": "string"
|
||||
},
|
||||
"noJVMOptions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"noOptimizingJVMOptions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"notCheckJVM": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"notCheckGame": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoMemory": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"minMemory": {
|
||||
"type": "integer"
|
||||
},
|
||||
"maxMemory": {
|
||||
"type": "integer"
|
||||
},
|
||||
"permSize": {
|
||||
"type": "string"
|
||||
},
|
||||
"windowType": {
|
||||
"enum": [
|
||||
"WINDOWED",
|
||||
"FULLSCREEN",
|
||||
"MAXIMIZED"
|
||||
]
|
||||
},
|
||||
"width": {
|
||||
"type": "number"
|
||||
},
|
||||
"height": {
|
||||
"type": "number"
|
||||
},
|
||||
"runningDirectory": {
|
||||
"type": "string"
|
||||
},
|
||||
"processPriority": {
|
||||
"enum": [
|
||||
"LOW",
|
||||
"BELOW_NORMAL",
|
||||
"NORMAL",
|
||||
"ABOVE_NORMAL",
|
||||
"HIGH"
|
||||
]
|
||||
},
|
||||
"launcherVisibility": {
|
||||
"enum": [
|
||||
"CLOSE",
|
||||
"HIDE",
|
||||
"KEEP",
|
||||
"HIDE_AND_REOPEN"
|
||||
]
|
||||
},
|
||||
"allowAutoAgent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableAutoGameOptions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gameArguments": {
|
||||
"type": "string"
|
||||
},
|
||||
"graphicsBackend": {
|
||||
"enum": [
|
||||
"DEFAULT",
|
||||
"OPENGL",
|
||||
"VULKAN"
|
||||
]
|
||||
},
|
||||
"openGLRenderer": {
|
||||
"type": "string"
|
||||
},
|
||||
"vulkanRenderer": {
|
||||
"type": "string"
|
||||
},
|
||||
"highPerformance": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"environmentVariables": {
|
||||
"type": "string"
|
||||
},
|
||||
"commandWrapper": {
|
||||
"type": "string"
|
||||
},
|
||||
"preLaunchCommand": {
|
||||
"type": "string"
|
||||
},
|
||||
"postExitCommand": {
|
||||
"type": "string"
|
||||
},
|
||||
"quickPlay": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
"MULTIPLAYER",
|
||||
"SINGLEPLAYER",
|
||||
"REALMS"
|
||||
]
|
||||
},
|
||||
"quickPlayMultiplayer": {
|
||||
"type": "string"
|
||||
},
|
||||
"quickPlaySingleplayer": {
|
||||
"type": "string"
|
||||
},
|
||||
"quickPlayRealms": {
|
||||
"type": "string"
|
||||
},
|
||||
"showLogs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableDebugLogOutput": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"notPatchNatives": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"useCustomNatives": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"nativesDirectory": {
|
||||
"type": "string"
|
||||
},
|
||||
"useNativeGLFW": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"useNativeOpenAL": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gameSettingsPresetID": {
|
||||
"type": "string",
|
||||
"pattern": "^game-settings-preset:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"localizedText": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.glavo.site/hmcl/instance-game-settings/1.0.1",
|
||||
"title": "HMCL Instance Game Settings",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/gameSettings"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"$schema"
|
||||
],
|
||||
"patternProperties": {
|
||||
"^\\$schema$": {
|
||||
"enum": [
|
||||
"https://schemas.glavo.site/hmcl/instance-game-settings/1.0.1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"parent": {
|
||||
"$ref": "#/$defs/gameSettingsPresetID"
|
||||
},
|
||||
"icon": {
|
||||
"enum": [
|
||||
"DEFAULT",
|
||||
"GRASS",
|
||||
"CHEST",
|
||||
"CHICKEN",
|
||||
"COMMAND",
|
||||
"OPTIFINE",
|
||||
"CRAFT_TABLE",
|
||||
"FABRIC",
|
||||
"FORGE",
|
||||
"NEO_FORGE",
|
||||
"FURNACE",
|
||||
"QUILT",
|
||||
"APRIL_FOOLS",
|
||||
"CLEANROOM",
|
||||
"LEGACY_FABRIC"
|
||||
]
|
||||
},
|
||||
"overrideProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"gameSettings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"javaType": {
|
||||
"enum": [
|
||||
"AUTO",
|
||||
"VERSION",
|
||||
"DETECTED",
|
||||
"CUSTOM"
|
||||
]
|
||||
},
|
||||
"customJavaVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"customJavaPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"detectedJava": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"pathHash": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notUseJavaw": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"jvmOptions": {
|
||||
"type": "string"
|
||||
},
|
||||
"noJVMOptions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"noOptimizingJVMOptions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"notCheckJVM": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"notCheckGame": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoMemory": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"minMemory": {
|
||||
"type": "integer"
|
||||
},
|
||||
"maxMemory": {
|
||||
"type": "integer"
|
||||
},
|
||||
"permSize": {
|
||||
"type": "string"
|
||||
},
|
||||
"windowType": {
|
||||
"enum": [
|
||||
"WINDOWED",
|
||||
"FULLSCREEN",
|
||||
"MAXIMIZED"
|
||||
]
|
||||
},
|
||||
"width": {
|
||||
"type": "number"
|
||||
},
|
||||
"height": {
|
||||
"type": "number"
|
||||
},
|
||||
"runningDirectory": {
|
||||
"type": "string"
|
||||
},
|
||||
"processPriority": {
|
||||
"enum": [
|
||||
"LOW",
|
||||
"BELOW_NORMAL",
|
||||
"NORMAL",
|
||||
"ABOVE_NORMAL",
|
||||
"HIGH"
|
||||
]
|
||||
},
|
||||
"launcherVisibility": {
|
||||
"enum": [
|
||||
"CLOSE",
|
||||
"HIDE",
|
||||
"KEEP",
|
||||
"HIDE_AND_REOPEN"
|
||||
]
|
||||
},
|
||||
"allowAutoAgent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableAutoGameOptions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"gameArguments": {
|
||||
"type": "string"
|
||||
},
|
||||
"graphicsBackend": {
|
||||
"enum": [
|
||||
"DEFAULT",
|
||||
"OPENGL",
|
||||
"VULKAN"
|
||||
]
|
||||
},
|
||||
"openGLRenderer": {
|
||||
"type": "string"
|
||||
},
|
||||
"vulkanRenderer": {
|
||||
"type": "string"
|
||||
},
|
||||
"highPerformance": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"environmentVariables": {
|
||||
"type": "string"
|
||||
},
|
||||
"commandWrapper": {
|
||||
"type": "string"
|
||||
},
|
||||
"preLaunchCommand": {
|
||||
"type": "string"
|
||||
},
|
||||
"postExitCommand": {
|
||||
"type": "string"
|
||||
},
|
||||
"quickPlay": {
|
||||
"enum": [
|
||||
"NONE",
|
||||
"MULTIPLAYER",
|
||||
"SINGLEPLAYER",
|
||||
"REALMS"
|
||||
]
|
||||
},
|
||||
"quickPlayMultiplayer": {
|
||||
"type": "string"
|
||||
},
|
||||
"quickPlaySingleplayer": {
|
||||
"type": "string"
|
||||
},
|
||||
"quickPlayRealms": {
|
||||
"type": "string"
|
||||
},
|
||||
"showLogs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableDebugLogOutput": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"notPatchNatives": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"useCustomNatives": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"nativesDirectory": {
|
||||
"type": "string"
|
||||
},
|
||||
"useNativeGLFW": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"useNativeOpenAL": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gameSettingsPresetID": {
|
||||
"type": "string",
|
||||
"pattern": "^game-settings-preset:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ jimfs = "1.3.0"
|
||||
# plugins
|
||||
shadow = "9.6.1"
|
||||
|
||||
# build scripts
|
||||
icu4j = "78.3"
|
||||
|
||||
[libraries]
|
||||
hmclauncher = { module = "org.glavo.hmcl:HMCLauncher", version.ref = "hmclauncher" }
|
||||
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" }
|
||||
@@ -73,5 +76,8 @@ commonmark-table = { module = "org.commonmark:commonmark-ext-gfm-tables", versio
|
||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
|
||||
jimfs = { module = "com.google.jimfs:jimfs", version.ref = "jimfs" }
|
||||
|
||||
# build scripts
|
||||
icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" }
|
||||
|
||||
[plugins]
|
||||
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
||||
Reference in new issue
Block a user