Compare commits

...
Author SHA1 Message Date
f921370c21 [release/3.10] 修复系统代理 JVM 参数格式 (#5591)
https://github.com/HMCL-dev/HMCL/pull/5265

Co-authored-by: Xirren <michaelqiancn@gmail.com>
Co-authored-by: xirren <xirren@outlook.com>
2026-02-20 23:13:37 +08:00
Glavo 61774c2efd [release/3.10] 修复部分请求头中 Accept-Language 字段包含 Java 版本的问题 (#5590)
https://github.com/HMCL-dev/HMCL/pull/5508
2026-02-20 23:13:19 +08:00
GlavoandCiiLu 4c209d1eb6 [release/3.10] fix: 安装整合包时任务弹窗可能空白 (#5589)
https://github.com/HMCL-dev/HMCL/pull/5550

Co-authored-by: CiiLu <109708109+CiiLu@users.noreply.github.com>
2026-02-20 23:12:42 +08:00
Glavo a1c9f1be7d [release/3.10] 修复 MultipleSourceVersionList 不会使用备用下载源的问题 (#5588)
https://github.com/HMCL-dev/HMCL/pull/5585
2026-02-20 23:11:24 +08:00
Glavo 3d1bd96d56 [release/3.10] 使用自定义 EasyTier 服务器节点 (#5511)
https://github.com/HMCL-dev/HMCL/pull/5504
2026-02-10 22:12:07 +08:00
Glavo d48a3c2109 [release/3.10] 修复 Unzipper 解压空 zip 文件时抛出异常的问题 (#5353)
https://github.com/HMCL-dev/HMCL/pull/5267
2026-01-28 21:47:26 +08:00
Glavoand辞庐 a0f9ba1976 [release/3.10] 点击实例列表条目的中央区域无法触发 Rippler 效果 (#5354)
https://github.com/HMCL-dev/HMCL/pull/5310

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-01-28 21:46:56 +08:00
Glavoand辞庐 b8df46ef87 [release/3.10] 修复返回页面时可能卡进空白页面的问题 (#5355)
https://github.com/HMCL-dev/HMCL/pull/5305

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-01-28 21:46:33 +08:00
Glavoand神奇的赵二 0c04a013c8 [release/3.10] 修复自动安装界面卡片上按钮无法点击的问题 (#5351)
https://github.com/HMCL-dev/HMCL/pull/5251

Co-authored-by: 神奇的赵二 (OrzMiku) <miku@ecy.pink>
2026-01-28 21:39:34 +08:00
Glavoand神奇的赵二 3cf0acc505 [release/3.10] 优化实例列表,限制宽度防止出现水平滚动条 (#5350)
https://github.com/HMCL-dev/HMCL/pull/5243

Co-authored-by: 神奇的赵二 (OrzMiku) <miku@ecy.pink>
2026-01-28 21:39:15 +08:00
Glavo 3be5ea409b [release/3.10] 修复使用 GIF 背景时调整背景不透明度导致启动器崩溃的问题 (#5349)
https://github.com/HMCL-dev/HMCL/pull/5262
2026-01-28 21:39:00 +08:00
Glavo 42f03411de [release/3.10] 修复 GameVersionNumber.getDefaultGameVersions() 包含未混淆版本的问题 (#5348)
https://github.com/HMCL-dev/HMCL/pull/5261
2026-01-28 21:38:35 +08:00
Glavoand辞庐 941e7cd858 [release/3.10] 修复 Cleanroom 本地安装完成后会删除安装器的问题 (#5347)
https://github.com/HMCL-dev/HMCL/pull/5257

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-01-28 21:38:16 +08:00
Glavo 61c1b041cd [release/3.10] RemoteImageLoader 在图标尚未加载时应当将图像更新为占位符 (#5346)
https://github.com/HMCL-dev/HMCL/pull/5239
2026-01-28 21:37:27 +08:00
Glavo 7a48be5364 [release/3.10] 将世界管理界面“生成启动脚本”按钮折叠到二级菜单 (#5345)
https://github.com/HMCL-dev/HMCL/pull/5198
2026-01-28 21:37:06 +08:00
Glavo 7d76fea48d 重构 WorldListPage 以使用 ListCell 展示元素 (#5224) 2026-01-14 20:33:55 +08:00
Glavo 600bd6e9ee 修复 CurseForge 源无法搜索到内容的问题 (#5220) 2026-01-14 20:15:37 +08:00
Glavo ccc64db805 重构 JavaManagementPage 以使用 ListCell 展示元素 (#5223) 2026-01-13 21:35:34 +08:00
Glavo ea407be5f0 更新 ToolbarListPageSkin 以使用 JFXListView 展示节点 (#5221) 2026-01-13 21:08:36 +08:00
Glavo 9b9471da49 惰性初始化 JFXRippler 属性 (#5208) 2026-01-13 21:03:49 +08:00
Glavo b1402d3821 优化下载页面图标缓存功能 (#5211) 2026-01-13 20:30:14 +08:00
Xirrenandxirren 62f0d0a1c0 修复实例列表不显示实例名的问题 (#5203)
fix #5202

Co-authored-by: xirren <xirren@outlook.com>
2026-01-12 22:13:00 +08:00
Glavo 2865f2ffca 将 InstallerItem.State 迁移至 sealed 接口 (#5209) 2026-01-12 21:52:16 +08:00
Burning_TNT 3860e68caa Feature: 下载并发控制 (#5026) 2026-01-11 21:45:31 +08:00
3gf8jv4dv 74d0a89820 fix(i18n): update button text for check mod updates (#5159)
resolves #5154
2026-01-11 21:30:00 +08:00
91e4c85a28 优化 SSL 异常处理 (#5168)
fix #5115 

Co-authored-by: xirren <xirren@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-11 21:28:54 +08:00
mineDiamond a05b24cfd7 fix: 解决数据包页面在加载超多数据包时卡顿的问题 (#5162) 2026-01-11 21:18:04 +08:00
辞庐 72e3925696 修复默认窗口大小 进入高级版本设置会向下滚动一段距离的问题 (#5197) 2026-01-11 21:14:52 +08:00
Glavo 9ea419c05b 修复未下载陶瓦联机时打开陶瓦联机页面抛出异常的问题 (#5166) 2026-01-11 21:00:35 +08:00
Glavo 3dc1631207 使用 JFXListView 显示实例列表 (#5174) 2026-01-11 20:55:17 +08:00
Glavo ab5ef6e0ff 将 FontReference 转换为 record (#5187) 2026-01-11 01:27:44 +08:00
Glavo 61113d5c08 仅在左键点击时触发 JFXRippler 波纹 (#5188) 2026-01-10 22:18:22 +08:00
辞庐 d73d7330eb 支持重置日志字体 (#5182) 2026-01-10 22:12:24 +08:00
辞庐andGlavo 45a85e006b 修复重置字体后没有遵循 hmcl.font.override HMCL_FONT 等的问题 (#5183)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-10 21:44:01 +08:00
ENC_Euphony 06cce5259b imp: use display categories for modrinth tag display (#5160) 2026-01-10 21:41:41 +08:00
辞庐andGlavo 3e393982e0 修复 InstallerItem 的 Rippler 没有正常显示的问题 (#5184)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-10 21:21:48 +08:00
辞庐 db2e2d89ee 优化 Popup 阴影 & 修复 Windows 高分屏模糊严重的问题 (#5186) 2026-01-10 21:19:08 +08:00
Glavo a3d9e29350 在资源包下载页面中隐藏不必要的标签 (#5180) 2026-01-10 20:39:15 +08:00
ENC_Euphony 682d88df02 imp: add padding for java selection list item (#5167) 2026-01-10 16:47:00 +08:00
3gf8jv4dv b5defd31ed fix: add "neo-forge" alias for modrinth modpack parsing (#5170)
resolves #5169
2026-01-10 16:46:24 +08:00
Glavo 553e67bda7 修复 JFXCheckBox 意外地显示阴影的问题 (#5179) 2026-01-10 16:17:41 +08:00
Glavo 95bb669ffc 修复 JFXButton 意外地显示阴影的问题 (#5178) 2026-01-10 16:09:53 +08:00
Glavo 505752476c 更新 README.md (#5156) 2026-01-07 22:33:03 +08:00
mineDiamond c723acfe30 feat: 允许重命名实例时使用原名 (#5137) 2026-01-07 22:32:43 +08:00
Wulian233and3gf8jv4dv 2fd7076ccf 修复第三方构建下禁用账户刷新的 tooltip 展示不支持的超链接标签 (#5133)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:32:10 +08:00
0705465ba4 优化账号SSL异常处理 (#5143)
Co-authored-by: xirren <xirren@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:30:53 +08:00
mineDiamondand3gf8jv4dv 7a4649f0cc feat: 优化世界管理界面和世界信息界面 (#4823)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:26:17 +08:00
Glavo 8b8a62ccf1 Fix #5067: 在 MinecraftInstanceTask 中使用 kala-compress 解压文件 (#5151) 2026-01-06 22:35:33 +08:00
Glavo 243f3e7371 修复窗口重新获取焦点时 JFXRadioButton 显示阴影的问题 (#5153) 2026-01-06 22:25:59 +08:00
Glavo 4ad368560c 调整平滑滚动的截止速度 (#5150) 2026-01-06 22:15:04 +08:00
Glavo c6c302e3d6 fix: update title label to use display name directly (#5152) 2026-01-06 22:14:30 +08:00
Glavo 686f59e21b 将 Unzipper 迁移至 kala-compress (#5148) 2026-01-06 21:19:21 +08:00
Glavo f1bd4bffb7 修复无法正确识别模组的问题 (#5149) 2026-01-06 21:08:49 +08:00
ZZZank 18a8556997 feat: 更新选中 mod (#4832) 2026-01-06 21:05:46 +08:00
Glavo f10c842aa4 使用 kala-compress 读取模组元数据 (#5146) 2026-01-05 21:55:52 +08:00
Glavo 74d379a09c 修复 FetchTask 未捕获部分异常的问题 (#5144) 2026-01-05 21:51:02 +08:00
NoClassDefFoundError 3dd1d5bd62 Fix #5123: 修复字体问题导致的空行 (#5124) 2026-01-05 20:55:46 +08:00
Burning_TNT 3247b77410 重构陶瓦下载功能 并 在加入联机房间时显示 p2p 难度 (#4929) 2026-01-04 22:06:44 +08:00
119 changed files with 4636 additions and 2875 deletions

No files matched your search

+26
View File
@@ -1,3 +1,4 @@
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
@@ -240,6 +241,11 @@ tasks.processResources {
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
inputs.property("terracotta_version", libs.versions.terracotta)
doLast {
upgradeTerracottaConfig.get().checkValid()
}
}
val makeExecutables by tasks.registering {
@@ -362,6 +368,26 @@ tasks.register<JavaExec>("run") {
}
}
// terracotta
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
))
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
templateFile.set(source)
outputFile.set(destination)
}
// Check Translations
tasks.register<CheckTranslations>("checkTranslations") {
@@ -0,0 +1,129 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.controls;
import com.jfoenix.converters.ButtonTypeConverter;
import com.jfoenix.skins.JFXButtonSkin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableObjectProperty;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.css.StyleableProperty;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
import javafx.scene.control.Labeled;
import javafx.scene.control.Skin;
import javafx.scene.paint.Paint;
public class JFXButton extends Button {
private static final String DEFAULT_STYLE_CLASS = "jfx-button";
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
public JFXButton() {
this.initialize();
}
public JFXButton(String text) {
super(text);
this.initialize();
}
public JFXButton(String text, Node graphic) {
super(text, graphic);
this.initialize();
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
protected Skin<?> createDefaultSkin() {
return new JFXButtonSkin(this);
}
private final ObjectProperty<Paint> ripplerFill = new SimpleObjectProperty<>(this, "ripplerFill", null);
public final ObjectProperty<Paint> ripplerFillProperty() {
return this.ripplerFill;
}
public final Paint getRipplerFill() {
return this.ripplerFillProperty().get();
}
public final void setRipplerFill(Paint ripplerFill) {
this.ripplerFillProperty().set(ripplerFill);
}
private final StyleableObjectProperty<ButtonType> buttonType = new SimpleStyleableObjectProperty<>(
JFXButton.StyleableProperties.BUTTON_TYPE, this, "buttonType", JFXButton.ButtonType.FLAT);
public ButtonType getButtonType() {
return this.buttonType == null ? JFXButton.ButtonType.FLAT : this.buttonType.get();
}
public StyleableObjectProperty<ButtonType> buttonTypeProperty() {
return this.buttonType;
}
public void setButtonType(ButtonType type) {
this.buttonType.set(type);
}
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (this.STYLEABLES == null) {
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(Labeled.getClassCssMetaData());
this.STYLEABLES = List.copyOf(styleables);
}
return this.STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return JFXButton.StyleableProperties.CHILD_STYLEABLES;
}
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
}
public enum ButtonType {
FLAT,
RAISED;
}
private static final class StyleableProperties {
private static final CssMetaData<JFXButton, ButtonType> BUTTON_TYPE;
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
BUTTON_TYPE = new CssMetaData<>("-jfx-button-type", ButtonTypeConverter.getInstance(), JFXButton.ButtonType.FLAT) {
public boolean isSettable(JFXButton control) {
return control.buttonType == null || !control.buttonType.isBound();
}
public StyleableProperty<ButtonType> getStyleableProperty(JFXButton control) {
return control.buttonTypeProperty();
}
};
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, BUTTON_TYPE);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
}
@@ -34,6 +34,7 @@ import javafx.geometry.Bounds;
import javafx.scene.CacheHint;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
@@ -77,14 +78,13 @@ public class JFXRippler extends StackPane {
protected Node control;
protected static final double RIPPLE_MAX_RADIUS = 300;
private boolean enabled = true;
private boolean forceOverlay = false;
private final Interpolator rippleInterpolator = Interpolator.SPLINE(0.0825,
private static final Interpolator RIPPLE_INTERPOLATOR = Interpolator.SPLINE(0.0825,
0.3025,
0.0875,
0.9975); //0.1, 0.54, 0.28, 0.95);
private boolean forceOverlay = false;
/// creates empty rippler node
public JFXRippler() {
this(null, RipplerMask.RECT, RipplerPos.FRONT);
@@ -176,10 +176,6 @@ public class JFXRippler extends StackPane {
return control;
}
public void setEnabled(boolean enable) {
this.enabled = enable;
}
// methods that can be changed by extending the rippler class
/// generate the clipping mask
@@ -255,10 +251,15 @@ public class JFXRippler extends StackPane {
if (getChildren().contains(control)) {
control.boundsInParentProperty().addListener(observable -> resetRippler());
}
control.addEventHandler(MouseEvent.MOUSE_PRESSED,
(event) -> createRipple(event.getX(), event.getY()));
control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
createRipple(event.getX(), event.getY());
});
// create fade out transition for the ripple
control.addEventHandler(MouseEvent.MOUSE_RELEASED, e -> releaseRipple());
control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
releaseRipple();
});
}
/**
@@ -363,25 +364,23 @@ public class JFXRippler extends StackPane {
}
void createRipple() {
if (enabled) {
if (!generating.getAndSet(true)) {
// create overlay once then change its color later
createOverlay();
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
this.setClip(getMask());
}
this.resetClip = false;
// create the ripple effect
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
getChildren().add(ripple);
ripplesQueue.add(ripple);
// animate the ripple
overlayRect.outAnimation.stop();
overlayRect.inAnimation.play();
ripple.inAnimation.play();
if (!generating.getAndSet(true)) {
// create overlay once then change its color later
createOverlay();
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
this.setClip(getMask());
}
this.resetClip = false;
// create the ripple effect
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
getChildren().add(ripple);
ripplesQueue.add(ripple);
// animate the ripple
overlayRect.outAnimation.stop();
overlayRect.inAnimation.play();
ripple.inAnimation.play();
}
}
@@ -415,15 +414,15 @@ public class JFXRippler extends StackPane {
overlayRect.setClip(getMask());
getChildren().add(0, overlayRect);
overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> {
if (ripplerFill.get() instanceof Color) {
return new Color(((Color) ripplerFill.get()).getRed(),
((Color) ripplerFill.get()).getGreen(),
((Color) ripplerFill.get()).getBlue(),
if (getRipplerFill() instanceof Color fill) {
return new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.2);
} else {
return Color.TRANSPARENT;
}
}, ripplerFill));
}, ripplerFillProperty()));
}
}
@@ -473,8 +472,8 @@ public class JFXRippler extends StackPane {
private Ripple(double centerX, double centerY) {
super(centerX,
centerY,
ripplerRadius.get().doubleValue() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius() : ripplerRadius.get().doubleValue(), null);
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius() : getRipplerRadius(), null);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
@@ -484,12 +483,12 @@ public class JFXRippler extends StackPane {
KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2];
outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3];
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, rippleInterpolator);
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, rippleInterpolator);
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR);
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR);
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, rippleInterpolator);
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, rippleInterpolator);
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, rippleInterpolator);
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR);
if (isRipplerRecenter()) {
double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55;
@@ -497,42 +496,42 @@ public class JFXRippler extends StackPane {
inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(),
Math.signum(dx) * Math.min(Math.abs(dx),
this.getRadius() / 2),
rippleInterpolator);
RIPPLE_INTERPOLATOR);
inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(),
Math.signum(dy) * Math.min(Math.abs(dy),
this.getRadius() / 2),
rippleInterpolator);
RIPPLE_INTERPOLATOR);
}
inAnimation = new Timeline(new KeyFrame(Duration.ZERO,
new KeyValue(scaleXProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(scaleYProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(translateXProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(translateYProperty(),
0,
rippleInterpolator),
RIPPLE_INTERPOLATOR),
new KeyValue(opacityProperty(),
1,
rippleInterpolator)
RIPPLE_INTERPOLATOR)
), new KeyFrame(Duration.millis(900), inKeyValues));
setScaleX(0);
setScaleY(0);
if (ripplerFill.get() instanceof Color) {
Color circleColor = new Color(((Color) ripplerFill.get()).getRed(),
((Color) ripplerFill.get()).getGreen(),
((Color) ripplerFill.get()).getBlue(),
if (getRipplerFill() instanceof Color fill) {
Color circleColor = new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.3);
setStroke(circleColor);
setFill(circleColor);
} else {
setStroke(ripplerFill.get());
setFill(ripplerFill.get());
setStroke(getRipplerFill());
setFill(getRipplerFill());
}
}
}
@@ -585,110 +584,125 @@ public class JFXRippler extends StackPane {
* the ripple recenter property, by default it's false.
* if true the ripple effect will show gravitational pull to the center of its control
*/
private final StyleableObjectProperty<Boolean> ripplerRecenter = new SimpleStyleableObjectProperty<>(
StyleableProperties.RIPPLER_RECENTER,
JFXRippler.this,
"ripplerRecenter",
false);
private StyleableBooleanProperty ripplerRecenter;
public Boolean isRipplerRecenter() {
public boolean isRipplerRecenter() {
return ripplerRecenter != null && ripplerRecenter.get();
}
public StyleableObjectProperty<Boolean> ripplerRecenterProperty() {
public StyleableBooleanProperty ripplerRecenterProperty() {
if (this.ripplerRecenter == null) {
this.ripplerRecenter = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_RECENTER,
JFXRippler.this,
"ripplerRecenter",
false);
}
return this.ripplerRecenter;
}
public void setRipplerRecenter(Boolean radius) {
this.ripplerRecenter.set(radius);
public void setRipplerRecenter(boolean recenter) {
ripplerRecenterProperty().set(recenter);
}
/**
* the ripple radius size, by default it will be automatically computed.
*/
private final StyleableObjectProperty<Number> ripplerRadius = new SimpleStyleableObjectProperty<>(
StyleableProperties.RIPPLER_RADIUS,
JFXRippler.this,
"ripplerRadius",
Region.USE_COMPUTED_SIZE);
private StyleableDoubleProperty ripplerRadius;
public Number getRipplerRadius() {
public double getRipplerRadius() {
return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get();
}
public StyleableObjectProperty<Number> ripplerRadiusProperty() {
public StyleableDoubleProperty ripplerRadiusProperty() {
if (this.ripplerRadius == null) {
this.ripplerRadius = new SimpleStyleableDoubleProperty(
StyleableProperties.RIPPLER_RADIUS,
JFXRippler.this,
"ripplerRadius",
Region.USE_COMPUTED_SIZE);
}
return this.ripplerRadius;
}
public void setRipplerRadius(Number radius) {
this.ripplerRadius.set(radius);
public void setRipplerRadius(double radius) {
ripplerRadiusProperty().set(radius);
}
private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255);
/**
* the default color of the ripple effect
*/
private final StyleableObjectProperty<Paint> ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
JFXRippler.this,
"ripplerFill",
Color.rgb(0,
200,
255));
private StyleableObjectProperty<Paint> ripplerFill;
public Paint getRipplerFill() {
return ripplerFill == null ? Color.rgb(0, 200, 255) : ripplerFill.get();
return ripplerFill == null ? DEFAULT_RIPPLER_FILL : ripplerFill.get();
}
public StyleableObjectProperty<Paint> ripplerFillProperty() {
if (this.ripplerFill == null) {
this.ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
JFXRippler.this,
"ripplerFill",
DEFAULT_RIPPLER_FILL);
}
return this.ripplerFill;
}
public void setRipplerFill(Paint color) {
this.ripplerFill.set(color);
ripplerFillProperty().set(color);
}
/// mask property used for clipping the rippler.
/// can be either CIRCLE/RECT
private final StyleableObjectProperty<RipplerMask> maskType = new SimpleStyleableObjectProperty<>(
StyleableProperties.MASK_TYPE,
JFXRippler.this,
"maskType",
RipplerMask.RECT);
private StyleableObjectProperty<RipplerMask> maskType;
public RipplerMask getMaskType() {
return maskType == null ? RipplerMask.RECT : maskType.get();
}
public StyleableObjectProperty<RipplerMask> maskTypeProperty() {
if (this.maskType == null) {
this.maskType = new SimpleStyleableObjectProperty<>(
StyleableProperties.MASK_TYPE,
JFXRippler.this,
"maskType",
RipplerMask.RECT);
}
return this.maskType;
}
public void setMaskType(RipplerMask type) {
this.maskType.set(type);
if (this.maskType != null || type != RipplerMask.RECT)
maskTypeProperty().set(type);
}
/**
* the ripple disable, by default it's false.
* if true the ripple effect will be hidden
*/
private final StyleableBooleanProperty ripplerDisabled = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_DISABLED,
JFXRippler.this,
"ripplerDisabled",
false);
private StyleableBooleanProperty ripplerDisabled;
public Boolean isRipplerDisabled() {
public boolean isRipplerDisabled() {
return ripplerDisabled != null && ripplerDisabled.get();
}
public StyleableBooleanProperty ripplerDisabledProperty() {
if (this.ripplerDisabled == null) {
this.ripplerDisabled = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_DISABLED,
JFXRippler.this,
"ripplerDisabled",
false);
}
return this.ripplerDisabled;
}
public void setRipplerDisabled(Boolean disabled) {
this.ripplerDisabled.set(disabled);
public void setRipplerDisabled(boolean disabled) {
ripplerDisabledProperty().set(disabled);
}
/**
* indicates whether the ripple effect is infront of or behind the node
*/
@@ -735,7 +749,7 @@ public class JFXRippler extends StackPane {
};
private static final CssMetaData<JFXRippler, Paint> RIPPLER_FILL =
new CssMetaData<>("-jfx-rippler-fill",
PaintConverter.getInstance(), Color.rgb(0, 200, 255)) {
PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerFill == null || !control.ripplerFill.isBound();
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2016 JFoenix
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.jfoenix.effects;
import javafx.scene.Node;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
/**
* it will create a shadow effect for a given node and a specified depth level.
* depth levels are {0,1,2,3,4,5}
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public final class JFXDepthManager {
private JFXDepthManager() {
throw new AssertionError();
}
private static final DropShadow[] depth = new DropShadow[] {
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 1),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 6, 0, 0, 2),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 10, 0, 0, 3),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 14, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 6)
};
/**
* this method is used to add shadow effect to the node,
* however the shadow is not real ( gets affected with node transformations)
* <p>
* use {@link #createMaterialNode(Node, int)} instead to generate a real shadow
*/
public static void setDepth(Node control, int level) {
level = level < 0 ? 0 : level;
level = level > 5 ? 5 : level;
control.setEffect(new DropShadow(BlurType.GAUSSIAN,
depth[level].getColor(),
depth[level].getRadius(),
depth[level].getSpread(),
depth[level].getOffsetX(),
depth[level].getOffsetY()));
}
public static int getLevels() {
return depth.length;
}
public static DropShadow getShadowAt(int level) {
return depth[level];
}
/**
* this method will generate a new container node that prevent
* control transformation to be applied to the shadow effect
* (which makes it looks as a real shadow)
*/
public static Node createMaterialNode(Node control, int level) {
Node container = new Pane(control) {
@Override
protected double computeMaxWidth(double height) {
return computePrefWidth(height);
}
@Override
protected double computeMaxHeight(double width) {
return computePrefHeight(width);
}
@Override
protected double computePrefWidth(double height) {
return control.prefWidth(height);
}
@Override
protected double computePrefHeight(double width) {
return control.prefHeight(width);
}
};
container.getStyleClass().add("depth-container");
container.setPickOnBounds(false);
level = level < 0 ? 0 : level;
level = level > 5 ? 5 : level;
container.setEffect(new DropShadow(BlurType.GAUSSIAN,
depth[level].getColor(),
depth[level].getRadius(),
depth[level].getSpread(),
depth[level].getOffsetX(),
depth[level].getOffsetY()));
return container;
}
public static void pop(Node control) {
control.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.26), 5, 0.05, 0, 1));
}
}
@@ -0,0 +1,222 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ButtonSkin;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
public class JFXButtonSkin extends ButtonSkin {
private final StackPane buttonContainer = new StackPane();
private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) {
protected Node getMask() {
StackPane mask = new StackPane();
mask.shapeProperty().bind(JFXButtonSkin.this.buttonContainer.shapeProperty());
mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background(
new BackgroundFill(Color.WHITE,
JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty()
? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getRadii()
: JFXButtonSkin.this.defaultRadii, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)),
JFXButtonSkin.this.buttonContainer.backgroundProperty()));
mask.resize(JFXButtonSkin.this.buttonContainer.getWidth() - JFXButtonSkin.this.buttonContainer.snappedRightInset() - JFXButtonSkin.this.buttonContainer.snappedLeftInset(), JFXButtonSkin.this.buttonContainer.getHeight() - JFXButtonSkin.this.buttonContainer.snappedBottomInset() - JFXButtonSkin.this.buttonContainer.snappedTopInset());
return mask;
}
private void initListeners() {
this.ripplerPane.setOnMousePressed((event) -> {
if (JFXButtonSkin.this.releaseManualRippler != null) {
JFXButtonSkin.this.releaseManualRippler.run();
}
JFXButtonSkin.this.releaseManualRippler = null;
this.createRipple(event.getX(), event.getY());
});
}
};
private Transition clickedAnimation;
private final CornerRadii defaultRadii = new CornerRadii(3.0);
private boolean invalid = true;
private Runnable releaseManualRippler = null;
public JFXButtonSkin(JFXButton button) {
super(button);
this.getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.releaseManualRippler = this.buttonRippler.createManualRipple();
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(1.0);
this.clickedAnimation.play();
}
} else {
if (this.releaseManualRippler != null) {
this.releaseManualRippler.run();
}
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(-1.0);
this.clickedAnimation.play();
}
}
});
this.buttonContainer.getChildren().add(this.buttonRippler);
button.buttonTypeProperty().addListener((o, oldVal, newVal) -> this.updateButtonType(newVal));
button.setOnMousePressed((e) -> {
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(1.0F);
this.clickedAnimation.play();
}
});
button.setOnMouseReleased((e) -> {
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(-1.0F);
this.clickedAnimation.play();
}
});
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(button);
if (focusVisibleProperty == null) {
focusVisibleProperty = button.focusedProperty();
}
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.buttonRippler.showOverlay();
}
} else {
this.buttonRippler.hideOverlay();
}
});
button.pressedProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.hideOverlay());
button.setPickOnBounds(false);
this.buttonContainer.setPickOnBounds(false);
this.buttonContainer.shapeProperty().bind(this.getSkinnable().shapeProperty());
this.buttonContainer.borderProperty().bind(this.getSkinnable().borderProperty());
this.buttonContainer.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground()) || this.isJavaDefaultClickedBackground(button.getBackground())) {
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
}
try {
return new Background(new BackgroundFill(this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getFill() : Color.TRANSPARENT, this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getRadii() : this.defaultRadii, Insets.EMPTY));
} catch (Exception var3) {
return this.getSkinnable().getBackground();
}
}, this.getSkinnable().backgroundProperty()));
button.ripplerFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground())) {
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
}
this.updateButtonType(button.getButtonType());
this.updateChildren();
}
protected void updateChildren() {
super.updateChildren();
if (this.buttonContainer != null) {
this.getChildren().add(0, this.buttonContainer);
}
for (int i = 1; i < this.getChildren().size(); ++i) {
this.getChildren().get(i).setMouseTransparent(true);
}
}
protected void layoutChildren(double x, double y, double w, double h) {
if (this.invalid) {
if (((JFXButton) this.getSkinnable()).getRipplerFill() == null) {
for (int i = this.getChildren().size() - 1; i >= 1; --i) {
if (this.getChildren().get(i) instanceof Shape shape) {
this.buttonRippler.setRipplerFill(shape.getFill());
shape.fillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
break;
}
if (this.getChildren().get(i) instanceof Label label) {
this.buttonRippler.setRipplerFill(label.getTextFill());
label.textFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
break;
}
}
} else {
this.buttonRippler.setRipplerFill(((JFXButton) this.getSkinnable()).getRipplerFill());
}
this.invalid = false;
}
double shift = 1.0F;
this.buttonContainer.resizeRelocate(this.getSkinnable().getLayoutBounds().getMinX() - shift, this.getSkinnable().getLayoutBounds().getMinY() - shift, this.getSkinnable().getWidth() + (double) 2.0F * shift, this.getSkinnable().getHeight() + (double) 2.0F * shift);
this.layoutLabelInArea(x, y, w, h);
}
private boolean isJavaDefaultBackground(Background background) {
try {
String firstFill = background.getFills().get(0).getFill().toString();
return "0xffffffba".equals(firstFill) || "0xffffffbf".equals(firstFill) || "0xffffff12".equals(firstFill) || "0xffffffbd".equals(firstFill);
} catch (Exception var3) {
return false;
}
}
private boolean isJavaDefaultClickedBackground(Background background) {
try {
return "0x039ed3ff".equals(background.getFills().get(0).getFill().toString());
} catch (Exception var3) {
return false;
}
}
private void updateButtonType(JFXButton.ButtonType type) {
switch (type) {
case RAISED -> {
JFXDepthManager.setDepth(this.buttonContainer, 2);
this.clickedAnimation = new ButtonClickTransition();
}
case FLAT -> this.buttonContainer.setEffect(null);
}
}
private class ButtonClickTransition extends CachedTransition {
public ButtonClickTransition() {
super(JFXButtonSkin.this.buttonContainer, new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), Interpolator.EASE_BOTH)),
new KeyFrame(Duration.millis(1000.0F),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), Interpolator.EASE_BOTH))));
this.setCycleDuration(Duration.seconds(0.2));
this.setDelay(Duration.seconds(0.0F));
}
}
}
@@ -17,6 +17,7 @@ import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
@@ -35,6 +36,7 @@ import javafx.scene.paint.Color;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
public class JFXCheckBoxSkin extends CheckBoxSkin {
private final StackPane box = new StackPane();
@@ -75,7 +77,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
this.updateRippleColor();
this.playSelectAnimation(newVal);
});
control.focusedProperty().addListener((o, oldVal, newVal) -> {
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null)
focusVisibleProperty = control.focusedProperty();
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.rippler.showOverlay();
@@ -12,6 +12,7 @@ import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
@@ -23,6 +24,7 @@ import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.text.Text;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
public class JFXRadioButtonSkin extends RadioButtonSkin {
@@ -58,7 +60,12 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
this.updateChildren();
control.focusedProperty().addListener((o, oldVal, newVal) -> {
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null) {
focusVisibleProperty = control.focusedProperty();
}
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.rippler.showOverlay();
@@ -66,7 +73,6 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
} else {
this.rippler.hideOverlay();
}
});
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
this.registerChangeListener(control.selectedColorProperty(), ignored -> updateColors());
@@ -414,6 +414,12 @@ public final class Accounts {
return i18n("account.failed.no_character");
} else if (exception instanceof ServerDisconnectException) {
if (exception.getCause() instanceof SSLException) {
if (exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated")) {
return i18n("account.failed.connect_authentication_server");
}
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("account.failed.dns");
}
return i18n("account.failed.ssl");
} else {
return i18n("account.failed.connect_authentication_server");
@@ -134,7 +134,10 @@ public final class DownloadProviders {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException) {
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.text.Font;
import org.jackhuang.hmcl.Metadata;
@@ -94,31 +95,26 @@ public final class FontManager {
return null;
});
private static final ObjectProperty<FontReference> fontProperty;
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
static {
updateFont();
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
}
private static void updateFont() {
String fontFamily = config().getLauncherFontFamily();
if (fontFamily == null)
fontFamily = System.getProperty("hmcl.font.override");
if (fontFamily == null)
fontFamily = System.getenv("HMCL_FONT");
FontReference fontReference;
if (fontFamily == null) {
Font defaultFont = DEFAULT_FONT.get();
fontReference = defaultFont != null ? new FontReference(defaultFont) : null;
} else
fontReference = new FontReference(fontFamily);
fontProperty = new SimpleObjectProperty<>(fontReference);
LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System"));
fontProperty.addListener((obs, oldValue, newValue) -> {
if (newValue != null)
config().setLauncherFontFamily(newValue.getFamily());
else
config().setLauncherFontFamily(null);
});
font.set(defaultFont != null ? new FontReference(defaultFont) : null);
} else {
font.set(new FontReference(fontFamily));
}
}
private static Font tryLoadDefaultFont(Path dir) {
@@ -231,64 +227,35 @@ public final class FontManager {
}
}
public static ObjectProperty<FontReference> fontProperty() {
return fontProperty;
public static ReadOnlyObjectProperty<FontReference> fontProperty() {
return font;
}
public static FontReference getFont() {
return fontProperty.get();
}
public static void setFont(FontReference font) {
fontProperty.set(font);
return font.get();
}
public static void setFontFamily(String fontFamily) {
setFont(fontFamily != null ? new FontReference(fontFamily) : null);
config().setLauncherFontFamily(fontFamily);
updateFont();
}
// https://github.com/HMCL-dev/HMCL/issues/4072
public static final class FontReference {
private final @NotNull String family;
private final @Nullable String style;
public record FontReference(@NotNull String family, @Nullable String style) {
public FontReference {
Objects.requireNonNull(family);
}
public FontReference(@NotNull String family) {
this.family = Objects.requireNonNull(family);
this.style = null;
this(family, null);
}
public FontReference(@NotNull Font font) {
this.family = font.getFamily();
this.style = font.getStyle();
}
public @NotNull String getFamily() {
return family;
}
public @Nullable String getStyle() {
return style;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof FontReference))
return false;
FontReference that = (FontReference) o;
return Objects.equals(family, that.family) && Objects.equals(style, that.style);
}
@Override
public int hashCode() {
return Objects.hash(family, style);
}
@Override
public String toString() {
return String.format("FontReference[family='%s', style='%s']", family, style);
this(font.getFamily(), font.getStyle());
}
}
private FontManager() {
}
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
@@ -114,6 +115,8 @@ public final class ProxyManager {
config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator);
FetchTask.notifyInitialized();
}
private static abstract class AbstractProxySelector extends ProxySelector {
@@ -90,11 +90,11 @@ public final class StyleSheets {
final String defaultCss = "/assets/css/font.css";
final FontManager.FontReference font = FontManager.getFont();
if (font == null || "System".equals(font.getFamily()))
if (font == null || "System".equals(font.family()))
return defaultCss;
String fontFamily = font.getFamily();
String style = font.getStyle();
String fontFamily = font.family();
String style = font.style();
String weight = null;
String posture = null;
@@ -0,0 +1,191 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
public final class TerracottaBundle {
private final Path root;
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck hash;
private final Map<String, FileDownloadTask.IntegrityCheck> files;
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
this.root = root;
this.links = links;
this.hash = hash;
this.files = files;
}
public Task<Path> download(AbstractTerracottaProvider.DownloadContext context) {
return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz"))
.thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
delegate.withResult(success);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
context.checkCancellation();
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess()) {
context.checkCancellation();
}
delegate.close();
}
};
}
};
context.bindProgress(download.progressProperty());
return download.thenSupplyAsync(() -> pkg);
});
}
public Task<?> install(Path pkg) {
return Task.runAsync(() -> {
Files.createDirectories(root);
FileUtils.cleanDirectory(root);
try (TarFileTree tree = TarFileTree.open(pkg)) {
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
String file = entry.getKey();
FileDownloadTask.IntegrityCheck check = entry.getValue();
Path path = root.resolve(file);
TarArchiveEntry archive = tree.getEntry("/" + file);
if (archive == null) {
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (
InputStream is = tree.getInputStream(archive);
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
) {
is.transferTo(os);
}
String hash = HexFormat.of().formatHex(digest.digest());
if (!check.getChecksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
}
switch (OperatingSystem.CURRENT_OS) {
case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode()));
}
}
}
}).whenComplete(exception -> {
if (exception != null) {
FileUtils.deleteDirectory(root);
}
});
}
public Path locate(String file) {
FileDownloadTask.IntegrityCheck check = files.get(file);
if (check == null) {
throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file));
}
return root.resolve(file).toAbsolutePath();
}
public AbstractTerracottaProvider.Status status() throws IOException {
if (Files.exists(root) && isLocalBundleValid()) {
return AbstractTerracottaProvider.Status.READY;
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.", e);
}
return AbstractTerracottaProvider.Status.NOT_EXIST;
}
private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer.
long total = 0;
byte[] buffer = new byte[8192];
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
Path path = root.resolve(entry.getKey());
FileDownloadTask.IntegrityCheck check = entry.getValue();
if (!Files.isReadable(path)) {
return false;
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
int n;
while ((n = is.read(buffer)) >= 0) {
total += n;
if (total >= 50 * 1024 * 1024) { // >=50MB
return false;
}
}
}
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
return false;
}
}
return true;
}
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
@@ -29,27 +28,32 @@ import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -70,9 +74,10 @@ public final class TerracottaManager {
switch (TerracottaMetadata.PROVIDER.status()) {
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
case READY -> launch(setState(new TerracottaState.Launching()));
case READY -> launch(setState(new TerracottaState.Launching()), false);
}
} catch (Exception e) {
LOG.warning("Cannot initialize Terracotta.", e);
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
});
@@ -82,114 +87,145 @@ public final class TerracottaManager {
return STATE.getReadOnlyProperty();
}
static {
Lang.thread(() -> {
while (true) {
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
LockSupport.parkNanos(500_000);
private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true);
@FXThread // Written in FXThread, read-only on background daemon
private static volatile boolean daemonRunning = false;
private static void runBackground() {
final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500);
final long BACKGROUND = TimeUnit.SECONDS.toMillis(15);
while (true) {
if (daemonRunning) {
LockSupport.parkNanos(ACTIVE);
} else {
long deadline = System.currentTimeMillis() + BACKGROUND;
do {
LockSupport.parkUntil(deadline);
} while (!daemonRunning && System.currentTimeMillis() < deadline - 100);
}
if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) {
continue;
}
int port = state.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port))
.retry(5)
.getJson(TerracottaState.Ready.class);
if (object.index <= index) {
continue;
}
int port = portSpecific.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(jsonString -> {
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
if (object.index <= index) {
return null;
}
object.port = port;
return object;
})
.setSignificance(Task.TaskSignificance.MINOR)
.run();
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
if (next != null) {
compareAndSet(state, next);
}
LockSupport.parkNanos(500_000);
object.port = port;
next = object;
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
}, "Terracotta Background Daemon", true);
compareAndSet(state, next);
}
}
public static boolean validate(Path file) {
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
public static TerracottaState.Preparing install(@Nullable Path file) {
@FXThread
public static void switchDaemon(boolean active) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
Task.supplyAsync(Schedulers.io(), () -> {
return file != null ? TarFileTree.open(file) : null;
}).thenComposeAsync(Schedulers.javafx(), tree -> {
return getProvider().install(preparing, tree).whenComplete(exception -> {
if (tree != null) {
tree.close();
}
if (exception != null) {
throw exception;
}
});
}).whenComplete(exception -> {
if (exception == null) {
try {
TerracottaMetadata.removeLegacyVersionFiles();
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching);
}
} else if (exception instanceof CancellationException) {
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
LOG.warning("Cannot install terracotta from local package.", exception);
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
boolean dr = daemonRunning;
if (dr != active) {
daemonRunning = active;
if (active) {
LockSupport.unpark(DAEMON);
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
}
private static ITerracottaProvider getProvider() {
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
private static AbstractTerracottaProvider getProvider() {
AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static TerracottaState recover(@Nullable Path file) {
public static boolean isInvalidBundle(Path file) {
return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
@FXThread
public static TerracottaState.Preparing download() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true);
Task.composeAsync(() -> getProvider().download(preparing))
.thenComposeAsync(pkg -> {
if (!preparing.requestInstallFence()) {
return null;
}
return getProvider().install(pkg).thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
});
}).whenComplete(exception -> {
if (exception instanceof CancellationException) {
// no-op
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState.Preparing install(Path bundle) {
FXUtils.checkFxUserThread();
if (isInvalidBundle(bundle)) {
return null;
}
TerracottaState state = STATE_V.get();
TerracottaState.Preparing preparing;
if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) {
preparing = previousPreparing;
} else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) {
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false);
} else {
return null;
}
Task.composeAsync(() -> getProvider().install(bundle))
.thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
})
.whenComplete(exception -> {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}).start();
return state != preparing && compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState recover() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
@@ -198,21 +234,23 @@ public final class TerracottaManager {
}
try {
// FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making
// hash check accept 50MB at most. Calling it on JavaFX should be safe.
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> install(file);
case NOT_EXIST, LEGACY_VERSION -> download();
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching);
launch(launching, false);
yield launching;
}
};
} catch (NullPointerException | IOException e) {
} catch (RuntimeException | IOException e) {
LOG.warning("Cannot determine Terracotta state.", e);
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}
private static void launch(TerracottaState.Launching state) {
private static void launch(TerracottaState.Launching state, boolean removeLegacy) {
Task.supplyAsync(() -> {
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
@@ -230,7 +268,7 @@ public final class TerracottaManager {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException("Process has exited for 10s.");
throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode()));
}
}
}
@@ -238,6 +276,9 @@ public final class TerracottaManager {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
if (removeLegacy) {
TerracottaMetadata.removeLegacyVersionFiles();
}
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
@@ -272,24 +313,39 @@ public final class TerracottaManager {
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
).setSignificance(Task.TaskSignificance.MINOR).start();
Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
"http://127.0.0.1:%d/state/scanning".formatted(portSpecific.port), query
)).setSignificance(Task.TaskSignificance.MINOR);
}).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
public static Task<TerracottaState.GuestConnecting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
return Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
});
} else {
return null;
}
@@ -20,10 +20,10 @@ package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
@@ -32,6 +32,8 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -40,11 +42,11 @@ import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -52,46 +54,60 @@ public final class TerracottaMetadata {
private TerracottaMetadata() {
}
public record Link(@SerializedName("desc") LocalizedText description, String link) {
private record Options(String version, String classifier) {
public String replace(String value) {
return value.replace("${version}", version).replace("${classifier}", classifier);
}
}
@JsonSerializable
public record Link(
@SerializedName("desc") LocalizedText description,
@SerializedName("link") String link
) {
}
@JsonSerializable
private record Package(
@SerializedName("hash") String hash,
@SerializedName("files") Map<String, String> files
) {
}
@JsonSerializable
private record Config(
@SerializedName("version_legacy") String legacy,
@SerializedName("version_recent") List<String> recent,
@SerializedName("version_latest") String latest,
@SerializedName("classifiers") Map<String, String> classifiers,
@SerializedName("packages") Map<String, Package> pkgs,
@SerializedName("downloads") List<String> downloads,
@SerializedName("downloads_CN") List<String> downloadsCN,
@SerializedName("links") List<Link> links
) {
private @Nullable TerracottaNative of(String classifier) {
String hash = this.classifiers.get(classifier);
if (hash == null)
private @Nullable TerracottaBundle resolve(Options options) {
Package pkg = pkgs.get(options.classifier);
if (pkg == null) {
return null;
if (!hash.startsWith("sha256:"))
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
hash = hash.substring("sha256:".length());
List<URI> links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size());
for (String download : LocaleUtils.IS_CHINA_MAINLAND
? Lang.merge(this.downloadsCN, this.downloads)
: Lang.merge(this.downloads, this.downloadsCN)) {
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
}
return new TerracottaNative(
Collections.unmodifiableList(links),
Metadata.DEPENDENCIES_DIRECTORY.resolve(
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
).toAbsolutePath(),
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> URI.create(options.replace(link)))
.toList();
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue())
));
return new TerracottaBundle(
Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(),
links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash),
files
);
}
}
public static final ITerracottaProvider PROVIDER;
public static final AbstractTerracottaProvider PROVIDER;
public static final String PACKAGE_NAME;
public static final List<Link> PACKAGE_LINKS;
public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of(
@@ -99,8 +115,6 @@ public final class TerracottaMetadata {
"launcher_version", Metadata.VERSION
));
private static final Pattern LEGACY;
private static final List<String> RECENT;
private static final String LATEST;
static {
@@ -111,95 +125,80 @@ public final class TerracottaMetadata {
throw new ExceptionInInitializerError(e);
}
LEGACY = Pattern.compile(config.legacy);
RECENT = config.recent;
LATEST = config.latest;
ProviderContext context = locateProvider(config);
PROVIDER = context != null ? context.provider() : null;
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
if (context != null) {
List<Link> packageLinks = new ArrayList<>(config.links.size());
for (Link link : config.links) {
packageLinks.add(new Link(
link.description,
link.link.replace("${version}", LATEST)
.replace("${classifier}", context.branch)
));
}
Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName());
TerracottaBundle bundle = config.resolve(options);
AbstractTerracottaProvider provider;
if (bundle == null || (provider = locateProvider(bundle, options)) == null) {
PROVIDER = null;
PACKAGE_NAME = null;
PACKAGE_LINKS = null;
} else {
PROVIDER = provider;
PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz");
List<Link> packageLinks = config.links.stream()
.map(link -> new Link(link.description, options.replace(link.link)))
.collect(Collectors.toList());
Collections.shuffle(packageLinks);
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
} else {
PACKAGE_LINKS = null;
}
}
private record ProviderContext(ITerracottaProvider provider, String branch) {
ProviderContext(ITerracottaProvider provider, String system, String arch) {
this(provider, system + "-" + arch);
}
}
@Nullable
private static ProviderContext locateProvider(Config config) {
String arch = Architecture.SYSTEM_ARCH.getCheckedName();
private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) {
String prefix = options.replace("terracotta-${version}-${classifier}");
// FIXME: As HMCL is a cross-platform application, developers may mistakenly locate
// non-existent files in non-native platform logic without assertion errors during debugging.
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
yield null;
TerracottaNative target = config.of("windows-%s.exe".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "windows", arch)
: null;
}
case LINUX -> {
TerracottaNative target = config.of("linux-%s".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "linux", arch)
: null;
}
case MACOS -> {
TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch));
TerracottaNative binary = config.of("macos-%s".formatted(arch));
yield installer != null && binary != null
? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch)
: null;
yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe"));
}
case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix));
case MACOS -> new MacOSProvider(
bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg")
);
default -> null;
};
}
public static void removeLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
continue;
}
public static void removeLegacyVersionFiles() {
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
if (terracotta == null)
return;
for (Path path : terracotta) {
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
}
public static boolean hasLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
return true;
}
}
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
return terracotta != null && terracotta.iterator().hasNext();
}
}
return false;
private static @Nullable DirectoryStream<Path> collectLegacyVersionFiles() throws IOException {
Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta");
if (Files.notExists(terracottaDir))
return null;
VersionRange<VersionNumber> range = VersionNumber.atMost(LATEST);
return Files.newDirectoryStream(terracottaDir, path -> {
String name = FileUtils.getName(path);
return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name));
});
}
}
@@ -1,147 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.CancellationException;
public final class TerracottaNative {
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck checking;
private final Path path;
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
this.links = links;
this.path = path;
this.checking = checking;
}
public Path getPath() {
return path;
}
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
if (tree == null) {
return new FileDownloadTask(links, path, checking) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
delegate.withResult(success);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
if (!context.hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess() && !context.requestInstallFence()) {
throw new CancellationException();
}
delegate.close();
}
};
}
};
}
return Task.runAsync(() -> {
String name = FileUtils.getName(path);
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
if (entry == null) {
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
}
if (!context.requestInstallFence()) {
throw new CancellationException();
}
Files.createDirectories(path.toAbsolutePath().getParent());
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
try (
InputStream stream = tree.getInputStream(entry);
OutputStream os = Files.newOutputStream(path)
) {
stream.transferTo(new OutputStream() {
@Override
public void write(int b) throws IOException {
os.write(b);
digest.update((byte) b);
}
@Override
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
os.write(buffer, offset, len);
digest.update(buffer, offset, len);
}
});
}
String checksum = HexFormat.of().formatHex(digest.digest());
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
Files.delete(path);
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
}
});
}
public ITerracottaProvider.Status status() throws IOException {
if (Files.exists(path)) {
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
return ITerracottaProvider.Status.READY;
}
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return ITerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
}
return ITerracottaProvider.Status.NOT_EXIST;
}
}
@@ -0,0 +1,103 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class TerracottaNodeList {
private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes";
@JsonSerializable
private record TerracottaNode(String url, @Nullable String region) implements Validation {
@Override
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
new URI(url);
} catch (URISyntaxException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
private static volatile List<URI> list;
public static List<URI> fetch() {
List<URI> list = TerracottaNodeList.list;
if (list != null)
return list;
synchronized (TerracottaNodeList.class) {
list = TerracottaNodeList.list;
if (list != null)
return list;
try {
List<TerracottaNode> nodes = HttpRequest.GET(NODE_LIST_URL)
.getJson(JsonUtils.listTypeOf(TerracottaNode.class));
if (nodes == null) {
list = List.of();
LOG.info("No available Terracotta nodes found");
} else {
list = nodes.stream()
.filter(node -> {
if (node == null)
return false;
try {
node.validate();
} catch (Exception e) {
LOG.warning("Invalid terracotta node: " + node, e);
return false;
}
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
})
.map(it -> URI.create(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
} catch (Exception e) {
LOG.warning("Failed to fetch terracotta node list", e);
list = List.of();
}
TerracottaNodeList.list = list;
return list;
}
}
private TerracottaNodeList() {
}
}
@@ -21,15 +21,16 @@ import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
@@ -63,32 +64,38 @@ public abstract sealed class TerracottaState {
}
}
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean installFence = new AtomicBoolean(false);
private final AtomicBoolean hasInstallFence;
Preparing(ReadOnlyDoubleWrapper progress) {
Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) {
this.progress = progress;
this.hasInstallFence = new AtomicBoolean(hasInstallFence);
}
public ReadOnlyDoubleProperty progressProperty() {
return progress.getReadOnlyProperty();
}
@Override
public void bindProgress(ObservableValue<? extends Number> value) {
progress.bind(value);
}
@Override
public boolean requestInstallFence() {
return installFence.compareAndSet(false, true);
return hasInstallFence.compareAndSet(true, false);
}
public boolean hasInstallFence() {
return hasInstallFence.get();
}
@Override
public boolean hasInstallFence() {
return !installFence.get();
public void bindProgress(ObservableDoubleValue progress) {
this.progress.bind(progress);
}
@Override
public void checkCancellation() {
if (!hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
}
}
@@ -112,7 +119,7 @@ public abstract sealed class TerracottaState {
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestConnecting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
@@ -202,12 +209,34 @@ public abstract sealed class TerracottaState {
}
}
public static final class GuestStarting extends Ready {
GuestStarting(int port, int index, String state) {
public static final class GuestConnecting extends Ready {
GuestConnecting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestStarting extends Ready {
public enum Difficulty {
UNKNOWN,
EASIEST,
SIMPLE,
MEDIUM,
TOUGH
}
@SerializedName("difficulty")
private final Difficulty difficulty;
GuestStarting(int port, int index, String state, Difficulty difficulty) {
super(port, index, state);
this.difficulty = difficulty;
}
public Difficulty getDifficulty() {
return difficulty;
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@@ -0,0 +1,63 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.FXThread;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
public abstract class AbstractTerracottaProvider {
public enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
public interface DownloadContext {
@FXThread
void bindProgress(ObservableDoubleValue progress);
void checkCancellation() throws CancellationException;
}
protected final TerracottaBundle bundle;
protected AbstractTerracottaProvider(TerracottaBundle bundle) {
this.bundle = bundle;
}
public Status status() throws IOException {
return bundle.status();
}
public final Task<Path> download(DownloadContext progress) {
return bundle.download(progress);
}
public Task<?> install(Path pkg) throws IOException {
return bundle.install(pkg);
}
public abstract List<String> ofCommandLine(Path portTransfer);
}
@@ -17,51 +17,21 @@
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
public final class GeneralProvider implements ITerracottaProvider {
private final TerracottaNative target;
public final class GeneralProvider extends AbstractTerracottaProvider {
private final Path executable;
public GeneralProvider(TerracottaNative target) {
this.target = target;
public GeneralProvider(TerracottaBundle bundle, Path executable) {
super(bundle);
this.executable = executable;
}
@Override
public Status status() throws IOException {
return target.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
Task<?> task = target.install(context, tree);
context.bindProgress(task.progressProperty());
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)));
}
return task;
}
@Override
public List<String> ofCommandLine(Path path) {
return List.of(target.getPath().toString(), "--hmcl", path.toString());
public List<String> ofCommandLine(Path portTransfer) {
return List.of(executable.toString(), "--hmcl", portTransfer.toString());
}
}
@@ -1,77 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
public interface ITerracottaProvider {
enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
interface Context {
void bindProgress(ObservableValue<? extends Number> value);
boolean requestInstallFence();
boolean hasInstallFence();
}
abstract class ProviderException extends IOException {
public ProviderException(String message) {
super(message);
}
public ProviderException(String message, Throwable cause) {
super(message, cause);
}
public ProviderException(Throwable cause) {
super(cause);
}
}
final class ArchiveFileMissingException extends ProviderException {
public ArchiveFileMissingException(String message) {
super(message);
}
public ArchiveFileMissingException(String message, Throwable cause) {
super(message, cause);
}
public ArchiveFileMissingException(Throwable cause) {
super(cause);
}
}
Status status() throws IOException;
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
List<String> ofCommandLine(Path path);
}
@@ -19,103 +19,77 @@ package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MacOSProvider implements ITerracottaProvider {
public final TerracottaNative installer, binary;
public final class MacOSProvider extends AbstractTerracottaProvider {
private final Path executable, installer;
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) {
super(bundle);
this.executable = executable;
this.installer = installer;
this.binary = binary;
}
@Override
public Status status() throws IOException {
assert binary != null;
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
return Status.NOT_EXIST;
}
return binary.status();
return bundle.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
assert installer != null && binary != null;
public Task<?> install(Path pkg) throws IOException {
return super.install(pkg).thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
Task<?> installerTask = installer.install(context, tree);
Task<?> binaryTask = binary.install(context, tree);
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer));
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
return Task.allOf(
installerTask.thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
movedInstaller, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer.getPath()));
Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(movedInstaller.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
pkg, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(pkg.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d",
pkg,
process.getExitCode()
));
}
});
}),
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)))
);
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode()
));
}
});
});
}
@Override
public List<String> ofCommandLine(Path path) {
assert binary != null;
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
return List.of(executable.toString(), "--hmcl", path.toString());
}
}
@@ -137,6 +137,7 @@ public final class FXUtils {
public static final @Nullable ReadOnlyObjectProperty<Color> ACCENT_COLOR;
public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY;
public static final @Nullable MethodHandle FOCUS_VISIBLE_PROPERTY;
static {
String jfxVersion = System.getProperty("javafx.version");
@@ -203,6 +204,20 @@ public final class FXUtils {
}
}
TEXT_TRUNCATED_PROPERTY = textTruncatedProperty;
MethodHandle focusVisibleProperty = null;
if (JAVAFX_MAJOR_VERSION >= 19) {
try {
focusVisibleProperty = MethodHandles.publicLookup().findVirtual(
Node.class,
"focusVisibleProperty",
MethodType.methodType(ReadOnlyBooleanProperty.class)
);
} catch (Throwable e) {
LOG.warning("Failed to lookup focusVisibleProperty", e);
}
}
FOCUS_VISIBLE_PROPERTY = focusVisibleProperty;
}
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
@@ -423,6 +438,20 @@ public final class FXUtils {
}
}
public static @Nullable ReadOnlyBooleanProperty focusVisibleProperty(Node node) {
if (FOCUS_VISIBLE_PROPERTY != null) {
try {
return (ReadOnlyBooleanProperty) FOCUS_VISIBLE_PROPERTY.invokeExact(node);
} catch (RuntimeException | Error e) {
throw e;
} catch (Throwable e) {
throw new RuntimeException(e);
}
} else {
return null;
}
}
private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50);
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
@@ -1188,12 +1217,14 @@ public final class FXUtils {
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
}
public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uri)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
}
@@ -1208,6 +1239,7 @@ public final class FXUtils {
LOG.warning("An exception encountered while loading remote image: " + url, exception);
}
})
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return image;
}
@@ -1404,16 +1436,6 @@ public final class FXUtils {
});
}
public static void clearFocus(Node node) {
Scene scene = node.getScene();
if (scene != null) {
Parent root = scene.getRoot();
if (root != null) {
root.requestFocus();
}
}
}
public static void copyOnDoubleClick(Labeled label) {
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRippler;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
@@ -62,7 +63,7 @@ public class InstallerItem extends Control {
private final ObjectProperty<Runnable> onInstall = new SimpleObjectProperty<>(this, "onInstall");
private final ObjectProperty<Runnable> onRemove = new SimpleObjectProperty<>(this, "onRemove");
public interface State {
public sealed interface State {
}
public static final class InstallableState implements State {
@@ -72,46 +73,10 @@ public class InstallerItem extends Control {
}
}
public static final class IncompatibleState implements State {
private final String incompatibleItemName;
private final String incompatibleItemVersion;
public IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) {
this.incompatibleItemName = incompatibleItemName;
this.incompatibleItemVersion = incompatibleItemVersion;
}
public String getIncompatibleItemName() {
return incompatibleItemName;
}
public String getIncompatibleItemVersion() {
return incompatibleItemVersion;
}
public record IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) implements State {
}
public static final class InstalledState implements State {
private final String version;
private final boolean external;
private final boolean incompatibleWithGame;
public InstalledState(String version, boolean external, boolean incompatibleWithGame) {
this.version = version;
this.external = external;
this.incompatibleWithGame = incompatibleWithGame;
}
public String getVersion() {
return version;
}
public boolean isExternal() {
return external;
}
public boolean isIncompatibleWithGame() {
return incompatibleWithGame;
}
public record InstalledState(String version, boolean external, boolean incompatibleWithGame) implements State {
}
public enum Style {
@@ -335,10 +300,15 @@ public class InstallerItem extends Control {
}
pane.getStyleClass().add("installer-item");
RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container);
container.setPosition(JFXRippler.RipplerPos.BACK);
StackPane paneWrapper = new StackPane();
paneWrapper.getStyleClass().add("installer-item-wrapper");
paneWrapper.getChildren().setAll(container);
getChildren().setAll(paneWrapper);
pane.pseudoClassStateChanged(LIST_ITEM, control.style == Style.LIST_ITEM);
pane.pseudoClassStateChanged(CARD, control.style == Style.CARD);
paneWrapper.pseudoClassStateChanged(CARD, control.style == Style.CARD);
if (control.iconType != null) {
ImageView view = new ImageView(control.iconType.getIcon());
@@ -367,21 +337,20 @@ public class InstallerItem extends Control {
statusLabel.textProperty().bind(Bindings.createStringBinding(() -> {
State state = control.resolvedStateProperty.get();
if (state instanceof InstalledState) {
InstalledState s = (InstalledState) state;
if (s.incompatibleWithGame) {
return i18n("install.installer.change_version", s.version);
if (state instanceof InstalledState installedState) {
if (installedState.incompatibleWithGame) {
return i18n("install.installer.change_version", installedState.version);
}
if (s.external) {
return i18n("install.installer.external_version", s.version);
if (installedState.external) {
return i18n("install.installer.external_version", installedState.version);
}
return i18n("install.installer.version", s.version);
return i18n("install.installer.version", installedState.version);
} else if (state instanceof InstallableState) {
return control.style == Style.CARD
? i18n("install.installer.do_not_install")
: i18n("install.installer.not_installed");
} else if (state instanceof IncompatibleState) {
return i18n("install.installer.incompatible", i18n("install.installer." + ((IncompatibleState) state).incompatibleItemName));
} else if (state instanceof IncompatibleState incompatibleState) {
return i18n("install.installer.incompatible", i18n("install.installer." + incompatibleState.incompatibleItemName));
} else {
throw new AssertionError("Unknown state type: " + state.getClass());
}
@@ -402,7 +371,7 @@ public class InstallerItem extends Control {
} else {
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
State state = control.resolvedStateProperty.get();
return state instanceof InstalledState && !((InstalledState) state).external;
return state instanceof InstalledState installedState && !installedState.external;
}, control.resolvedStateProperty));
}
removeButton.managedProperty().bind(removeButton.visibleProperty());
@@ -54,6 +54,8 @@ final class ScrollUtils {
private static final double DEFAULT_SPEED = 1.0;
private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0;
private static final double CUTOFF_DELTA = 0.01;
/**
* Determines if the given ScrollEvent comes from a trackpad.
* <p></p>
@@ -210,7 +212,7 @@ final class ScrollUtils {
break;
}
if (Math.abs(dy) < 0.001) {
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
@@ -255,7 +257,7 @@ final class ScrollUtils {
double dy = derivatives[derivatives.length - 1];
virtualFlow.scrollPixels(dy);
if (Math.abs(dy) < 0.001) {
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
@@ -18,24 +18,26 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ListCell;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import java.util.List;
public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>> extends SkinBase<T> {
public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends SkinBase<P> {
public ToolbarListPageSkin(T skinnable) {
protected final JFXListView<E> listView;
public ToolbarListPageSkin(P skinnable) {
super(skinnable);
SpinnerPane spinnerPane = new SpinnerPane();
@@ -58,18 +60,12 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
}
{
ScrollPane scrollPane = new ScrollPane();
ComponentList.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
VBox content = new VBox();
Bindings.bindContent(content.getChildren(), skinnable.itemsProperty());
scrollPane.setContent(content);
FXUtils.smoothScrolling(scrollPane);
root.getContent().add(scrollPane);
this.listView = new JFXListView<>();
this.listView.setPadding(Insets.EMPTY);
this.listView.setCellFactory(listView -> createListCell((JFXListView<E>) listView));
ComponentList.setVgrow(listView, Priority.ALWAYS);
Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty());
root.getContent().add(listView);
}
spinnerPane.setContent(root);
@@ -89,10 +85,7 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
ret.getStyleClass().add("jfx-tool-bar-button");
ret.setGraphic(wrap(svg.createIcon()));
ret.setText(text);
ret.setOnAction(e -> {
onClick.run();
FXUtils.clearFocus(ret);
});
ret.setOnAction(e -> onClick.run());
return ret;
}
@@ -105,5 +98,21 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
return ret;
}
protected abstract List<Node> initializeToolbar(T skinnable);
protected abstract List<Node> initializeToolbar(P skinnable);
protected ListCell<E> createListCell(JFXListView<E> listView) {
return new ListCell<>() {
@Override
protected void updateItem(E item, boolean empty) {
super.updateItem(item, empty);
if (!empty && item instanceof Node node) {
setGraphic(node);
setText(null);
} else {
setGraphic(null);
setText(null);
}
}
};
}
}
@@ -55,4 +55,8 @@ public class WeakListenerHolder {
public boolean remove(Object obj) {
return refs.remove(obj);
}
public void clear() {
refs.clear();
}
}
@@ -130,7 +130,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
btnRefresh.setDisable(true);
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot.tooltip"));
}
btnRefresh.setOnAction(e -> {
spinnerRefresh.showSpinner();
@@ -164,6 +164,12 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
private String resolveFetchExceptionMessage(Throwable exception) {
if (exception instanceof SSLException) {
if (exception.getMessage() != null && exception.getMessage().contains("Remote host terminated")) {
return i18n("account.failed.connect_injector_server");
}
if (exception.getMessage() != null && (exception.getMessage().contains("No name matching") || exception.getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("account.failed.dns");
}
return i18n("account.failed.ssl");
} else if (exception instanceof IOException) {
return i18n("account.failed.connect_injector_server");
@@ -24,6 +24,7 @@ import com.jfoenix.controls.JFXTextField;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.Label;
import javafx.scene.input.DragEvent;
import javafx.scene.input.TransferMode;
@@ -174,11 +175,19 @@ public class OfflineAccountSkinPane extends StackPane {
case LITTLE_SKIN:
HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO);
hint.setText(i18n("account.skin.type.little_skin.hint"));
// Allow the tooltip to span two columns and expand horizontally to avoid overlapping with the dialog action area/top-right help button
// Spanning two columns and expanding horizontally
GridPane.setColumnSpan(hint, 2);
GridPane.setHgrow(hint, Priority.ALWAYS);
hint.setMaxWidth(Double.MAX_VALUE);
// Force top alignment within cells (to avoid vertical offset caused by the baseline)
GridPane.setValignment(hint, VPos.TOP);
// Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space.
hint.setMaxHeight(Region.USE_PREF_SIZE);
hint.setMinHeight(Region.USE_PREF_SIZE);
gridPane.addRow(0, hint);
break;
case LOCAL_FILE:
@@ -82,7 +82,9 @@ public class TransitionPane extends StackPane {
duration, interpolator);
newAnimation.setOnFinished(e -> {
setMouseTransparent(false);
getChildren().remove(previousNode);
if (previousNode != currentNode) {
getChildren().remove(previousNode);
}
if (cacheHint != null) {
newView.setCache(false);
@@ -87,7 +87,7 @@ public final class MessageDialogPane extends HBox {
{
StackPane titlePane = new StackPane();
titlePane.getStyleClass().addAll("jfx-layout-heading", "title");
titlePane.getChildren().setAll(new Label(title != null ? title : i18n(type.getDisplayName())));
titlePane.getChildren().setAll(new Label(title != null ? title : type.getDisplayName()));
StackPane content = new StackPane();
content.getStyleClass().add("jfx-layout-body");
@@ -190,6 +190,7 @@ public final class MessageDialogPane extends HBox {
public Builder addCancel(String cancelText, @Nullable Runnable cancel) {
JFXButton btnCancel = new JFXButton(cancelText);
btnCancel.setButtonType(JFXButton.ButtonType.FLAT);
btnCancel.getStyleClass().add("dialog-cancel");
if (cancel != null) {
btnCancel.setOnAction(e -> cancel.run());
@@ -181,6 +181,7 @@ public final class MultiFileItem<T> extends VBox {
center.setWrapText(true);
center.getStyleClass().add("subtitle-label");
center.setStyle("-fx-font-size: 10;");
center.setPadding(new Insets(0, 0, 0, 15));
pane.setCenter(center);
}
@@ -144,12 +144,20 @@ public class RipplerContainer extends StackPane {
}
protected void updateChildren() {
getChildren().addAll(buttonContainer, getContainer());
if (buttonRippler.getPosition() == JFXRippler.RipplerPos.BACK)
getChildren().setAll(buttonContainer, getContainer());
else
getChildren().setAll(getContainer(), buttonContainer);
for (int i = 1; i < getChildren().size(); ++i)
getChildren().get(i).setPickOnBounds(false);
}
public void setPosition(JFXRippler.RipplerPos pos) {
buttonRippler.setPosition(pos);
updateChildren();
}
public Node getContainer() {
return container.get();
}
@@ -264,7 +264,7 @@ public class DecoratorController {
private Background createBackgroundWithOpacity(Image image, int opacity) {
if (opacity <= 0) {
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
} else if (opacity >= 100) {
} else if (opacity >= 100 || image.getPixelReader() == null) {
return new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
@@ -50,7 +50,7 @@ public class InstallersPage extends AbstractInstallersPage {
@Override
public String getTitle() {
return group.getGame().versionProperty().get().getVersion();
return group.getGame().versionProperty().get().version();
}
private String getVersion(String id) {
@@ -111,7 +111,7 @@ public class InstallersPage extends AbstractInstallersPage {
}
private void setTxtNameWithLoaders() {
StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().getVersion());
StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().version());
for (InstallerItem library : group.getLibraries()) {
String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), "");
@@ -139,7 +139,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
} else {
reject.accept(e.getMessage());
}
}).executor(true), i18n("message.downloading"), TaskCancellationAction.NORMAL);
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
} else {
// otherwise we still consider the file as modpack zip file
// since casually the url may not ends with ".zip"
@@ -156,7 +156,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
} else {
reject.accept(e.getMessage());
}
}).executor(true),
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL
);
@@ -175,10 +175,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
{
if ("game".equals(control.libraryId)) {
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> {
onOpenWiki();
FXUtils.clearFocus(wikiButton);
});
wikiButton.setOnAction(event -> onOpenWiki());
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
actions.getChildren().add(wikiButton);
}
@@ -18,17 +18,19 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.java.JavaInfo;
import org.jackhuang.hmcl.java.JavaManager;
@@ -42,6 +44,7 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -62,7 +65,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class JavaManagementPage extends ListPageBase<JavaManagementPage.JavaItem> {
public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
@SuppressWarnings("FieldCanBeLocal")
private final ChangeListener<Collection<JavaRuntime>> listener;
@@ -172,130 +175,17 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
}).start();
}
// FXThread
@FXThread
private void loadJava(Collection<JavaRuntime> javaRuntimes) {
if (javaRuntimes != null) {
List<JavaItem> items = new ArrayList<>();
for (JavaRuntime java : javaRuntimes) {
items.add(new JavaItem(java));
}
this.setItems(FXCollections.observableList(items));
this.setItems(FXCollections.observableArrayList(javaRuntimes));
this.setLoading(false);
} else
} else {
this.setLoading(true);
}
static final class JavaItem extends Control {
private final JavaRuntime java;
public JavaItem(JavaRuntime java) {
this.java = java;
}
public JavaRuntime getJava() {
return java;
}
public void onReveal() {
Path target;
Path parent = java.getBinary().getParent();
if (parent != null
&& parent.getParent() != null
&& parent.getFileName() != null
&& parent.getFileName().toString().equals("bin")
&& Files.exists(parent.getParent().resolve("release"))) {
target = parent.getParent();
} else {
target = java.getBinary();
}
FXUtils.showFileInExplorer(target);
}
public void onRemove() {
if (java.isManaged()) {
Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL);
} else {
String path = java.getBinary().toString();
ConfigHolder.globalConfig().getUserJava().remove(path);
ConfigHolder.globalConfig().getDisabledJava().add(path);
try {
JavaManager.removeJava(java);
} catch (InterruptedException ignored) {
}
}
}
@Override
protected Skin<?> createDefaultSkin() {
return new JavaRuntimeItemSkin(this);
}
}
private static final class JavaRuntimeItemSkin extends SkinBase<JavaItem> {
JavaRuntimeItemSkin(JavaItem control) {
super(control);
JavaRuntime java = control.getJava();
String vendor = JavaInfo.normalizeVendor(java.getVendor());
BorderPane root = new BorderPane();
HBox center = new HBox();
center.setMouseTransparent(true);
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
TwoLineListItem item = new TwoLineListItem();
item.setTitle((java.isJDK() ? "JDK" : "JRE") + " " + java.getVersion());
item.setSubtitle(java.getBinary().toString());
item.addTag(i18n("java.info.architecture") + ": " + java.getArchitecture().getDisplayName());
if (vendor != null)
item.addTag(i18n("java.info.vendor") + ": " + vendor);
BorderPane.setAlignment(item, Pos.CENTER);
center.getChildren().setAll(item);
root.setCenter(center);
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
{
JFXButton revealButton = new JFXButton();
revealButton.getStyleClass().add("toggle-icon4");
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
revealButton.setOnAction(e -> control.onReveal());
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
JFXButton removeButton = new JFXButton();
removeButton.getStyleClass().add("toggle-icon4");
removeButton.setOnAction(e -> Controllers.confirm(
java.isManaged() ? i18n("java.uninstall.confirm") : i18n("java.disable.confirm"),
i18n("message.warning"),
control::onRemove,
null
));
if (java.isManaged()) {
removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE_FOREVER.createIcon(24), 24, 24));
FXUtils.installFastTooltip(removeButton, i18n("java.uninstall"));
if (JavaRuntime.CURRENT_JAVA != null && java.getBinary().equals(JavaRuntime.CURRENT_JAVA.getBinary()))
removeButton.setDisable(true);
} else {
removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE.createIcon(24), 24, 24));
FXUtils.installFastTooltip(removeButton, i18n("java.disable"));
}
right.getChildren().setAll(revealButton, removeButton);
}
root.setRight(right);
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
getChildren().setAll(new RipplerContainer(root));
}
}
private static final class JavaPageSkin extends ToolbarListPageSkin<JavaManagementPage> {
private static final class JavaPageSkin extends ToolbarListPageSkin<JavaRuntime, JavaManagementPage> {
JavaPageSkin(JavaManagementPage skinnable) {
super(skinnable);
@@ -317,5 +207,139 @@ public final class JavaManagementPage extends ListPageBase<JavaManagementPage.Ja
return res;
}
@Override
protected ListCell<JavaRuntime> createListCell(JFXListView<JavaRuntime> listView) {
return new JavaItemCell();
}
}
private static final class JavaItemCell extends ListCell<JavaRuntime> {
private final Node graphic;
private final TwoLineListItem content;
private SVG removeIcon;
private final StackPane removeIconPane;
private final Tooltip removeTooltip = new Tooltip();
JavaItemCell() {
BorderPane root = new BorderPane();
HBox center = new HBox();
center.setMouseTransparent(true);
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
this.content = new TwoLineListItem();
BorderPane.setAlignment(content, Pos.CENTER);
center.getChildren().setAll(content);
root.setCenter(center);
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
{
JFXButton revealButton = new JFXButton();
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
revealButton.getStyleClass().add("toggle-icon4");
revealButton.setOnAction(e -> {
JavaRuntime java = getItem();
if (java != null)
onReveal(java);
});
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
JFXButton removeButton = new JFXButton();
removeButton.getStyleClass().add("toggle-icon4");
removeButton.setOnAction(e -> {
JavaRuntime java = getItem();
if (java != null)
onRemove(java);
});
FXUtils.installFastTooltip(removeButton, removeTooltip);
this.removeIconPane = new StackPane();
removeIconPane.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(removeIconPane, 24);
FXUtils.setLimitHeight(removeIconPane, 24);
removeButton.setGraphic(removeIconPane);
right.getChildren().setAll(revealButton, removeButton);
}
root.setRight(right);
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
this.graphic = new RipplerContainer(root);
}
@Override
protected void updateItem(JavaRuntime item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setGraphic(null);
} else {
content.setTitle((item.isJDK() ? "JDK" : "JRE") + " " + item.getVersion());
content.setSubtitle(item.getBinary().toString());
content.getTags().clear();
content.addTag(i18n("java.info.architecture") + ": " + item.getArchitecture().getDisplayName());
String vendor = JavaInfo.normalizeVendor(item.getVendor());
if (vendor != null)
content.addTag(i18n("java.info.vendor") + ": " + vendor);
SVG newRemoveIcon = item.isManaged() ? SVG.DELETE_FOREVER : SVG.DELETE;
if (removeIcon != newRemoveIcon) {
removeIcon = newRemoveIcon;
removeIconPane.getChildren().setAll(removeIcon.createIcon(24));
removeTooltip.setText(item.isManaged() ? i18n("java.uninstall") : i18n("java.disable"));
}
setGraphic(graphic);
}
}
private void onReveal(JavaRuntime java) {
Path target;
Path parent = java.getBinary().getParent();
if (parent != null
&& parent.getParent() != null
&& parent.getFileName() != null
&& parent.getFileName().toString().equals("bin")
&& Files.exists(parent.getParent().resolve("release"))) {
target = parent.getParent();
} else {
target = java.getBinary();
}
FXUtils.showFileInExplorer(target);
}
private void onRemove(JavaRuntime java) {
if (java.isManaged()) {
Controllers.confirm(
i18n("java.uninstall.confirm"),
i18n("message.warning"),
() -> Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL),
null
);
} else {
Controllers.confirm(
i18n("java.disable.confirm"),
i18n("message.warning"),
() -> {
String path = java.getBinary().toString();
ConfigHolder.globalConfig().getUserJava().remove(path);
ConfigHolder.globalConfig().getDisabledJava().add(path);
try {
JavaManager.removeJava(java);
} catch (InterruptedException ignored) {
}
},
null
);
}
}
}
}
@@ -192,7 +192,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
}
}
private static final class JavaRestorePageSkin extends ToolbarListPageSkin<JavaRestorePage> {
private static final class JavaRestorePageSkin extends ToolbarListPageSkin<DisabledJavaItem, JavaRestorePage> {
JavaRestorePageSkin(JavaRestorePage skinnable) {
super(skinnable);
}
@@ -22,14 +22,12 @@ import com.jfoenix.controls.JFXPopup;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
@@ -59,10 +57,10 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.GameItem;
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
@@ -73,7 +71,6 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.io.IOException;
import java.util.List;
@@ -92,16 +89,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final PopupMenu menu = new PopupMenu();
private final StackPane popupWrapper = new StackPane(menu);
private final JFXPopup popup = new JFXPopup(popupWrapper);
private final StringProperty currentGame = new SimpleStringProperty(this, "currentGame");
private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
private final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>(this, "latestVersion");
private final ObservableList<Version> versions = FXCollections.observableArrayList();
private final ObservableList<Node> versionNodes;
private Profile profile;
private TransitionPane announcementPane;
@@ -273,19 +264,6 @@ public final class MainPage extends StackPane implements DecoratorPage {
getChildren().addAll(updatePane, launchPane);
menu.setMaxHeight(365);
menu.setMaxWidth(545);
menu.setAlwaysShowingVBar(false);
FXUtils.onClicked(menu, popup::hide);
versionNodes = MappedObservableList.create(versions, version -> {
Node node = PopupMenu.wrapPopupMenuItem(new GameItem(profile, version.getId()));
FXUtils.onClicked(node, () -> {
profile.setSelectedVersion(version.getId());
popup.hide();
});
return node;
});
Bindings.bindContent(menu.getContent(), versionNodes);
}
private void showUpdate(boolean show) {
@@ -365,20 +343,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
private void onMenu() {
Node contentNode;
if (menu.getContent().isEmpty()) {
Label placeholder = new Label(i18n("version.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
contentNode = placeholder;
} else {
contentNode = menu;
}
popupWrapper.getChildren().setAll(contentNode);
if (popup.isShowing()) {
popup.hide();
}
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
@@ -238,7 +238,14 @@ public class PersonalizationPage extends StackPane {
.fallbackTo(12.0)
.asPredicate(Validator.addTo(txtLogFontSize)));
hBox.getChildren().setAll(cboLogFont, txtLogFontSize);
JFXButton clearButton = new JFXButton();
clearButton.getStyleClass().add("toggle-icon4");
clearButton.setGraphic(SVG.RESTORE.createIcon());
clearButton.setOnAction(e -> cboLogFont.setValue(null));
FXUtils.installFastTooltip(clearButton, i18n("button.reset"));
hBox.getChildren().setAll(cboLogFont, txtLogFontSize, clearButton);
borderPane.setRight(hBox);
}
@@ -57,7 +57,13 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
@@ -96,6 +102,11 @@ public class TerracottaControllerPage extends StackPane {
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
Consider moving UI into multiple files for each state respectively. */
public TerracottaControllerPage() {
holder.add(FXUtils.observeWeak(() -> {
// Run daemon process only if HMCL is focused and is displaying current node.
TerracottaManager.switchDaemon(getScene() != null && Controllers.getStage().isFocused());
}, this.sceneProperty(), Controllers.getStage().focusedProperty()));
TransitionPane transition = new TransitionPane();
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
@@ -107,7 +118,7 @@ public class TerracottaControllerPage extends StackPane {
if (state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK
state instanceof TerracottaState.Fatal fatal && fatal.isRecoverable()
) {
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
} else {
@@ -116,7 +127,7 @@ public class TerracottaControllerPage extends StackPane {
}, files -> {
Path path = files.get(0);
if (!TerracottaManager.validate(path)) {
if (TerracottaManager.isInvalidBundle(path)) {
Controllers.dialog(
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
i18n("message.error"),
@@ -125,25 +136,7 @@ public class TerracottaControllerPage extends StackPane {
return;
}
TerracottaState state = UI_STATE.get(), next;
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5,
MessageDialogPane.MessageType.INFO, () -> {
TerracottaState.Preparing s = TerracottaManager.install(path);
if (s != null) {
UI_STATE.set(s);
}
}, null);
return;
}
next = TerracottaManager.install(path);
} else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
next = TerracottaManager.recover(path);
} else {
return;
}
TerracottaState.Preparing next = TerracottaManager.install(path);
if (next != null) {
UI_STATE.set(next);
}
@@ -176,7 +169,7 @@ public class TerracottaControllerPage extends StackPane {
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
download.setRightIcon(SVG.ARROW_FORWARD);
FXUtils.onClicked(download, () -> {
TerracottaState.Preparing s = TerracottaManager.install(null);
TerracottaState.Preparing s = TerracottaManager.download();
if (s != null) {
UI_STATE.set(s);
}
@@ -253,7 +246,7 @@ public class TerracottaControllerPage extends StackPane {
guest.setRightIcon(SVG.ARROW_FORWARD);
FXUtils.onClicked(guest, () -> {
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> {
Task<TerracottaState.GuestStarting> task = TerracottaManager.setGuesting(code);
Task<TerracottaState.GuestConnecting> task = TerracottaManager.setGuesting(code);
if (task != null) {
task.whenComplete(Schedulers.javafx(), (s, e) -> {
if (e != null) {
@@ -372,7 +365,7 @@ public class TerracottaControllerPage extends StackPane {
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
}
}
} else if (state instanceof TerracottaState.GuestStarting) {
} else if (state instanceof TerracottaState.GuestConnecting || state instanceof TerracottaState.GuestStarting) {
statusProperty.set(i18n("terracotta.status.guest_starting"));
progressProperty.set(-1);
@@ -387,7 +380,26 @@ public class TerracottaControllerPage extends StackPane {
}
});
nodesProperty.setAll(room);
nodesProperty.clear();
if (state instanceof TerracottaState.GuestStarting) {
TerracottaState.GuestStarting.Difficulty difficulty = ((TerracottaState.GuestStarting) state).getDifficulty();
if (difficulty != null && difficulty != TerracottaState.GuestStarting.Difficulty.UNKNOWN) {
LineButton info = LineButton.of();
info.setLeftIcon(switch (difficulty) {
case UNKNOWN -> throw new AssertionError();
case EASIEST, SIMPLE -> SVG.INFO;
case MEDIUM, TOUGH -> SVG.WARNING;
});
String difficultyID = difficulty.name().toLowerCase(Locale.ROOT);
info.setTitle(i18n(String.format("terracotta.difficulty.%s", difficultyID)));
info.setSubtitle(i18n("terracotta.difficulty.estimate_only"));
nodesProperty.add(info);
}
}
nodesProperty.add(room);
} else if (state instanceof TerracottaState.GuestOK guestOK) {
if (guestOK.isForkOf(legacyState)) {
if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) {
@@ -486,7 +498,7 @@ public class TerracottaControllerPage extends StackPane {
retry.setTitle(i18n("terracotta.status.fatal.retry"));
retry.setSubtitle(message);
FXUtils.onClicked(retry, () -> {
TerracottaState s = TerracottaManager.recover(null);
TerracottaState s = TerracottaManager.recover();
if (s != null) {
UI_STATE.set(s);
}
@@ -85,8 +85,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> mainPage.getProfile().setSelectedVersion(it.getId()));
})
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK))
.addNavigationDrawerItem(i18n("terracotta.easytier"), SVG.HOST, () -> FXUtils.openLink("https://easytier.cn/"));
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
left.setBottom(toolbar);
@@ -73,6 +73,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
getChildren().setAll(scrollPane);
VBox rootPane = new VBox();
rootPane.setFocusTraversable(true);
rootPane.setFillWidth(true);
scrollPane.setContent(rootPane);
FXUtils.smoothScrolling(scrollPane);
@@ -48,14 +48,12 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
public DatapackListPage(WorldManagePage worldManagePage) {
this.worldDir = worldManagePage.getWorld().getFile();
datapack = new Datapack(worldDir.resolve("datapacks"));
datapack.loadFromDir();
setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new));
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
mods -> mods.forEach(this::installSingleDatapack), this::refresh);
refresh();
}
private void installSingleDatapack(Path datapack) {
@@ -74,9 +72,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
public void refresh() {
setLoading(true);
Task.runAsync(datapack::loadFromDir)
.withRunAsync(Schedulers.javafx(), () -> {
setLoading(false);
})
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
.start();
}
@@ -56,29 +56,20 @@ import org.jackhuang.hmcl.ui.construct.FloatListCell;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.AggregatedObservableList;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadListPage extends Control implements DecoratorPage, VersionPage.VersionLoadable {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
@@ -208,6 +199,10 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
: i18n("curse.category." + category);
}
protected boolean shouldDisplayCategory(String category) {
return !"minecraft".equals(category);
}
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
return StringUtils.repeats(' ', category.indent * 4) +
(category.getCategory() == null
@@ -243,6 +238,12 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteMod> listView = new JFXListView<>();
private final RemoteImageLoader iconLoader = new RemoteImageLoader() {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull URI uri) {
return FXUtils.getRemoteImageTask(uri, 80, 80, true, true);
}
};
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
@@ -373,6 +374,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
EventHandler<ActionEvent> searchAction = e -> {
iconLoader.clearInvalidCache();
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
control.pageOffset.set(0);
}
@@ -532,7 +534,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
var iconCache = new WeakHashMap<String, WeakReference<CompletableFuture<Image>>>();
listView.setCellFactory(x -> new FloatListCell<>(listView) {
private final TwoLineListItem content = new TwoLineListItem();
private final ImageView imageView = new ImageView();
@@ -556,69 +558,13 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : dataItem.getTitle());
content.setSubtitle(dataItem.getDescription());
content.getTags().clear();
dataItem.getCategories().stream()
.map(category -> getSkinnable().getLocalizedCategory(category))
.forEach(content::addTag);
loadIcon(dataItem);
for (String category : dataItem.getCategories()) {
if (getSkinnable().shouldDisplayCategory(category))
content.addTag(getSkinnable().getLocalizedCategory(category));
}
iconLoader.load(imageView.imageProperty(), dataItem.getIconUrl());
}
private void loadIcon(RemoteMod mod) {
if (StringUtils.isBlank(mod.getIconUrl())) {
imageView.setImage(null);
return;
}
WeakReference<CompletableFuture<Image>> cacheRef = iconCache.get(mod.getIconUrl());
CompletableFuture<Image> cache;
if (cacheRef != null && (cache = cacheRef.get()) != null) {
loadIcon(cache, mod.getIconUrl());
return;
}
URI iconUrl = NetworkUtils.toURIOrNull(mod.getIconUrl());
if (iconUrl == null) {
imageView.setImage(null);
return;
}
CompletableFuture<Image> future = new CompletableFuture<>();
WeakReference<CompletableFuture<Image>> futureRef = new WeakReference<>(future);
iconCache.put(mod.getIconUrl(), futureRef);
FXUtils.getRemoteImageTask(iconUrl, 80, 80, true, true)
.whenComplete(Schedulers.defaultScheduler(), (result, exception) -> {
if (exception == null) {
future.complete(result);
} else {
LOG.warning("Failed to load image from " + iconUrl, exception);
future.completeExceptionally(exception);
}
}).start();
loadIcon(future, mod.getIconUrl());
}
private void loadIcon(@NotNull CompletableFuture<Image> future,
@NotNull String iconUrl) {
Image image;
try {
image = future.getNow(null);
} catch (CancellationException | CompletionException ignored) {
imageView.setImage(null);
return;
}
if (image != null) {
imageView.setImage(image);
} else {
imageView.setImage(null);
future.thenAcceptAsync(result -> {
RemoteMod item = getItem();
if (item != null && iconUrl.equals(item.getIconUrl())) {
this.imageView.setImage(result);
}
}, Schedulers.javafx());
}
}
});
}
@@ -357,9 +357,10 @@ public class DownloadPage extends Control implements DecoratorPage {
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(page.repository.getType()).getModByCurseForgeId(addon.getSlug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.getTitle());
content.setSubtitle(addon.getDescription());
addon.getCategories().stream()
.map(page::getLocalizedCategory)
.forEach(content::addTag);
for (String category : addon.getCategories()) {
if (page.shouldDisplayCategory(category))
content.addTag(page.getLocalizedCategory(category));
}
if (StringUtils.isNotBlank(addon.getIconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 80, 80, true, true));
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,107 +17,122 @@
*/
package org.jackhuang.hmcl.ui.versions;
import com.google.gson.JsonParseException;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.beans.property.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
import static org.jackhuang.hmcl.util.Lang.handleUncaught;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameItem extends Control {
public class GameItem {
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 1, TimeUnit.SECONDS);
protected final Profile profile;
protected final String id;
private final Profile profile;
private final String version;
private final StringProperty title = new SimpleStringProperty();
private final StringProperty tag = new SimpleStringProperty();
private final StringProperty subtitle = new SimpleStringProperty();
private final ObjectProperty<Image> image = new SimpleObjectProperty<>();
private boolean initialized = false;
private StringProperty title;
private StringProperty tag;
private StringProperty subtitle;
private ObjectProperty<Image> image;
public GameItem(Profile profile, String id) {
this.profile = profile;
this.version = id;
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
CompletableFuture.supplyAsync(() -> profile.getRepository().getGameVersion(id), POOL_VERSION_RESOLVE)
.thenAcceptAsync(game -> {
StringBuilder libraries = new StringBuilder(game.orElse(i18n("message.unknown")));
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), game.orElse(null));
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
String libraryId = mark.getLibraryId();
String libraryVersion = mark.getLibraryVersion();
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
if (I18n.hasKey("install.installer." + libraryId)) {
libraries.append(", ").append(i18n("install.installer." + libraryId));
if (libraryVersion != null)
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
}
}
subtitle.set(libraries.toString());
}, Platform::runLater)
.exceptionally(handleUncaught);
CompletableFuture.runAsync(() -> {
try {
ModpackConfiguration<?> config = profile.getRepository().readModpackConfiguration(version);
if (config == null) return;
tag.set(config.getVersion());
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to read modpack configuration from " + version, e);
}
}, Platform::runLater)
.exceptionally(handleUncaught);
title.set(id);
image.set(profile.getRepository().getVersionIconImage(version));
}
@Override
protected Skin<?> createDefaultSkin() {
return new GameItemSkin(this);
this.id = id;
}
public Profile getProfile() {
return profile;
}
public String getVersion() {
return version;
public String getId() {
return id;
}
public StringProperty titleProperty() {
private void init() {
if (initialized)
return;
initialized = true;
title = new SimpleStringProperty();
tag = new SimpleStringProperty();
subtitle = new SimpleStringProperty();
image = new SimpleObjectProperty<>();
record Result(@Nullable String gameVersion, @Nullable String tag) {
}
CompletableFuture.supplyAsync(() -> {
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
Optional<String> gameVersion = profile.getRepository().getGameVersion(id);
String modPackVersion = null;
try {
ModpackConfiguration<?> config = profile.getRepository().readModpackConfiguration(id);
modPackVersion = config != null ? config.getVersion() : null;
} catch (IOException e) {
LOG.warning("Failed to read modpack configuration from " + id, e);
}
return new Result(gameVersion.orElse(null), modPackVersion);
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
if (exception == null) {
if (result.tag != null) {
tag.set(result.tag);
}
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), result.gameVersion);
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
String libraryId = mark.getLibraryId();
String libraryVersion = mark.getLibraryVersion();
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
if (I18n.hasKey("install.installer." + libraryId)) {
libraries.append(", ").append(i18n("install.installer." + libraryId));
if (libraryVersion != null)
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
}
}
subtitle.set(libraries.toString());
} else {
LOG.warning("Failed to read version info from " + id, exception);
}
}, Schedulers.javafx());
title.set(id);
image.set(profile.getRepository().getVersionIconImage(id));
}
public ReadOnlyStringProperty titleProperty() {
init();
return title;
}
public StringProperty tagProperty() {
public ReadOnlyStringProperty tagProperty() {
init();
return tag;
}
public StringProperty subtitleProperty() {
public ReadOnlyStringProperty subtitleProperty() {
init();
return subtitle;
}
public ObjectProperty<Image> imageProperty() {
public ReadOnlyObjectProperty<Image> imageProperty() {
init();
return image;
}
}
@@ -1,59 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import javafx.geometry.Pos;
import javafx.scene.control.SkinBase;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.StringUtils;
public class GameItemSkin extends SkinBase<GameItem> {
public GameItemSkin(GameItem skinnable) {
super(skinnable);
HBox center = new HBox();
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
StackPane imageViewContainer = new StackPane();
FXUtils.setLimitWidth(imageViewContainer, 32);
FXUtils.setLimitHeight(imageViewContainer, 32);
ImageView imageView = new ImageView();
FXUtils.limitSize(imageView, 32, 32);
imageView.imageProperty().bind(skinnable.imageProperty());
imageViewContainer.getChildren().setAll(imageView);
TwoLineListItem item = new TwoLineListItem();
item.titleProperty().bind(skinnable.titleProperty());
FXUtils.onChangeAndOperate(skinnable.tagProperty(), tag -> {
item.getTags().clear();
if (StringUtils.isNotBlank(tag))
item.addTag(tag);
});
item.subtitleProperty().bind(skinnable.subtitleProperty());
BorderPane.setAlignment(item, Pos.CENTER);
center.getChildren().setAll(imageView, item);
getChildren().setAll(center);
}
}
@@ -0,0 +1,214 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.*;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.ListCell;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class GameListCell extends ListCell<GameListItem> {
private final Region graphic;
private final ImageView imageView;
private final TwoLineListItem content;
private final JFXRadioButton chkSelected;
private final JFXButton btnUpgrade;
private final JFXButton btnLaunch;
private final JFXButton btnManage;
private final HBox right;
private final StringProperty tag = new SimpleStringProperty();
public GameListCell() {
BorderPane root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8, 8, 8, 0));
RipplerContainer container = new RipplerContainer(root);
this.graphic = container;
{
this.chkSelected = new JFXRadioButton() {
@Override
public void fire() {
if (!isDisable() && !isSelected()) {
fireEvent(new ActionEvent());
GameListItem item = GameListCell.this.getItem();
if (item != null) {
item.getProfile().setSelectedVersion(item.getId());
}
}
}
};
root.setLeft(chkSelected);
BorderPane.setAlignment(chkSelected, Pos.CENTER);
}
{
HBox center = new HBox();
center.setMouseTransparent(true);
root.setCenter(center);
center.setPrefWidth(Region.USE_PREF_SIZE);
center.setSpacing(8);
center.setAlignment(Pos.CENTER_LEFT);
this.imageView = new ImageView();
FXUtils.limitSize(imageView, 32, 32);
this.content = new TwoLineListItem();
BorderPane.setAlignment(content, Pos.CENTER);
FXUtils.onChangeAndOperate(tag, tag -> {
content.getTags().clear();
if (StringUtils.isNotBlank(tag))
content.addTag(tag);
});
center.getChildren().setAll(imageView, content);
}
{
this.right = new HBox();
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
this.btnUpgrade = new JFXButton();
btnUpgrade.setOnAction(e -> {
GameListItem item = this.getItem();
if (item != null)
item.update();
});
btnUpgrade.getStyleClass().add("toggle-icon4");
btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnUpgrade, i18n("version.update"));
right.getChildren().add(btnUpgrade);
this.btnLaunch = new JFXButton();
btnLaunch.setOnAction(e -> {
GameListItem item = this.getItem();
if (item != null)
item.testGame();
});
btnLaunch.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnLaunch, Pos.CENTER);
btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test"));
right.getChildren().add(btnLaunch);
this.btnManage = new JFXButton();
btnManage.setOnAction(e -> {
GameListItem item = this.getItem();
if (item == null)
return;
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
});
btnManage.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnManage, Pos.CENTER);
btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
right.getChildren().add(btnManage);
}
root.setCursor(Cursor.HAND);
container.setOnMouseClicked(e -> {
GameListItem item = getItem();
if (item == null)
return;
if (e.getButton() == MouseButton.PRIMARY) {
if (e.getClickCount() == 1) {
item.modifyGameSettings();
}
} else if (e.getButton() == MouseButton.SECONDARY) {
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
}
});
}
@Override
public void updateItem(GameListItem item, boolean empty) {
super.updateItem(item, empty);
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
this.right.getChildren().clear();
this.chkSelected.selectedProperty().unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.chkSelected.selectedProperty().bind(item.selectedProperty());
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
if (item.canUpdate())
this.right.getChildren().add(btnUpgrade);
this.right.getChildren().addAll(btnLaunch, btnManage);
}
}
private static JFXPopup getPopup(GameListItem item) {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), item::testGame, popup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), item::modifyGameSettings, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), item::remove, popup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), item::export, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
return popup;
}
}
@@ -18,87 +18,58 @@
package org.jackhuang.hmcl.ui.versions;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.control.ToggleGroup;
import org.jackhuang.hmcl.setting.Profile;
public class GameListItem extends Control {
private final Profile profile;
private final String version;
public class GameListItem extends GameItem {
private final boolean isModpack;
private final ToggleGroup toggleGroup;
private final BooleanProperty selected = new SimpleBooleanProperty();
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public GameListItem(ToggleGroup toggleGroup, Profile profile, String id) {
this.profile = profile;
this.version = id;
this.toggleGroup = toggleGroup;
public GameListItem(Profile profile, String id) {
super(profile, id);
this.isModpack = profile.getRepository().isModpack(id);
selected.set(id.equals(profile.getSelectedVersion()));
selected.bind(profile.selectedVersionProperty().isEqualTo(id));
}
@Override
protected Skin<?> createDefaultSkin() {
return new GameListItemSkin(this);
}
public ToggleGroup getToggleGroup() {
return toggleGroup;
}
public Profile getProfile() {
return profile;
}
public String getVersion() {
return version;
}
public BooleanProperty selectedProperty() {
public ReadOnlyBooleanProperty selectedProperty() {
return selected;
}
public void checkSelection() {
selected.set(version.equals(profile.getSelectedVersion()));
}
public void rename() {
Versions.renameVersion(profile, version);
Versions.renameVersion(profile, id);
}
public void duplicate() {
Versions.duplicateVersion(profile, version);
Versions.duplicateVersion(profile, id);
}
public void remove() {
Versions.deleteVersion(profile, version);
Versions.deleteVersion(profile, id);
}
public void export() {
Versions.exportVersion(profile, version);
Versions.exportVersion(profile, id);
}
public void browse() {
Versions.openFolder(profile, version);
Versions.openFolder(profile, id);
}
public void testGame() {
Versions.testGame(profile, version);
Versions.testGame(profile, id);
}
public void launch() {
Versions.launch(profile, version);
Versions.launch(profile, id);
}
public void modifyGameSettings() {
Versions.modifyGameSettings(profile, version);
Versions.modifyGameSettings(profile, id);
}
public void generateLaunchScript() {
Versions.generateLaunchScript(profile, version);
Versions.generateLaunchScript(profile, id);
}
public boolean canUpdate() {
@@ -106,6 +77,6 @@ public class GameListItem extends Control {
}
public void update() {
Versions.updateVersion(profile, version);
Versions.updateVersion(profile, id);
}
}
@@ -1,135 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXRadioButton;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.SkinBase;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.Lazy;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListItemSkin extends SkinBase<GameListItem> {
private static GameListItem currentSkinnable;
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), () -> currentSkinnable.testGame(), popup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> currentSkinnable.generateLaunchScript(), popup),
new MenuSeparator(),
new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), () -> currentSkinnable.modifyGameSettings(), popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), () -> currentSkinnable.rename(), popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), () -> currentSkinnable.duplicate(), popup),
new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), () -> currentSkinnable.remove(), popup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), () -> currentSkinnable.export(), popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), () -> currentSkinnable.browse(), popup));
return popup;
});
public GameListItemSkin(GameListItem skinnable) {
super(skinnable);
BorderPane root = new BorderPane();
JFXRadioButton chkSelected = new JFXRadioButton();
BorderPane.setAlignment(chkSelected, Pos.CENTER);
chkSelected.setUserData(skinnable);
chkSelected.selectedProperty().bindBidirectional(skinnable.selectedProperty());
chkSelected.setToggleGroup(skinnable.getToggleGroup());
root.setLeft(chkSelected);
GameItem gameItem = new GameItem(skinnable.getProfile(), skinnable.getVersion());
gameItem.setMouseTransparent(true);
root.setCenter(gameItem);
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
if (skinnable.canUpdate()) {
JFXButton btnUpgrade = new JFXButton();
btnUpgrade.setOnAction(e -> skinnable.update());
btnUpgrade.getStyleClass().add("toggle-icon4");
btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnUpgrade, i18n("version.update"));
right.getChildren().add(btnUpgrade);
}
{
JFXButton btnLaunch = new JFXButton();
btnLaunch.setOnAction(e -> skinnable.testGame());
btnLaunch.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnLaunch, Pos.CENTER);
btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test"));
right.getChildren().add(btnLaunch);
}
{
JFXButton btnManage = new JFXButton();
btnManage.setOnAction(e -> {
currentSkinnable = skinnable;
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get());
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
});
btnManage.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnManage, Pos.CENTER);
btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(24), 24, 24));
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
right.getChildren().add(btnManage);
}
root.setRight(right);
root.getStyleClass().add("md-list-cell");
root.setStyle("-fx-padding: 8 8 8 0");
RipplerContainer container = new RipplerContainer(root);
getChildren().setAll(container);
root.setCursor(Cursor.HAND);
container.setOnMouseClicked(e -> {
if (e.getButton() == MouseButton.PRIMARY) {
if (e.getClickCount() == 1) {
skinnable.modifyGameSettings();
}
} else if (e.getButton() == MouseButton.SECONDARY) {
currentSkinnable = skinnable;
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get());
popup.get().show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
}
});
}
}
@@ -17,16 +17,16 @@
*/
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.ListCell;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.ui.*;
@@ -36,13 +36,12 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.profile.ProfileListItem;
import org.jackhuang.hmcl.ui.profile.ProfilePage;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
@@ -53,8 +52,6 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
private final ObservableList<ProfileListItem> profileListItems;
private final ObjectProperty<Profile> selectedProfile;
private ToggleGroup toggleGroup;
public GameListPage() {
profileListItems = MappedObservableList.create(profilesProperty(), profile -> {
ProfileListItem item = new ProfileListItem(profile);
@@ -123,50 +120,31 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
return state.getReadOnlyProperty();
}
private class GameList extends ListPageBase<GameListItem> {
public GameList() {
super();
private static class GameList extends ListPageBase<GameListItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
public GameList() {
Profiles.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
}
@FXThread
private void loadVersions(Profile profile) {
listenerHolder.clear();
setLoading(true);
setFailedReason(null);
HMCLGameRepository repository = profile.getRepository();
toggleGroup = new ToggleGroup();
WeakListenerHolder listenerHolder = new WeakListenerHolder();
toggleGroup.getProperties().put("ReferenceHolder", listenerHolder);
runInFX(() -> {
if (profile == Profiles.getSelectedProfile()) {
setLoading(false);
List<GameListItem> children = repository.getDisplayVersions()
.map(version -> new GameListItem(toggleGroup, profile, version.getId()))
.collect(Collectors.toList());
itemsProperty().setAll(children);
children.forEach(GameListItem::checkSelection);
if (profile != Profiles.getSelectedProfile())
return;
if (children.isEmpty()) {
setFailedReason(i18n("version.empty.hint"));
}
profile.selectedVersionProperty().addListener(listenerHolder.weak((a, b, newValue) -> {
FXUtils.checkFxUserThread();
children.forEach(it -> it.selectedProperty().set(false));
children.stream()
.filter(it -> it.getVersion().equals(newValue))
.findFirst()
.ifPresent(it -> it.selectedProperty().set(true));
}));
}
toggleGroup.selectedToggleProperty().addListener((o, a, toggle) -> {
if (toggle == null) return;
GameListItem model = (GameListItem) toggle.getUserData();
model.getProfile().setSelectedVersion(model.getVersion());
});
});
ObservableList<GameListItem> children = FXCollections.observableList(profile.getRepository().getDisplayVersions()
.map(instance -> new GameListItem(profile, instance.getId()))
.toList());
setItems(children);
if (children.isEmpty()) {
setFailedReason(i18n("version.empty.hint"));
}
setLoading(false);
}
public void refreshList() {
@@ -178,7 +156,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
return new GameListSkin();
}
private class GameListSkin extends ToolbarListPageSkin<GameList> {
private class GameListSkin extends ToolbarListPageSkin<GameListItem, GameList> {
public GameListSkin() {
super(GameList.this);
@@ -188,6 +166,11 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
protected List<Node> initializeToolbar(GameList skinnable) {
return Collections.emptyList();
}
@Override
protected ListCell<GameListItem> createListCell(JFXListView<GameListItem> listView) {
return new GameListCell();
}
}
}
@@ -0,0 +1,144 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.StringUtils;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
private final JFXListView<GameItem> listView = new JFXListView<>();
private final BooleanBinding isEmpty = Bindings.isEmpty(listView.getItems());
public GameListPopupMenu() {
this.setMaxHeight(365);
this.getStyleClass().add("popup-menu-content");
listView.setCellFactory(Cell::new);
listView.setFixedCellSize(60);
listView.setPrefWidth(300);
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(60).add(2));
Label placeholder = new Label(i18n("version.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> {
getChildren().setAll(empty ? placeholder : listView);
});
}
public ObservableList<GameItem> getItems() {
return listView.getItems();
}
private static final class Cell extends ListCell<GameItem> {
private final Region graphic;
private final ImageView imageView;
private final TwoLineListItem content;
private final StringProperty tag = new SimpleStringProperty();
public Cell(ListView<GameItem> listView) {
this.setPadding(Insets.EMPTY);
HBox root = new HBox();
root.setSpacing(8);
root.setAlignment(Pos.CENTER_LEFT);
StackPane imageViewContainer = new StackPane();
FXUtils.setLimitWidth(imageViewContainer, 32);
FXUtils.setLimitHeight(imageViewContainer, 32);
this.imageView = new ImageView();
FXUtils.limitSize(imageView, 32, 32);
imageViewContainer.getChildren().setAll(imageView);
this.content = new TwoLineListItem();
FXUtils.onChangeAndOperate(tag, tag -> {
content.getTags().clear();
if (StringUtils.isNotBlank(tag)) {
content.addTag(tag);
}
});
BorderPane.setAlignment(content, Pos.CENTER);
root.getChildren().setAll(imageView, content);
StackPane pane = new StackPane();
pane.getChildren().setAll(root);
pane.getStyleClass().add("menu-container");
root.setMouseTransparent(true);
RipplerContainer ripplerContainer = new RipplerContainer(pane);
FXUtils.onClicked(ripplerContainer, () -> {
GameItem item = getItem();
if (item != null) {
item.getProfile().setSelectedVersion(item.getId());
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
});
this.graphic = ripplerContainer;
ripplerContainer.maxWidthProperty().bind(listView.widthProperty().subtract(5));
}
@Override
protected void updateItem(GameItem item, boolean empty) {
super.updateItem(item, empty);
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
}
}
}
}
@@ -165,7 +165,7 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ve
executor.start();
}
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerListPage> {
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
InstallerListPageSkin() {
super(InstallerListPage.this);
@@ -229,12 +229,13 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
FXUtils.openFolder(profile.getRepository().getRunDirectory(instanceId).resolve("mods"));
}
public void checkUpdates() {
public void checkUpdates(Collection<LocalModFile> mods) {
Objects.requireNonNull(mods);
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
Optional<String> gameVersion = profile.getRepository().getGameVersion(instanceId);
if (gameVersion.isPresent()) {
return new ModCheckUpdatesTask(gameVersion.get(), modManager.getMods());
return new ModCheckUpdatesTask(gameVersion.get(), mods);
}
return null;
})
@@ -141,7 +141,13 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, skinnable::checkUpdates),
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getItems().stream()
.map(ModInfoObject::getModInfo)
.toList()
)
),
createToolbarButton2(i18n("download"), SVG.DOWNLOAD, skinnable::download),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
@@ -157,6 +163,13 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream()
.map(ModInfoObject::getModInfo)
.toList()
)
),
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
listView.getSelectionModel().selectAll()),
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
@@ -599,7 +599,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
}
}
private final class SchematicsPageSkin extends ToolbarListPageSkin<SchematicsPage> {
private final class SchematicsPageSkin extends ToolbarListPageSkin<Item, SchematicsPage> {
SchematicsPageSkin() {
super(SchematicsPage.this);
}
@@ -103,7 +103,7 @@ public final class Versions {
i18n("install.failed.downloading.detail", file.getFile().getUrl()) + "\n" + StringUtils.getStackTrace(e),
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
}
}).executor(true),
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL
);
@@ -130,6 +130,10 @@ public final class Versions {
public static CompletableFuture<String> renameVersion(Profile profile, String version) {
return Controllers.prompt(i18n("version.manage.rename.message"), (newName, resolve, reject) -> {
if (newName.equals(version)) {
resolve.run();
return;
}
if (profile.getRepository().renameVersion(version, newName)) {
resolve.run();
profile.getRepository().refreshVersionsAsync()
@@ -142,7 +146,7 @@ public final class Versions {
reject.accept(i18n("version.manage.rename.fail"));
}
}, version, new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId),
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName)));
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName) || newVersionName.equals(version)));
}
public static void exportVersion(Profile profile, String version) {
@@ -155,7 +155,7 @@ public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.Backup
}), i18n("world.backup"), null);
}
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<WorldBackupsPage> {
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
WorldBackupsPageSkin() {
super(WorldBackupsPage.this);
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui.versions;
import com.github.steveice10.opennbt.tag.builtin.*;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.SimpleBooleanProperty;
@@ -28,27 +29,36 @@ import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.effect.BoxBlur;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.glavo.png.javafx.PNGJavaFXUtils;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jetbrains.annotations.PropertyKey;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.time.Instant;
import java.util.Arrays;
import java.util.Locale;
import java.util.concurrent.Callable;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
@@ -58,6 +68,8 @@ public final class WorldInfoPage extends SpinnerPane {
private final World world;
private CompoundTag levelDat;
ImageView iconImageView = new ImageView();
public WorldInfoPage(WorldManagePage worldManagePage) {
this.worldManagePage = worldManagePage;
this.world = worldManagePage.getWorld();
@@ -80,7 +92,7 @@ public final class WorldInfoPage extends SpinnerPane {
if (!Files.isDirectory(world.getFile()))
throw new IOException("Not a valid world directory");
return world.readLevelDat();
return world.getLevelData();
}
private void updateControls() {
@@ -103,98 +115,132 @@ public final class WorldInfoPage extends SpinnerPane {
{
BorderPane worldNamePane = new BorderPane();
{
Label label = new Label(i18n("world.name"));
worldNamePane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
setLeftLabel(worldNamePane, "world.name");
JFXTextField worldNameField = new JFXTextField();
setRightTextField(worldNamePane, worldNameField, 200);
Label worldNameLabel = new Label();
FXUtils.copyOnDoubleClick(worldNameLabel);
worldNameLabel.setText(world.getWorldName());
BorderPane.setAlignment(worldNameLabel, Pos.CENTER_RIGHT);
worldNamePane.setRight(worldNameLabel);
Tag tag = dataTag.get("LevelName");
if (tag instanceof StringTag stringTag) {
worldNameField.setText(stringTag.getValue());
worldNameField.textProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null) {
try {
world.setWorldName(newValue);
} catch (Exception e) {
LOG.warning("Failed to set world name", e);
}
}
});
} else {
worldNameField.setDisable(true);
}
}
BorderPane gameVersionPane = new BorderPane();
{
Label label = new Label(i18n("world.info.game_version"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
gameVersionPane.setLeft(label);
setLeftLabel(gameVersionPane, "world.info.game_version");
Label gameVersionLabel = new Label();
FXUtils.copyOnDoubleClick(gameVersionLabel);
if (world.getGameVersion() != null)
gameVersionLabel.setText(world.getGameVersion().toNormalizedString());
BorderPane.setAlignment(gameVersionLabel, Pos.CENTER_RIGHT);
gameVersionPane.setRight(gameVersionLabel);
setRightTextLabel(gameVersionPane, gameVersionLabel, () -> world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString());
}
BorderPane randomSeedPane = new BorderPane();
BorderPane iconPane = new BorderPane();
{
setLeftLabel(iconPane, "world.icon");
HBox left = new HBox(8);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
left.setAlignment(Pos.CENTER_LEFT);
randomSeedPane.setLeft(left);
Runnable onClickAction = () -> Controllers.confirm(
i18n("world.icon.change.tip"), i18n("world.icon.change"), MessageDialogPane.MessageType.INFO,
this::changeWorldIcon,
null
);
Label label = new Label(i18n("world.info.random_seed"));
FXUtils.limitSize(iconImageView, 32, 32);
{
iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
JFXButton editIconButton = new JFXButton();
JFXButton resetIconButton = new JFXButton();
{
editIconButton.setGraphic(SVG.EDIT.createIcon(20));
editIconButton.setDisable(worldManagePage.isReadOnly());
FXUtils.onClicked(editIconButton, onClickAction);
FXUtils.installFastTooltip(editIconButton, i18n("button.edit"));
editIconButton.getStyleClass().add("toggle-icon4");
resetIconButton.setGraphic(SVG.RESTORE.createIcon(20));
resetIconButton.setDisable(worldManagePage.isReadOnly());
FXUtils.onClicked(resetIconButton, this::clearWorldIcon);
FXUtils.installFastTooltip(resetIconButton, i18n("button.reset"));
resetIconButton.getStyleClass().add("toggle-icon4");
}
HBox hBox = new HBox(8);
hBox.setAlignment(Pos.CENTER_LEFT);
hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton);
iconPane.setRight(hBox);
}
BorderPane seedPane = new BorderPane();
{
setLeftLabel(seedPane, "world.info.random_seed");
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
StackPane visibilityButton = new StackPane();
visibilityButton.setCursor(Cursor.HAND);
FXUtils.setLimitWidth(visibilityButton, 12);
FXUtils.setLimitHeight(visibilityButton, 12);
FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get()));
left.getChildren().setAll(label, visibilityButton);
Label randomSeedLabel = new Label();
FXUtils.copyOnDoubleClick(randomSeedLabel);
BorderPane.setAlignment(randomSeedLabel, Pos.CENTER_RIGHT);
randomSeedPane.setRight(randomSeedLabel);
Tag tag = worldGenSettings != null ? worldGenSettings.get("seed") : dataTag.get("RandomSeed");
if (tag instanceof LongTag) {
randomSeedLabel.setText(tag.getValue().toString());
{
visibilityButton.setCursor(Cursor.HAND);
visibilityButton.setAlignment(Pos.BOTTOM_RIGHT);
FXUtils.setLimitWidth(visibilityButton, 12);
FXUtils.setLimitHeight(visibilityButton, 12);
FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get()));
}
BoxBlur blur = new BoxBlur();
blur.setIterations(3);
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
visibilityButton.getChildren().setAll(icon.createIcon(12));
randomSeedLabel.setEffect(isVisibility ? null : blur);
});
Label seedLabel = new Label();
{
FXUtils.copyOnDoubleClick(seedLabel);
seedLabel.setAlignment(Pos.CENTER_RIGHT);
seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : "");
BoxBlur blur = new BoxBlur();
blur.setIterations(3);
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
visibilityButton.getChildren().setAll(icon.createIcon(12));
seedLabel.setEffect(isVisibility ? null : blur);
});
}
HBox right = new HBox(8);
{
BorderPane.setAlignment(right, Pos.CENTER_RIGHT);
right.getChildren().setAll(visibilityButton, seedLabel);
seedPane.setRight(right);
}
}
BorderPane lastPlayedPane = new BorderPane();
{
Label label = new Label(i18n("world.info.last_played"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
lastPlayedPane.setLeft(label);
setLeftLabel(lastPlayedPane, "world.info.last_played");
Label lastPlayedLabel = new Label();
FXUtils.copyOnDoubleClick(lastPlayedLabel);
lastPlayedLabel.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
BorderPane.setAlignment(lastPlayedLabel, Pos.CENTER_RIGHT);
lastPlayedPane.setRight(lastPlayedLabel);
setRightTextLabel(lastPlayedPane, lastPlayedLabel, () -> formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
}
BorderPane timePane = new BorderPane();
{
Label label = new Label(i18n("world.info.time"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
timePane.setLeft(label);
setLeftLabel(timePane, "world.info.time");
Label timeLabel = new Label();
FXUtils.copyOnDoubleClick(timeLabel);
BorderPane.setAlignment(timeLabel, Pos.CENTER_RIGHT);
timePane.setRight(timeLabel);
Tag tag = dataTag.get("Time");
if (tag instanceof LongTag) {
long days = ((LongTag) tag).getValue() / 24000;
timeLabel.setText(i18n("world.info.time.format", days));
}
setRightTextLabel(timePane, timeLabel, () -> {
Tag tag = dataTag.get("Time");
if (tag instanceof LongTag) {
long days = ((LongTag) tag).getValue() / 24000;
return i18n("world.info.time.format", days);
} else {
return "";
}
});
}
OptionToggleButton allowCheatsButton = new OptionToggleButton();
@@ -203,21 +249,7 @@ public final class WorldInfoPage extends SpinnerPane {
allowCheatsButton.setDisable(worldManagePage.isReadOnly());
Tag tag = dataTag.get("allowCommands");
if (tag instanceof ByteTag) {
ByteTag byteTag = (ByteTag) tag;
byte value = byteTag.getValue();
if (value == 0 || value == 1) {
allowCheatsButton.setSelected(value == 1);
allowCheatsButton.selectedProperty().addListener((o, oldValue, newValue) -> {
byteTag.setValue(newValue ? (byte) 1 : (byte) 0);
saveLevelDat();
});
} else {
allowCheatsButton.setDisable(true);
}
} else {
allowCheatsButton.setDisable(true);
}
checkTagAndSetListener(tag, allowCheatsButton);
}
OptionToggleButton generateFeaturesButton = new OptionToggleButton();
@@ -226,28 +258,12 @@ public final class WorldInfoPage extends SpinnerPane {
generateFeaturesButton.setDisable(worldManagePage.isReadOnly());
Tag tag = worldGenSettings != null ? worldGenSettings.get("generate_features") : dataTag.get("MapFeatures");
if (tag instanceof ByteTag) {
ByteTag byteTag = (ByteTag) tag;
byte value = byteTag.getValue();
if (value == 0 || value == 1) {
generateFeaturesButton.setSelected(value == 1);
generateFeaturesButton.selectedProperty().addListener((o, oldValue, newValue) -> {
byteTag.setValue(newValue ? (byte) 1 : (byte) 0);
saveLevelDat();
});
} else {
generateFeaturesButton.setDisable(true);
}
} else {
generateFeaturesButton.setDisable(true);
}
checkTagAndSetListener(tag, generateFeaturesButton);
}
BorderPane difficultyPane = new BorderPane();
{
Label label = new Label(i18n("world.info.difficulty"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
difficultyPane.setLeft(label);
setLeftLabel(difficultyPane, "world.info.difficulty");
JFXComboBox<Difficulty> difficultyBox = new JFXComboBox<>(Difficulty.items);
difficultyBox.setDisable(worldManagePage.isReadOnly());
@@ -255,8 +271,7 @@ public final class WorldInfoPage extends SpinnerPane {
difficultyPane.setRight(difficultyBox);
Tag tag = dataTag.get("Difficulty");
if (tag instanceof ByteTag) {
ByteTag byteTag = (ByteTag) tag;
if (tag instanceof ByteTag byteTag) {
Difficulty difficulty = Difficulty.of(byteTag.getValue());
if (difficulty != null) {
difficultyBox.setValue(difficulty);
@@ -274,86 +289,93 @@ public final class WorldInfoPage extends SpinnerPane {
}
}
OptionToggleButton difficultyLockPane = new OptionToggleButton();
{
difficultyLockPane.setTitle(i18n("world.info.difficulty_lock"));
difficultyLockPane.setDisable(worldManagePage.isReadOnly());
Tag tag = dataTag.get("DifficultyLocked");
checkTagAndSetListener(tag, difficultyLockPane);
}
basicInfo.getContent().setAll(
worldNamePane, gameVersionPane, randomSeedPane, lastPlayedPane, timePane,
allowCheatsButton, generateFeaturesButton, difficultyPane);
worldNamePane, gameVersionPane, iconPane, seedPane, lastPlayedPane, timePane,
allowCheatsButton, generateFeaturesButton, difficultyPane, difficultyLockPane);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.basic")), basicInfo);
}
Tag playerTag = dataTag.get("Player");
if (playerTag instanceof CompoundTag) {
CompoundTag player = (CompoundTag) playerTag;
if (playerTag instanceof CompoundTag player) {
ComponentList playerInfo = new ComponentList();
BorderPane locationPane = new BorderPane();
{
Label label = new Label(i18n("world.info.player.location"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
locationPane.setLeft(label);
setLeftLabel(locationPane, "world.info.player.location");
Label locationLabel = new Label();
FXUtils.copyOnDoubleClick(locationLabel);
BorderPane.setAlignment(locationLabel, Pos.CENTER_RIGHT);
locationPane.setRight(locationLabel);
Dimension dim = Dimension.of(player.get("Dimension"));
if (dim != null) {
String posString = dim.formatPosition(player.get("Pos"));
if (posString != null)
locationLabel.setText(posString);
}
setRightTextLabel(locationPane, locationLabel, () -> {
Dimension dim = Dimension.of(player.get("Dimension"));
if (dim != null) {
String posString = dim.formatPosition(player.get("Pos"));
if (posString != null)
return posString;
}
return "";
});
}
BorderPane lastDeathLocationPane = new BorderPane();
{
Label label = new Label(i18n("world.info.player.last_death_location"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
lastDeathLocationPane.setLeft(label);
setLeftLabel(lastDeathLocationPane, "world.info.player.last_death_location");
Label lastDeathLocationLabel = new Label();
FXUtils.copyOnDoubleClick(lastDeathLocationLabel);
BorderPane.setAlignment(lastDeathLocationLabel, Pos.CENTER_RIGHT);
lastDeathLocationPane.setRight(lastDeathLocationLabel);
Tag tag = player.get("LastDeathLocation");
if (tag instanceof CompoundTag) {
Dimension dim = Dimension.of(((CompoundTag) tag).get("dimension"));
if (dim != null) {
String posString = dim.formatPosition(((CompoundTag) tag).get("pos"));
if (posString != null)
lastDeathLocationLabel.setText(posString);
setRightTextLabel(lastDeathLocationPane, lastDeathLocationLabel, () -> {
Tag tag = player.get("LastDeathLocation");// Valid after 22w14a; prior to this version, the game did not record the last death location data.
if (tag instanceof CompoundTag compoundTag) {
Dimension dim = Dimension.of(compoundTag.get("dimension"));
if (dim != null) {
String posString = dim.formatPosition(compoundTag.get("pos"));
if (posString != null)
return posString;
}
}
}
return "";
});
}
BorderPane spawnPane = new BorderPane();
{
Label label = new Label(i18n("world.info.player.spawn"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
spawnPane.setLeft(label);
setLeftLabel(spawnPane, "world.info.player.spawn");
Label spawnLabel = new Label();
FXUtils.copyOnDoubleClick(spawnLabel);
BorderPane.setAlignment(spawnLabel, Pos.CENTER_RIGHT);
spawnPane.setRight(spawnLabel);
setRightTextLabel(spawnPane, spawnLabel, () -> {
Dimension dim = Dimension.of(player.get("SpawnDimension"));
if (dim != null) {
Tag x = player.get("SpawnX");
Tag y = player.get("SpawnY");
Tag z = player.get("SpawnZ");
Dimension dimension;
if (player.get("respawn") instanceof CompoundTag respawnTag && respawnTag.get("dimension") != null) { // Valid after 25w07a
dimension = Dimension.of(respawnTag.get("dimension"));
Tag posTag = respawnTag.get("pos");
if (x instanceof IntTag && y instanceof IntTag && z instanceof IntTag)
spawnLabel.setText(dim.formatPosition(((IntTag) x).getValue(), ((IntTag) y).getValue(), ((IntTag) z).getValue()));
}
if (posTag instanceof IntArrayTag intArrayTag && intArrayTag.length() >= 3) {
return dimension.formatPosition(intArrayTag.getValue(0), intArrayTag.getValue(1), intArrayTag.getValue(2));
}
} else if (player.get("SpawnX") instanceof IntTag intX
&& player.get("SpawnY") instanceof IntTag intY
&& player.get("SpawnZ") instanceof IntTag intZ) { // Valid before 25w07a
// SpawnDimension tag is valid after 20w12a. Prior to this version, the game did not record the respawn point dimension and respawned in the Overworld.
dimension = Dimension.of(player.get("SpawnDimension") == null ? new IntTag("SpawnDimension", 0) : player.get("SpawnDimension"));
if (dimension == null) {
return "";
}
return dimension.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue());
}
return "";
});
}
BorderPane playerGameTypePane = new BorderPane();
{
Label label = new Label(i18n("world.info.player.game_type"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
playerGameTypePane.setLeft(label);
setLeftLabel(playerGameTypePane, "world.info.player.game_type");
JFXComboBox<GameType> gameTypeBox = new JFXComboBox<>(GameType.items);
gameTypeBox.setDisable(worldManagePage.isReadOnly());
@@ -364,8 +386,7 @@ public final class WorldInfoPage extends SpinnerPane {
Tag hardcoreTag = dataTag.get("hardcore");
boolean isHardcore = hardcoreTag instanceof ByteTag && ((ByteTag) hardcoreTag).getValue() == 1;
if (tag instanceof IntTag) {
IntTag intTag = (IntTag) tag;
if (tag instanceof IntTag intTag) {
GameType gameType = GameType.of(intTag.getValue(), isHardcore);
if (gameType != null) {
gameTypeBox.setValue(gameType);
@@ -395,33 +416,13 @@ public final class WorldInfoPage extends SpinnerPane {
BorderPane healthPane = new BorderPane();
{
Label label = new Label(i18n("world.info.player.health"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
healthPane.setLeft(label);
setLeftLabel(healthPane, "world.info.player.health");
JFXTextField healthField = new JFXTextField();
healthField.setDisable(worldManagePage.isReadOnly());
healthField.setPrefWidth(50);
healthField.setAlignment(Pos.CENTER_RIGHT);
BorderPane.setAlignment(healthField, Pos.CENTER_RIGHT);
healthPane.setRight(healthField);
setRightTextField(healthPane, healthField, 50);
Tag tag = player.get("Health");
if (tag instanceof FloatTag) {
FloatTag floatTag = (FloatTag) tag;
healthField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue()));
healthField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
floatTag.setValue(Float.parseFloat(newValue));
saveLevelDat();
} catch (Throwable ignored) {
}
}
});
FXUtils.setValidateWhileTextChanged(healthField, true);
healthField.setValidators(new DoubleValidator(i18n("input.number"), true));
if (tag instanceof FloatTag floatTag) {
setTagAndTextField(floatTag, healthField);
} else {
healthField.setDisable(true);
}
@@ -429,33 +430,13 @@ public final class WorldInfoPage extends SpinnerPane {
BorderPane foodLevelPane = new BorderPane();
{
Label label = new Label(i18n("world.info.player.food_level"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
foodLevelPane.setLeft(label);
setLeftLabel(foodLevelPane, "world.info.player.food_level");
JFXTextField foodLevelField = new JFXTextField();
foodLevelField.setDisable(worldManagePage.isReadOnly());
foodLevelField.setPrefWidth(50);
foodLevelField.setAlignment(Pos.CENTER_RIGHT);
BorderPane.setAlignment(foodLevelField, Pos.CENTER_RIGHT);
foodLevelPane.setRight(foodLevelField);
setRightTextField(foodLevelPane, foodLevelField, 50);
Tag tag = player.get("foodLevel");
if (tag instanceof IntTag) {
IntTag intTag = (IntTag) tag;
foodLevelField.setText(String.valueOf(intTag.getValue()));
foodLevelField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
intTag.setValue(Integer.parseInt(newValue));
saveLevelDat();
} catch (Throwable ignored) {
}
}
});
FXUtils.setValidateWhileTextChanged(foodLevelField, true);
foodLevelField.setValidators(new NumberValidator(i18n("input.number"), true));
if (tag instanceof IntTag intTag) {
setTagAndTextField(intTag, foodLevelField);
} else {
foodLevelField.setDisable(true);
}
@@ -463,33 +444,13 @@ public final class WorldInfoPage extends SpinnerPane {
BorderPane xpLevelPane = new BorderPane();
{
Label label = new Label(i18n("world.info.player.xp_level"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
xpLevelPane.setLeft(label);
setLeftLabel(xpLevelPane, "world.info.player.xp_level");
JFXTextField xpLevelField = new JFXTextField();
xpLevelField.setDisable(worldManagePage.isReadOnly());
xpLevelField.setPrefWidth(50);
xpLevelField.setAlignment(Pos.CENTER_RIGHT);
BorderPane.setAlignment(xpLevelField, Pos.CENTER_RIGHT);
xpLevelPane.setRight(xpLevelField);
setRightTextField(xpLevelPane, xpLevelField, 50);
Tag tag = player.get("XpLevel");
if (tag instanceof IntTag) {
IntTag intTag = (IntTag) tag;
xpLevelField.setText(String.valueOf(intTag.getValue()));
xpLevelField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
intTag.setValue(Integer.parseInt(newValue));
saveLevelDat();
} catch (Throwable ignored) {
}
}
});
FXUtils.setValidateWhileTextChanged(xpLevelField, true);
xpLevelField.setValidators(new NumberValidator(i18n("input.number"), true));
if (tag instanceof IntTag intTag) {
setTagAndTextField(intTag, xpLevelField);
} else {
xpLevelField.setDisable(true);
}
@@ -504,6 +465,88 @@ public final class WorldInfoPage extends SpinnerPane {
}
}
private void setLeftLabel(BorderPane borderPane, @PropertyKey(resourceBundle = "assets.lang.I18N") String key) {
Label label = new Label(i18n(key));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
borderPane.setLeft(label);
}
private void setRightTextField(BorderPane borderPane, JFXTextField textField, int perfWidth) {
textField.setDisable(worldManagePage.isReadOnly());
textField.setPrefWidth(perfWidth);
textField.setAlignment(Pos.CENTER_RIGHT);
borderPane.setRight(textField);
}
private void setRightTextLabel(BorderPane borderPane, Label label, Callable<String> setNameCall) {
FXUtils.copyOnDoubleClick(label);
BorderPane.setAlignment(label, Pos.CENTER_RIGHT);
try {
label.setText(setNameCall.call());
} catch (Exception e) {
LOG.warning("Exception happened when setting name", e);
}
borderPane.setRight(label);
}
private void checkTagAndSetListener(Tag tag, OptionToggleButton toggleButton) {
if (tag instanceof ByteTag byteTag) {
byte value = byteTag.getValue();
if (value == 0 || value == 1) {
toggleButton.setSelected(value == 1);
toggleButton.selectedProperty().addListener((o, oldValue, newValue) -> {
try {
byteTag.setValue((byte) (newValue ? 1 : 0));
saveLevelDat();
} catch (Exception e) {
toggleButton.setSelected(oldValue);
LOG.warning("Exception happened when saving level.dat", e);
}
});
} else {
toggleButton.setDisable(true);
}
} else {
toggleButton.setDisable(true);
}
}
private void setTagAndTextField(IntTag intTag, JFXTextField jfxTextField) {
jfxTextField.setText(String.valueOf(intTag.getValue()));
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
intTag.setValue(Integer.parseInt(newValue));
saveLevelDat();
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving level.dat", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true));
}
private void setTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) {
jfxTextField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue()));
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
floatTag.setValue(Float.parseFloat(newValue));
saveLevelDat();
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving level.dat", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true));
}
private void saveLevelDat() {
LOG.info("Saving level.dat of world " + world.getWorldName());
try {
@@ -513,54 +556,34 @@ public final class WorldInfoPage extends SpinnerPane {
}
}
private static final class Dimension {
private record Dimension(String name) {
static final Dimension OVERWORLD = new Dimension(null);
static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether"));
static final Dimension THE_END = new Dimension(i18n("world.info.dimension.the_end"));
final String name;
static Dimension of(Tag tag) {
if (tag instanceof IntTag) {
switch (((IntTag) tag).getValue()) {
case 0:
return OVERWORLD;
case 1:
return THE_NETHER;
case 2:
return THE_END;
default:
return null;
}
} else if (tag instanceof StringTag) {
String id = ((StringTag) tag).getValue();
switch (id) {
case "overworld":
case "minecraft:overworld":
return OVERWORLD;
case "the_nether":
case "minecraft:the_nether":
return THE_NETHER;
case "the_end":
case "minecraft:the_end":
return THE_END;
default:
return new Dimension(id);
}
if (tag instanceof IntTag intTag) {
return switch (intTag.getValue()) {
case 0 -> OVERWORLD;
case 1 -> THE_NETHER;
case 2 -> THE_END;
default -> null;
};
} else if (tag instanceof StringTag stringTag) {
String id = stringTag.getValue();
return switch (id) {
case "overworld", "minecraft:overworld" -> OVERWORLD;
case "the_nether", "minecraft:the_nether" -> THE_NETHER;
case "the_end", "minecraft:the_end" -> THE_END;
default -> new Dimension(id);
};
} else {
return null;
}
}
private Dimension(String name) {
this.name = name;
}
String formatPosition(Tag tag) {
if (tag instanceof ListTag) {
ListTag listTag = (ListTag) tag;
if (listTag.size() != 3)
return null;
if (tag instanceof ListTag listTag && listTag.size() == 3) {
Tag x = listTag.get(0);
Tag y = listTag.get(1);
@@ -575,8 +598,7 @@ public final class WorldInfoPage extends SpinnerPane {
return null;
}
if (tag instanceof IntArrayTag) {
IntArrayTag intArrayTag = (IntArrayTag) tag;
if (tag instanceof IntArrayTag intArrayTag) {
int x = intArrayTag.getValue(0);
int y = intArrayTag.getValue(1);
@@ -609,7 +631,7 @@ public final class WorldInfoPage extends SpinnerPane {
static final ObservableList<Difficulty> items = FXCollections.observableList(Arrays.asList(values()));
static Difficulty of(int d) {
return d >= 0 && d <= items.size() ? items.get(d) : null;
return (d >= 0 && d < items.size()) ? items.get(d) : null;
}
@Override
@@ -633,4 +655,51 @@ public final class WorldInfoPage extends SpinnerPane {
return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT));
}
}
private void changeWorldIcon() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.icon.choose.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png"));
fileChooser.setInitialFileName("icon.png");
File file = fileChooser.showOpenDialog(Controllers.getStage());
if (file == null) return;
Image image;
try {
image = FXUtils.loadImage(file.toPath());
} catch (Exception e) {
LOG.warning("Failed to load image", e);
Controllers.dialog(i18n("world.icon.change.fail.load.text"), i18n("world.icon.change.fail.load.title"), MessageDialogPane.MessageType.ERROR);
return;
}
if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) {
Path output = world.getFile().resolve("icon.png");
saveImage(image, output);
} else {
Controllers.dialog(i18n("world.icon.change.fail.not_64x64.text", (int) image.getWidth(), (int) image.getHeight()), i18n("world.icon.change.fail.not_64x64.title"), MessageDialogPane.MessageType.ERROR);
}
}
private void saveImage(Image image, Path path) {
Image oldImage = iconImageView.getImage();
try {
PNGJavaFXUtils.writeImage(image, path);
iconImageView.setImage(image);
Controllers.showToast(i18n("world.icon.change.succeed.toast"));
} catch (IOException e) {
LOG.warning("Failed to save world icon " + e.getMessage());
iconImageView.setImage(oldImage);
}
}
private void clearWorldIcon() {
Path output = world.getFile().resolve("icon.png");
try {
Files.deleteIfExists(output);
iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png"));
} catch (IOException e) {
LOG.warning("Failed to delete world icon " + e.getMessage());
}
}
}
@@ -1,109 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class WorldListItem extends Control {
private final World world;
private final Path backupsDir;
private final WorldListPage parent;
private final Profile profile;
private final String id;
public WorldListItem(WorldListPage parent, World world, Path backupsDir, Profile profile, String id) {
this.world = world;
this.backupsDir = backupsDir;
this.parent = parent;
this.profile = profile;
this.id = id;
}
@Override
protected Skin<?> createDefaultSkin() {
return new WorldListItemSkin(this);
}
public World getWorld() {
return world;
}
public void export() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.export.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
fileChooser.setInitialFileName(world.getWorldName());
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
if (file == null) {
return;
}
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
}
public void delete() {
Controllers.confirm(
i18n("button.remove.confirm"),
i18n("world.delete"),
() -> Task.runAsync(world::delete)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
parent.remove(this);
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageType.WARNING);
} else {
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageType.WARNING);
}
}).start(),
null
);
}
public void reveal() {
FXUtils.openFolder(world.getFile());
}
public void showManagePage() {
Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id));
}
public void launch() {
Versions.launchAndEnterWorld(profile, id, world.getFileName());
}
public void generateLaunchScript() {
Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName());
}
}
@@ -1,156 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.SkinBase;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.i18n.I18n;
import java.time.Instant;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class WorldListItemSkin extends SkinBase<WorldListItem> {
private final BorderPane root;
public WorldListItemSkin(WorldListItem skinnable) {
super(skinnable);
World world = skinnable.getWorld();
root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
{
StackPane left = new StackPane();
FXUtils.installSlowTooltip(left, world.getFile().toString());
root.setLeft(left);
left.setPadding(new Insets(0, 8, 0, 0));
ImageView imageView = new ImageView();
left.getChildren().add(imageView);
FXUtils.limitSize(imageView, 32, 32);
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
{
TwoLineListItem item = new TwoLineListItem();
root.setCenter(item);
item.setMouseTransparent(true);
if (world.getWorldName() != null)
item.setTitle(parseColorEscapes(world.getWorldName()));
item.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
if (world.getGameVersion() != null)
item.addTag(I18n.getDisplayVersion(world.getGameVersion()));
if (world.isLocked())
item.addTag(i18n("world.locked"));
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnMore = new JFXButton();
right.getChildren().add(btnMore);
btnMore.getStyleClass().add("toggle-icon4");
btnMore.setGraphic(SVG.MORE_VERT.createIcon());
btnMore.setOnAction(event -> showPopupMenu(JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight()));
}
RipplerContainer container = new RipplerContainer(root);
container.setOnMouseClicked(event -> {
if (event.getClickCount() != 1)
return;
if (event.getButton() == MouseButton.PRIMARY)
skinnable.showManagePage();
else if (event.getButton() == MouseButton.SECONDARY)
showPopupMenu(JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
});
getChildren().setAll(container);
}
// Popup Menu
public void showPopupMenu(JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
WorldListItem item = getSkinnable();
World world = item.getWorld();
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), item::launch, popup);
launchItem.setDisable(world.isLocked());
popupMenu.getContent().add(launchItem);
popupMenu.getContent().addAll(
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup),
new MenuSeparator()
);
}
popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), item::showManagePage, popup));
if (ChunkBaseApp.isSupported(world)) {
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
);
if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) {
popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"),
() -> ChunkBaseApp.openEndCityFinder(world), popup));
}
}
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), item::export, popup),
new IconedMenuItem(SVG.DELETE, i18n("world.delete"), item::delete, popup),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), item::reveal, popup));
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY);
}
}
@@ -17,17 +17,32 @@
*/
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -35,15 +50,18 @@ import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldListPage extends ListPageBase<WorldListItem> implements VersionPage.VersionLoadable {
public final class WorldListPage extends ListPageBase<World> implements VersionPage.VersionLoadable {
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
private Path savesDir;
@@ -51,23 +69,19 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
private List<World> worlds;
private Profile profile;
private String id;
private GameVersionNumber gameVersion;
private int refreshCount = 0;
public WorldListPage() {
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
installWorld(modpacks.get(0));
});
showAll.addListener(e -> {
if (worlds != null)
itemsProperty().setAll(worlds.stream()
.filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
.map(world -> new WorldListItem(this, world, backupsDir, profile, id)).toList());
});
showAll.addListener(e -> updateWorldList());
}
@Override
protected ToolbarListPageSkin<WorldListPage> createDefaultSkin() {
protected Skin<WorldListPage> createDefaultSkin() {
return new WorldListPageSkin();
}
@@ -80,33 +94,46 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
refresh();
}
public void remove(WorldListItem item) {
itemsProperty().remove(item);
private void updateWorldList() {
if (worlds == null) {
getItems().clear();
} else if (showAll.get()) {
getItems().setAll(worlds);
} else {
GameVersionNumber gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null);
getItems().setAll(worlds.stream()
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
.toList());
}
}
public void refresh() {
if (profile == null || id == null)
return;
int currentRefresh = ++refreshCount;
setLoading(true);
Task.runAsync(() -> gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null))
.thenApplyAsync(unused -> {
try (Stream<World> stream = World.getWorlds(savesDir)) {
return stream.parallel().collect(Collectors.toList());
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
worlds = result;
setLoading(false);
if (exception == null) {
itemsProperty().setAll(result.stream()
.filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
.map(world -> new WorldListItem(this, world, backupsDir, profile, id))
.collect(Collectors.toList()));
} else {
LOG.warning("Failed to load world list page", exception);
}
}).start();
Task.supplyAsync(Schedulers.io(), () -> {
// Ensure the game version number is parsed
profile.getRepository().getGameVersion(id);
try (Stream<World> stream = World.getWorlds(savesDir)) {
return stream.toList();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (refreshCount != currentRefresh) {
// A newer refresh task is running, discard this result
return;
}
worlds = result;
updateWorldList();
if (exception != null)
LOG.warning("Failed to load world list page", exception);
setLoading(false);
}).start();
}
public void add() {
@@ -132,8 +159,8 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
Controllers.prompt(i18n("world.name.enter"), (name, resolve, reject) -> {
Task.runAsync(() -> world.install(savesDir, name))
.whenComplete(Schedulers.javafx(), () -> {
itemsProperty().add(new WorldListItem(this, new World(savesDir.resolve(name)), backupsDir, profile, id));
resolve.run();
refresh();
}, e -> {
if (e instanceof FileAlreadyExistsException)
reject.accept(i18n("world.import.failed", i18n("world.import.already_exists")));
@@ -149,19 +176,39 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
}).start();
}
public boolean isShowAll() {
return showAll.get();
private void showManagePage(World world) {
Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id));
}
public void export(World world) {
WorldManageUIUtils.export(world);
}
public void delete(World world) {
WorldManageUIUtils.delete(world, this::refresh);
}
public void copy(World world) {
WorldManageUIUtils.copyWorld(world, this::refresh);
}
public void reveal(World world) {
FXUtils.openFolder(world.getFile());
}
public void launch(World world) {
Versions.launchAndEnterWorld(profile, id, world.getFileName());
}
public void generateLaunchScript(World world) {
Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName());
}
public BooleanProperty showAllProperty() {
return showAll;
}
public void setShowAll(boolean showAll) {
this.showAll.set(showAll);
}
private final class WorldListPageSkin extends ToolbarListPageSkin<WorldListPage> {
private final class WorldListPageSkin extends ToolbarListPageSkin<World, WorldListPage> {
WorldListPageSkin() {
super(WorldListPage.this);
@@ -178,5 +225,162 @@ public final class WorldListPage extends ListPageBase<WorldListItem> implements
createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("world.download"), SVG.DOWNLOAD, skinnable::download));
}
@Override
protected ListCell<World> createListCell(JFXListView<World> listView) {
return new WorldListCell(getSkinnable());
}
}
private static final class WorldListCell extends ListCell<World> {
private final WorldListPage page;
private final RipplerContainer graphic;
private final ImageView imageView;
private final Tooltip leftTooltip;
private final TwoLineListItem content;
public WorldListCell(WorldListPage page) {
this.page = page;
var root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
{
StackPane left = new StackPane();
this.leftTooltip = new Tooltip();
FXUtils.installSlowTooltip(left, leftTooltip);
root.setLeft(left);
left.setPadding(new Insets(0, 8, 0, 0));
this.imageView = new ImageView();
left.getChildren().add(imageView);
FXUtils.limitSize(imageView, 32, 32);
}
{
this.content = new TwoLineListItem();
root.setCenter(content);
content.setMouseTransparent(true);
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnMore = new JFXButton();
right.getChildren().add(btnMore);
btnMore.getStyleClass().add("toggle-icon4");
btnMore.setGraphic(SVG.MORE_VERT.createIcon());
btnMore.setOnAction(event -> {
World world = getItem();
if (world != null)
showPopupMenu(world, JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight());
});
}
this.graphic = new RipplerContainer(root);
graphic.setOnMouseClicked(event -> {
if (event.getClickCount() != 1)
return;
World world = getItem();
if (world == null)
return;
if (event.getButton() == MouseButton.PRIMARY)
page.showManagePage(world);
else if (event.getButton() == MouseButton.SECONDARY)
showPopupMenu(world, JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
});
}
@Override
protected void updateItem(World world, boolean empty) {
super.updateItem(world, empty);
this.content.getTags().clear();
if (empty || world == null) {
setGraphic(null);
imageView.setImage(null);
leftTooltip.setText("");
content.setTitle("");
content.setSubtitle("");
} else {
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
leftTooltip.setText(world.getFile().toString());
content.setTitle(world.getWorldName() != null ? parseColorEscapes(world.getWorldName()) : "");
if (world.getGameVersion() != null)
content.addTag(I18n.getDisplayVersion(world.getGameVersion()));
if (world.isLocked())
content.addTag(i18n("world.locked"));
content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
setGraphic(graphic);
}
}
// Popup Menu
public void showPopupMenu(World world, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), () -> page.launch(world), popup);
launchItem.setDisable(world.isLocked());
popupMenu.getContent().add(launchItem);
popupMenu.getContent().addAll(
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> page.generateLaunchScript(world), popup),
new MenuSeparator()
);
}
popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), () -> page.showManagePage(world), popup));
if (ChunkBaseApp.isSupported(world)) {
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
);
if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) {
popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"),
() -> ChunkBaseApp.openEndCityFinder(world), popup));
}
}
IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup);
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> page.delete(world), popup);
IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup);
boolean worldLocked = world.isLocked();
Stream.of(exportMenuItem, deleteMenuItem, duplicateMenuItem)
.forEach(iconedMenuItem -> iconedMenuItem.setDisable(worldLocked));
popupMenu.getContent().addAll(
new MenuSeparator(),
exportMenuItem,
deleteMenuItem,
duplicateMenuItem
);
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), () -> page.reveal(world), popup)
);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup);
popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY);
}
}
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui.versions;
import com.jfoenix.controls.JFXPopup;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
@@ -27,6 +28,7 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
@@ -34,6 +36,7 @@ import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.nio.channels.FileChannel;
@@ -53,6 +56,8 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
private final Profile profile;
private final String id;
private boolean loadFailed = false;
private final TabHeader header;
private final TabHeader.Tab<WorldInfoPage> worldInfoTab = new TabHeader.Tab<>("worldInfoPage");
private final TabHeader.Tab<WorldBackupsPage> worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage");
@@ -65,25 +70,25 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
public WorldManagePage(World world, Path backupsDir, Profile profile, String id) {
this.world = world;
this.backupsDir = backupsDir;
this.profile = profile;
this.id = id;
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
try {
world.reloadLevelDat();
} catch (IOException e) {
LOG.warning("Can not load world level.dat of world: " + world.getFile(), e);
loadFailed = true;
}
this.worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this));
this.worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this));
this.datapackTab.setNodeSupplier(() -> new DatapackListPage(this));
this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", world.getWorldName())));
this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName()))));
this.header = new TabHeader(transitionPane, worldInfoTab, worldBackupsTab);
header.select(worldInfoTab);
// Does it need to be done in the background?
try {
sessionLockChannel = world.lock();
LOG.info("Acquired lock on world " + world.getFileName());
} catch (IOException ignored) {
}
setCenter(transitionPane);
BorderPane left = new BorderPane();
@@ -106,37 +111,87 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
AdvancedListBox toolbar = new AdvancedListBox();
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, null);
toolbar.addNavigationDrawerItem(i18n("version.launch_script"), SVG.SCRIPT, this::generateLaunchScript, null);
toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, advancedListItem -> advancedListItem.setDisable(isReadOnly()));
}
if (ChunkBaseApp.isSupported(world)) {
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
PopupMenu chunkBasePopupMenu = new PopupMenu();
JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu);
popupMenu.getContent().addAll(
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
chunkBasePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), chunkBasePopup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), chunkBasePopup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), chunkBasePopup)
);
if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) {
popupMenu.getContent().add(
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup));
chunkBasePopupMenu.getContent().add(
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), chunkBasePopup));
}
toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem ->
chunkBaseMenuItem.setOnAction(e ->
popup.show(chunkBaseMenuItem,
chunkBasePopup.show(chunkBaseMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
chunkBaseMenuItem.getWidth(), 0)));
}
toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(world.getFile()), null);
{
PopupMenu managePopupMenu = new PopupMenu();
JFXPopup managePopup = new JFXPopup(managePopupMenu);
if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) {
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), this::launch, managePopup),
new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), this::generateLaunchScript, managePopup),
new MenuSeparator()
);
}
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup),
new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> WorldManageUIUtils.delete(world, () -> fireEvent(new PageCloseEvent()), sessionLockChannel), managePopup),
new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(world, null), managePopup)
);
toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem ->
{
managePopupMenuItem.setOnAction(e ->
managePopup.show(managePopupMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
managePopupMenuItem.getWidth(), 0));
managePopupMenuItem.setDisable(isReadOnly());
});
}
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
left.setBottom(toolbar);
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
}
private void onNavigated(Navigator.NavigationEvent event) {
if (loadFailed) {
Platform.runLater(() -> {
fireEvent(new PageCloseEvent());
Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR);
});
return;
}
if (sessionLockChannel == null || !sessionLockChannel.isOpen()) {
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
}
}
public void onExited(Navigator.NavigationEvent event) {
try {
WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException ignored) {
}
}
@Override
@@ -156,19 +211,6 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
return sessionLockChannel == null;
}
public void onExited(Navigator.NavigationEvent event) {
if (sessionLockChannel != null) {
try {
sessionLockChannel.close();
LOG.info("Releases the lock on world " + world.getFileName());
} catch (IOException e) {
LOG.warning("Failed to close session lock channel", e);
}
sessionLockChannel = null;
}
}
public void launch() {
fireEvent(new PageCloseEvent());
Versions.launchAndEnterWorld(profile, id, world.getFileName());
@@ -0,0 +1,152 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.versions;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.InputDialogPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldManageUIUtils {
private WorldManageUIUtils() {
}
public static void delete(World world, Runnable runnable) {
delete(world, runnable, null);
}
public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) {
Controllers.confirm(
i18n("button.remove.confirm"),
i18n("world.delete"),
() -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel))
.thenRunAsync(world::delete)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
runnable.run();
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
} else {
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}).start(),
null
);
}
public static void export(World world) {
export(world, null);
}
public static void export(World world, FileChannel sessionLockChannel) {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.export.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
fileChooser.setInitialFileName(world.getWorldName());
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
if (file == null) {
return;
}
try {
closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException e) {
return;
}
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
}
public static void copyWorld(World world, Runnable runnable) {
Path worldPath = world.getFile();
Controllers.dialog(new InputDialogPane(
i18n("world.duplicate.prompt"),
"",
(result, resolve, reject) -> {
if (StringUtils.isBlank(result)) {
reject.accept(i18n("world.duplicate.failed.empty_name"));
return;
}
if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) {
reject.accept(i18n("world.duplicate.failed.invalid_name"));
return;
}
Path targetDir = worldPath.resolveSibling(result);
if (Files.exists(targetDir)) {
reject.accept(i18n("world.duplicate.failed.already_exists"));
return;
}
Task.runAsync(Schedulers.io(), () -> world.copy(result))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast")))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> {
if (runnable != null) {
runnable.run();
}
}
).whenComplete(Schedulers.javafx(), (throwable) -> {
if (throwable == null) {
resolve.run();
} else {
reject.accept(i18n("world.duplicate.failed"));
LOG.warning("Failed to duplicate world " + world.getFile(), throwable);
}
})
.start();
}));
}
public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException {
if (sessionLockChannel != null) {
try {
sessionLockChannel.close();
LOG.info("Closed session lock channel of the world " + world.getFileName());
} catch (IOException e) {
throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e);
}
}
}
public static FileChannel getSessionLockChannel(World world) {
try {
FileChannel lock = world.lock();
LOG.info("Acquired lock on world " + world.getFileName());
return lock;
} catch (IOException ignored) {
return null;
}
}
}
@@ -0,0 +1,117 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import javafx.beans.value.WritableValue;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public abstract class RemoteImageLoader {
private final Map<URI, WeakReference<Image>> cache = new HashMap<>();
private final Map<URI, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
private final WeakHashMap<WritableValue<Image>, URI> reverseLookup = new WeakHashMap<>();
public RemoteImageLoader() {
}
protected @Nullable Image getPlaceholder() {
return null;
}
protected abstract @NotNull Task<Image> createLoadTask(@NotNull URI uri);
@FXThread
public void load(@NotNull WritableValue<Image> writableValue, String url) {
URI uri = NetworkUtils.toURIOrNull(url);
if (uri == null) {
reverseLookup.remove(writableValue);
writableValue.setValue(getPlaceholder());
return;
}
WeakReference<Image> reference = cache.get(uri);
if (reference != null) {
Image image = reference.get();
if (image != null) {
reverseLookup.remove(writableValue);
writableValue.setValue(image);
return;
}
cache.remove(uri);
}
writableValue.setValue(getPlaceholder());
{
List<WeakReference<WritableValue<Image>>> list = pendingRequests.get(uri);
if (list != null) {
list.add(new WeakReference<>(writableValue));
reverseLookup.put(writableValue, uri);
return;
} else {
list = new ArrayList<>(1);
list.add(new WeakReference<>(writableValue));
pendingRequests.put(uri, list);
reverseLookup.put(writableValue, uri);
}
}
createLoadTask(uri).whenComplete(Schedulers.javafx(), (result, exception) -> {
Image image;
if (exception == null) {
image = result;
} else {
LOG.warning("Failed to load image from " + uri, exception);
image = getPlaceholder();
}
cache.put(uri, new WeakReference<>(image));
List<WeakReference<WritableValue<Image>>> list = pendingRequests.remove(uri);
if (list != null) {
for (WeakReference<WritableValue<Image>> ref : list) {
WritableValue<Image> target = ref.get();
if (target != null && uri.equals(reverseLookup.get(target))) {
reverseLookup.remove(target);
target.setValue(image);
}
}
}
}).start();
}
@FXThread
public void unload(@NotNull WritableValue<Image> writableValue) {
reverseLookup.remove(writableValue);
}
@FXThread
public void clearInvalidCache() {
cache.entrySet().removeIf(entry -> entry.getValue().get() == null);
}
}
+13 -11
View File
@@ -388,31 +388,33 @@
-fx-cursor: hand;
}
.installer-item {
-fx-padding: 8px;
.installer-item-wrapper {
-fx-background-color: -monet-surface;
-fx-background-radius: 4;
-fx-pref-width: 180px;
}
.installer-item:list-item {
.installer-item-wrapper:card {
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2);
}
.installer-item-wrapper .installer-item:list-item {
-fx-padding: 8px;
-fx-border-color: -monet-outline-variant;
-fx-border-width: 0 0 1 0;
-fx-alignment: center-left;
}
.installer-item:list-item > .installer-item-name {
.installer-item-wrapper .installer-item:list-item > .installer-item-name {
-fx-pref-width: 80px;
}
.installer-item:list-item > .installer-item-status {
.installer-item-wrapper .installer-item:list-item > .installer-item-status {
-fx-max-width: infinity;
}
.installer-item:card {
-fx-background-color: -monet-surface;
-fx-background-radius: 4;
.installer-item-wrapper .installer-item:card {
-fx-alignment: center;
-fx-pref-width: 180px;
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2);
}
/*******************************************************************************
@@ -75,6 +75,7 @@ account.failed.server_disconnected=Failed to connect to the authentication serve
If the issue persists after multiple attempts, please try logging in to the account again.
account.failed.server_response_malformed=Invalid server response. The authentication server may be down.
account.failed.ssl=An SSL error occurred while connecting to the server. Please try updating your Java.
account.failed.dns=An SSL error occurred while connecting to the server. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service.
account.failed.wrong_account=You have logged in to the wrong account.
account.hmcl.hint=You need to click "Log in" and complete the process in the opened browser window.
account.injector.add=New Auth Server
@@ -125,6 +126,7 @@ account.methods.microsoft.manual=Please enter the code shown above on the pop-up
account.methods.microsoft.profile=Account Profile
account.methods.microsoft.purchase=Buy Minecraft
account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the <a href="https://hmcl.huangyuhui.net/download">official build</a> to log in.
account.methods.microsoft.snapshot.tooltip=You are using an unofficial build of HMCL. Please download the official build to refresh the account.
account.methods.microsoft.snapshot.website=Official Website
account.methods.offline=Offline
account.methods.offline.name.special_characters=Use only letters, numbers, and underscores (max 16 chars)
@@ -380,6 +382,7 @@ exception.access_denied=HMCL is unable to access the file "%s". It may be locked
If not, please check if your user account has adequate permissions to access it.
exception.artifact_malformed=Cannot verify the integrity of the downloaded files.
exception.ssl_handshake=Failed to establish SSL connection because the SSL certificate is missing from the current Java installation. You can try opening HMCL with another Java installation and try again.
exception.dns.pollution=Failed to establish an SSL connection. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service.
extension.bat=Windows Batch File
extension.mod=Mod File
@@ -1131,6 +1134,13 @@ world.chunkbase.end_city=End City
world.chunkbase.seed_map=Seed Map
world.chunkbase.stronghold=Stronghold
world.chunkbase.nether_fortress=Nether Fortress
world.duplicate=Duplicate the World
world.duplicate.prompt=Please enter the name of the duplicated world
world.duplicate.failed.already_exists=Directory already exists
world.duplicate.failed.empty_name=Name cannot be empty
world.duplicate.failed.invalid_name=Name contains invalid characters
world.duplicate.failed=Failed to duplicate the world
world.duplicate.success.toast=Successfully duplicated the world
world.datapack=Datapacks
world.datetime=Last played on %s
world.delete=Delete the World
@@ -1143,6 +1153,15 @@ world.export.location=Save As
world.export.wizard=Export World "%s"
world.extension=World Archive
world.game_version=Game Version
world.icon=World Icon
world.icon.change=Change world icon
world.icon.change.fail.load.title=Failed to parse image
world.icon.change.fail.load.text=This image appears to be corrupted, and HMCL cannot parse it.
world.icon.change.fail.not_64x64.title=Image size error
world.icon.change.fail.not_64x64.text=The image resolution is %d×%d instead of 64×64. Please provide a 64×64 image and try again.
world.icon.change.succeed.toast=Successfully updated the world icon.
world.icon.change.tip=A 64×64 PNG image is required. Images with an incorrect resolution cannot be parsed by Minecraft.
world.icon.choose.title=Select world icon
world.import.already_exists=This world already exists.
world.import.choose=Choose world archive you want to import
world.import.failed=Failed to import this world: %s
@@ -1157,6 +1176,7 @@ world.info.difficulty.peaceful=Peaceful
world.info.difficulty.easy=Easy
world.info.difficulty.normal=Normal
world.info.difficulty.hard=Hard
world.info.difficulty_lock=Lock Difficulty
world.info.failed=Failed to read the world info
world.info.game_version=Game Version
world.info.last_played=Last Played
@@ -1177,6 +1197,7 @@ world.info.player.xp_level=Experience Level
world.info.random_seed=Seed
world.info.time=Game Time
world.info.time.format=%s days
world.load.fail=Failed to load world
world.locked=In use
world.locked.failed=The world is currently in use. Please close the game and try again.
world.manage=Worlds
@@ -1436,13 +1457,17 @@ system.architecture=Architecture
system.operating_system=Operating System
terracotta=Multiplayer
terracotta.easytier=About EasyTier
terracotta.terracotta=Terracotta | Multiplayer
terracotta.status=Lobby
terracotta.back=Exit
terracotta.feedback.title=Fill Out Feedback Form
terracotta.feedback.desc=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
terracotta.difficulty.easiest=Excellent network: almost connected!
terracotta.difficulty.simple=Good network: connection may take some time
terracotta.difficulty.medium=Average network: enabling fallback routes, though connection may fail
terracotta.difficulty.tough=Poor network: enabling fallback routes, though connection may fail
terracotta.difficulty.estimate_only=Success rate is an estimate based on host and client NAT types, for reference only!
terracotta.from_local.title=Third-party download channels for Multiplayer Core
terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable.
terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it.
@@ -1610,4 +1635,4 @@ wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s
wizard.prev=< Prev
wizard.failed=Failed
wizard.finish=Finish
wizard.next=Next >
wizard.next=Next >
@@ -74,6 +74,7 @@ account.failed.server_disconnected=فشل الاتصال بخادم المصاد
إذا استمرت المشكلة بعد محاولات متعددة، يرجى محاولة تسجيل الدخول إلى الحساب مرة أخرى.
account.failed.server_response_malformed=استجابة خادم غير صالحة. قد يكون خادم المصادقة معطلاً.
account.failed.ssl=حدث خطأ SSL أثناء الاتصال بالخادم. يرجى محاولة تحديث Java الخاص بك.
account.failed.dns=حدث خطأ SSL عند الاتصال بالخادم. قد تكون هناك مشكلة في تحليل أسماء النطاقات (DNS). يرجى محاولة تغيير خادم DNS أو استخدام خدمة وكيل.
account.failed.wrong_account=لقد قمت بتسجيل الدخول إلى حساب خاطئ.
account.hmcl.hint=يجب عليك النقر على "تسجيل الدخول" وإكمال العملية في نافذة المتصفح المفتوحة.
account.injector.add=خادم مصادقة جديد
@@ -359,6 +360,7 @@ exception.access_denied=HMCL غير قادر على الوصول إلى المل
إذا لم يكن الأمر كذلك، يرجى التحقق مما إذا كان حساب المستخدم الخاص بك لديه أذونات كافية للوصول إليه.
exception.artifact_malformed=لا يمكن التحقق من سلامة الملفات المحملة.
exception.ssl_handshake=فشل إنشاء اتصال SSL لأن شهادة SSL مفقودة من تثبيت Java الحالي. يمكنك محاولة فتح HMCL باستخدام تثبيت Java آخر والمحاولة مرة أخرى.
exception.dns.pollution=فشل إنشاء اتصال SSL. قد تكون هناك مشكلة في تحليل أسماء النطاقات (DNS). يرجى محاولة تغيير خادم DNS أو استخدام خدمة وكيل.
extension.bat=ملف Batch لـ Windows
extension.mod=ملف التعديل
@@ -1400,7 +1402,6 @@ system.architecture=البنية
system.operating_system=نظام التشغيل
terracotta=اللعب الجماعي
terracotta.easytier=حول EasyTier
terracotta.terracotta=Terracotta | اللعب الجماعي
terracotta.status=الردهة
terracotta.back=خروج
@@ -74,6 +74,7 @@ account.failed.server_disconnected=No se ha podido conectar con el servidor de a
Si lo intentas varias veces y sigue fallando, vuelve a intentar iniciar sesión en la cuenta.
account.failed.server_response_malformed=Respuesta del servidor no válida, el servidor de autenticación puede no estar funcionando.
account.failed.ssl=Se ha producido un error SSL al conectar con el servidor. Por favor, intente actualizar su Java.
account.failed.dns=Se produjo un error SSL al conectar con el servidor. Es posible que la resolución DNS sea incorrecta. Intente cambiar el servidor DNS o usar un servicio proxy.
account.failed.wrong_account=Ha iniciado sesión en la cuenta equivocada.
account.hmcl.hint=Debe hacer clic en «Iniciar sesión» y completar el proceso en la ventana abierta del navegador.
account.injector.add=Nuevo servidor Auth
@@ -359,6 +360,7 @@ exception.access_denied=HMCL no puede acceder al archivo %s. Puede estar bloquea
Si no es así, comprueba si tu cuenta tiene permisos suficientes para acceder a ella.
exception.artifact_malformed=No se puede verificar la integridad de los archivos descargados.
exception.ssl_handshake=No se pudo establecer una conexión SSL porque falta el certificado SSL en la instalación actual de Java. Puede intentar abrir HMCL con otro Java y volver a intentarlo.
exception.dns.pollution=No se pudo establecer una conexión SSL. Es posible que la resolución DNS sea incorrecta. Por favor, intente cambiar el servidor DNS o usar un servicio proxy.
extension.bat=Archivo por lotes de Windows
extension.mod=Archivo mod
@@ -1111,6 +1113,9 @@ world.chunkbase.end_city=Ciudad del End
world.chunkbase.seed_map=Vista previa de la generación mundial
world.chunkbase.stronghold=Fortaleza
world.chunkbase.nether_fortress=Fortaleza del Nether
world.duplicate.failed.already_exists=El directorio ya existe
world.duplicate.failed.empty_name=El nombre no puede estar vacío
world.duplicate.failed.invalid_name=El nombre contiene caracteres no válidos
world.datapack=Paquetes de datos
world.datetime=Jugado por última vez en %s
world.delete=Eliminar este mundo
@@ -310,6 +310,7 @@ exception.access_denied=ファイル %s にアクセスできないので、HMCL
Windowsユーザーの場合、リソースモニターでプログラムがファイルを占有しているかどうかを確認し、もしそうなら、このファイルを占有している関連プログラムを閉じるか、コンピュータを再起動して、もう一度試してみることも可能です。
exception.artifact_malformed=ダウンロードしたファイルがチェックサムを通過していない。
exception.ssl_handshake=現在のJava仮想マシンに該当するSSL証明書がないため、SSL接続を確立できませんでした。別のJava仮想マシンでHMCLを起動して、もう一度試してみてください。
exception.dns.pollution=SSL 接続を確立できませんでした。DNS 解決が正しくない可能性があります。DNS サーバーを変更するか、プロキシサービスを使用してみてください。
extension.bat=WindowsBatファイル
extension.mod=Modファイル
@@ -78,6 +78,7 @@ account.failed.server_disconnected=不可訪所入伺服器。戶簿訊更敗矣
君可求助於右上之鈕。
account.failed.server_response_malformed=鑒權伺服器之訊有謬。伺服器或壞。
account.failed.ssl=將訪伺服器而 SSL 有謬。站證或舊,抑爪哇之版舊。宜新爪哇,抑廢代而再試之。\n君可求助於右上之鈕。
account.failed.dns=將訪伺服器而 SSL 有謬。或由 DNS 解析有誤也。宜更 DNS 伺服,抑用代理以訪。\n君可求助於右上之鈕。
account.failed.wrong_account=入謬戶簿
account.hmcl.hint=子須擊「登戶簿」之紐,並登簿於所見之頁。
account.injector.add=增鑒權伺服器
@@ -352,6 +353,7 @@ exception.access_denied=不能訪案「%s」。HMCL 或無權以訪之,抑既
凡有謬,遽求助於右上之鈕。
exception.artifact_malformed=所引之案未能經校。\n君可求助於右上之鈕
exception.ssl_handshake=無築 SSL 鏈。爪哇缺證。宜改爪哇,抑制廢爾代。\n君可求助於右上之鈕。
exception.dns.pollution=無築 SSL 鏈。或由 DNS 污染、解析有誤也。宜更 DNS 伺服,抑用代理以訪。\n君可求助於右上之鈕。
extension.bat=視窗角本
extension.mod=改囊案
@@ -77,6 +77,7 @@ account.failed.server_disconnected=Невозможно получить дос
аккаунта.
account.failed.server_response_malformed=Неверный ответ сервера, видимо сервер авторизации не работает.
account.failed.ssl=При подключении к серверу произошла ошибка SSL. Пожалуйста, попробуйте обновить Java.
account.failed.dns=При подключении к серверу произошла ошибка SSL. Возможно, проблемы с разрешением DNS. Попробуйте сменить DNS‑сервер или воспользоваться прокси-службой.
account.failed.wrong_account=Вы вошли в неверный аккаунт.
account.hmcl.hint=Необходимо нажать на «Войти» и завершить процесс в открывшейся вкладке вашего браузера.
account.injector.add=Новый сервер авторизации
@@ -359,6 +360,7 @@ exception.access_denied=Лаунчер не может получить дост
Если нет, проверьте, достаточно ли привилегий у вашего аккаунта для доступа к нему.
exception.artifact_malformed=Не удалось проверить целостность скачаных файлов.
exception.ssl_handshake=Не удалось установить SSL-соединение из-за отсутствия SSL-сертификатов в текущей установке Java. Вы можете попробовать запустить лаунчер в другой версии Java, а затем повторить попытку.
exception.dns.pollution=Не удалось установить SSL‑соединение. Возможно, некорректно разрешаются DNS‑имена. Попробуйте сменить DNS‑сервер или воспользоваться прокси-службой.
extension.bat=Пакетный файл Windows
extension.mod=Файл мода
@@ -1104,6 +1106,9 @@ world.chunkbase.end_city=Город Края
world.chunkbase.seed_map=Предпросмотр генерации мира
world.chunkbase.stronghold=Крепость
world.chunkbase.nether_fortress=Крепость Нижнего мира
world.duplicate.failed.already_exists=Папка уже существует
world.duplicate.failed.empty_name=Название не может быть пустым
world.duplicate.failed.invalid_name=Название содержит недопустимые символы
world.delete=Удалить этот мир
world.delete.failed=Не удалось удалить мир.\n%s
world.datapack=Наборы данных
@@ -73,6 +73,7 @@ account.failed.server_disconnected=Не вдалося підключитися
Якщо проблема не зникає після кількох спроб, спробуйте знову увійти до облікового запису.
account.failed.server_response_malformed=Недійсна відповідь сервера. Можливо, сервер автентифікації не працює.
account.failed.ssl=Під час підключення до сервера сталася помилка SSL. Спробуйте оновити Java.
account.failed.dns=Під час підключення до сервера сталася помилка SSL. Можливо, DNS неправильно розв'язується. Спробуйте змінити DNS-сервер або використовувати проксі-сервіс.
account.failed.wrong_account=Ви увійшли до неправильного облікового запису.
account.hmcl.hint=Вам потрібно натиснути "Увійти" і завершити процес у відкритому вікні браузера.
account.injector.add=Новий сервер автентифікації
@@ -357,6 +358,7 @@ exception.access_denied=HMCL не може отримати доступ до ф
Якщо ні, перевірте, чи має ваш обліковий запис достатні дозволи для доступу до нього.
exception.artifact_malformed=Не вдається перевірити цілісність завантажених файлів.
exception.ssl_handshake=Не вдалося встановити SSL-з'єднання, оскільки SSL-сертифікат відсутній у поточній інсталяції Java. Ви можете спробувати відкрити HMCL іншою інсталяцією Java і спробувати знову.
exception.dns.pollution=Не вдалося встановити SSL-з'єднання. Можливо, DNS неправильно розв'язується. Спробуйте змінити DNS-сервер або використовувати проксі-сервіс.
extension.bat=Пакетний файл Windows
extension.mod=Файл мода
@@ -1048,6 +1050,9 @@ world.chunkbase.end_city=Кінцеве місто
world.chunkbase.seed_map=Карта насіння
world.chunkbase.stronghold=Фортеця
world.chunkbase.nether_fortress=Форт Незеру
world.duplicate.failed.already_exists=Каталог вже існує
world.duplicate.failed.empty_name=Назва не може бути порожньою
world.duplicate.failed.invalid_name=Назва містить недійсні символи
world.datapack=Datapacks
world.datetime=Останній раз грали %s
world.delete=Видалити цей світ
@@ -78,6 +78,7 @@ account.failed.server_disconnected=無法訪問登入伺服器。帳戶資訊重
若嘗試多次無法重新整理,可嘗試重新增加該帳戶,或許可以解決該問題。
account.failed.server_response_malformed=無法解析認證伺服器回應,可能是伺服器故障。
account.failed.ssl=連線伺服器時發生了 SSL 錯誤。可能網站證書已過期或你使用的 Java 版本過低。請嘗試更新 Java。
account.failed.dns=連線伺服器時發生了 SSL 錯誤。可能是 DNS 解析有誤。請嘗試更換 DNS 伺服器或使用代理服務。
account.failed.wrong_account=登入了錯誤的帳戶
account.hmcl.hint=你需要點擊「登入」按鈕,並在開啟的網頁中完成登入
account.injector.add=新增認證伺服器
@@ -124,6 +125,7 @@ account.methods.microsoft.purchase=購買 Minecraft
account.methods.forgot_password=忘記密碼
account.methods.ban_query=查詢帳戶是否被封禁
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。請下載 <a href="https://hmcl.huangyuhui.net/download">官方版本</a> 進行登入。
account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。請下載官方版本來重新整理帳戶。
account.methods.microsoft.snapshot.website=官方網站
account.methods.offline=離線模式
account.methods.offline.name.special_characters=建議使用英文字母、數字以及底線命名,且長度不超過 16 個字元
@@ -370,6 +372,7 @@ exception.access_denied=無法存取檔案「%s」。因為 HMCL 沒有對該檔
對於 Windows 使用者,你還可以嘗試透過資源監視器查看是否有程式占用了該檔案。如果是,你可以關閉占用此檔案的程式,或者重啟電腦再試。
exception.artifact_malformed=下載的檔案正確,但無法透過校驗。
exception.ssl_handshake=無法建立 SSL 連線。目前 Java 缺少相關的 SSL 證書。你可以嘗試使用其他 Java 或關閉網路代理開啟 HMCL 再試。
exception.dns.pollution=無法建立 SSL 連線。可能是 DNS 解析有誤。請嘗試更換 DNS 伺服器或使用代理服務。
extension.bat=Windows 批次檔
extension.mod=模組檔案
@@ -919,6 +922,13 @@ world.chunkbase.end_city=終界城地圖
world.chunkbase.seed_map=種子地圖
world.chunkbase.stronghold=要塞地圖
world.chunkbase.nether_fortress=地獄要塞地圖
world.duplicate=複製此世界
world.duplicate.prompt=輸入複製後的世界名稱
world.duplicate.failed.already_exists=目錄已存在
world.duplicate.failed.empty_name=名稱不能為空
world.duplicate.failed.invalid_name=名稱中包含無效字元
world.duplicate.failed=複製世界失敗
world.duplicate.success.toast=複製世界成功
world.datapack=資料包管理
world.datetime=上一次遊戲時間: %s
world.delete=刪除此世界
@@ -930,6 +940,15 @@ world.export.title=選取該世界的儲存位置
world.export.location=儲存到
world.export.wizard=匯出世界「%s」
world.extension=世界壓縮檔
world.icon=世界圖示
world.icon.change=修改世界圖示
world.icon.change.fail.load.title=圖片解析失敗
world.icon.change.fail.load.text=該圖片似乎已損毀,HMCL 無法解析它
world.icon.change.fail.not_64x64.title=圖片大小錯誤
world.icon.change.fail.not_64x64.text=該圖片的解析度為 %d×%d,而不是 64×64,請提供一張 64×64 解析度的圖片再次嘗試
world.icon.change.succeed.toast=世界圖示修改成功
world.icon.change.tip=請提供一張 64×64 PNG 格式的圖片。錯誤解析度的圖片將無法被 Minecraft 解析。
world.icon.choose.title=選擇世界圖示
world.import.already_exists=此世界已經存在
world.import.choose=選取要匯入的世界壓縮檔
world.import.failed=無法匯入此世界: %s
@@ -944,6 +963,7 @@ world.info.difficulty.peaceful=和平
world.info.difficulty.easy=簡單
world.info.difficulty.normal=普通
world.info.difficulty.hard=困難
world.info.difficulty_lock=鎖定難易度
world.info.failed=讀取世界資訊失敗
world.info.game_version=遊戲版本
world.info.last_played=上一次遊戲時間
@@ -964,6 +984,7 @@ world.info.player.xp_level=經驗等級
world.info.random_seed=種子碼
world.info.time=遊戲內時間
world.info.time.format=%s 天
world.load.fail=世界載入失敗
world.locked=使用中
world.locked.failed=該世界正在使用中,請關閉遊戲後重試。
world.game_version=遊戲版本
@@ -1215,13 +1236,17 @@ system.architecture=架構
system.operating_system=作業系統
terracotta=多人遊戲
terracotta.easytier=關於 EasyTier
terracotta.terracotta=Terracotta | 陶瓦聯機
terracotta.status=聯機大廳
terracotta.back=退出
terracotta.feedback.title=填寫回饋表
terracotta.feedback.desc=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
terracotta.difficulty.easiest=當前網路狀況極佳:稍等一下就成功!
terracotta.difficulty.simple=目前網路狀況較好:建立連線需要一段時間…
terracotta.difficulty.medium=目前網路狀態中等:已啟用抗干擾備用線路,連線可能失敗
terracotta.difficulty.tough=目前網路狀態極差:已啟用抗干擾備用線路,連線可能失敗
terracotta.difficulty.estimate_only=連接成功率由房主和房客的 NAT 類型推算得到,僅供參考。
terracotta.from_local.title=線上核心第三方下載管道
terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。
@@ -79,6 +79,7 @@ account.failed.server_disconnected=无法访问登录服务器。账户信息刷
你可以点击右上角帮助按钮进行求助。
account.failed.server_response_malformed=无法解析认证服务器响应。可能是服务器故障。
account.failed.ssl=连接服务器时发生了 SSL 错误。可能网站证书已过期或你使用的 Java 版本过低。请尝试更新 Java,或关闭网络代理后再试。\n你可以点击右上角帮助按钮进行求助。
account.failed.dns=连接服务器时发生了 SSL 错误。可能是 DNS 解析有误。请尝试更换 DNS 服务器或使用代理服务。\n你可以点击右上角帮助按钮进行求助。
account.failed.wrong_account=登录了错误的账户
account.hmcl.hint=你需要点击“登录”按钮,并在弹出的网页中完成登录。
account.injector.add=添加认证服务器
@@ -126,6 +127,7 @@ account.methods.microsoft.purchase=购买 Minecraft
account.methods.forgot_password=忘记密码
account.methods.ban_query=检测账户是否被封禁
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。请下载 <a href="https://hmcl.huangyuhui.net/download">官方版本</a> 来登录微软账户。
account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。请下载官方版本来刷新账户。
account.methods.microsoft.snapshot.website=官方网站
account.methods.offline=离线模式
account.methods.offline.name.special_characters=建议使用英文字符、数字以及下划线命名,且长度不超过 16 个字符
@@ -373,6 +375,7 @@ exception.access_denied=无法访问文件“%s”。HMCL 没有对该文件的
如遇到问题,你可以点击右上角帮助按钮进行求助。
exception.artifact_malformed=下载的文件无法通过校验。\n你可以点击右上角帮助按钮进行求助。
exception.ssl_handshake=无法建立 SSL 连接。当前 Java 缺少相关的 SSL 证书。你可以尝试使用其他 Java 启动 HMCL 再试。\n你可以点击右上角帮助按钮进行求助。
exception.dns.pollution=无法建立 SSL 连接。可能是 DNS 解析有误。请尝试更换 DNS 服务器或使用代理服务。\n你可以点击右上角帮助按钮进行求助。
extension.bat=Windows 脚本
extension.mod=模组文件
@@ -923,6 +926,13 @@ world.chunkbase.end_city=末地城地图
world.chunkbase.seed_map=种子地图
world.chunkbase.stronghold=要塞地图
world.chunkbase.nether_fortress=下界要塞地图
world.duplicate=复制此世界
world.duplicate.prompt=输入复制后的世界名称
world.duplicate.failed.already_exists=文件夹已存在
world.duplicate.failed.empty_name=名称不能为空
world.duplicate.failed.invalid_name=名称中包含非法字符
world.duplicate.failed=复制世界失败
world.duplicate.success.toast=复制世界成功
world.datapack=数据包管理
world.datetime=上一次游戏时间: %s
world.delete=删除此世界
@@ -935,6 +945,15 @@ world.export.location=保存到
world.export.wizard=导出世界“%s”
world.extension=世界压缩包
world.game_version=游戏版本
world.icon=世界图标
world.icon.change=修改世界图标
world.icon.change.fail.load.title=图片解析出错
world.icon.change.fail.load.text=该图片似乎已损坏,HMCL 无法解析它
world.icon.change.fail.not_64x64.title=图片大小错误
world.icon.change.fail.not_64x64.text=该图片的分辨率为 %d×%d,而不是 64×64,请提供一张 64×64 分辨率的图片再次尝试
world.icon.change.succeed.toast=世界图标修改成功
world.icon.change.tip=请提供一张 64×64 PNG 格式的图片。错误分辨率的图片将无法被 Minecraft 解析。
world.icon.choose.title=选择世界图标
world.import.already_exists=此世界已经存在
world.import.choose=选择要导入的世界压缩包
world.import.failed=无法导入此世界:%s
@@ -949,6 +968,7 @@ world.info.difficulty.peaceful=和平
world.info.difficulty.easy=简单
world.info.difficulty.normal=普通
world.info.difficulty.hard=困难
world.info.difficulty_lock=锁定难度
world.info.failed=读取世界信息失败
world.info.game_version=游戏版本
world.info.last_played=上一次游戏时间
@@ -969,6 +989,7 @@ world.info.player.xp_level=经验等级
world.info.random_seed=种子
world.info.time=游戏内时间
world.info.time.format=%s 天
world.load.fail=世界加载失败
world.locked=使用中
world.locked.failed=该世界正在使用中,请关闭游戏后重试。
world.manage=世界管理
@@ -1219,13 +1240,17 @@ system.architecture=架构
system.operating_system=操作系统
terracotta=多人联机
terracotta.easytier=关于 EasyTier
terracotta.terracotta=Terracotta | 陶瓦联机
terracotta.status=联机大厅
terracotta.back=退出
terracotta.feedback.title=填写反馈表
terracotta.feedback.desc=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
terracotta.difficulty.easiest=当前网络状态极好:稍等一下就成功!
terracotta.difficulty.simple=当前网络状态较好:建立连接需要一段时间……
terracotta.difficulty.medium=当前网络状态中等:已启用抗干扰备用线路,连接可能失败
terracotta.difficulty.tough=当前网络状态极差:已启用抗干扰备用线路,连接可能失败
terracotta.difficulty.estimate_only=连接成功率由房主和房客的 NAT 类型推算得到,仅供参考。
terracotta.from_local.title=联机核心第三方下载渠道
terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢
terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。
+66 -22
View File
@@ -1,29 +1,73 @@
{
"version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|(1[0-3]))",
"version_recent": [
"0.3.13"
],
"version_latest": "0.3.14",
"classifiers": {
"freebsd-x86_64": "sha256:ff8c50b701e55fde9701a1f7fbe9d7317b24087c28ca6337ba9d695c53ad499a",
"linux-arm64": "sha256:6912a14255cdeedee512244c686dd7f80dcc45d8dfbe129ea4ea3f1b22d3de60",
"linux-x86_64": "sha256:9b13f51dc41c5112f2ab163df5c0dccdff3d22deb793a6698c013e9c09badd00",
"linux-loongarch64": "sha256:729ca24ef643547b6a3917520ec3335b92e226ad1884c85d6bb6c2cfa917cb03",
"linux-riscv64": "sha256:afbd8cffeacc47431f18c3ae659f7a308e1d3f2efab0ab9c5ece5176d068bf30",
"macos-arm64": "sha256:9ed9c7a35b01dce440135c91e4a5e617d84f6c8ba13a117d13a2796dc69584f5",
"macos-arm64.pkg": "sha256:49dbebd608c4036c260f09d4005dedaacb26fc1582b10479ed51476baa48f4a9",
"macos-x86_64": "sha256:40a44703237b0a194b0fb18075e3b2361ace8f4319b60269cb56c5548d2afb3a",
"macos-x86_64.pkg": "sha256:95a9de782b9c3a352ae6ec702c2fc4ca34ba807e8e4a768e61902a0c78391332",
"windows-arm64.exe": "sha256:8428c6bc6228213c5f645ab3dd0f3b684da515be103ee53d5bdc4adf59fc8a5f",
"windows-x86_64.exe": "sha256:942b91888264e1c96a10b7c94bd134f4b10d3e352e3c731def0ff6a3069c8221"
"__comment__": "THIS FILE IS MACHINE GENERATED! DO NOT EDIT!",
"version_latest": "0.4.2",
"packages": {
"windows-x86_64": {
"hash": "6a98f524d4f00373696517306af8aa50d01d55ce4eadb27e9e4bc2f882707a0b5f20d5d4c33371d1459dcf5bf144ffed9beb414202d9ccf32b11dbbfcf19d650",
"files": {
"VCRUNTIME140.DLL": "3d4b24061f72c0e957c7b04a0c4098c94c8f1afb4a7e159850b9939c7210d73398be6f27b5ab85073b4e8c999816e7804fef0f6115c39cd061f4aaeb4dcda8cf",
"terracotta-0.4.2-windows-x86_64.exe": "6e98d1f2380ed22fb5a2dd4aafce6c773e9cf69100c8bb8e49e7d6983756bdb9a31f80e06bcfbe5a2742144fe806d3d687dec54d8f09d87c659341f99dd9fd80"
}
},
"windows-arm64": {
"hash": "fc1077247014ac0c712469498bde2ef7f6d881d5fcb7bdd5e11ebe20218fed365be19afdb8d453a79d77b729f866058522b910741767f4df947faa891434b463",
"files": {
"VCRUNTIME140.DLL": "5cb5ce114614101d260f4754c09e8a0dd57e4da885ebb96b91e274326f3e1dd95ed0ade9f542f1922fad0ed025e88a1f368e791e1d01fae69718f0ec3c7b98c8",
"terracotta-0.4.2-windows-arm64.exe": "30a15c5c53e5817c5a3634532172559327474741d3b2c7ef4e8a30acc6f59cdcf3570bf5f583e3cbe9e2abc8253e977c1abda1e9f36c88c4e99240da257347d0"
}
},
"macos-x86_64": {
"hash": "a762e4b2d6f84e899292b9e3856d009411a516d3c47f54575f843ce082f63dff2baa68ba0faa844b8b64fb12e91017386f15f5e7f975f8ee605bf8d4217cb091",
"files": {
"terracotta-0.4.2-macos-x86_64": "24efb85390eff88a538ed7e503fb1488e5e622730ca30c741a0e8b4c8f4e8d4868a2f9f38da8de540aeb535af2fd1e41c7081dae9c700e8a1a03b6c540218164",
"terracotta-0.4.2-macos-x86_64.pkg": "0f80437061231018ec0f860f875aba02cae9e0b36d21f2db8e99d25948806e1119f7844a4d4acca01d6124d7079718762cdebe3756b005a55632de7029fe0d24"
}
},
"macos-arm64": {
"hash": "09e444fea2d9fd19f3e5cb62e29055228345be163924cbd408d947646fafed1012cf48508ee6a155ede3d571e2ffaa72d09ceeb1493c8a60feb05e0699f19ba3",
"files": {
"terracotta-0.4.2-macos-arm64": "8e59a9d78acd57702dc044d6f2799c6af586b075a262f7d4dbbf0876e1af8d8271e04783c24ff820b801e3b14cd0190ab8403d097f3f2d98b6d911f95ed1e972",
"terracotta-0.4.2-macos-arm64.pkg": "b0b72f8883767c359f0700e958bd859dd5932c4674fbdf2a32f7de189fb9f54822d4825a754dc856416c8760338eb8ffca80b40c0a57c608fbe6ae9928888993"
}
},
"linux-x86_64": {
"hash": "d326ad95815d04568d485b5038e40ffc47ca54292fa0925eee6f5cea014024f901d661708aac2a743037b990882ad82b4d0b7bb03dc3b2fe720dbf0f3efe1c98",
"files": {
"terracotta-0.4.2-linux-x86_64": "fac328ba8957a711b03557bb913940f22d61b76608cd203fdf51024b6f94b19f5bc91c9b8a9fa80baf6968e1e6873c1880fd4cf54a2f8e3c6cf1e6ac161f8d0c"
}
},
"linux-arm64": {
"hash": "57c08f48d9535e93ad547d2dfc852d267992cc164a7208b42a2da0a6cbc2f21862f610e02a746b4b67150f4dec26b86a4f96eb9bd2f58d124d5b40ba50c6d55e",
"files": {
"terracotta-0.4.2-linux-arm64": "d807744c2041c98686e4b505324713badea7a0f31e8810be49ae053a63fb6dfc474ac58d678fb93eea0dd5cccff7372d9ec6135a1046f4b306cad35cd90ecacd"
}
},
"linux-loongarch64": {
"hash": "a19f4ad4fa1fd2f3c1c867f8743178ab0524d1634d63e122ffc080623e4f14207ecab58830fa0d888696ef28ce4cfa189e6ffbe92c6e1aa1836652617a96a801",
"files": {
"terracotta-0.4.2-linux-loongarch64": "7d4896b275207def0861e544cf2afc9501c1946f1bc8bb2bad9ef4510dd915eabb63eeedff725e69565cdf5c27c146b52dd75d24115d5141909c0865f89cd7b9"
}
},
"linux-riscv64": {
"hash": "ef3dfcf3c64dfcaf94adc518f0184d017a32a924f67ef33ffb8128f20669cf177cd345df3be8c00225a275bd44f70657b94fb43c60afeb21238000ac7a7f1721",
"files": {
"terracotta-0.4.2-linux-riscv64": "afddc55f16f5cf3103548d13f7a3238964c8899dba8eb0e1fdc9e354da65e91fa4486de4b8680a7a3d9326eacf18c1ddb6b5b1f9459908f35f77b36f769172ea"
}
},
"freebsd-x86_64": {
"hash": "a4d02ebbb1419f4e9265f5b24060432799151a27e0d1871690d2f3a51f40bcfd6d92c176001a15a62caa9bf31ea1b67a7b2da10e3351bfcdd36bfac1377d08d9",
"files": {
"terracotta-0.4.2-freebsd-x86_64": "a50da02752cef73d21804b26eb02261568ed3053c8fef1074014d50226843810f71e6eda39a53af3bad8e173cec40826631a4c8877af460d14a307f1b0a62519"
}
}
},
"downloads": [
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"downloads_CN": [
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}",
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}"
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"links": [
{
@@ -40,7 +84,7 @@
"zh": "QQ 群",
"zh-Hant": "QQ 群"
},
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
"link": "https://qm.qq.com/cgi-bin/qm/qr?k\u003dnIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5\u0026jump_from\u003dwebapi\u0026authKey\u003dsXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
}
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"__comment__": "Run upgradeTerracottaConfig task with Gradle to resolve this template.",
"version_latest": "@script_generated",
"packages": "@script_generated",
"downloads": [
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"downloads_CN": [
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"links": [
{
"desc": {
"default": "GitHub Release",
"zh": "GitHub 发布页",
"zh-Hant": "GitHub 發布頁"
},
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
},
{
"desc": {
"default": "Tencent QQ Group",
"zh": "QQ 群",
"zh-Hant": "QQ 群"
},
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
}
]
}
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.task.Task;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -47,25 +49,58 @@ public class MultipleSourceVersionList extends VersionList<RemoteVersion> {
private Task<?> refreshAsync(String gameVersion, int sourceIndex) {
VersionList<?> versionList = backends[sourceIndex];
return versionList.refreshAsync(gameVersion)
.thenComposeAsync(() -> {
Task<?> refreshTask = versionList.refreshAsync(gameVersion);
return new Task<Object>() {
private Task<?> nextTask = null;
{
setSignificance(TaskSignificance.MODERATE);
setName("MultipleSourceVersionList.refreshAsync(" + sourceIndex + ")");
}
@Override
public Collection<Task<?>> getDependents() {
return List.of(refreshTask);
}
@Override
public Collection<? extends Task<?>> getDependencies() {
return nextTask != null ? List.of(nextTask) : List.of();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
lock.writeLock().lock();
try {
versions.putAll(gameVersion, versionList.getVersions(gameVersion));
} catch (Exception e) {
if (sourceIndex == backends.length - 1) {
LOG.warning("Failed to fetch versions list from all sources", e);
throw e;
} else {
LOG.warning("Failed to fetch versions list and try to fetch from other source", e);
return refreshAsync(gameVersion, sourceIndex + 1);
}
} finally {
lock.writeLock().unlock();
}
return null;
});
setResult(refreshTask.getResult());
} else {
Exception exception = refreshTask.getException();
assert exception != null;
if (sourceIndex == backends.length - 1) {
LOG.warning("Failed to fetch versions list from all sources", exception);
setSignificance(TaskSignificance.MINOR);
throw exception;
} else {
LOG.warning("Failed to fetch versions list and try to fetch from other source", exception);
nextTask = refreshAsync(gameVersion, sourceIndex + 1);
nextTask.storeTo(this::setResult);
}
}
}
};
}
@Override
@@ -92,7 +92,10 @@ public final class CleanroomInstallTask extends Task<Version> {
@Override
public void postExecute() throws Exception {
Files.deleteIfExists(installer);
if (remote != null) {
Files.deleteIfExists(installer);
}
setResult(task.getResult());
}
@@ -18,10 +18,7 @@
package org.jackhuang.hmcl.game;
import com.github.steveice10.opennbt.NBTIO;
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
import com.github.steveice10.opennbt.tag.builtin.LongTag;
import com.github.steveice10.opennbt.tag.builtin.StringTag;
import com.github.steveice10.opennbt.tag.builtin.Tag;
import com.github.steveice10.opennbt.tag.builtin.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.util.io.*;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -48,13 +45,10 @@ public final class World {
private final Path file;
private String fileName;
private String worldName;
private GameVersionNumber gameVersion;
private long lastPlayed;
private CompoundTag levelData;
private Image icon;
private Long seed;
private boolean largeBiomes;
private boolean isLocked;
private Path levelDataPath;
public World(Path file) throws IOException {
this.file = file;
@@ -67,10 +61,100 @@ public final class World {
throw new IOException("Path " + file + " cannot be recognized as a Minecraft world");
}
public Path getFile() {
return file;
}
public String getFileName() {
return fileName;
}
public String getWorldName() {
CompoundTag data = levelData.get("Data");
StringTag levelNameTag = data.get("LevelName");
return levelNameTag.getValue();
}
public void setWorldName(String worldName) throws IOException {
if (levelData.get("Data") instanceof CompoundTag data && data.get("LevelName") instanceof StringTag levelNameTag) {
levelNameTag.setValue(worldName);
writeLevelDat(levelData);
}
}
public Path getLevelDatFile() {
return file.resolve("level.dat");
}
public Path getSessionLockFile() {
return file.resolve("session.lock");
}
public CompoundTag getLevelData() {
return levelData;
}
public long getLastPlayed() {
CompoundTag data = levelData.get("Data");
LongTag lastPlayedTag = data.get("LastPlayed");
return lastPlayedTag.getValue();
}
public @Nullable GameVersionNumber getGameVersion() {
if (levelData.get("Data") instanceof CompoundTag data &&
data.get("Version") instanceof CompoundTag versionTag &&
versionTag.get("Name") instanceof StringTag nameTag) {
return GameVersionNumber.asGameVersion(nameTag.getValue());
}
return null;
}
public @Nullable Long getSeed() {
CompoundTag data = levelData.get("Data");
if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag && worldGenSettingsTag.get("seed") instanceof LongTag seedTag) { //Valid after 1.16
return seedTag.getValue();
} else if (data.get("RandomSeed") instanceof LongTag seedTag) { //Valid before 1.16
return seedTag.getValue();
}
return null;
}
public boolean isLargeBiomes() {
CompoundTag data = levelData.get("Data");
if (data.get("generatorName") instanceof StringTag generatorNameTag) { //Valid before 1.16
return "largeBiomes".equals(generatorNameTag.getValue());
} else {
if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag
&& worldGenSettingsTag.get("dimensions") instanceof CompoundTag dimensionsTag
&& dimensionsTag.get("minecraft:overworld") instanceof CompoundTag overworldTag
&& overworldTag.get("generator") instanceof CompoundTag generatorTag) {
if (generatorTag.get("biome_source") instanceof CompoundTag biomeSourceTag
&& biomeSourceTag.get("large_biomes") instanceof ByteTag largeBiomesTag) { //Valid between 1.16 and 1.16.2
return largeBiomesTag.getValue() == (byte) 1;
} else if (generatorTag.get("settings") instanceof StringTag settingsTag) { //Valid after 1.16.2
return "minecraft:large_biomes".equals(settingsTag.getValue());
}
}
return false;
}
}
public Image getIcon() {
return icon;
}
public boolean isLocked() {
return isLocked;
}
private void loadFromDirectory() throws IOException {
fileName = FileUtils.getName(file);
Path levelDat = file.resolve("level.dat");
loadWorldInfo(levelDat);
if (!Files.exists(levelDat)) { // version 20w14infinite
levelDat = file.resolve("special_level.dat");
}
loadAndCheckLevelDat(levelDat);
this.levelDataPath = levelDat;
isLocked = isLocked(getSessionLockFile());
Path iconFile = file.resolve("icon.png");
@@ -85,56 +169,16 @@ public final class World {
}
}
public Path getFile() {
return file;
}
public String getFileName() {
return fileName;
}
public String getWorldName() {
return worldName;
}
public Path getLevelDatFile() {
return file.resolve("level.dat");
}
public Path getSessionLockFile() {
return file.resolve("session.lock");
}
public long getLastPlayed() {
return lastPlayed;
}
public @Nullable GameVersionNumber getGameVersion() {
return gameVersion;
}
public @Nullable Long getSeed() {
return seed;
}
public boolean isLargeBiomes() {
return largeBiomes;
}
public Image getIcon() {
return icon;
}
public boolean isLocked() {
return isLocked;
}
private void loadFromZipImpl(Path root) throws IOException {
Path levelDat = root.resolve("level.dat");
if (!Files.exists(levelDat))
throw new IOException("Not a valid world zip file since level.dat cannot be found.");
if (!Files.exists(levelDat)) { //version 20w14infinite
levelDat = root.resolve("special_level.dat");
}
if (!Files.exists(levelDat)) {
throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found.");
}
loadWorldInfo(levelDat);
loadAndCheckLevelDat(levelDat);
Path iconFile = root.resolve("icon.png");
if (Files.isRegularFile(iconFile)) {
@@ -167,50 +211,22 @@ public final class World {
}
}
private void loadWorldInfo(Path levelDat) throws IOException {
CompoundTag nbt = parseLevelDat(levelDat);
CompoundTag data = nbt.get("Data");
private void loadAndCheckLevelDat(Path levelDat) throws IOException {
this.levelData = parseLevelDat(levelDat);
CompoundTag data = levelData.get("Data");
if (data == null)
throw new IOException("level.dat missing Data");
if (data.get("LevelName") instanceof StringTag)
worldName = data.<StringTag>get("LevelName").getValue();
else
if (!(data.get("LevelName") instanceof StringTag))
throw new IOException("level.dat missing LevelName");
if (data.get("LastPlayed") instanceof LongTag)
lastPlayed = data.<LongTag>get("LastPlayed").getValue();
else
if (!(data.get("LastPlayed") instanceof LongTag))
throw new IOException("level.dat missing LastPlayed");
}
gameVersion = null;
if (data.get("Version") instanceof CompoundTag) {
CompoundTag version = data.get("Version");
if (version.get("Name") instanceof StringTag)
gameVersion = GameVersionNumber.asGameVersion(version.<StringTag>get("Name").getValue());
}
Tag worldGenSettings = data.get("WorldGenSettings");
if (worldGenSettings instanceof CompoundTag) {
Tag seedTag = ((CompoundTag) worldGenSettings).get("seed");
if (seedTag instanceof LongTag) {
seed = ((LongTag) seedTag).getValue();
}
}
if (seed == null) {
Tag seedTag = data.get("RandomSeed");
if (seedTag instanceof LongTag) {
seed = ((LongTag) seedTag).getValue();
}
}
// FIXME: Only work for 1.15 and below
if (data.get("generatorName") instanceof StringTag) {
largeBiomes = "largeBiomes".equals(data.<StringTag>get("generatorName").getValue());
} else {
largeBiomes = false;
public void reloadLevelDat() throws IOException {
if (levelDataPath != null) {
loadAndCheckLevelDat(this.levelDataPath);
}
}
@@ -219,10 +235,9 @@ public final class World {
throw new IOException("Not a valid world directory");
// Change the name recorded in level.dat
CompoundTag nbt = readLevelDat();
CompoundTag data = nbt.get("Data");
CompoundTag data = levelData.get("Data");
data.put(new StringTag("LevelName", newName));
writeLevelDat(nbt);
writeLevelDat(levelData);
// then change the folder's name
Files.move(file, file.resolveSibling(newName));
@@ -283,11 +298,19 @@ public final class World {
FileUtils.forceDelete(file);
}
public CompoundTag readLevelDat() throws IOException {
if (!Files.isDirectory(file))
public void copy(String newName) throws IOException {
if (!Files.isDirectory(file)) {
throw new IOException("Not a valid world directory");
}
return parseLevelDat(getLevelDatFile());
if (isLocked()) {
throw new WorldLockedException("The world " + getFile() + " has been locked");
}
Path newPath = file.resolveSibling(newName);
FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock"));
World newWorld = new World(newPath);
newWorld.rename(newName);
}
public FileChannel lock() throws WorldLockedException {
@@ -172,7 +172,7 @@ public class DefaultLauncher extends Launcher {
Proxy.Type proxyType = options.getProxyType();
if (proxyType == null) {
res.addDefault("-Djava.net.useSystemProxies", "true");
res.addDefault("-Djava.net.useSystemProxies=", "true");
} else {
String proxyHost = options.getProxyHost();
int proxyPort = options.getProxyPort();
@@ -397,9 +397,12 @@ public class DefaultLauncher extends Launcher {
for (Library library : version.getLibraries())
if (library.isNative())
new Unzipper(repository.getLibraryFile(version, library), destination)
.setFilter((zipEntry, isDirectory, destFile, path) -> {
if (!isDirectory && Files.isRegularFile(destFile) && Files.size(destFile) == Files.size(zipEntry))
.setFilter((zipEntry, destFile, relativePath) -> {
if (!zipEntry.isDirectory() && !zipEntry.isUnixSymlink()
&& Files.isRegularFile(destFile)
&& zipEntry.getSize() == Files.size(destFile)) {
return false;
}
String ext = FileUtils.getExtension(destFile);
if (ext.equals("sha1") || ext.equals("git"))
return false;
@@ -411,7 +414,7 @@ public class DefaultLauncher extends Launcher {
return false;
}
return library.getExtract().shouldExtract(path);
return library.getExtract().shouldExtract(relativePath);
})
.setReplaceExistentFile(false).unzip();
} catch (IOException e) {
@@ -17,19 +17,22 @@
*/
package org.jackhuang.hmcl.mod;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public final class MinecraftInstanceTask<T> extends Task<ModpackConfiguration<T>> {
@@ -53,26 +56,42 @@ public final class MinecraftInstanceTask<T> extends Task<ModpackConfiguration<T>
this.version = version;
}
private static void getOverrides(List<ModpackConfiguration.FileInformation> overrides,
ZipFileTree tree,
ArchiveFileTree.Dir<ZipArchiveEntry> dir,
List<String> names) throws IOException {
String prefix = String.join("/", names);
if (!prefix.isEmpty())
prefix = prefix + "/";
for (Map.Entry<String, ZipArchiveEntry> entry : dir.getFiles().entrySet()) {
String hash;
try (InputStream input = tree.getInputStream(entry.getValue())) {
hash = DigestUtils.digestToString("SHA-1", input);
}
overrides.add(new ModpackConfiguration.FileInformation(prefix + entry.getKey(), hash));
}
for (ArchiveFileTree.Dir<ZipArchiveEntry> subDir : dir.getSubDirs().values()) {
names.add(subDir.getName());
getOverrides(overrides, tree, subDir, names);
names.remove(names.size() - 1);
}
}
@Override
public void execute() throws Exception {
List<ModpackConfiguration.FileInformation> overrides = new ArrayList<>();
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(encoding).build()) {
try (var tree = new ZipFileTree(CompressingUtils.openZipFileWithPossibleEncoding(zipFile, encoding))) {
for (String subDirectory : subDirectories) {
Path root = fs.getPath(subDirectory);
if (Files.exists(root))
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
String relativePath = root.relativize(file).normalize().toString().replace(File.separatorChar, '/');
overrides.add(new ModpackConfiguration.FileInformation(relativePath, DigestUtils.digestToString("SHA-1", file)));
return FileVisitResult.CONTINUE;
}
});
ArchiveFileTree.Dir<ZipArchiveEntry> root = tree.getDirectory(subDirectory);
if (root == null)
continue;
var names = new ArrayList<String>();
getOverrides(overrides, tree, root, names);
}
}
ModpackConfiguration<T> configuration = new ModpackConfiguration<>(manifest, type, name, version, overrides);
Files.createDirectories(jsonFile.getParent());
JsonUtils.writeToJsonFile(jsonFile, configuration);
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Unmodifiable;
@@ -38,7 +39,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ModManager {
@FunctionalInterface
private interface ModMetadataReader {
LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException;
LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException;
}
private static final Map<String, List<Pair<ModMetadataReader, ModLoaderType>>> READERS;
@@ -125,10 +126,10 @@ public final class ModManager {
LocalModFile modInfo = null;
List<Exception> exceptions = new ArrayList<>();
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file)) {
try (ZipFileTree tree = CompressingUtils.openZipTree(file)) {
for (ModMetadataReader reader : supportedReaders) {
try {
modInfo = reader.fromFile(this, file, fs);
modInfo = reader.fromFile(this, file, tree);
break;
} catch (Exception e) {
exceptions.add(e);
@@ -138,7 +139,7 @@ public final class ModManager {
if (modInfo == null) {
for (ModMetadataReader reader : unsupportedReaders) {
try {
modInfo = reader.fromFile(this, file, fs);
modInfo = reader.fromFile(this, file, tree);
break;
} catch (Exception ignored) {
}
@@ -73,22 +73,22 @@ public class ModpackInstallTask<T> extends Task<Void> {
.setTerminateIfSubDirectoryNotExists()
.setReplaceExistentFile(true)
.setEncoding(charset)
.setFilter((destPath, isDirectory, zipEntry, entryPath) -> {
if (isDirectory) return true;
if (!callback.test(entryPath)) return false;
entries.add(entryPath);
.setFilter((zipEntry, destFile, relativePath) -> {
if (zipEntry.isDirectory()) return true;
if (!callback.test(relativePath)) return false;
entries.add(relativePath);
if (!files.containsKey(entryPath)) {
if (!files.containsKey(relativePath)) {
// If old modpack does not have this entry, add this entry or override the file that user added.
return true;
} else if (!Files.exists(destPath)) {
} else if (!Files.exists(destFile)) {
// If both old and new modpacks have this entry, but the file is deleted by user, leave it missing.
return false;
} else {
// If both old and new modpacks have this entry, and user has modified this file,
// we will not replace it since this modified file is what user expects.
String fileHash = DigestUtils.digestToString("SHA-1", destPath);
String oldHash = files.get(entryPath).getHash();
String fileHash = DigestUtils.digestToString("SHA-1", destFile);
String oldHash = files.get(relativePath).getHash();
return Objects.equals(oldHash, fileHash);
}
}).unzip();
@@ -36,6 +36,7 @@ import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.Semaphore;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -46,6 +47,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
private static final String PREFIX = "https://api.curseforge.com";
private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
private static final Semaphore SEMAPHORE = new Semaphore(16);
private static final int WORD_PERFECT_MATCH_WEIGHT = 5;
@@ -110,46 +112,55 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
int categoryId = 0;
if (category != null && category.getSelf() instanceof CurseAddon.Category) {
categoryId = ((CurseAddon.Category) category.getSelf()).getId();
}
Response<List<CurseAddon>> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", mapOf(
pair("gameId", "432"),
pair("classId", Integer.toString(section)),
pair("categoryId", Integer.toString(categoryId)),
pair("gameVersion", gameVersion),
pair("searchFilter", searchFilter),
pair("sortField", Integer.toString(toModsSearchSortField(sortType))),
pair("sortOrder", toSortOrder(sortOrder)),
pair("index", Integer.toString(pageOffset * pageSize)),
pair("pageSize", Integer.toString(pageSize)))))))
.getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
if (searchFilter.isEmpty()) {
return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
}
// https://github.com/HMCL-dev/HMCL/issues/1549
String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT);
Map<String, Integer> searchFilterWords = new HashMap<>();
for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) {
searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1);
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> {
String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT);
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
for (String s : StringUtils.tokenize(lowerCaseResult)) {
if (searchFilterWords.containsKey(s)) {
diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length();
}
SEMAPHORE.acquireUninterruptibly();
try {
int categoryId = 0;
if (category != null && category.getSelf() instanceof CurseAddon.Category) {
categoryId = ((CurseAddon.Category) category.getSelf()).getId();
}
return pair(remoteMod, diff);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
var query = new LinkedHashMap<String, String>();
query.put("gameId", "432");
query.put("classId", Integer.toString(section));
if (categoryId != 0)
query.put("categoryId", Integer.toString(categoryId));
query.put("gameVersion", gameVersion);
query.put("searchFilter", searchFilter);
query.put("sortField", Integer.toString(toModsSearchSortField(sortType)));
query.put("sortOrder", toSortOrder(sortOrder));
query.put("index", Integer.toString(pageOffset * pageSize));
query.put("pageSize", Integer.toString(pageSize));
Response<List<CurseAddon>> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", query))))
.getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
if (searchFilter.isEmpty()) {
return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
}
// https://github.com/HMCL-dev/HMCL/issues/1549
String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT);
Map<String, Integer> searchFilterWords = new HashMap<>();
for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) {
searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1);
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> {
String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT);
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
for (String s : StringUtils.tokenize(lowerCaseResult)) {
if (searchFilterWords.containsKey(s)) {
diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length();
}
}
return pair(remoteMod, diff);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
} finally {
SEMAPHORE.release();
}
}
@Override
@@ -173,48 +184,69 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
return Optional.empty();
}
Response<FingerprintMatchesResult> response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432"))
.json(mapOf(pair("fingerprints", Collections.singletonList(hash))))
.getJson(Response.typeOf(FingerprintMatchesResult.class));
SEMAPHORE.acquireUninterruptibly();
try {
Response<FingerprintMatchesResult> response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432"))
.json(mapOf(pair("fingerprints", Collections.singletonList(hash))))
.getJson(Response.typeOf(FingerprintMatchesResult.class));
if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) {
return Optional.empty();
if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) {
return Optional.empty();
}
return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion());
} finally {
SEMAPHORE.release();
}
return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion());
}
@Override
public RemoteMod getModById(String id) throws IOException {
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
.getJson(Response.typeOf(CurseAddon.class));
return response.data.toMod();
SEMAPHORE.acquireUninterruptibly();
try {
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
.getJson(Response.typeOf(CurseAddon.class));
return response.data.toMod();
} finally {
SEMAPHORE.release();
}
}
@Override
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
Response<CurseAddon.LatestFile> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId)))
.getJson(Response.typeOf(CurseAddon.LatestFile.class));
return response.getData().toVersion().getFile();
SEMAPHORE.acquireUninterruptibly();
try {
Response<CurseAddon.LatestFile> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId)))
.getJson(Response.typeOf(CurseAddon.LatestFile.class));
return response.getData().toVersion().getFile();
} finally {
SEMAPHORE.release();
}
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
pair("pageSize", "10000")))
.getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class)));
return response.getData().stream().map(CurseAddon.LatestFile::toVersion);
}
public List<CurseAddon.Category> getCategoriesImpl() throws IOException {
Response<List<CurseAddon.Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
.getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class)));
return reorganizeCategories(categories.getData(), section);
SEMAPHORE.acquireUninterruptibly();
try {
Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
pair("pageSize", "10000")))
.getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class)));
return response.getData().stream().map(CurseAddon.LatestFile::toVersion);
} finally {
SEMAPHORE.release();
}
}
@Override
public Stream<RemoteModRepository.Category> getCategories() throws IOException {
return getCategoriesImpl().stream().map(CurseAddon.Category::toCategory);
SEMAPHORE.acquireUninterruptibly();
try {
Response<List<CurseAddon.Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
.getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class)));
return reorganizeCategories(categories.getData(), section).stream().map(CurseAddon.Category::toCategory);
} finally {
SEMAPHORE.release();
}
}
private List<CurseAddon.Category> reorganizeCategories(List<CurseAddon.Category> categories, int rootId) {
@@ -19,16 +19,16 @@ package org.jackhuang.hmcl.mod.modinfo;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
@@ -59,11 +59,11 @@ public final class FabricModMetadata {
this.contact = contact;
}
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
Path mcmod = fs.getPath("fabric.mod.json");
if (Files.notExists(mcmod))
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
ZipArchiveEntry mcmod = tree.getEntry("fabric.mod.json");
if (mcmod == null)
throw new IOException("File " + modFile + " is not a Fabric mod.");
FabricModMetadata metadata = JsonUtils.fromNonNullJson(Files.readString(mcmod), FabricModMetadata.class);
FabricModMetadata metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), FabricModMetadata.class);
String authors = metadata.authors == null ? "" : metadata.authors.stream().map(author -> author.name).collect(Collectors.joining(", "));
return new LocalModFile(modManager, modManager.getLocalMod(metadata.id, ModLoaderType.FABRIC), modFile, metadata.name, new LocalModFile.Description(metadata.description),
authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon);
@@ -8,6 +8,7 @@ import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.annotations.JsonAdapter;
import com.moandjiezana.toml.Toml;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
@@ -16,14 +17,13 @@ import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -149,33 +149,33 @@ public final class ForgeNewModMetadata {
}
}
public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException {
return fromFile(modManager, modFile, fs, ModLoaderType.FORGE);
public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException {
return fromFile(modManager, modFile, tree, ModLoaderType.FORGE);
}
public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException {
return fromFile(modManager, modFile, fs, ModLoaderType.NEO_FORGED);
public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException {
return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGED);
}
private static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException {
private static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException {
if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGED) {
throw new IOException("Invalid mod loader: " + modLoaderType);
}
if (modLoaderType == ModLoaderType.NEO_FORGED) {
try {
return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, fs);
return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, tree);
} catch (Exception ignored) {
}
}
try {
return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, fs);
return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, tree);
} catch (Exception ignored) {
}
try {
return fromEmbeddedMod(modManager, modFile, fs, modLoaderType);
return fromEmbeddedMod(modManager, modFile, tree, modLoaderType);
} catch (Exception ignored) {
}
@@ -187,19 +187,19 @@ public final class ForgeNewModMetadata {
ModLoaderType modLoaderType,
ModManager modManager,
Path modFile,
FileSystem fs) throws IOException, JsonParseException {
Path modToml = fs.getPath(tomlPath);
if (Files.notExists(modToml))
ZipFileTree tree) throws IOException, JsonParseException {
ZipArchiveEntry modToml = tree.getEntry(tomlPath);
if (modToml == null)
throw new IOException("File " + modFile + " is not a Forge 1.13+ or NeoForge mod.");
Toml toml = new Toml().read(Files.readString(modToml));
Toml toml = new Toml().read(tree.readTextEntry(modToml));
ForgeNewModMetadata metadata = toml.to(ForgeNewModMetadata.class);
if (metadata == null || metadata.getMods().isEmpty())
throw new IOException("Mod " + modFile + " `mods.toml` is malformed..");
Mod mod = metadata.getMods().get(0);
Path manifestMF = fs.getPath("META-INF/MANIFEST.MF");
ZipArchiveEntry manifestMF = tree.getEntry("META-INF/MANIFEST.MF");
String jarVersion = "";
if (Files.exists(manifestMF)) {
try (InputStream is = Files.newInputStream(manifestMF)) {
if (manifestMF != null) {
try (InputStream is = tree.getInputStream(manifestMF)) {
Manifest manifest = new Manifest(is);
jarVersion = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
} catch (IOException e) {
@@ -215,30 +215,30 @@ public final class ForgeNewModMetadata {
metadata.getLogoFile());
}
private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException {
Path manifestFile = fs.getPath("META-INF/MANIFEST.MF");
if (!Files.isRegularFile(manifestFile))
throw new IOException("Missing MANIFEST.MF in file " + manifestFile);
private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException {
ZipArchiveEntry manifestFile = tree.getEntry("META-INF/MANIFEST.MF");
if (manifestFile == null)
throw new IOException("Missing MANIFEST.MF in file " + modFile);
Manifest manifest;
try (InputStream input = Files.newInputStream(manifestFile)) {
try (InputStream input = tree.getInputStream(manifestFile)) {
manifest = new Manifest(input);
}
List<Path> embeddedModFiles = List.of();
List<ZipArchiveEntry> embeddedModFiles = List.of();
String embeddedDependenciesMod = manifest.getMainAttributes().getValue("Embedded-Dependencies-Mod");
if (embeddedDependenciesMod != null) {
Path embeddedModFile = fs.getPath(embeddedDependenciesMod);
if (!Files.isRegularFile(embeddedModFile)) {
LOG.warning("Missing embedded-dependencies-mod: " + embeddedModFile);
ZipArchiveEntry embeddedModFile = tree.getEntry(embeddedDependenciesMod);
if (embeddedModFile == null) {
LOG.warning("Missing embedded-dependencies-mod: " + embeddedDependenciesMod);
throw new IOException();
}
embeddedModFiles = List.of(embeddedModFile);
} else {
Path jarInJarMetadata = fs.getPath("META-INF/jarjar/metadata.json");
if (Files.isRegularFile(jarInJarMetadata)) {
JarInJarMetadata metadata = JsonUtils.fromJsonFile(jarInJarMetadata, JarInJarMetadata.class);
ZipArchiveEntry jarInJarMetadata = tree.getEntry("META-INF/jarjar/metadata.json");
if (jarInJarMetadata != null) {
JarInJarMetadata metadata = JsonUtils.fromJsonFully(tree.getInputStream(jarInJarMetadata), JarInJarMetadata.class);
if (metadata == null)
throw new IOException("Invalid metadata file: " + jarInJarMetadata);
@@ -246,11 +246,11 @@ public final class ForgeNewModMetadata {
embeddedModFiles = new ArrayList<>();
for (EmbeddedJarMetadata jar : metadata.jars) {
Path path = fs.getPath(jar.path);
if (Files.isRegularFile(path)) {
ZipArchiveEntry path = tree.getEntry(jar.path);
if (path != null) {
embeddedModFiles.add(path);
} else {
LOG.warning("Missing embedded-dependencies-mod: " + path);
LOG.warning("Missing embedded-dependencies-mod: " + jar.path);
}
}
}
@@ -262,10 +262,10 @@ public final class ForgeNewModMetadata {
Path tempFile = Files.createTempFile("hmcl-", ".zip");
try {
for (Path embeddedModFile : embeddedModFiles) {
Files.copy(embeddedModFile, tempFile, StandardCopyOption.REPLACE_EXISTING);
try (FileSystem embeddedFs = CompressingUtils.createReadOnlyZipFileSystem(tempFile)) {
return fromFile(modManager, modFile, embeddedFs, modLoaderType);
for (ZipArchiveEntry embeddedModFile : embeddedModFiles) {
tree.extractTo(embeddedModFile, tempFile);
try (ZipFileTree embeddedTree = CompressingUtils.openZipTree(tempFile)) {
return fromFile(modManager, modFile, embeddedTree, modLoaderType);
} catch (Exception ignored) {
}
}
@@ -21,16 +21,16 @@ import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -123,14 +123,14 @@ public final class ForgeOldModMetadata {
return authors;
}
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
Path mcmod = fs.getPath("mcmod.info");
if (Files.notExists(mcmod))
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
ZipArchiveEntry mcmod = tree.getEntry("mcmod.info");
if (mcmod == null)
throw new IOException("File " + modFile + " is not a Forge mod.");
List<ForgeOldModMetadata> modList;
try (var reader = Files.newBufferedReader(mcmod);
try (var reader = tree.getBufferedReader(mcmod);
var jsonReader = new JsonReader(reader)) {
JsonToken firstToken = jsonReader.peek();
@@ -18,17 +18,16 @@
package org.jackhuang.hmcl.mod.modinfo;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
*
@@ -109,18 +108,16 @@ public final class LiteModMetadata {
public String getUpdateURI() {
return updateURI;
}
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
try (ZipFile zipFile = new ZipFile(modFile.toFile())) {
ZipEntry entry = zipFile.getEntry("litemod.json");
if (entry == null)
throw new IOException("File " + modFile + "is not a LiteLoader mod.");
LiteModMetadata metadata = JsonUtils.fromJsonFully(zipFile.getInputStream(entry), LiteModMetadata.class);
if (metadata == null)
throw new IOException("Mod " + modFile + " `litemod.json` is malformed.");
return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalModFile.Description(metadata.getDescription()), metadata.getAuthor(),
metadata.getVersion(), metadata.getGameVersion(), metadata.getUpdateURI(), "");
}
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
ZipArchiveEntry entry = tree.getEntry("litemod.json");
if (entry == null)
throw new IOException("File " + modFile + " is not a LiteLoader mod.");
LiteModMetadata metadata = JsonUtils.fromJsonFully(tree.getInputStream(entry), LiteModMetadata.class);
if (metadata == null)
throw new IOException("Mod " + modFile + " `litemod.json` is malformed.");
return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalModFile.Description(metadata.getDescription()), metadata.getAuthor(),
metadata.getVersion(), metadata.getGameVersion(), metadata.getUpdateURI(), "");
}
}
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.mod.modinfo;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
@@ -29,11 +30,10 @@ import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -183,11 +183,11 @@ public record PackMcMeta(@SerializedName("pack") PackInfo pack) implements Valid
}
}
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
Path mcmod = fs.getPath("pack.mcmeta");
if (Files.notExists(mcmod))
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
ZipArchiveEntry mcmod = tree.getEntry("pack.mcmeta");
if (mcmod == null)
throw new IOException("File " + modFile + " is not a resource pack.");
PackMcMeta metadata = JsonUtils.fromNonNullJson(Files.readString(mcmod), PackMcMeta.class);
PackMcMeta metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), PackMcMeta.class);
return new LocalModFile(
modManager,
modManager.getLocalMod(FileUtils.getNameWithoutExtension(modFile), ModLoaderType.PACK),
@@ -2,15 +2,15 @@ package org.jackhuang.hmcl.mod.modinfo;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -53,13 +53,13 @@ public final class QuiltModMetadata {
this.quilt_loader = quiltLoader;
}
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
Path path = fs.getPath("quilt.mod.json");
if (Files.notExists(path)) {
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
ZipArchiveEntry path = tree.getEntry("quilt.mod.json");
if (path == null) {
throw new IOException("File " + modFile + " is not a Quilt mod.");
}
QuiltModMetadata root = JsonUtils.fromNonNullJson(Files.readString(path), QuiltModMetadata.class);
QuiltModMetadata root = JsonUtils.fromNonNullJsonFully(tree.getInputStream(path), QuiltModMetadata.class);
if (root.schema_version != 1) {
throw new IOException("File " + modFile + " is not a supported Quilt mod.");
}
@@ -77,6 +77,8 @@ public class ModrinthInstallTask extends Task<Void> {
builder.version("forge", modLoader.getValue());
break;
case "neoforge":
// https://github.com/HMCL-dev/HMCL/pull/5170
case "neo-forge":
builder.version("neoforge", modLoader.getValue());
break;
case "fabric-loader":
@@ -24,7 +24,11 @@ import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -35,7 +39,14 @@ import java.io.IOException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Semaphore;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -49,6 +60,8 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
public static final ModrinthRemoteModRepository RESOURCE_PACKS = new ModrinthRemoteModRepository("resourcepack");
public static final ModrinthRemoteModRepository SHADER_PACKS = new ModrinthRemoteModRepository("shader");
private static final Semaphore SEMAPHORE = new Semaphore(16);
private static final String PREFIX = "https://api.modrinth.com";
private final String projectType;
@@ -81,30 +94,36 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
List<List<String>> facets = new ArrayList<>();
facets.add(Collections.singletonList("project_type:" + projectType));
if (StringUtils.isNotBlank(gameVersion)) {
facets.add(Collections.singletonList("versions:" + gameVersion));
SEMAPHORE.acquireUninterruptibly();
try {
List<List<String>> facets = new ArrayList<>();
facets.add(Collections.singletonList("project_type:" + projectType));
if (StringUtils.isNotBlank(gameVersion)) {
facets.add(Collections.singletonList("versions:" + gameVersion));
}
if (category != null && StringUtils.isNotBlank(category.getId())) {
facets.add(Collections.singletonList("categories:" + category.getId()));
}
Map<String, String> query = mapOf(
pair("query", searchFilter),
pair("facets", JsonUtils.UGLY_GSON.toJson(facets)),
pair("offset", Integer.toString(pageOffset * pageSize)),
pair("limit", Integer.toString(pageSize)),
pair("index", convertSortType(sort))
);
Response<ProjectSearchResult> response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query)))
.getJson(Response.typeOf(ProjectSearchResult.class));
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
} finally {
SEMAPHORE.release();
}
if (category != null && StringUtils.isNotBlank(category.getId())) {
facets.add(Collections.singletonList("categories:" + category.getId()));
}
Map<String, String> query = mapOf(
pair("query", searchFilter),
pair("facets", JsonUtils.UGLY_GSON.toJson(facets)),
pair("offset", Integer.toString(pageOffset * pageSize)),
pair("limit", Integer.toString(pageSize)),
pair("index", convertSortType(sort))
);
Response<ProjectSearchResult> response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query)))
.getJson(Response.typeOf(ProjectSearchResult.class));
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
}
@Override
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
String sha1 = DigestUtils.digestToString("SHA-1", file);
SEMAPHORE.acquireUninterruptibly();
try {
ProjectVersion mod = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1,
pair("algorithm", "sha1"))
@@ -118,14 +137,21 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
}
} catch (NoSuchFileException e) {
return Optional.empty();
} finally {
SEMAPHORE.release();
}
}
@Override
public RemoteMod getModById(String id) throws IOException {
id = StringUtils.removePrefix(id, "local-");
Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class);
return project.toMod();
SEMAPHORE.acquireUninterruptibly();
try {
id = StringUtils.removePrefix(id, "local-");
Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class);
return project.toMod();
} finally {
SEMAPHORE.release();
}
}
@Override
@@ -135,20 +161,28 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
id = StringUtils.removePrefix(id, "local-");
List<ProjectVersion> versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version")
.getJson(listTypeOf(ProjectVersion.class));
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream);
}
public List<Category> getCategoriesImpl() throws IOException {
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class));
return categories.stream().filter(category -> category.getProjectType().equals(projectType)).collect(Collectors.toList());
SEMAPHORE.acquireUninterruptibly();
try {
id = StringUtils.removePrefix(id, "local-");
List<ProjectVersion> versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version")
.getJson(listTypeOf(ProjectVersion.class));
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream);
} finally {
SEMAPHORE.release();
}
}
@Override
public Stream<RemoteModRepository.Category> getCategories() throws IOException {
return getCategoriesImpl().stream().map(Category::toCategory);
SEMAPHORE.acquireUninterruptibly();
try {
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class));
return categories.stream()
.filter(category -> category.getProjectType().equals(projectType))
.map(Category::toCategory);
} finally {
SEMAPHORE.release();
}
}
public static class Category {
@@ -160,7 +194,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
private final String projectType;
public Category() {
this("","","");
this("", "", "");
}
public Category(String icon, String name, String projectType) {
@@ -576,6 +610,9 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
private final List<String> categories;
@SerializedName("display_categories")
private final List<String> displayCategories;
@SerializedName("project_type")
private final String projectType;
@@ -600,11 +637,12 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
@SerializedName("latest_version")
private final String latestVersion;
public ProjectSearchResult(String slug, String title, String description, List<String> categories, String projectType, int downloads, String iconUrl, String projectId, String author, List<String> versions, Instant dateCreated, Instant dateModified, String latestVersion) {
public ProjectSearchResult(String slug, String title, String description, List<String> categories, List<String> displayCategories, String projectType, int downloads, String iconUrl, String projectId, String author, List<String> versions, Instant dateCreated, Instant dateModified, String latestVersion) {
this.slug = slug;
this.title = title;
this.description = description;
this.categories = categories;
this.displayCategories = displayCategories;
this.projectType = projectType;
this.downloads = downloads;
this.iconUrl = iconUrl;
@@ -632,6 +670,10 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
return categories;
}
public List<String> getDisplayCategories() {
return displayCategories;
}
public String getProjectType() {
return projectType;
}
@@ -691,7 +733,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
author,
title,
description,
categories,
displayCategories,
String.format("https://modrinth.com/%s/%s", projectType, projectId),
iconUrl,
this
@@ -185,7 +185,7 @@ public abstract class FetchTask<T> extends Task<T> {
}
}
ArrayList<IOException> exceptions = null;
ArrayList<Exception> exceptions = null;
// If loading the cache fails, the cache should not be loaded again.
boolean useCachedResult = true;
@@ -212,7 +212,7 @@ public abstract class FetchTask<T> extends Task<T> {
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.header("User-Agent", Holder.USER_AGENT);
.header("User-Agent", NetworkUtils.USER_AGENT);
headers.forEach(requestBuilder::header);
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
@@ -284,10 +284,12 @@ public abstract class FetchTask<T> extends Task<T> {
contentLength,
contentEncoding);
return;
} catch (InterruptedException e) {
throw e;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (IOException ex) {
} catch (Exception ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
@@ -301,7 +303,7 @@ public abstract class FetchTask<T> extends Task<T> {
}
private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException {
ArrayList<IOException> exceptions = null;
ArrayList<Exception> exceptions = null;
for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) {
throw new InterruptedException();
@@ -317,11 +319,13 @@ public abstract class FetchTask<T> extends Task<T> {
conn.getContentLengthLong(),
ContentEncoding.fromConnection(conn));
return;
} catch (InterruptedException e) {
throw e;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found", ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (IOException ex) {
} catch (Exception ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
@@ -333,7 +337,7 @@ public abstract class FetchTask<T> extends Task<T> {
throw toDownloadException(uri, null, exceptions);
}
private static DownloadException toDownloadException(URI uri, @Nullable IOException last, @Nullable ArrayList<IOException> exceptions) {
private static DownloadException toDownloadException(URI uri, @Nullable Exception last, @Nullable ArrayList<Exception> exceptions) {
if (exceptions == null || exceptions.isEmpty()) {
return new DownloadException(uri, last != null
? last
@@ -342,7 +346,7 @@ public abstract class FetchTask<T> extends Task<T> {
if (last == null)
last = exceptions.remove(exceptions.size() - 1);
for (IOException e : exceptions) {
for (Exception e : exceptions) {
last.addSuppressed(e);
}
return new DownloadException(uri, last);
@@ -505,12 +509,21 @@ public abstract class FetchTask<T> extends Task<T> {
return downloadExecutorConcurrency;
}
private static volatile boolean initialized = false;
public static void notifyInitialized() {
initialized = true;
}
/// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized.
private static final class Holder {
private static final HttpClient HTTP_CLIENT;
private static final String USER_AGENT = System.getProperty("http.agent", "HMCL");
static {
if (!initialized) {
throw new AssertionError("FetchTask.Holder accessed before ProxyManager initialization.");
}
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
HTTP_CLIENT = HttpClient.newBuilder()
@@ -21,6 +21,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import java.io.IOException;
import java.nio.ByteBuffer;
@@ -127,13 +128,33 @@ public final class CompressingUtils {
throw new IOException("Cannot find suitable encoding for the zip.");
}
public static ZipFileTree openZipTree(Path zipFile) throws IOException {
return new ZipFileTree(openZipFile(zipFile));
}
public static ZipArchiveReader openZipFile(Path zipFile) throws IOException {
return openZipFileWithPossibleEncoding(zipFile, StandardCharsets.UTF_8);
}
public static ZipArchiveReader openZipFile(Path zipFile, Charset charset) throws IOException {
return new ZipArchiveReader(zipFile, charset);
}
public static ZipArchiveReader openZipFileWithPossibleEncoding(Path zipFile, Charset possibleEncoding) throws IOException {
if (possibleEncoding == null)
possibleEncoding = StandardCharsets.UTF_8;
ZipArchiveReader zipReader = new ZipArchiveReader(Files.newByteChannel(zipFile));
Charset suitableEncoding;
try {
suitableEncoding = findSuitableEncoding(zipReader);
if (suitableEncoding == StandardCharsets.UTF_8)
return zipReader;
if (possibleEncoding != StandardCharsets.UTF_8 && CompressingUtils.testEncoding(zipReader, possibleEncoding)) {
suitableEncoding = possibleEncoding;
} else {
suitableEncoding = CompressingUtils.findSuitableEncoding(zipReader);
if (suitableEncoding == StandardCharsets.UTF_8)
return zipReader;
}
} catch (Throwable e) {
IOUtils.closeQuietly(zipReader, e);
throw e;
@@ -143,10 +164,6 @@ public final class CompressingUtils {
return new ZipArchiveReader(Files.newByteChannel(zipFile), suitableEncoding);
}
public static ZipArchiveReader openZipFile(Path zipFile, Charset charset) throws IOException {
return new ZipArchiveReader(zipFile, charset);
}
public static final class Builder {
private boolean autoDetectEncoding = false;
private Charset encoding = StandardCharsets.UTF_8;
@@ -527,4 +527,25 @@ public final class FileUtils {
public static String printFileStructure(Path path, int maxDepth) throws IOException {
return DirectoryStructurePrinter.list(path, maxDepth);
}
public static EnumSet<PosixFilePermission> parsePosixFilePermission(int unixMode) {
EnumSet<PosixFilePermission> permissions = EnumSet.noneOf(PosixFilePermission.class);
// Owner permissions
if ((unixMode & 0400) != 0) permissions.add(PosixFilePermission.OWNER_READ);
if ((unixMode & 0200) != 0) permissions.add(PosixFilePermission.OWNER_WRITE);
if ((unixMode & 0100) != 0) permissions.add(PosixFilePermission.OWNER_EXECUTE);
// Group permissions
if ((unixMode & 0040) != 0) permissions.add(PosixFilePermission.GROUP_READ);
if ((unixMode & 0020) != 0) permissions.add(PosixFilePermission.GROUP_WRITE);
if ((unixMode & 0010) != 0) permissions.add(PosixFilePermission.GROUP_EXECUTE);
// Others permissions
if ((unixMode & 0004) != 0) permissions.add(PosixFilePermission.OTHERS_READ);
if ((unixMode & 0002) != 0) permissions.add(PosixFilePermission.OTHERS_WRITE);
if ((unixMode & 0001) != 0) permissions.add(PosixFilePermission.OTHERS_EXECUTE);
return permissions;
}
}
@@ -40,6 +40,8 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
* @author huangyuhui
*/
public final class NetworkUtils {
public static final String USER_AGENT = System.getProperty("http.agent", "HMCL");
public static final String PARAMETER_SEPARATOR = "&";
public static final String NAME_VALUE_SEPARATOR = "=";
public static final int TIME_OUT = 8000;
@@ -95,6 +97,27 @@ public final class NetworkUtils {
return sb.toString();
}
public static String withQuery(String baseUrl, List<Pair<String, String>> params) {
StringBuilder sb = new StringBuilder(baseUrl);
boolean first = true;
for (Pair<String, String> param : params) {
if (param.getValue() == null)
continue;
if (first) {
if (!baseUrl.isEmpty()) {
sb.append('?');
}
first = false;
} else {
sb.append(PARAMETER_SEPARATOR);
}
sb.append(encodeURL(param.getKey()));
sb.append(NAME_VALUE_SEPARATOR);
sb.append(encodeURL(param.getValue()));
}
return sb.toString();
}
public static List<URI> withQuery(List<URI> list, Map<String, String> params) {
return list.stream().map(uri -> URI.create(withQuery(uri.toString(), params))).collect(Collectors.toList());
}
@@ -147,6 +170,7 @@ public final class NetworkUtils {
connection.setReadTimeout(TIME_OUT);
if (connection instanceof HttpURLConnection httpConnection) {
httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag());
httpConnection.setRequestProperty("User-Agent", USER_AGENT);
httpConnection.setInstanceFollowRedirects(false);
}
return connection;
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more