Compare commits

...
22 Commits
Author SHA1 Message Date
Glavo 583e004d71 在中国大陆地区禁用游戏下载功能 (#7017) 2026-10-20 16:37:22 +08:00
Glavo 63207affc6 更新配置文件 Json Schema (#7012) 2026-10-19 20:34:27 +08:00
Glavo 8b147bf20a Windows 平台自动使用 javaw.exe 启动游戏 (#7009) 2026-10-19 20:21:15 +08:00
鱼前三百 15754119d5 fix: 修复原生窗口透明标题栏重复绘制背景 (#7010)
fix #6959
2026-10-19 20:08:39 +08:00
快乐凤爪王 8119324a92 fix: JavaFX Alert 失败时回退到 Swing (#6918) 2026-10-18 21:15:34 +08:00
辞庐 c0ad7764b0 更新用户未购买 Minecraft 时的提示文本 (#6998) 2026-10-18 20:55:43 +08:00
辞庐 3ea054ae31 将 JFXSpinner 改为顺时针旋转 (#7000) 2026-10-18 20:52:20 +08:00
快乐凤爪王 d03ad38e24 fix: ModpackInfoPage 文件选择器关闭后不清空页面栈 (#6992) 2026-10-17 20:38:35 +08:00
快乐凤爪王 93eaee317c feat: 参考 VersionsPage 样式改进 AddonUpdatesPage (#6970) 2026-10-16 21:51:41 +08:00
ToobLac 68b3a9f076 enhance(i18n): 优化拖拽以安装本地整合包的文字提示 (#6893)
Fixes #6891
2026-10-16 21:47:09 +08:00
鱼前三百 c943e4c74f fix: 在模组/资源包文件操作失败时提示 (#6922)
fix #4314, fix #6396
2026-10-16 21:44:50 +08:00
Minecraft269 4dba96e635 chore: 在 Gitee 与 CNB Issue 模板中添加 GitHub Issue 入口 (#6967) 2026-10-16 21:19:32 +08:00
Glavo e521a1b91a Fix #6969: 修复第二次打开游戏下载页面时页面空白的问题 (#6988) 2026-10-16 21:16:18 +08:00
gunngz a7e0fe7499 Fix indeterminate checkbox rendering in modpack export wizard (#6989)
Fixes #6929
2026-10-16 21:15:44 +08:00
鱼前三百andGlavo 4e2d5cac9e chore: 转换 TaskCancellationAction 为函数式接口 (#6991)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-16 20:49:23 +08:00
ToobLac 452d030817 refactor(addon): Make LocalAddonFile implement Comparable (#6984) 2026-10-15 20:33:13 +08:00
ToobLac da9ec2c1e1 chore: sync translations (#6983) 2026-10-15 20:31:07 +08:00
Glavo 4ebbb400ab 重构 ModTranslations (#6987) 2026-10-15 20:29:12 +08:00
Glavo 0cfc2471a0 使用 BellSoft Full JDK 运行 GitHub Action (#6978) 2026-10-13 21:13:42 +08:00
Glavo 413a7291ec 修复 DateRangeDialog 调用 JavaFX 21 API 的问题 (#6979) 2026-10-13 20:55:15 +08:00
鱼前三百 e4893a6df9 feat: 缓存 MicrosoftAccount 的 CompleteGameProfile (#6975)
Fix #5055
2026-10-13 20:16:44 +08:00
ToobLac bb16a0b6c0 fix(addon): AddonUpdatePage 显示测试版本复选框无效 (#6976)
Fixes #6974
2026-10-13 20:16:03 +08:00
75 changed files with 35650 additions and 33624 deletions

No files matched your search

+3
View File
@@ -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
View File
@@ -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 上提交。
+2 -2
View File
@@ -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
View File
@@ -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);
@@ -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
1 1 shared First First Name FST first;shared-id
2 2 shared Second Second Name SND second;shared-id
3 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");
+1
View File
@@ -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 `&amp;`, `&lt;`, and `&gt;`.
name = name
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">");
}
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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">");
}
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);
}
}
}
}
}
}
+248
View File
@@ -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}$"
}
}
}
+6
View File
@@ -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" }