Compare commits

..
Author SHA1 Message Date
Glavo cf21cf4a3c 修复导出日志文件时文件为空的问题 (#6649) 2026-08-11 21:36:51 +08:00
Apostrophe a0d1fa8f38 修复异步任务 OOM 后界面假死并显示错误信息 (#6636) 2026-08-10 21:58:49 +08:00
Glavo 78b8d37891 Fix #6627: 修复安装新实例时未正确写入 patches 的问题 (#6642) 2026-08-10 21:17:58 +08:00
Glavo 0807c04572 优化 32 位平台自动分配内存策略 (#6641) 2026-08-10 19:45:23 +08:00
Glavo 8eec18450e 修复再次点击实例菜单按钮时菜单重新弹出的问题 (#6633) 2026-08-09 21:41:35 +08:00
Apostrophe 15d4874366 解决导出 MCBBS 整合包因文件过多导致的 Out Of Memory (#6630)
#6086 #6173
2026-08-09 21:22:35 +08:00
ToobLac c149401cf3 [Enhancement] enhance html spacing (#6617) 2026-08-09 20:05:28 +08:00
ToobLac ff459a5bc4 [Enhance] 模组/资源包信息对话框中 CF/MR 链接跳转到网页而非 HMCL 下载页面 (#6620) 2026-08-09 20:05:08 +08:00
Glavo 5796dad18c 改进图像类型探测功能 (#6613) 2026-08-06 19:49:33 +08:00
0443ff7cd1 [Feature] 模组等的更新日志以及 HTMLRenderer 的完善 (#6098)
Resolves #4685

Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: CiiLu <109708109+ciilu@users.noreply.github.com>
2026-08-05 21:56:48 +08:00
3gf8jv4dv 4c6fe865f5 fix(l10n): correct inaccurate text for instance isolation option (#6592)
resolves #6589
2026-08-05 20:48:03 +08:00
辞庐 1e54482f3b 禁用动画时禁用 JFXCheckbox 过渡动画 (#6583) 2026-08-05 00:59:38 +08:00
bluebullet c329f0d2e8 fix(modpack): update DefaultGameBuilder whenComplete exception check (#6593) 2026-08-04 19:34:24 +08:00
Burning_TNT af4d9feea5 修复无法正确安装 MultiMC 整合包的问题 (#6584) 2026-08-04 19:33:20 +08:00
3gf8jv4dv af71c71663 fix(l10n): correct recommend part title on addon ver. list page (#6590)
resolves #6588
2026-08-04 19:12:30 +08:00
快乐凤爪王 4675442ecd Chore: 同步 Modrinth 官方 Modded 分类的 zh 和 zh_CN 翻译 (#6604) 2026-08-04 15:09:31 +08:00
辞庐andGlavo c8929c80d9 关闭动画时跳过侧边栏图标过渡动画 (#6581)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-02 23:50:26 +08:00
ToobLac 81634b14df [Chores] 优化 addon 类型推断逻辑 (#6536) 2026-08-02 19:58:33 +08:00
ToobLac ccca396d91 [Enhancement] 在 IO 线程上清理下载缓存 (#6560) 2026-08-02 19:27:44 +08:00
53 changed files with 1769 additions and 347 deletions

No files matched your search

@@ -12,31 +12,20 @@ import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.transitions.CachedTransition;
import com.jfoenix.transitions.JFXFillTransition;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.animation.*;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.CheckBox;
import javafx.scene.control.skin.CheckBoxSkin;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.*;
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;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
public class JFXCheckBoxSkin extends CheckBoxSkin {
private final StackPane box = new StackPane();
@@ -186,15 +175,32 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
}
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.transition.setRate(selection ? 1.0 : -1.0);
this.select.setRate(selection ? 1.0 : -1.0);
this.transition.play();
this.select.play();
this.box.setBorder(new Border(new BorderStroke(
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
BorderStrokeStyle.SOLID,
new CornerRadii(2.0),
new BorderWidths(this.lineThick))));
if (!AnimationUtils.isAnimationEnabled()) {
if (selection) {
this.mark.setVisible(true);
this.mark.setScaleX(1.0);
this.mark.setScaleY(1.0);
JFXNodeUtils.updateBackground(box.getBackground(), box, control.getCheckedColor());
} else {
this.mark.setVisible(false);
this.mark.setScaleX(0.0);
this.mark.setScaleY(0.0);
JFXNodeUtils.updateBackground(box.getBackground(), box, Color.TRANSPARENT);
}
return;
}
this.transition.setRate(selection ? 1.0 : -1.0);
this.select.setRate(selection ? 1.0 : -1.0);
this.transition.play();
this.select.play();
}
private void createFillTransition() {
@@ -54,7 +54,9 @@ import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Bits;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
@@ -732,7 +734,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
boolean allowSave) {
}
public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime java, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveGameSettings(instanceId);
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
@@ -742,7 +744,10 @@ public final class HMCLGameRepository extends DefaultGameRepository {
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L);
: Math.toIntExact(getAutoAllocatedMemory(
SystemInfo.getPhysicalMemoryStatus().available(),
java.getPlatform()
) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
@@ -750,7 +755,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(instanceId)
.setGameDir(gameDir)
.setJava(javaVersion)
.setJava(java)
.setVersionType(Metadata.TITLE)
.setVersionName(instanceId.id())
.setProfileName(Metadata.TITLE)
@@ -892,7 +897,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
public static long getAutoAllocatedMemory(long available) {
public static long getAutoAllocatedMemory(long available, Platform platform) {
long usable = available - 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
if (usable <= 0) {
return available;
@@ -906,7 +911,9 @@ public final class HMCLGameRepository extends DefaultGameRepository {
suggested = Math.min(
(long) (threshold * 0.8 + (usable - threshold) * 0.2),
16L * 1024 * 1024 * 1024);
return suggested;
return platform.getBits() == Bits.BIT_32
? Math.min(suggested, 768 * 1024 * 1024) // https://github.com/HMCL-dev/HMCL/issues/6638
: suggested;
}
public static ProxyOption getProxyOption() {
@@ -23,6 +23,7 @@ import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.ui.instances.ModTranslations;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Path;
@@ -40,6 +41,16 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public String getApiBaseUrl() {
return getBackedRemoteModRepository().getApiBaseUrl();
}
@Override
public String getBaseUrl() {
return getBackedRemoteModRepository().getBaseUrl();
}
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
@@ -49,7 +60,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(getType()).searchMod(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
@@ -82,7 +93,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
continue;
}
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteAddon.slug());
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteAddon;
} else {
@@ -94,7 +105,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.slug());
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
@@ -136,4 +147,14 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId);
}
@Override
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
return getBackedRemoteModRepository().getVersionPageUrl(version);
}
}
@@ -17,7 +17,7 @@
*/
package org.jackhuang.hmcl.game;
import kala.encdet.EncodingDetector;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -90,7 +90,7 @@ public final class LogExporter {
for (Path file : stream) {
if (Files.isRegularFile(file)) {
if (logMatcher == null || logMatcher.matches(file)) {
try (BufferedReader reader = EncodingDetector.MODERN_WEB.newBufferedReader(file)) {
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
} catch (IOException e) {
LOG.warning("Failed to read log file: " + file, e);
@@ -63,6 +63,7 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
@@ -561,6 +562,24 @@ public final class Controllers {
}
}
public static void openUriOrCopy(@Nullable URI uri) {
if (uri == null) return;
openUriOrCopy(uri.toString());
}
public static void openUriOrCopy(@Nullable String uri) {
if (uri == null) return;
var dialog = new MessageDialogPane.Builder(
i18n("web.open_in_browser", uri),
i18n("message.confirm"),
MessageDialogPane.MessageType.QUESTION
)
.addAction(i18n("button.copy"), () -> FXUtils.copyText(uri))
.yesOrNo(() -> FXUtils.openLink(uri), null)
.build();
dialog(dialog);
}
public static boolean isStopped() {
return decorator == null;
}
@@ -32,6 +32,7 @@ import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableBooleanValue;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.event.Event;
import javafx.event.EventDispatcher;
@@ -44,6 +45,7 @@ import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.skin.TableViewSkin;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
@@ -83,6 +85,7 @@ import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.Nullable;
import org.jsoup.Jsoup;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
@@ -1713,4 +1716,62 @@ public final class FXUtils {
e.consume();
});
}
public static TextFlow renderAddonChangelog(String changelogHtml, String baseUri) {
var textFlow = new HTMLRenderer(Controllers::openUriOrCopy).appendNode(Jsoup.parse(changelogHtml, baseUri)).mergeLineBreaks().render();
textFlow.getStyleClass().add("addon-changelog");
return textFlow;
}
/// Be cautious when dealing with large amounts of data
public static <S> TableView<S> newAutoHeightTable(ObservableList<S> items) {
return new AutoHeightTableView<>(items);
}
private static final class AutoHeightTableView<S> extends TableView<S> {
public AutoHeightTableView() {
super();
}
public AutoHeightTableView(ObservableList<S> items) {
super(items);
}
@Override
protected Skin<?> createDefaultSkin() {
return new AutoSizeSkin<>(this);
}
private static final class AutoSizeSkin<S> extends TableViewSkin<S> {
private final VirtualFlow<TableRow<S>> flow;
public AutoSizeSkin(TableView<S> control) {
super(control);
this.flow = getVirtualFlow();
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
double fixedCellSize = getSkinnable().getFixedCellSize();
double w = width - rightInset - leftInset;
int itemCount = getItemCount();
double headerHeight = getTableHeaderRow().prefHeight(w);
double prefHeight = topInset + bottomInset + headerHeight;
if (fixedCellSize > 0 || itemCount == 0) return prefHeight + fixedCellSize * itemCount;
int i = 0;
TableRow<S> r;
while (i < itemCount && !(r = flow.getCell(i)).isEmpty() && r.getItem() != null) {
prefHeight += r.prefHeight(w);
i++;
}
return prefHeight;
}
}
}
}
@@ -33,7 +33,6 @@ import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import kala.encdet.EncodingDetector;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.*;
@@ -49,6 +48,7 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
@@ -149,7 +149,7 @@ public class GameCrashWindow extends Stage {
String log;
try {
log = EncodingDetector.MODERN_WEB.readString(latestLog);
log = FileUtils.readTextMaybeNativeEncoding(latestLog);
} catch (IOException e) {
LOG.warning("Failed to read logs/latest.log", e);
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
@@ -17,27 +17,41 @@
*/
package org.jackhuang.hmcl.ui;
import javafx.beans.InvalidationListener;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.image.Image;
import javafx.scene.control.Separator;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.*;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class HTMLRenderer {
private static final String INDENT = " ";
private static URI resolveLink(Node linkNode) {
String href = linkNode.absUrl("href");
if (href.isEmpty())
@@ -50,6 +64,42 @@ public final class HTMLRenderer {
}
}
/// @see org.jsoup.internal.StringUtil#isWhitespace(int)
public static boolean isWhitespace(int c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\f' || c == '\r';
}
/// @see org.jsoup.internal.StringUtil#isInvisibleChar(int)
public static boolean isInvisibleChar(int c) {
return c == 8203 || c == 173; // zero width sp, soft hyphen
// previously also included zw non join, zw join - but removing those breaks semantic meaning of text
}
/// @see org.jsoup.internal.StringUtil#normaliseWhitespace(String)
/// @see org.jsoup.internal.StringUtil#isActuallyWhitespace(int)
public static String normaliseWhitespace(String str) {
var accum = new StringBuilder();
boolean lastWasWhite = false;
int len = str.length();
int i = 0;
while (i < len) {
int c = str.codePointAt(i);
if (isWhitespace(c)) { // Ignore &nbsp;
if (lastWasWhite) {
i += Character.charCount(c);
continue;
}
accum.append(' ');
lastWasWhite = true;
} else if (!isInvisibleChar(c)) {
accum.appendCodePoint(c);
lastWasWhite = false;
}
i += Character.charCount(c);
}
return accum.toString();
}
private final List<javafx.scene.Node> children = new ArrayList<>();
private final List<Node> stack = new ArrayList<>();
@@ -58,12 +108,17 @@ public final class HTMLRenderer {
private boolean underline;
private boolean strike;
private boolean highlight;
private int indentLevel;
private boolean preformatted;
private boolean code;
private int listDepth;
private String headerLevel;
private Node hyperlink;
private String fxStyle;
private final Consumer<URI> onClickHyperlink;
private boolean rendered;
public HTMLRenderer(Consumer<URI> onClickHyperlink) {
this.onClickHyperlink = onClickHyperlink;
}
@@ -74,42 +129,32 @@ public final class HTMLRenderer {
underline = false;
strike = false;
highlight = false;
preformatted = false;
code = false;
listDepth = 0;
headerLevel = null;
hyperlink = null;
fxStyle = null;
var declarations = new SimpleCssDeclarations();
for (Node node : stack) {
String nodeName = node.nodeName();
switch (nodeName) {
case "b":
case "strong":
bold = true;
break;
case "i":
case "em":
italic = true;
break;
case "ins":
underline = true;
break;
case "del":
strike = true;
break;
case "mark":
highlight = true;
break;
case "a":
hyperlink = node;
break;
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
headerLevel = nodeName;
break;
case "b", "strong" -> bold = true;
case "i", "em" -> italic = true;
case "ins" -> underline = true;
case "del" -> strike = true;
case "mark" -> highlight = true;
case "pre" -> preformatted = true;
case "code" -> code = true;
case "a" -> hyperlink = node;
case "h1", "h2", "h3", "h4", "h5", "h6" -> headerLevel = nodeName;
case "li" -> listDepth++;
}
declarations.add(node.attr("style"));
}
fxStyle = declarations.asString();
}
private void pushNode(Node node) {
@@ -123,6 +168,14 @@ public final class HTMLRenderer {
}
private void applyStyle(Text text) {
if (code) {
text.getStyleClass().add("html-code");
text.setStyle("-fx-font-family: \"%s\";".formatted(Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)));
return;
}
var styleBuilder = new StringBuilder();
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
if (target != null) {
@@ -146,12 +199,22 @@ public final class HTMLRenderer {
if (headerLevel != null)
text.getStyleClass().add("html-" + headerLevel);
if (fxStyle != null)
styleBuilder.append(fxStyle);
text.setStyle(styleBuilder.toString());
}
private void appendText(String text) {
Text textNode = new Text(text);
applyStyle(textNode);
children.add(textNode);
if (code) {
var codeFlow = new CodeFlow(textNode);
codeFlow.getStyleClass().add("html-code-block");
children.add(codeFlow);
} else {
children.add(textNode);
}
}
private void appendAutoLineBreak(String text) {
@@ -185,20 +248,22 @@ public final class HTMLRenderer {
}
try {
Image image = FXUtils.getRemoteImageTask(src, width, height, true, true)
.run();
if (image == null)
throw new AssertionError("Image loading task returned null");
ImageView imageView = new ImageView();
imageView.imageProperty().bind(FXUtils.newRemoteImage(src, width, height, true, true));
imageView.setPreserveRatio(true);
ImagePane imagePane = new ImagePane(imageView, alt);
ImageView imageView = new ImageView(image);
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
if (target != null) {
FXUtils.onClicked(imageView, () -> onClickHyperlink.accept(target));
imageView.setCursor(Cursor.HAND);
FXUtils.onClicked(imagePane, () -> onClickHyperlink.accept(target));
imagePane.setCursor(Cursor.HAND);
}
}
children.add(imageView);
children.add(imagePane);
return;
} catch (Throwable e) {
LOG.warning("Failed to load image: " + src, e);
@@ -209,59 +274,203 @@ public final class HTMLRenderer {
appendText(alt);
}
public void appendNode(Node node) {
if (node instanceof TextNode) {
appendText(((TextNode) node).text());
private void appendTable(Node table) {
var childElements = ((Element) table).children();
List<Element> captions = new ArrayList<>();
List<Element> head = new ArrayList<>();
List<List<Element>> body = new ArrayList<>();
List<Element> foot = new ArrayList<>();
boolean hasHead = false;
boolean hasBody = childElements.stream().map(Element::nodeName).anyMatch("tbody"::equals);
boolean hasFoot = false;
int columnCount = 0;
for (Element child : childElements) {
switch (child.nodeName()) {
case "caption" -> captions.add(child);
case "thead" -> {
if (hasHead) continue;
hasHead = true;
for (Element e : child.children()) {
if (e.nameIs("tr")) {
head.clear();
head.addAll(
e.children().stream()
.filter(n -> n.nameIs("th") || n.nameIs("td"))
.toList()
);
break;
}
if (e.nameIs("th") || e.nameIs("td")) {
head.add(e);
}
}
columnCount = Math.max(columnCount, head.size());
}
case "tbody" -> {
var rows = child.children().stream()
.filter(e -> e.nameIs("tr"))
.map(e ->
e.children().stream().filter(n -> n.nameIs("th") || n.nameIs("td")).toList())
.filter(row -> !row.isEmpty())
.toList();
body.addAll(rows);
columnCount = Math.max(columnCount, rows.stream().mapToInt(List::size).max().orElse(0));
}
case "tfoot" -> {
if (hasFoot) continue;
hasFoot = true;
for (Element e : child.children()) {
if (e.nameIs("tr")) {
foot.clear();
foot.addAll(
e.children().stream()
.filter(n -> n.nameIs("th") || n.nameIs("td"))
.toList()
);
break;
}
if (e.nameIs("th") || e.nameIs("td")) {
foot.add(e);
}
}
columnCount = Math.max(columnCount, foot.size());
}
case "tr" -> {
if (hasBody) continue;
List<Element> row = child.children().stream()
.filter(n -> n.nameIs("th") || n.nameIs("td"))
.toList();
if (!row.isEmpty()) {
body.add(row);
columnCount = Math.max(columnCount, row.size());
}
}
}
}
head = Lang.copyWithSize(head, columnCount, null);
List<List<Element>> rows = new ArrayList<>(hasFoot ? body.size() + 1 : body.size());
for (List<Element> row : body)
rows.add(Lang.copyWithSize(row, columnCount, null));
if (hasFoot)
rows.add(Lang.copyWithSize(foot, columnCount, null));
TableView<List<Element>> tableView = FXUtils.newAutoHeightTable(FXCollections.observableList(rows));
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
for (int i = 0; i < columnCount; i++) {
int finalI = i;
TableColumn<List<Element>, Element> c = new TableColumn<>();
Element e = head.get(i);
if (e != null) {
var box = new VBox(new HTMLRenderer(onClickHyperlink).appendNode(e).mergeLineBreaks().render());
box.setAlignment(Pos.CENTER_LEFT);
c.setGraphic(box);
}
c.setCellFactory(param -> new TableCell<>() {
@Override
protected void updateItem(Element item, boolean empty) {
if (item == getItem()) return;
super.updateItem(item, empty);
if (item == null) {
setGraphic(null);
} else {
setGraphic(new Pane(new HTMLRenderer(onClickHyperlink).appendNode(item).mergeLineBreaks().render()));
}
}
});
c.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().get(finalI)));
tableView.getColumns().add(c);
}
for (Element caption : captions) {
appendAutoLineBreak("\n\n");
appendChildren(caption);
appendAutoLineBreak("\n");
}
children.add(tableView);
}
private void appendOrderedList(Node node) {
pushNode(node);
int ordinal = StringUtils.toInt(node.attr("start")).orElse(1);
for (Node childNode : node.childNodes()) {
if (childNode.nameIs("li")) {
ordinal = StringUtils.toInt(childNode.attr("value")).orElse(ordinal);
appendAutoLineBreak("\n");
appendText(" " + " ".repeat(listDepth) + ordinal++ + ". ");
appendChildren(childNode);
continue;
}
appendNode(childNode);
}
popNode();
}
private void appendChildren(Node node) {
if (node.childNodeSize() > 0) {
if (node.nameIs("table")) {
appendTable(node);
} else if (node.nameIs("ol")) {
appendOrderedList(node);
} else {
pushNode(node);
for (Node childNode : node.childNodes()) {
appendNode(childNode);
}
popNode();
}
}
}
public HTMLRenderer appendNode(Node node) {
if (node instanceof TextNode n) {
appendText(preformatted ? n.getWholeText() : normaliseWhitespace(n.getWholeText()));
}
String name = node.nodeName();
switch (name) {
case "img":
case "img" -> {
if (!children.isEmpty())
appendAutoLineBreak("\n");
appendImage(node);
break;
case "li":
appendText("\n" + INDENT.repeat(indentLevel) + " \u2022 ");
break;
case "dt":
appendText(" ");
break;
case "p":
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
case "tr":
appendAutoLineBreak("\n");
}
case "li" -> {
appendAutoLineBreak("\n");
appendText(" " + " ".repeat(listDepth) + "\u2022 ");
}
case "dt" -> appendText(" ");
case "p" -> {
var n = node.parent();
if (!children.isEmpty() && (n == null || !n.nameIs("li")))
appendAutoLineBreak("\n\n");
}
case "h1", "h2", "h3", "h4", "h5", "h6" -> {
if (!children.isEmpty())
appendAutoLineBreak("\n\n");
break;
}
case "hr" -> {
appendAutoLineBreak("\n\n");
this.children.add(new Separator());
}
}
if (node.childNodeSize() > 0) {
boolean isLiNode = "li".equals(name);
if (isLiNode) indentLevel++;
pushNode(node);
for (Node childNode : node.childNodes()) {
appendNode(childNode);
}
popNode();
if (isLiNode) indentLevel--;
}
appendChildren(node);
switch (name) {
case "br":
case "dd":
case "p":
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
appendAutoLineBreak("\n");
break;
case "br", "dd", "h1", "h2", "h3", "h4", "h5", "h6" -> appendAutoLineBreak("\n");
case "p" -> {
var n = node.parent();
if (n == null || !n.nameIs("li"))
appendAutoLineBreak("\n");
}
}
return this;
}
private static boolean isSpacing(String text) {
@@ -276,7 +485,7 @@ public final class HTMLRenderer {
return true;
}
public void mergeLineBreaks() {
public HTMLRenderer mergeLineBreaks() {
for (int i = 0; i < this.children.size(); i++) {
javafx.scene.Node child = this.children.get(i);
if (child instanceof AutoLineBreak) {
@@ -287,7 +496,7 @@ public final class HTMLRenderer {
if (otherChild instanceof AutoLineBreak) {
lastAutoLineBreak = j;
} else if (otherChild instanceof Text && isSpacing(((Text) otherChild).getText())) {
} else if (otherChild instanceof Text txt && isSpacing(txt.getText())) {
// do nothing
} else {
break;
@@ -303,12 +512,58 @@ public final class HTMLRenderer {
}
}
}
{
// Remove empty lines at the beginning
int size = children.size();
for (int i = 0; i < size; i++) {
var child = children.get(i);
if (child instanceof AutoLineBreak || child instanceof Text txt && isSpacing(txt.getText())) {
// NO-OP
} else {
this.children.subList(0, i).clear();
break;
}
}
}
{
// Remove empty lines and spaces at the end
int size = children.size();
for (int i = size - 1; i > -1; i--) {
var child = children.get(i);
if (child instanceof AutoLineBreak || child instanceof Text txt && isSpacing(txt.getText())) {
// NO-OP
} else {
this.children.subList(i + 1, size).clear();
break;
}
}
}
return this;
}
public TextFlow render() {
if (rendered) throw new IllegalStateException("Should not render twice");
rendered = true;
TextFlow textFlow = new TextFlow();
textFlow.getStyleClass().add("html");
textFlow.getChildren().setAll(children);
for (javafx.scene.Node node : children) {
if (node instanceof ImagePane imgPane) {
ImageView img = imgPane.imageView;
InvalidationListener listener = __ -> img.setFitWidth(Math.min(textFlow.getWidth() * 0.9, img.getImage() == null ? 0D : img.getImage().getWidth()));
textFlow.widthProperty().addListener(listener);
img.imageProperty().addListener(listener);
listener.invalidated(null);
} else if (node instanceof TableView<?> table) {
table.prefWidthProperty().bind(textFlow.widthProperty().multiply(0.8));
} else if (node instanceof CodeFlow codeFlow) {
codeFlow.maxWidthProperty().bind(textFlow.widthProperty().multiply(0.8));
} else if (node instanceof Separator separator) {
separator.prefWidthProperty().bind(textFlow.widthProperty().subtract(24));
}
}
return textFlow;
}
@@ -317,4 +572,62 @@ public final class HTMLRenderer {
super(text);
}
}
private static final class ImagePane extends StackPane {
public final ImageView imageView;
public ImagePane(ImageView imageView, @Nullable String alt) {
super();
this.imageView = imageView;
var txt = new Text(alt);
if (StringUtils.isNotBlank(alt)) getChildren().add(txt);
getChildren().add(imageView);
FXUtils.onChangeAndOperate(imageView.imageProperty(), img -> {
if (img != null) getChildren().remove(txt);
});
}
}
private static final class CodeFlow extends TextFlow {
public CodeFlow(Text text) {
super(text);
}
}
private static final class SimpleCssDeclarations {
@Unmodifiable
private static final Map<String, String> cssPropertyMapping = Map.of(
"color", "-fx-fill",
"font-size", "-fx-font-size"
);
private final Map<String, String> declarations = new HashMap<>();
public void add(String inlineCss) {
if (StringUtils.isBlank(inlineCss)) return;
Arrays.stream(inlineCss.split(";")).filter(StringUtils::isNotBlank).forEach(s -> {
String[] declaration = s.split(":", 2);
if (declaration.length != 2) return;
declaration[0] = declaration[0].trim();
declaration[1] = declaration[1].trim();
String mappedKey = cssPropertyMapping.get(declaration[0].toLowerCase(Locale.ROOT));
if (mappedKey != null) {
declarations.put(mappedKey, declaration[1]);
} // Ignore unsupported fields
});
}
public String asString() {
StringBuilder sb = new StringBuilder();
declarations.forEach((property, value) -> sb.append(property).append(": ").append(value).append(";"));
return sb.toString();
}
}
}
@@ -23,6 +23,7 @@ import javafx.animation.Timeline;
import javafx.scene.Parent;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
/// A lightweight wrapper for displaying [SVG] icons.
@@ -108,7 +109,7 @@ public final class SVGContainer extends Parent {
SVG oldIcon = this.icon;
this.icon = newIcon;
if (animationDuration.equals(Duration.ZERO)) {
if (animationDuration.equals(Duration.ZERO) || !AnimationUtils.isAnimationEnabled()) {
path.setContent(newIcon.getPath());
path.setOpacity(1);
if (getChildren().size() != 1)
@@ -47,11 +47,7 @@ public final class WebPage extends SpinnerPane implements DecoratorPage {
Task.supplyAsync(() -> {
Document document = Jsoup.parseBodyFragment(content);
HTMLRenderer renderer = new HTMLRenderer(uri -> {
Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> {
FXUtils.openLink(uri.toString());
}, null);
});
HTMLRenderer renderer = new HTMLRenderer(Controllers::openUriOrCopy);
renderer.appendNode(document);
renderer.mergeLineBreaks();
return renderer.render();
@@ -38,6 +38,7 @@ import org.jackhuang.hmcl.ui.construct.LineInheritableToggleButton;
import org.jackhuang.hmcl.ui.construct.RadioChoiceList;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus;
import org.jetbrains.annotations.NotNullByDefault;
@@ -621,7 +622,7 @@ final class IndependentSettingBinder {
long memoryAllocated;
if (Boolean.TRUE.equals(autoMemory)) {
memoryAllocated = HMCLGameRepository.getAutoAllocatedMemory(memoryStatus.available());
memoryAllocated = HMCLGameRepository.getAutoAllocatedMemory(memoryStatus.available(), Platform.SYSTEM_PLATFORM);
} else if (maxMemory != null && maxMemory > 0) {
memoryAllocated = maxMemory * 1024L * 1024L;
} else {
@@ -40,6 +40,7 @@ import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -54,9 +55,11 @@ import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
/// Provides image-format detection and loaders for image formats not handled
/// directly by JavaFX.
///
/// @author Glavo
@NotNullByDefault
public final class ImageUtils {
// ImageLoaders
@@ -81,7 +84,7 @@ public final class ImageUtils {
LoaderParameters parameters = new LoaderParameters();
parameters.autoStartAnimations = false;
SVGImage image;
@Nullable SVGImage image;
if (Platform.isFxApplicationThread()) {
image = SVGLoader.load(content, parameters);
@@ -198,6 +201,7 @@ public final class ImageUtils {
// ------
/// Maximum number of leading bytes inspected when selecting an image loader.
public static final int HEADER_BUFFER_SIZE = 1024;
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
@@ -209,12 +213,272 @@ public final class ImageUtils {
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
}
private static final byte[] SVG_HEADER = "<svg".getBytes(StandardCharsets.US_ASCII);
/// ASCII local name of an SVG document element.
private static final String SVG_ELEMENT_NAME = "svg";
// This is currently a simple check, more complex checks can be considered in the future
/// ASCII prefix of an XML comment.
private static final String XML_COMMENT_START = "<!--";
/// ASCII suffix of an XML comment.
private static final String XML_COMMENT_END = "-->";
/// ASCII prefix of an XML processing instruction.
private static final String XML_PROCESSING_INSTRUCTION_START = "<?";
/// ASCII suffix of an XML processing instruction.
private static final String XML_PROCESSING_INSTRUCTION_END = "?>";
/// Case-sensitive ASCII prefix of an XML document type declaration.
private static final String XML_DOCTYPE_START = "<!DOCTYPE";
/// Returns whether the supplied prefix identifies an SVG XML document.
///
/// An optional UTF-8 byte-order mark, XML whitespace, comments, processing
/// instructions, and a document type declaration may precede the document
/// element. The method identifies the document element only; it does not
/// validate the remainder of the XML document. XML markup must use its
/// ASCII byte representation; UTF-16 and UTF-32 prefixes are not recognized.
///
/// @param headerBuffer leading bytes of the candidate image
/// @return whether the first XML document element is `svg`
public static boolean isSVG(byte[] headerBuffer) {
return headerBuffer.length > SVG_HEADER.length
&& Arrays.equals(headerBuffer, 0, SVG_HEADER.length, SVG_HEADER, 0, SVG_HEADER.length);
int offset = hasUtf8ByteOrderMark(headerBuffer) ? 3 : 0;
while (true) {
offset = skipXmlWhitespace(headerBuffer, offset);
if (isSvgElementStart(headerBuffer, offset))
return true;
if (matchesAscii(headerBuffer, offset, XML_COMMENT_START)) {
offset = indexAfterAscii(
headerBuffer,
offset + XML_COMMENT_START.length(),
XML_COMMENT_END
);
} else if (matchesAscii(headerBuffer, offset, XML_PROCESSING_INSTRUCTION_START)) {
offset = indexAfterAscii(
headerBuffer,
offset + XML_PROCESSING_INSTRUCTION_START.length(),
XML_PROCESSING_INSTRUCTION_END
);
} else if (isDoctypeStart(headerBuffer, offset)) {
offset = skipDoctype(headerBuffer, offset);
} else {
return false;
}
if (offset < 0)
return false;
}
}
/// Returns whether the byte array starts with a UTF-8 byte-order mark.
///
/// @param data bytes to inspect
/// @return whether `data` begins with `EF BB BF`
private static boolean hasUtf8ByteOrderMark(byte[] data) {
return svgScanLimit(data) >= 3
&& data[0] == (byte) 0xef
&& data[1] == (byte) 0xbb
&& data[2] == (byte) 0xbf;
}
/// Returns the bounded length available to SVG detection.
///
/// @param data source bytes
/// @return the lesser of the array length and [#HEADER_BUFFER_SIZE]
private static int svgScanLimit(byte[] data) {
return Math.min(data.length, HEADER_BUFFER_SIZE);
}
/// Skips XML whitespace from an array offset.
///
/// @param data source bytes
/// @param start inclusive offset at which to begin
/// @return the first offset that does not contain XML whitespace
private static int skipXmlWhitespace(byte[] data, int start) {
int limit = svgScanLimit(data);
while (start < limit && isXmlWhitespace(data[start]))
start++;
return start;
}
/// Returns whether an SVG document-element qualified name begins at an array offset.
///
/// @param data source bytes
/// @param start offset of a possible element opening delimiter
/// @return whether the unprefixed or prefixed element has the local name `svg`
private static boolean isSvgElementStart(byte[] data, int start) {
int limit = svgScanLimit(data);
if (start < 0 || start >= limit || data[start] != '<')
return false;
int nameStart = start + 1;
int localNameStart = nameStart;
boolean hasPrefix = false;
for (int index = nameStart; index < limit; index++) {
byte value = data[index];
if (value == '>' || isXmlWhitespace(value)
|| value == '/' && index + 1 < limit && data[index + 1] == '>') {
return index - localNameStart == SVG_ELEMENT_NAME.length()
&& matchesAscii(data, localNameStart, SVG_ELEMENT_NAME);
}
if (value == ':') {
if (hasPrefix || index == nameStart)
return false;
hasPrefix = true;
localNameStart = index + 1;
continue;
}
boolean nameStartCharacter = index == nameStart || index == localNameStart;
if (nameStartCharacter
? !isAsciiXmlNameStart(value)
: !isAsciiXmlNamePart(value)) {
return false;
}
}
return false;
}
/// Returns whether a byte can begin an ASCII XML name component.
///
/// @param value byte to inspect
/// @return whether the byte is an ASCII letter or underscore
private static boolean isAsciiXmlNameStart(byte value) {
return value >= 'A' && value <= 'Z'
|| value >= 'a' && value <= 'z'
|| value == '_';
}
/// Returns whether a byte can continue an ASCII XML name component.
///
/// @param value byte to inspect
/// @return whether the byte is accepted after the first name character
private static boolean isAsciiXmlNamePart(byte value) {
return isAsciiXmlNameStart(value)
|| value >= '0' && value <= '9'
|| value == '-'
|| value == '.';
}
/// Returns whether an XML document type declaration begins at an offset.
///
/// @param data source bytes
/// @param start offset of a possible declaration
/// @return whether the case-sensitive keyword is followed by XML whitespace
private static boolean isDoctypeStart(byte[] data, int start) {
int keywordEnd = start + XML_DOCTYPE_START.length();
return matchesAscii(data, start, XML_DOCTYPE_START)
&& keywordEnd < svgScanLimit(data)
&& isXmlWhitespace(data[keywordEnd]);
}
/// Skips an XML document type declaration, including its internal subset.
///
/// Quoted values, comments, and processing instructions are ignored while
/// locating the declaration-closing `>`.
///
/// @param data source bytes
/// @param start offset of the `<!DOCTYPE` prefix
/// @return the offset after the declaration, or `-1` if it is incomplete
private static int skipDoctype(byte[] data, int start) {
int internalSubsetDepth = 0;
byte quote = 0;
int limit = svgScanLimit(data);
for (int index = start + XML_DOCTYPE_START.length(); index < limit; index++) {
byte value = data[index];
if (quote != 0) {
if (value == quote)
quote = 0;
continue;
}
if (value == '\'' || value == '"') {
quote = value;
continue;
}
if (matchesAscii(data, index, XML_COMMENT_START)) {
int commentEnd = indexAfterAscii(
data,
index + XML_COMMENT_START.length(),
XML_COMMENT_END
);
if (commentEnd < 0)
return -1;
index = commentEnd - 1;
continue;
}
if (matchesAscii(data, index, XML_PROCESSING_INSTRUCTION_START)) {
int instructionEnd = indexAfterAscii(
data,
index + XML_PROCESSING_INSTRUCTION_START.length(),
XML_PROCESSING_INSTRUCTION_END
);
if (instructionEnd < 0)
return -1;
index = instructionEnd - 1;
continue;
}
if (value == '[') {
internalSubsetDepth++;
} else if (value == ']' && internalSubsetDepth > 0) {
internalSubsetDepth--;
} else if (value == '>' && internalSubsetDepth == 0) {
return index + 1;
}
}
return -1;
}
/// Finds an ASCII sequence in a byte array.
///
/// @param data source bytes
/// @param start inclusive offset at which to begin searching
/// @param asciiValue ASCII sequence to find
/// @return the offset after the first match, or `-1` if no match exists
private static int indexAfterAscii(byte[] data, int start, String asciiValue) {
int lastStart = svgScanLimit(data) - asciiValue.length();
for (int index = start; index <= lastStart; index++) {
if (matchesAscii(data, index, asciiValue))
return index + asciiValue.length();
}
return -1;
}
/// Compares an ASCII string with bytes at an array offset.
///
/// @param data source bytes
/// @param start offset at which to compare
/// @param asciiValue ASCII sequence to compare
/// @return whether the complete sequence matches
private static boolean matchesAscii(byte[] data, int start, String asciiValue) {
if (start < 0 || start > svgScanLimit(data) - asciiValue.length())
return false;
for (int index = 0; index < asciiValue.length(); index++) {
if (Byte.toUnsignedInt(data[start + index]) != asciiValue.charAt(index))
return false;
}
return true;
}
/// Returns whether a byte is XML whitespace.
///
/// @param value byte to inspect
/// @return whether the byte is space, tab, carriage return, or line feed
private static boolean isXmlWhitespace(byte value) {
return value == ' ' || value == '\t' || value == '\r' || value == '\n';
}
private static final byte[] PNG_HEADER = {
@@ -249,7 +513,7 @@ public final class ImageUtils {
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
PngChunkHeader header;
@Nullable PngChunkHeader header;
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
// https://wiki.mozilla.org/APNG_Specification#Structure
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
@@ -436,7 +700,7 @@ public final class ImageUtils {
}
}
PngAnimationControl animationControl = sequence.getAnimationControl();
@Nullable PngAnimationControl animationControl = sequence.getAnimationControl();
int cycleCount;
if (animationControl != null) {
cycleCount = animationControl.numPlays();
@@ -465,6 +729,7 @@ public final class ImageUtils {
return argb;
}
/// Prevents instantiation.
private ImageUtils() {
}
}
@@ -19,27 +19,29 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXDialogLayout;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.LocalAddonManager;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.FileDownloadTask;
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.JFXCheckBoxTableCell;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
@@ -83,26 +85,42 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
enabledColumn.setMinWidth(40);
TableColumn<AddonUpdateObject, String> fileNameColumn = new TableColumn<>(i18n("addon.check_update.file"));
fileNameColumn.setPrefWidth(200);
fileNameColumn.setPrefWidth(180);
setupCellValueFactory(fileNameColumn, AddonUpdateObject::fileNameProperty);
TableColumn<AddonUpdateObject, String> currentVersionColumn = new TableColumn<>(i18n("addon.check_update.current_version"));
currentVersionColumn.setPrefWidth(200);
currentVersionColumn.setPrefWidth(180);
setupCellValueFactory(currentVersionColumn, AddonUpdateObject::currentVersionProperty);
TableColumn<AddonUpdateObject, String> targetVersionColumn = new TableColumn<>(i18n("addon.check_update.target_version"));
targetVersionColumn.setPrefWidth(200);
targetVersionColumn.setPrefWidth(180);
setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty);
TableColumn<AddonUpdateObject, String> sourceColumn = new TableColumn<>(i18n("addon.check_update.source"));
setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty);
TableColumn<AddonUpdateObject, String> changelogColumn = new TableColumn<>(i18n("addon.changelog"));
{
var oldCellFactory = changelogColumn.getCellFactory();
changelogColumn.setCellFactory(param -> {
TableCell<AddonUpdateObject, String> cell = oldCellFactory.call(param);
cell.getStyleClass().add("addon-changelog-table-cell");
cell.setOnMouseClicked(event -> {
AddonUpdateObject object;
if (cell.isEmpty() || cell.getTableRow() == null || (object = cell.getTableRow().getItem()) == null) return;
Controllers.dialog(new AddonChangelog(object));
});
return cell;
});
changelogColumn.setCellValueFactory(__ -> new SimpleStringProperty(i18n("button.view")));
}
objects = FXCollections.observableList(updates.stream().map(AddonUpdateObject::new).collect(Collectors.toList()));
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
TableView<AddonUpdateObject> table = new TableView<>(objects);
table.setEditable(true);
table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn);
table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn, changelogColumn);
setMargin(table, new Insets(10, 10, 5, 10));
setCenter(table);
@@ -198,6 +216,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
final StringProperty currentVersion = new SimpleStringProperty();
final StringProperty targetVersion = new SimpleStringProperty();
final StringProperty source = new SimpleStringProperty();
String changelog = null;
public AddonUpdateObject(LocalAddonFile.AddonUpdate data) {
this.data = data;
@@ -280,6 +299,88 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(AddonUpdateObject object) {
RemoteAddon.Version targetVersion = object.data.targetVersion();
this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(object, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(object, versionPageBtn);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(versionPageBtn, closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
Task.supplyAsync(() -> {
if (object.changelog != null) {
return object.changelog;
}
RemoteAddon.Version version = object.data.targetVersion();
if (repo == null) return null;
return StringUtils.convertToHtml(
repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()),
"238222".equals(object.data.targetVersion().projectId())
);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo == null ? "" : repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
Task.supplyAsync(() -> {
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion());
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
}).start();
}
}
public static class AddonUpdateTask extends Task<Void> {
private final Collection<Task<?>> dependents;
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
@@ -586,12 +586,12 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
setGraphic(null);
} else {
setPadding(
getIndex() == getListView().getItems().size() - 1
? LAST_PADDING
: PADDING
getIndex() == getListView().getItems().size() - 1
? LAST_PADDING
: PADDING
);
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.title());
String description = item.description();
if (description != null) {
@@ -57,8 +57,10 @@ import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends Control implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
private final BooleanProperty loading = new SimpleBooleanProperty(false);
@@ -70,7 +72,7 @@ public class DownloadPage extends Control implements DecoratorPage {
private final HMCLGameRepository.InstanceReference instanceReference;
private final DownloadCallback callback;
private final DownloadListPage page;
private final RemoteAddonRepository.Type type;
private final RemoteAddon.Type type;
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> versions;
@@ -78,8 +80,8 @@ public class DownloadPage extends Control implements DecoratorPage {
this.page = page;
this.repository = page.repository;
this.addon = addon;
this.type = Objects.requireNonNullElse(addon.repoType(), repository.getType());
this.translations = ModTranslations.getTranslationsByRepositoryType(this.type);
this.type = Objects.requireNonNullElse(addon.type(), repository.getType());
this.translations = ModTranslations.getTranslationsByAddonType(this.type);
this.mod = translations.getModByCurseForgeId(addon.slug());
this.instanceReference = instanceReference;
this.callback = callback;
@@ -275,26 +277,26 @@ public class DownloadPage extends Control implements DecoratorPage {
String gameVersion = repository.getGameVersion(resolvedManifest.unresolved()).orElse(null);
if (gameVersion != null && control.versions.containsKey(gameVersion)) {
List<RemoteAddon.Version> modVersions = control.versions.get(gameVersion);
if (modVersions != null && !modVersions.isEmpty()) {
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
if (addonVersions != null && !addonVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders();
resolve:
for (RemoteAddon.Version modVersion : modVersions) {
if (getSkinnable().type == RemoteAddonRepository.Type.MOD) {
for (ModLoaderType loader : modVersion.loaders()) {
for (RemoteAddon.Version addonVersion : addonVersions) {
if (getSkinnable().type == RemoteAddon.Type.MOD) {
for (ModLoaderType loader : addonVersion.loaders()) {
if (targetLoaders.contains(loader)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
new AddonItem(control.addon, modVersion, control)
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break resolve;
}
}
} else {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
new AddonItem(control.addon, modVersion, control)
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break;
}
@@ -386,8 +388,8 @@ public class DownloadPage extends Control implements DecoratorPage {
pane.getChildren().setAll(imageView, content);
FXUtils.setLimitHeight(this, 60);
RemoteAddonRepository.Type type = addon.repoType();
DownloadCallback callback = switch (type) {
RemoteAddon.Type type = addon.type();
DownloadCallback callback = type == null ? null : switch (type) {
case MOD -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD;
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
@@ -400,7 +402,7 @@ public class DownloadPage extends Control implements DecoratorPage {
setNode(IDX_LEADING, pane);
if (addon != RemoteAddon.BROKEN) {
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(type).getModByCurseForgeId(addon.slug());
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(addon.slug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title());
content.setSubtitle(addon.description());
for (String category : addon.categories()) {
@@ -510,7 +512,7 @@ public class DownloadPage extends Control implements DecoratorPage {
private static final class AddonVersion extends JFXDialogLayout {
public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage selfPage) {
RemoteAddonRepository.Type type = selfPage.type;
RemoteAddon.Type type = selfPage.type;
String title = switch (type) {
case WORLD -> "world.download.title";
@@ -523,9 +525,20 @@ public class DownloadPage extends Control implements DecoratorPage {
VBox box = new VBox(8);
box.setPadding(new Insets(8));
AddonItem addonItem = new AddonItem(mod, version, selfPage);
var addonItem = new AddonItem(mod, version, selfPage);
addonItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again
box.getChildren().setAll(addonItem);
JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog"));
changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider())));
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(version, selfPage.repository, versionPageBtn);
HBox additionalBox = new HBox(changelogButton, versionPageBtn);
box.getChildren().add(additionalBox);
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
ComponentList dependenciesList = new ComponentList();
@@ -546,10 +559,10 @@ public class DownloadPage extends Control implements DecoratorPage {
JFXButton downloadButton = null;
if (selfPage.callback != null) {
downloadButton = new JFXButton(type == RemoteAddonRepository.Type.MODPACK ? i18n("install.modpack") : i18n("mods.install"));
downloadButton = new JFXButton(type == RemoteAddon.Type.MODPACK ? i18n("install.modpack") : i18n("mods.install"));
downloadButton.getStyleClass().add("dialog-accept");
downloadButton.setOnAction(e -> {
if (type == RemoteAddonRepository.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
if (type == RemoteAddon.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.download(mod, version);
@@ -576,7 +589,7 @@ public class DownloadPage extends Control implements DecoratorPage {
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, cancelButton::fire);
}
@@ -619,7 +632,6 @@ public class DownloadPage extends Control implements DecoratorPage {
).toList()
);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
spinnerPane.setLoading(false);
if (exception == null) {
dependenciesList.getContent().setAll(result);
spinnerPane.setFailedReason(null);
@@ -627,6 +639,76 @@ public class DownloadPage extends Control implements DecoratorPage {
dependenciesList.getContent().setAll();
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(RemoteAddon.Version version, RemoteAddonRepository repo, JFXHyperlink button) {
Task.supplyAsync(() -> repo.getVersionPageUrl(version))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
})
.start();
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider) {
setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + version.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(version, repo, provider, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, provider, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
Task.supplyAsync(() ->
StringUtils.convertToHtml(
repo.getAddonChangelog(provider, version.projectId(), version.versionId()),
"238222".equals(version.projectId()) // https://github.com/HMCL-dev/HMCL/pull/4828#issuecomment-3791068569 FUCK YOU JEI, FUCK YOU CURSEFORGE
)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
String changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
}
@@ -33,6 +33,7 @@ import javafx.scene.control.ListView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.HMCLGameRepository;
@@ -49,6 +50,18 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
private static final String KEY = GameListPopupMenu.class.getName() + ".popup";
public static boolean hideShowing(Node owner) {
JFXPopup popup = (JFXPopup) owner.getProperties().get(KEY);
if (popup != null && popup.isShowing()) {
popup.hide();
return true;
} else {
return false;
}
}
/// Shows an instance selection popup relative to its owner.
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
@@ -66,7 +79,9 @@ public final class GameListPopupMenu extends StackPane {
.map(it -> new GameItem(repository, it.id()))
.toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY);
owner.getProperties().put(KEY, popup);
popup.addEventFilter(WindowEvent.WINDOW_HIDDEN, event -> owner.getProperties().remove(KEY, popup));
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY, true);
return popup;
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
@@ -31,40 +32,24 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
public static DownloadListPage ofMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofCurseForgeMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, null);
}
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
}
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
}
public static DownloadListPage ofCurseForgeResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, null);
}
public static DownloadListPage ofModrinthResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, null, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
page.supportChinese.set(false);
return page;
}
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddonRepository.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
super(null, callback, instanceSelection);
repository = new Repository(type, curseForge, modrinth);
@@ -98,11 +83,11 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
}
private class Repository extends LocalizedRemoteAddonRepository {
private final RemoteAddonRepository.Type type;
private final RemoteAddon.Type type;
private final CurseForgeRemoteAddonRepository curseForge;
private final ModrinthRemoteAddonRepository modrinth;
public Repository(Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
public Repository(RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
this.type = type;
this.curseForge = curseForge;
this.modrinth = modrinth;
@@ -127,7 +112,7 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
}
@Override
public Type getType() {
public RemoteAddon.Type getType() {
return type;
}
}
@@ -44,7 +44,6 @@ import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
@@ -481,15 +480,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
}
}
button.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(
repository instanceof CurseForgeRemoteAddonRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false),
remoteAddon,
new HMCLGameRepository.InstanceReference(ModListPageSkin.this.getSkinnable().getRepository(), ModListPageSkin.this.getSkinnable().getInstanceId()),
org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD
));
});
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
@@ -501,31 +492,21 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
officialPageButton.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
FXUtils.openLink(modInfo.getModInfo().getUrl());
});
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
getActions().add(officialPageButton);
}
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
searchButton.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
FXUtils.openLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
pair("key", modInfo.getModInfo().getName()),
pair("site", "all"),
pair("filter", "0")
)));
});
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
pair("key", modInfo.getModInfo().getName()),
pair("site", "all"),
pair("filter", "0")
)));
getActions().add(searchButton);
} else {
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
mcmodButton.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
FXUtils.openLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
});
mcmodButton.setExternalLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
getActions().add(mcmodButton);
}
@@ -17,7 +17,7 @@
*/
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
@@ -56,7 +56,8 @@ public enum ModTranslations {
}
};
public static ModTranslations getTranslationsByRepositoryType(RemoteAddonRepository.Type type) {
public static ModTranslations getTranslationsByAddonType(@Nullable RemoteAddon.Type type) {
if (type == null) return EMPTY;
return switch (type) {
case MOD -> MOD;
case MODPACK -> MODPACK;
@@ -638,17 +638,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
button.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(
repository instanceof CurseForgeRemoteAddonRepository
? HMCLLocalizedDownloadListPage.ofCurseForgeResourcePack(null, false)
: HMCLLocalizedDownloadListPage.ofModrinthResourcePack(null, false),
remoteAddon,
new HMCLGameRepository.InstanceReference(page.repository, page.instanceId),
org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK
));
});
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
@@ -31,9 +31,12 @@ import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.setting.ProxyType;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
@@ -128,7 +131,7 @@ public class DownloadSettingsPage extends StackPane {
settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory());
cleanButton.setOnAction(e -> clearCacheDirectory(cleanButton));
fileCommonLocationSublist.setHeaderRight(cleanButton);
ComponentSublist downloadThreadsSublist = new ComponentSublist(() -> {
@@ -348,10 +351,27 @@ public class DownloadSettingsPage extends StackPane {
}
private void clearCacheDirectory() {
@FXThread
private boolean cleaningCache = false;
private void clearCacheDirectory(JFXButton cleanButton) {
if (cleaningCache) return;
String commonDirectory = settings().getResolvedCommonDirectory();
if (commonDirectory != null) {
FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache"));
cleaningCache = true;
cleanButton.setMinWidth(cleanButton.getWidth());
var txt = cleanButton.getText();
cleanButton.setText("");
var spinner = new JFXSpinner();
spinner.setRadius(8);
cleanButton.setGraphic(spinner);
Task.runAsync("Clear Cache Directory", Schedulers.io(), () -> FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache")))
.whenComplete(Schedulers.javafx(), ignored -> {
cleanButton.setGraphic(null);
cleanButton.setText(txt);
cleaningCache = false;
}).setSignificance(Task.TaskSignificance.MINOR)
.start();
}
}
}
@@ -259,6 +259,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
menuButton = new JFXButton();
menuButton.getStyleClass().add("menu-button");
menuButton.setOnAction(e -> {
if (GameListPopupMenu.hideShowing(menuButton)) {
return;
}
JFXPopup popup = GameListPopupMenu.showAndGetPopup(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
@@ -68,14 +68,25 @@ public abstract class TaskExecutorDialogWizardDisplayer extends AbstractWizardDi
else if (!settings.containsKey("forbid_success_message"))
Controllers.dialog(i18n("message.success"), null, MessageType.SUCCESS, () -> onEnd());
} else {
if (executor.getException() == null)
if (executor.getException() == null) {
onEnd();
return;
}
if (executor.getException() instanceof CancellationException) {
onEnd();
return;
}
if (executor.getException().getCause() instanceof OutOfMemoryError outOfMemoryError) {
try {
Controllers.dialog(StringUtils.getStackTrace(outOfMemoryError), null, MessageType.ERROR, () -> onEnd());
} catch (OutOfMemoryError ignored) {
onEnd();
}
return;
}
String appendix = StringUtils.getStackTrace(executor.getException());
if (settings.get(WizardProvider.FailureCallback.KEY) != null)
settings.get(WizardProvider.FailureCallback.KEY).onFail(settings, executor.getException(), () -> onEnd());
@@ -113,5 +113,10 @@
"title": "uuid-tools",
"subtitle": "Copyright © 2026 Glavo.\nLicensed under the Apache 2.0 License.",
"externalLink": "https://github.com/Glavo/uuid-tools"
},
{
"title": "CommonMark",
"subtitle": "Copyright (c) 2015 Robin Stocker.\nAll rights reserved.",
"externalLink": "https://github.com/commonmark/commonmark-java"
}
]
@@ -1923,6 +1923,15 @@
-fx-font-style: italic;
}
.html-code {
-fx-font-size: 15;
}
.html .separator > .line {
-fx-border-color: -monet-outline-variant;
-fx-border-width: 0.5;
}
/*******************************************************************************
* *
* Tooltip *
@@ -2004,3 +2013,49 @@
.update-pane:active .subtitle-label {
-fx-text-fill: -monet-tertiary;
}
/*******************************************************************************
* *
* Mod Changelog *
* *
******************************************************************************/
.addon-changelog {
-fx-background-color: -monet-surface;
-fx-background-radius: 4;
-fx-padding: 10;
-fx-font-size: 12;
-fx-text-fill: -monet-on-surface;
-fx-line-spacing: 2;
}
.addon-changelog .html {
-fx-font-size: 12;
}
.addon-changelog .html-h1 {
-fx-font-size: 16.5;
}
.addon-changelog .html-h2 {
-fx-font-size: 15;
}
.addon-changelog .html-h3 {
-fx-font-size: 13.5;
}
.addon-changelog .html-code {
-fx-font-size: 11.25;
}
.addon-changelog .html-code-block {
-fx-padding: 0 3 0 3;
-fx-background-radius: 5;
/* Should be -monet-surface-bright in dark mode and -dim in light mode, however that's not supported */
-fx-background-color: -monet-surface-container;
}
.addon-changelog-table-cell {
-fx-text-fill: -monet-primary;
}
@@ -175,6 +175,8 @@ account.username=Username
addon.broken_dependency.title=Broken dependency
addon.broken_dependency.desc=This dependency existed before, but it does not exist anymore. Try using another download source.
addon.category=Category
addon.changelog=Changelog
addon.changelog.empty=Currently no changelog
addon.channel.alpha=Alpha
addon.channel.beta=Beta
addon.channel.release=Release
@@ -196,6 +198,7 @@ addon.dependency.tool=Required Dependencies (Must be downloaded separately. Miss
addon.dependency.include=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately)
addon.dependency.incompatible=Incompatible Addons (Installing these addons at the same time will prevent the game from launching)
addon.dependency.broken=Broken Dependencies (This addon existed before, but it does not exist anymore. Try using another download source.)
addon.download.recommend=Recommended Version for Minecraft %1s
addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (Snapshots)
addon.modrinth=Modrinth
@@ -212,6 +215,7 @@ assets.index.malformed=Index files of downloaded assets are corrupted. You can r
button.cancel=Cancel
button.change_source=Change Download Source
button.clear=Clear
button.copy=Copy
button.copy_and_exit=Copy and Exit
button.delete=Delete
button.disable=Disable
@@ -399,7 +403,7 @@ download.external_link=Visit Download Website
download.failed=Failed to download "%1$s", response code: %2$d.
download.failed.empty=No versions are available. Please click here to go back.
download.failed.no_code=Failed to download
download.failed.refresh=Failed to fetch version list. Please click here to retry.
download.failed.refresh=Failed to fetch data. Please click here to retry.
download.game=New Game
download.provider.bmclapi=BMCLAPI & MCIM
download.provider.bmclapi.desc=This option is provided exclusively for users in Chinese Mainland.
@@ -1201,7 +1205,6 @@ mods.add.title=Choose mod file you want to add
mods.disable=Disable
mods.download=Download Mod
mods.download.title=Download Mod - %1s
mods.download.recommend=Recommended Mod Version - Minecraft %1s
mods.empty=No Mods
mods.enable=Enable
mods.game.version=Game Version
@@ -1561,7 +1564,7 @@ settings.game.inherit_global=This setting currently inherits from the game setti
settings.game.instance_settings.unsupported=The current game settings file was saved by a newer version of HMCL. The current version of HMCL cannot load it properly.
settings.game.presets.unsupported=The game settings preset file was saved by a newer version of HMCL. The current version of HMCL cannot modify global game settings.
settings.game.isolation=Instance Isolation
settings.game.isolation.subtitle=When enabled, this instance uses its own working directory setting instead of inheriting the global one.
settings.game.isolation.subtitle=When enabled, this instance uses its own working directory setting.
settings.game.java_directory=Java
settings.game.java_directory.auto=Automatically Choose
settings.game.java_directory.auto.not_found=No suitable Java version was installed.
@@ -1143,7 +1143,6 @@ mods.add.title=اختر ملف المود الذي تريد إضافته
mods.disable=تعطيل
mods.download=تنزيل مود
mods.download.title=تنزيل مود - %1s
mods.download.recommend=إصدار المود المقترح - Minecraft %1s
mods.enable=تفعيل
mods.game.version=إصدار اللعبة
mods.manage=المودات
@@ -163,6 +163,7 @@ addon.check_update.file=Archivo
addon.check_update.source=Fuente
addon.check_update.target_version=Versión de destino
addon.curseforge=CurseForge
addon.download.recommend=Versión recomendada - Minecraft %1s
addon.modrinth=Modrinth
archive.author=Autor(es)
@@ -1087,7 +1088,6 @@ mods.add.success=%s se ha añadido correctamente.
mods.disable=Desactivar
mods.download=Descargar mod
mods.download.title=Descargar mod - %1s
mods.download.recommend=Versión recomendada - Minecraft %1s
mods.enable=Activar
mods.game.version=Versión del juego
mods.manage=Mods
@@ -152,6 +152,7 @@ account.username=戶名
addon.broken_dependency.title=所依之壞者
addon.broken_dependency.desc=夫改囊素存於改囊庫,今闕矣,宜易他源。
addon.category=類
addon.changelog=迭更誌
addon.channel.alpha=預版
addon.channel.beta=試版
addon.channel.release=當版
@@ -165,6 +166,7 @@ addon.check_update.file=案
addon.check_update.source=源
addon.check_update.target_version=將至之版
addon.curseforge=CurseForge
addon.download.recommend=宜版 - 礦藝 %1s
addon.modrinth=Modrinth
archive.author=作者
@@ -179,6 +181,7 @@ assets.index.malformed=資案之目有謬。或至於是例「司例」之頁,
button.cancel=罷
button.change_source=迭引源
button.clear=清
button.copy=鈔
button.copy_and_exit=鈔而辭
button.delete=刪
button.edit=改
@@ -910,7 +913,6 @@ mods.add.title=擇改囊
mods.disable=禁此改囊
mods.download=引改囊
mods.download.title=引改囊 - %1s
mods.download.recommend=宜版 - 礦藝 %1s
mods.enable=啟之
mods.game.version=戲版
mods.manage=司改囊
@@ -1081,7 +1081,6 @@ mods.add.success=%s был успешно добавлен.
mods.disable=Отключить
mods.download=Скачать мод
mods.download.title=Скачать мод - %1s
mods.download.recommend=Рекомендуемая версия мода - Minecraft %1s
mods.enable=Включить
mods.game.version=Версия игры
mods.manage=Моды
@@ -1035,7 +1035,6 @@ mods.add.success=%s успішно додано.
mods.disable=Вимкнути
mods.download=Завантажити мод
mods.download.title=Завантажити мод - %1s
mods.download.recommend=Рекомендована версія мода - Minecraft %1s
mods.enable=Увімкнути
mods.game.version=Версія гри
mods.manage=Моди
@@ -173,6 +173,8 @@ account.username=使用者名稱
addon.broken_dependency.title=損壞的相依內容
addon.broken_dependency.desc=該相依內容曾經存在於附加內容下載源中,但現在已被刪除,請嘗試其他下載源。
addon.category=類別
addon.changelog=更新日誌
addon.changelog.empty=暫無更新日誌
addon.channel.alpha=Alpha
addon.channel.beta=Beta
addon.channel.release=Release
@@ -194,6 +196,7 @@ addon.dependency.tool=相依庫 (必須單獨下載,缺少可能會導致遊
addon.dependency.include=內建相依內容 (作者已經打包在檔案中,無需單獨下載)
addon.dependency.incompatible=不相容附加內容 (與正在下載的檔案同時安裝會導致遊戲無法啟動)
addon.dependency.broken=損壞的相依內容 (該相依內容曾經存在於附加內容下載源中,但現在已被刪除,請嘗試其他下載源)
addon.download.recommend=推薦版本 - Minecraft %1s
addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (快照)
addon.modrinth=Modrinth
@@ -210,6 +213,7 @@ assets.index.malformed=資源檔案的索引檔案損壞。你可以在相應實
button.cancel=取消
button.change_source=切換下載源
button.clear=清除
button.copy=複製
button.copy_and_exit=複製並退出
button.delete=刪除
button.disable=停用
@@ -397,7 +401,7 @@ download.external_link=開啟下載網站
download.failed=下載失敗:%1$s\n錯誤碼:%2$d
download.failed.empty=沒有可供安裝的版本,按一下此處返回。
download.failed.no_code=下載失敗
download.failed.refresh=載入版本清單失敗,按一下此處重試。
download.failed.refresh=載入資料失敗,按一下此處重試。
download.game=新遊戲
download.provider.bmclapi=BMCLAPI & MCIM
download.provider.bmclapi.desc=適用於中國大陸的鏡像源
@@ -954,7 +958,7 @@ modrinth.category.minecraft=Minecraft
modrinth.category.minigame=小遊戲
modrinth.category.misc=其他
modrinth.category.mobs=生物
modrinth.category.modded=Modded
modrinth.category.modded=含模組
modrinth.category.models=模型
modrinth.category.modloader=ModLoader
modrinth.category.multiplayer=多人
@@ -1008,7 +1012,6 @@ mods.add.title=選取要新增的模組檔案
mods.disable=停用
mods.download=下載模組
mods.download.title=模組下載 - %1s
mods.download.recommend=推薦版本 - Minecraft %1s
mods.empty=沒有模組
mods.enable=啟用
mods.game.version=遊戲版本
@@ -1364,7 +1367,7 @@ settings.game.inherit_global=目前繼承遊戲設定預設。點擊後使用實
settings.game.instance_settings.unsupported=目前遊戲設定檔案是由更高版本的 HMCL 儲存的,目前版本的 HMCL 無法正常載入。
settings.game.presets.unsupported=遊戲設定預設檔案是由更高版本的 HMCL 儲存的,目前版本的 HMCL 無法修改全域遊戲設定。
settings.game.isolation=版本隔離
settings.game.isolation.subtitle=啟用後,目前實例將使用獨立的遊戲執行路徑設定,而不是繼承全域設定
settings.game.isolation.subtitle=啟用後,目前實例將使用獨立的遊戲執行路徑設定
settings.game.java_directory=遊戲 Java
settings.game.java_directory.auto=自動選取合適的 Java
settings.game.java_directory.auto.not_found=沒有合適的 Java
@@ -175,6 +175,8 @@ account.username=用户名
addon.broken_dependency.title=损坏的前置内容
addon.broken_dependency.desc=该前置内容曾经在该附加内容下载源上存在过,但现在被删除了。换个下载源试试吧。
addon.category=类别
addon.changelog=更新日志
addon.changelog.empty=暂无更新日志
addon.channel.alpha=快照版本
addon.channel.beta=测试版本
addon.channel.release=稳定版本
@@ -196,6 +198,7 @@ addon.dependency.tool=前置库 (必须另外下载,缺失可能会导致游
addon.dependency.include=内置的前置内容 (已经由作者打包在文件中,无需另外下载)
addon.dependency.incompatible=不兼容的附加内容 (同时与正在下载的文件安装会导致游戏无法启动)
addon.dependency.broken=损坏的前置内容 (该前置内容曾经在该附加内容下载源上存在过,但现在被删除了,换个下载源试试吧)
addon.download.recommend=推荐版本 - Minecraft %1s
addon.download.title.release=Minecraft %s
addon.download.title.snapshot=Minecraft %s (快照)
addon.modrinth=Modrinth
@@ -212,6 +215,7 @@ assets.index.malformed=资源文件的索引文件损坏。你可以在相应实
button.cancel=取消
button.change_source=切换下载源
button.clear=清除
button.copy=复制
button.copy_and_exit=复制并退出
button.delete=删除
button.disable=禁用
@@ -399,7 +403,7 @@ download.external_link=打开下载网站
download.failed=下载失败: %1$s,\n错误码:%2$d\n你可以点击右上角帮助按钮进行求助。
download.failed.empty=[没有可供安装的版本,点击此处返回]\n(你可以点击右上角帮助按钮进行求助)
download.failed.no_code=下载失败
download.failed.refresh=[加载版本列表失败,点击此处重试]\n(你可以点击右上角帮助按钮进行求助)
download.failed.refresh=[加载数据失败,点击此处重试]\n(你可以点击右上角帮助按钮进行求助)
download.game=新游戏
download.provider.bmclapi=BMCLAPI & MCIM
download.provider.bmclapi.desc=适用于中国大陆的镜像源
@@ -960,7 +964,7 @@ modrinth.category.minecraft=Minecraft
modrinth.category.minigame=小游戏
modrinth.category.misc=其他
modrinth.category.mobs=生物
modrinth.category.modded=Modded
modrinth.category.modded=模组适配
modrinth.category.models=模型
modrinth.category.modloader=Modloader
modrinth.category.multiplayer=多人
@@ -1008,7 +1012,6 @@ mods.add.title=选择要添加的模组文件
mods.disable=禁用
mods.download=下载模组
mods.download.title=模组下载 - %1s
mods.download.recommend=推荐版本 - Minecraft %1s
mods.empty=没有模组
mods.enable=启用
mods.game.version=游戏版本
@@ -1364,7 +1367,7 @@ settings.game.inherit_global=当前继承游戏设置预设。点击后使用实
settings.game.instance_settings.unsupported=当前游戏设置文件是由更高版本的 HMCL 保存的,当前版本的 HMCL 无法正常加载。
settings.game.presets.unsupported=游戏设置预设文件是由更高版本的 HMCL 保存的,当前版本的 HMCL 无法修改全局游戏设置。
settings.game.isolation=版本隔离
settings.game.isolation.subtitle=启用后,当前实例将使用独立的游戏运行路径设置,而不是继承全局设置
settings.game.isolation.subtitle=启用后,当前实例将使用独立的游戏运行路径设置
settings.game.java_directory=游戏 Java
settings.game.java_directory.auto=自动选择合适的 Java
settings.game.java_directory.auto.not_found=没有合适的 Java
@@ -17,17 +17,26 @@
*/
package org.jackhuang.hmcl.ui.image;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/// Verifies image-format detection from bounded input prefixes.
@NotNullByDefault
public final class ImageUtilsTest {
/// Reads the image-loader detection prefix of a test resource.
///
/// @param fileName resource file name
/// @return up to [ImageUtils#HEADER_BUFFER_SIZE] leading bytes
private static byte[] readHeaderBuffer(String fileName) {
try (var input = Files.newInputStream(Path.of("src/test/resources/image/" + fileName))) {
return input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
@@ -37,6 +46,15 @@ public final class ImageUtilsTest {
}
}
/// Encodes an XML fixture without introducing non-ASCII bytes.
///
/// @param value fixture text
/// @return US-ASCII bytes
private static byte[] asciiBytes(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
/// Verifies APNG detection distinguishes animated and static PNG files.
@Test
public void testIsApng() {
assertTrue(ImageUtils.isApng(readHeaderBuffer("16x16.apng")));
@@ -47,6 +65,7 @@ public final class ImageUtilsTest {
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-animation-lossy.webp")));
}
/// Verifies WebP detection recognizes all supported WebP variants.
@Test
public void testIsWebP() {
assertFalse(ImageUtils.isWebP(readHeaderBuffer("16x16.apng")));
@@ -57,4 +76,66 @@ public final class ImageUtilsTest {
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-animation-lossy.webp")));
}
/// Verifies SVG detection accepts XML prologs and an SVG document element.
@Test
public void testIsSVG() {
assertTrue(ImageUtils.isSVG(asciiBytes("<svg/>")));
assertTrue(ImageUtils.isSVG(asciiBytes(" \t\r\n<svg viewBox='0 0 16 16'>")));
assertTrue(ImageUtils.isSVG(
"\ufeff<?xml version='1.0' encoding='UTF-8'?>\n<svg/>"
.getBytes(StandardCharsets.UTF_8)
));
assertTrue(ImageUtils.isSVG(asciiBytes(
"<?xml version='1.0'?>"
+ "<?xml-stylesheet href='<svg.css'?>"
+ "<!-- <svg/> -->"
+ "<svg xmlns='http://www.w3.org/2000/svg'>"
)));
assertTrue(ImageUtils.isSVG(asciiBytes(
"<!DOCTYPE svg ["
+ "<!ENTITY quoted ']> <svg>'>"
+ "<!-- ]> <svg -->"
+ "<!ELEMENT svg ANY>"
+ "]><svg/>"
)));
assertTrue(ImageUtils.isSVG(asciiBytes(
"<!--" + "x".repeat(ImageUtils.HEADER_BUFFER_SIZE - 13) + "--><svg/>"
)));
assertTrue(ImageUtils.isSVG(asciiBytes(
"<svg:svg xmlns:svg='http://www.w3.org/2000/svg'/>"
)));
assertTrue(ImageUtils.isSVG(asciiBytes(
"<image:svg xmlns:image='http://www.w3.org/2000/svg'/>"
)));
}
/// Verifies SVG detection rejects incomplete, embedded, and non-SVG roots.
@Test
public void testIsNotSVG() {
assertFalse(ImageUtils.isSVG(new byte[0]));
assertFalse(ImageUtils.isSVG(asciiBytes("<svg")));
assertFalse(ImageUtils.isSVG(asciiBytes("<svg-image/>")));
assertFalse(ImageUtils.isSVG(asciiBytes("<svg:circle/>")));
assertFalse(ImageUtils.isSVG(asciiBytes("<image:svg-icon/>")));
assertFalse(ImageUtils.isSVG(asciiBytes("<SVG/>")));
assertFalse(ImageUtils.isSVG(asciiBytes("plain text <svg/>")));
assertFalse(ImageUtils.isSVG(asciiBytes("<!-- <svg/> -->")));
assertFalse(ImageUtils.isSVG(asciiBytes(
"<?xml version='1.0'?><html><svg/></html>"
)));
assertFalse(ImageUtils.isSVG(asciiBytes(
"<!DOCTYPE svg [<!-- incomplete ]> <svg -->"
)));
assertFalse(ImageUtils.isSVG(asciiBytes(
"<!--" + "x".repeat(ImageUtils.HEADER_BUFFER_SIZE) + "--><svg/>"
)));
}
/// Verifies sniffing routes a prefixed SVG document to the SVG loader.
@Test
public void testGuessLoaderDetectsPrefixedSVG() {
assertSame(ImageUtils.SVG, ImageUtils.guessLoader(asciiBytes(
"<svg:svg xmlns:svg='http://www.w3.org/2000/svg'/>"
)));
}
}
+6
View File
@@ -29,6 +29,12 @@ dependencies {
api(libs.hello.nbt)
api(libs.weburl)
api(libs.uuid.tools)
api(libs.commonmark)
api(libs.commonmark.autolink)
api(libs.commonmark.underline)
api(libs.commonmark.strikethrough)
api(libs.commonmark.table)
compileOnlyApi(libs.jetbrains.annotations)
testImplementation(libs.jna.platform)
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.addon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -53,9 +54,14 @@ public abstract class LocalAddonFile {
public abstract void delete() throws IOException;
@Nullable
public abstract AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException;
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
return null;
}
@NotNullByDefault
public record AddonUpdate(
RemoteAddon.Source source,
RemoteAddon.Type repoType,
LocalAddonFile localAddonFile,
RemoteAddon.Version currentVersion,
RemoteAddon.Version targetVersion,
@@ -32,7 +32,7 @@ import java.util.Map;
import java.util.stream.Stream;
public record RemoteAddon(String slug, String author, String title, String description, List<String> categories,
String pageUrl, String iconUrl, IAddon data, RemoteAddonRepository.Type repoType) {
String pageUrl, String iconUrl, IAddon data, @Nullable Type type) {
public static final RemoteAddon BROKEN = new RemoteAddon("", "", "RemoteAddon.BROKEN", "", Collections.emptyList(), "", "", new IAddon() {
@Override
@@ -44,7 +44,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
public Stream<Version> loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException {
throw new IOException();
}
}, RemoteAddonRepository.Type.MOD);
}, Type.MOD);
public enum VersionType {
Release,
@@ -67,23 +67,23 @@ public record RemoteAddon(String slug, String author, String title, String descr
private final DependencyType type;
private final @Nullable RemoteAddonRepository remoteAddonRepository;
private final @Nullable Source source;
private final @Nullable String id;
private transient RemoteAddon remoteAddon = null;
private Dependency(DependencyType type, @Nullable RemoteAddonRepository remoteAddonRepository, @Nullable String id) {
private Dependency(DependencyType type, @Nullable Source source, @Nullable String id) {
this.type = type;
this.remoteAddonRepository = remoteAddonRepository;
this.source = source;
this.id = id;
}
public static Dependency ofGeneral(DependencyType type, RemoteAddonRepository remoteAddonRepository, String id) {
public static Dependency ofGeneral(DependencyType type, Source source, String id) {
if (type == DependencyType.BROKEN) {
return ofBroken();
} else {
return new Dependency(type, remoteAddonRepository, id);
return new Dependency(type, source, id);
}
}
@@ -99,8 +99,8 @@ public record RemoteAddon(String slug, String author, String title, String descr
}
@Nullable
public RemoteAddonRepository getRemoteModRepository() {
return this.remoteAddonRepository;
public Source getSource() {
return this.source;
}
@Nullable
@@ -113,7 +113,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
if (this.type == DependencyType.BROKEN) {
this.remoteAddon = RemoteAddon.BROKEN;
} else {
this.remoteAddon = this.remoteAddonRepository.resolveDependency(downloadProvider, this.id);
this.remoteAddon = this.source.getCommonRepo().resolveDependency(downloadProvider, this.id);
}
}
return this.remoteAddon;
@@ -127,14 +127,14 @@ public record RemoteAddon(String slug, String author, String title, String descr
Dependency that = (Dependency) o;
if (type != that.type) return false;
if (!remoteAddonRepository.equals(that.remoteAddonRepository)) return false;
if (source != that.source) return false;
return id.equals(that.id);
}
@Override
public int hashCode() {
int result = type.hashCode();
result = 31 * result + remoteAddonRepository.hashCode();
result = 31 * result + source.hashCode();
result = 31 * result + id.hashCode();
return result;
}
@@ -142,6 +142,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
public enum Source {
CURSEFORGE(
CurseForgeRemoteAddonRepository.COMMON,
CurseForgeRemoteAddonRepository.MODS,
CurseForgeRemoteAddonRepository.RESOURCE_PACKS,
CurseForgeRemoteAddonRepository.SHADERS,
@@ -150,6 +151,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
CurseForgeRemoteAddonRepository.CUSTOMIZATIONS
),
MODRINTH(
ModrinthRemoteAddonRepository.COMMON,
ModrinthRemoteAddonRepository.MODS,
ModrinthRemoteAddonRepository.RESOURCE_PACKS,
ModrinthRemoteAddonRepository.SHADER_PACKS,
@@ -158,15 +160,16 @@ public record RemoteAddon(String slug, String author, String title, String descr
null
);
public final RemoteAddonRepository modRepo;
public final RemoteAddonRepository resourcePackRepo;
public final RemoteAddonRepository shaderPackRepo;
public final RemoteAddonRepository worldRepo;
public final RemoteAddonRepository modpackRepo;
public final RemoteAddonRepository customizationRepo;
private final RemoteAddonRepository commonRepo;
private final RemoteAddonRepository modRepo;
private final RemoteAddonRepository resourcePackRepo;
private final RemoteAddonRepository shaderPackRepo;
private final RemoteAddonRepository worldRepo;
private final RemoteAddonRepository modpackRepo;
private final RemoteAddonRepository customizationRepo;
@Nullable
public RemoteAddonRepository getRepoForType(RemoteAddonRepository.Type type) {
public RemoteAddonRepository getRepoForType(Type type) {
return switch (type) {
case MOD -> modRepo;
case RESOURCE_PACK -> resourcePackRepo;
@@ -177,7 +180,12 @@ public record RemoteAddon(String slug, String author, String title, String descr
};
}
public RemoteAddonRepository getCommonRepo() {
return commonRepo;
}
Source(
RemoteAddonRepository commonRepo,
RemoteAddonRepository modRepo,
RemoteAddonRepository resourcePackRepo,
RemoteAddonRepository shaderPackRepo,
@@ -185,6 +193,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
RemoteAddonRepository modpackRepo,
RemoteAddonRepository customizationRepo
) {
this.commonRepo = commonRepo;
this.modRepo = modRepo;
this.resourcePackRepo = resourcePackRepo;
this.shaderPackRepo = shaderPackRepo;
@@ -194,6 +203,15 @@ public record RemoteAddon(String slug, String author, String title, String descr
}
}
public enum Type {
MOD,
MODPACK,
RESOURCE_PACK,
SHADER_PACK,
WORLD,
CUSTOMIZATION
}
public interface IAddon {
List<RemoteAddon> loadDependencies(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException;
@@ -204,7 +222,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
Source getSource();
}
public record Version(IVersion self, String projectId, String name, String version, String changelog,
public record Version(IVersion self, String versionId, String projectId, String name, String version,
Instant datePublished, VersionType versionType, File file, List<Dependency> dependencies,
List<String> gameVersions, List<ModLoaderType> loaders) {
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.addon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -28,16 +29,11 @@ import java.util.stream.Stream;
public interface RemoteAddonRepository {
enum Type {
MOD,
MODPACK,
RESOURCE_PACK,
SHADER_PACK,
WORLD,
CUSTOMIZATION
}
RemoteAddon.Type getType();
Type getType();
String getApiBaseUrl();
String getBaseUrl();
enum SortType {
POPULARITY,
@@ -100,6 +96,12 @@ public interface RemoteAddonRepository {
Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException;
@Nullable
String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException;
@NotNull
String getVersionPageUrl(RemoteAddon.Version version) throws IOException;
Stream<Category> getCategories() throws IOException;
record Category(Object self, String id, List<Category> subcategories) {
@@ -196,7 +196,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
RemoteAddonRepository repository = source.getRepoForType(RemoteAddonRepository.Type.MOD);
RemoteAddonRepository repository = source.getRepoForType(RemoteAddon.Type.MOD);
if (repository == null) return null;
Optional<RemoteAddon.Version> currentVersion = repository.getRemoteVersionByLocalFile(file);
if (currentVersion.isEmpty()) return null;
@@ -207,7 +207,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
.sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed())
.toList();
if (remoteVersions.isEmpty()) return null;
return new AddonUpdate(this, currentVersion.get(), remoteVersions.get(0), true);
return new AddonUpdate(source, RemoteAddon.Type.MOD, this, currentVersion.get(), remoteVersions.get(0), true);
}
@Override
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayOutputStream;
@@ -53,6 +54,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class CurseForgeRemoteAddonRepository implements RemoteAddonRepository {
private static final String PREFIX = "https://api.curseforge.com";
private static final String BASE = "https://www.curseforge.com";
private static final Semaphore SEMAPHORE = new Semaphore(16);
public static final String API_KEY = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
@@ -70,19 +72,36 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
return !API_KEY.isEmpty();
}
private final Type type;
private final @Nullable RemoteAddon.Type type;
private final int section;
public CurseForgeRemoteAddonRepository(Type type, int section) {
this.type = type;
public CurseForgeRemoteAddonRepository() {
this.type = null;
this.section = -1;
}
public CurseForgeRemoteAddonRepository(int section) {
this.type = toAddonType(section);
if (type == null) throw new IllegalArgumentException("Unsupported CurseForge section id: " + section);
this.section = section;
}
@Override
public Type getType() {
public RemoteAddon.Type getType() {
if (type == null) throw new UnsupportedOperationException();
return type;
}
@Override
public String getApiBaseUrl() {
return PREFIX;
}
@Override
public String getBaseUrl() {
return BASE;
}
private static int toModsSearchSortField(SortType sort) {
// https://docs.curseforge.com/rest-api/#tocS_ModsSearchSortField
return switch (sort) {
@@ -110,6 +129,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteAddonRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
if (type == null) throw new UnsupportedOperationException();
SEMAPHORE.acquireUninterruptibly();
try {
int categoryId = 0;
@@ -139,7 +159,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
response = withApiKey(HttpRequest.GET(candidate.toString()))
.getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
if (searchFilter.isEmpty()) {
return new SearchResult(response.data().stream().map(addon -> addon.toAddon(type)), calculateTotalPages(response, pageSize));
return new SearchResult(response.data().stream().map(CurseAddon::toAddon), calculateTotalPages(response, pageSize));
}
break;
} catch (IOException e) {
@@ -168,7 +188,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(response.data().stream().map(addon -> addon.toAddon(type)).map(remoteAddon -> {
return new SearchResult(response.data().stream().map(CurseAddon::toAddon).map(remoteAddon -> {
String lowerCaseResult = remoteAddon.title().toLowerCase(Locale.ROOT);
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
@@ -179,7 +199,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
}
return pair(remoteAddon, diff);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.data().stream().map(addon -> addon.toAddon(type)), calculateTotalPages(response, pageSize));
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.data().stream().map(CurseAddon::toAddon), calculateTotalPages(response, pageSize));
} finally {
SEMAPHORE.release();
}
@@ -228,7 +248,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
try {
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
.getJson(Response.typeOf(CurseAddon.class));
return response.data.toAddon(type);
return response.data.toAddon();
} finally {
SEMAPHORE.release();
}
@@ -259,8 +279,47 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
}
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
Response<String> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s/changelog", PREFIX, addonId, versionId)))
.getJson(Response.typeOf(String.class));
return response.data();
} finally {
SEMAPHORE.release();
}
}
@Override
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + version.projectId()))
.getJson(Response.typeOf(CurseAddon.class));
var addon = response.data();
var classId = addon.classId();
var clazz = switch (classId) {
case SECTION_MOD -> "mc-mods";
case SECTION_RESOURCE_PACK -> "texture-packs";
case SECTION_WORLD -> "worlds";
case SECTION_MODPACK -> "modpacks";
case SECTION_DATAPACK -> "data-packs";
case SECTION_BUKKIT_PLUGIN -> "bukkit-plugins";
case SECTION_ADDONS -> "mc-addons";
case SECTION_CUSTOMIZATION -> "customization";
case SECTION_SHADER -> "shaders";
default -> throw new IllegalArgumentException("Unsupported CurseForge class id [%d]".formatted(classId));
};
return "%s/minecraft/%s/%s/files/%s".formatted(BASE, clazz, addon.slug(), version.versionId());
} finally {
SEMAPHORE.release();
}
}
@Override
public Stream<RemoteAddonRepository.Category> getCategories() throws IOException {
if (type == null) throw new UnsupportedOperationException();
SEMAPHORE.acquireUninterruptibly();
try {
Response<List<Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
@@ -296,6 +355,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
public static final int SECTION_BUKKIT_PLUGIN = 5;
public static final int SECTION_MOD = 6;
public static final int SECTION_RESOURCE_PACK = 12;
public static final int SECTION_DATAPACK = 6945;
public static final int SECTION_WORLD = 17;
public static final int SECTION_MODPACK = 4471;
public static final int SECTION_SHADER = 6552;
@@ -305,12 +365,26 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
public static final int SECTION_UNKNOWN2 = 4979;
public static final int SECTION_UNKNOWN3 = 4984;
public static final CurseForgeRemoteAddonRepository MODS = new CurseForgeRemoteAddonRepository(RemoteAddonRepository.Type.MOD, SECTION_MOD);
public static final CurseForgeRemoteAddonRepository MODPACKS = new CurseForgeRemoteAddonRepository(RemoteAddonRepository.Type.MODPACK, SECTION_MODPACK);
public static final CurseForgeRemoteAddonRepository RESOURCE_PACKS = new CurseForgeRemoteAddonRepository(RemoteAddonRepository.Type.RESOURCE_PACK, SECTION_RESOURCE_PACK);
public static final CurseForgeRemoteAddonRepository WORLDS = new CurseForgeRemoteAddonRepository(RemoteAddonRepository.Type.WORLD, SECTION_WORLD);
public static final CurseForgeRemoteAddonRepository CUSTOMIZATIONS = new CurseForgeRemoteAddonRepository(RemoteAddonRepository.Type.CUSTOMIZATION, SECTION_CUSTOMIZATION);
public static final CurseForgeRemoteAddonRepository SHADERS = new CurseForgeRemoteAddonRepository(RemoteAddonRepository.Type.SHADER_PACK, SECTION_SHADER);
public static final CurseForgeRemoteAddonRepository COMMON = new CurseForgeRemoteAddonRepository();
public static final CurseForgeRemoteAddonRepository MODS = new CurseForgeRemoteAddonRepository(SECTION_MOD);
public static final CurseForgeRemoteAddonRepository MODPACKS = new CurseForgeRemoteAddonRepository(SECTION_MODPACK);
public static final CurseForgeRemoteAddonRepository RESOURCE_PACKS = new CurseForgeRemoteAddonRepository(SECTION_RESOURCE_PACK);
public static final CurseForgeRemoteAddonRepository WORLDS = new CurseForgeRemoteAddonRepository(SECTION_WORLD);
public static final CurseForgeRemoteAddonRepository CUSTOMIZATIONS = new CurseForgeRemoteAddonRepository(SECTION_CUSTOMIZATION);
public static final CurseForgeRemoteAddonRepository SHADERS = new CurseForgeRemoteAddonRepository(SECTION_SHADER);
@Nullable
private static RemoteAddon.Type toAddonType(int classId) {
return switch (classId) {
case SECTION_MODPACK -> RemoteAddon.Type.MODPACK;
case SECTION_RESOURCE_PACK -> RemoteAddon.Type.RESOURCE_PACK;
case SECTION_WORLD -> RemoteAddon.Type.WORLD;
case SECTION_CUSTOMIZATION -> RemoteAddon.Type.CUSTOMIZATION;
case SECTION_SHADER -> RemoteAddon.Type.SHADER_PACK;
case SECTION_MOD -> RemoteAddon.Type.MOD;
default -> null;
};
}
public record Pagination(int index, int pageSize, int resultCount, int totalCount) {
}
@@ -380,7 +454,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
return repo.getRemoteVersionsById(downloadProvider, Integer.toString(id));
}
public RemoteAddon toAddon(Type type) {
public RemoteAddon toAddon() {
String iconUrl = "";
if (logo != null) {
if (StringUtils.isNotBlank(logo.thumbnailUrl()))
@@ -398,7 +472,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
links.websiteUrl,
iconUrl,
this,
type
toAddonType(classId)
);
}
@@ -469,10 +543,10 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
return new RemoteAddon.Version(
this,
Integer.toString(modId),
Integer.toString(id()),
Integer.toString(modId()),
displayName(),
fileName(),
null,
fileDate(),
versionType,
new RemoteAddon.File(Collections.emptyMap(), downloadUrl(), fileName()),
@@ -480,7 +554,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
if (!RELATION_TYPE.containsKey(dependency.relationType())) {
throw new IllegalStateException("Broken datas.");
}
return RemoteAddon.Dependency.ofGeneral(RELATION_TYPE.get(dependency.relationType()), MODS, Integer.toString(dependency.modId()));
return RemoteAddon.Dependency.ofGeneral(RELATION_TYPE.get(dependency.relationType()), RemoteAddon.Source.CURSEFORGE, Integer.toString(dependency.modId()));
}).distinct().filter(Objects::nonNull).collect(Collectors.toList()),
gameVersions.stream().filter(GameVersionNumber::isKnown).toList(),
gameVersions.stream().flatMap(version -> {
@@ -24,10 +24,12 @@ import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
@@ -49,6 +51,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @see <a href="https://docs.modrinth.com/api">Modrinth API Doc</a>
public final class ModrinthRemoteAddonRepository implements RemoteAddonRepository {
public static final ModrinthRemoteAddonRepository COMMON = new ModrinthRemoteAddonRepository();
public static final ModrinthRemoteAddonRepository MODS = new ModrinthRemoteAddonRepository("mod");
public static final ModrinthRemoteAddonRepository MODPACKS = new ModrinthRemoteAddonRepository("modpack");
public static final ModrinthRemoteAddonRepository RESOURCE_PACKS = new ModrinthRemoteAddonRepository("resourcepack");
@@ -89,29 +92,54 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
false
);
@Nullable
private static RemoteAddon.Type toAddonType(String projectType) {
return switch (projectType) {
case "modpack" -> RemoteAddon.Type.MODPACK;
case "resourcepack" -> RemoteAddon.Type.RESOURCE_PACK;
case "shader" -> RemoteAddon.Type.SHADER_PACK;
case "mod" -> RemoteAddon.Type.MOD;
default -> null;
};
}
private static final Semaphore SEMAPHORE = new Semaphore(16);
private static final String PREFIX = "https://api.modrinth.com";
private final String projectType;
private static final String BASE = "https://modrinth.com";
private final RemoteAddonRepository.Type type;
private final @Nullable String projectType;
private ModrinthRemoteAddonRepository(String projectType) {
private final @Nullable RemoteAddon.Type type;
private ModrinthRemoteAddonRepository() {
this.projectType = null;
this.type = null;
}
private ModrinthRemoteAddonRepository(@NotNull String projectType) {
this.projectType = projectType;
this.type = switch (projectType) {
case "modpack" -> Type.MODPACK;
case "resourcepack" -> Type.RESOURCE_PACK;
case "shader" -> Type.SHADER_PACK;
default -> Type.MOD;
};
this.type = toAddonType(projectType);
if (type == null) throw new IllegalArgumentException("Unsupported Modrinth project type: " + projectType);
}
@Override
public Type getType() {
public RemoteAddon.Type getType() {
if (type == null) throw new UnsupportedOperationException();
return this.type;
}
@Override
public String getApiBaseUrl() {
return PREFIX;
}
@Override
public String getBaseUrl() {
return BASE;
}
private static String convertSortType(SortType sortType) {
return switch (sortType) {
case DATE_CREATED -> "newest";
@@ -131,6 +159,7 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteAddonRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (projectType == null) throw new UnsupportedOperationException();
SEMAPHORE.acquireUninterruptibly();
try {
List<List<String>> facets = new ArrayList<>();
@@ -284,8 +313,44 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
}
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
List<URI> candidates = downloadProvider.injectURLWithCandidates(PREFIX + "/v2/version/" + versionId);
IOException exception = null;
for (URI uri : candidates) {
try {
ProjectVersion version = HttpRequest.GET(uri.toString()).getJson(ProjectVersion.class);
return version.changelog();
} catch (IOException e) {
IOException wrapper = new IOException("Failed to get addon changelog: " + uri, e);
if (candidates.size() == 1) {
exception = wrapper;
} else {
if (exception == null) {
exception = new IOException("Failed to get addon changelog");
}
exception.addSuppressed(wrapper);
}
}
}
throw exception != null ? exception : new IOException("No candidates found");
} finally {
SEMAPHORE.release();
}
}
@Override
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) {
return "%s/project/%s/version/%s".formatted(BASE, version.projectId(), version.versionId()); // Modrinth will help us redirect
}
@Override
public Stream<RemoteAddonRepository.Category> getCategories() throws IOException {
if (projectType == null) throw new UnsupportedOperationException();
SEMAPHORE.acquireUninterruptibly();
try {
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class));
@@ -297,6 +362,7 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
}
}
@JsonSerializable
public record Category(String icon, String name, @SerializedName("project_type") String projectType) {
public Category() {
this("", "", "");
@@ -336,12 +402,6 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
}
public RemoteAddon toAddon() {
Type type = switch (projectType) {
case "modpack" -> Type.MODPACK;
case "resourcepack" -> Type.RESOURCE_PACK;
case "shader" -> Type.SHADER_PACK;
default -> Type.MOD;
};
return new RemoteAddon(
slug,
"",
@@ -351,7 +411,7 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
String.format("https://modrinth.com/%s/%s", projectType, id),
iconUrl,
this,
type
toAddonType(projectType)
);
}
}
@@ -401,10 +461,10 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
return Optional.of(new RemoteAddon.Version(
this,
id,
projectId,
name,
versionNumber,
changelog,
datePublished,
type,
files.get(0).toFile(),
@@ -417,7 +477,7 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
throw new IllegalStateException("Broken datas");
}
return RemoteAddon.Dependency.ofGeneral(DEPENDENCY_TYPE.get(dependency.dependencyType), MODS, dependency.projectId);
return RemoteAddon.Dependency.ofGeneral(DEPENDENCY_TYPE.get(dependency.dependencyType), RemoteAddon.Source.MODRINTH, dependency.projectId);
}).filter(Objects::nonNull).collect(Collectors.toList()),
gameVersions,
loaders.stream().flatMap(loader -> {
@@ -468,12 +528,6 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
}
public RemoteAddon toAddon() {
Type type = switch (projectType) {
case "modpack" -> Type.MODPACK;
case "resourcepack" -> Type.RESOURCE_PACK;
case "shader" -> Type.SHADER_PACK;
default -> Type.MOD;
};
return new RemoteAddon(
slug,
author,
@@ -483,7 +537,7 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
String.format("https://modrinth.com/%s/%s", projectType, projectId),
iconUrl,
this,
type
toAddonType(projectType)
);
}
}
@@ -18,8 +18,6 @@
package org.jackhuang.hmcl.addon.resourcepack;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.meta.PackMcMeta;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
@@ -78,8 +76,4 @@ final class ResourcePackFolder extends ResourcePackFile {
FileUtils.deleteDirectory(file);
}
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) {
return null;
}
}
@@ -91,7 +91,7 @@ final class ResourcePackZipFile extends ResourcePackFile {
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
RemoteAddonRepository repository = source.getRepoForType(RemoteAddonRepository.Type.RESOURCE_PACK);
RemoteAddonRepository repository = source.getRepoForType(RemoteAddon.Type.RESOURCE_PACK);
if (repository == null) return null;
Optional<RemoteAddon.Version> currentVersion = repository.getRemoteVersionByLocalFile(file);
if (currentVersion.isEmpty()) return null;
@@ -101,7 +101,7 @@ final class ResourcePackZipFile extends ResourcePackFile {
.sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed())
.toList();
if (remoteVersions.isEmpty()) return null;
return new AddonUpdate(this, currentVersion.get(), remoteVersions.get(0), false);
return new AddonUpdate(source, RemoteAddon.Type.RESOURCE_PACK, this, currentVersion.get(), remoteVersions.get(0), false);
}
}
@@ -60,9 +60,13 @@ public class DefaultGameBuilder extends GameBuilder {
hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion())));
}
return libraryTask.thenComposeAsync(dependencyManager.getGameRepository()::saveAsync).whenComplete(exception -> {
if (exception != null)
dependencyManager.getGameRepository().removeInstanceFromDisk(name);
var repository = dependencyManager.getGameRepository();
boolean isUpdate = repository.hasInstance(name);
return libraryTask.thenComposeAsync(repository::saveAsync).whenComplete(exception -> {
if (exception != null && !isUpdate) {
repository.removeInstanceFromDisk(name);
}
}).withStagesHints(hints);
}
@@ -95,7 +95,7 @@ public class MaintainTask extends Task<GameInstanceManifest> {
if (!manifest.isResolvedPreservingPatches())
throw new IllegalArgumentException("MaintainTask requires independent game version");
GameInstanceManifest newVersion = maintain(repository, manifest.resolve(repository));
return newVersion.withPatches(manifest.getPatches());
return manifest.patches() == null ? newVersion : newVersion.withPatches(manifest.getPatches());
}
private static GameInstanceManifest maintainGameWithLaunchWrapper(GameRepository repository, GameInstanceManifest manifest, boolean reorderTweakClass) {
@@ -35,6 +35,9 @@ import org.jackhuang.hmcl.util.io.Zipper;
import java.io.File;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -67,6 +70,8 @@ public class McbbsModpackExportTask extends Task<Void> {
});
}
/// Exports the selected game files and manifests to the target archive.
@Override
public void execute() throws Exception {
ArrayList<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
@@ -123,7 +128,9 @@ public class McbbsModpackExportTask extends Task<Void> {
McbbsModpackManifest.LaunchInfo launchInfo = new McbbsModpackManifest.LaunchInfo(info.getMinMemory(), info.getSupportedJavaVersions(), StringUtils.tokenize(info.getLaunchArguments()), StringUtils.tokenize(info.getJavaArguments()));
McbbsModpackManifest mcbbsManifest = new McbbsModpackManifest(McbbsModpackManifest.MANIFEST_TYPE, 2, info.getName(), info.getVersion(), info.getAuthor(), info.getDescription(), info.getFileApi() == null ? null : StringUtils.removeSuffix(info.getFileApi(), "/"), info.getUrl(), info.isForceUpdate(), origins, addons, libraries, files, settings, launchInfo);
zip.putTextFile(JsonUtils.GSON.toJson(mcbbsManifest), "mcbbs.packmeta");
try (Writer writer = new OutputStreamWriter(zip.putStream("mcbbs.packmeta"), StandardCharsets.UTF_8)) {
JsonUtils.GSON.toJson(mcbbsManifest, writer);
}
// CurseForge manifest
List<CurseManifestModLoader> modLoaders = new ArrayList<>();
@@ -65,7 +65,10 @@ public final class AsyncTaskExecutor extends TaskExecutor {
return success;
})
.exceptionally(e -> {
Lang.handleUncaughtException(resolveException(e));
Throwable resolved = resolveException(e);
if (resolved instanceof OutOfMemoryError)
taskListeners.forEach(it -> it.onStop(false, this));
Lang.handleUncaughtException(resolved);
return false;
});
return this;
@@ -190,6 +193,8 @@ public final class AsyncTaskExecutor extends TaskExecutor {
}
task.setState(Task.TaskState.FAILED);
} else if (resolved instanceof OutOfMemoryError e) {
handleOutOfMemoryError(task, e);
}
throw new CompletionException(resolved); // rethrow error
@@ -301,6 +306,8 @@ public final class AsyncTaskExecutor extends TaskExecutor {
taskListeners.forEach(it -> it.onFailed(task, e));
task.setState(Task.TaskState.FAILED);
} else if (resolved instanceof OutOfMemoryError e) {
handleOutOfMemoryError(task, e);
}
throw new CompletionException(resolved); // rethrow error
@@ -315,6 +322,16 @@ public final class AsyncTaskExecutor extends TaskExecutor {
}
}
/// Completes the failed task lifecycle while preserving the original error for the global handler.
private void handleOutOfMemoryError(Task<?> task, OutOfMemoryError error) {
Exception taskException = new Exception(error);
task.setException(taskException);
exception = taskException;
task.fireDoneEvent(this, true);
taskListeners.forEach(it -> it.onFailed(task, error));
task.setState(Task.TaskState.FAILED);
}
private void checkCancellation() {
if (isCancelled()) {
throw new CancellationException("Cancelled by user");
@@ -381,6 +381,16 @@ public final class Lang {
return () -> iterator;
}
public static <T> List<T> copyWithSize(List<T> list, int newSize, T defaultValue) {
if (list.size() == newSize) return new ArrayList<>(list);
if (list.size() > newSize) return new ArrayList<>(list.subList(0, newSize));
List<T> result = new ArrayList<>(newSize);
result.addAll(list);
for (int i = list.size(); i < newSize; i++)
result.add(defaultValue);
return result;
}
public static Throwable resolveException(Throwable e) {
if (e instanceof ExecutionException || e instanceof CompletionException)
return resolveException(e.getCause());
@@ -17,6 +17,18 @@
*/
package org.jackhuang.hmcl.util;
import org.commonmark.ext.autolink.AutolinkExtension;
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.ext.ins.InsExtension;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlNodeRendererContext;
import org.commonmark.renderer.html.HtmlRenderer;
import org.commonmark.renderer.html.HtmlWriter;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.Nullable;
@@ -581,11 +593,7 @@ public final class StringUtils {
}
public static String repeats(char ch, int repeat) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < repeat; i++) {
result.append(ch);
}
return result.toString();
return String.valueOf(ch).repeat(Math.max(0, repeat));
}
public static String truncate(String str, int limit) {
@@ -630,6 +638,60 @@ public final class StringUtils {
return JsonUtils.fromNonNullJson(json, JsonUtils.listTypeOf(String.class));
}
private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(List.of(
InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create()
)).build();
private static final HtmlRenderer HTML_RENDERER_RAW_INDENTED_BLOCK = HtmlRenderer.builder()
.extensions(List.of(InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create()))
.nodeRendererFactory(IndentedBlockRawRenderer::new)
.build();
private static final Parser MD_PARSER = Parser.builder().extensions(List.of(
AutolinkExtension.create(), InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create()
)).build();
public static @Nullable String convertToHtml(String str, boolean rawIndentedBlocks) {
if (str == null) return null;
if (isBlank(str)) return "";
if (rawIndentedBlocks)
return HTML_RENDERER_RAW_INDENTED_BLOCK.render(MD_PARSER.parse(str));
return HTML_RENDERER.render(MD_PARSER.parse(str));
}
private static final class IndentedBlockRawRenderer implements NodeRenderer {
private final HtmlWriter html;
public IndentedBlockRawRenderer(HtmlNodeRendererContext context) {
this.html = context.getWriter();
}
@Override
public Set<Class<? extends Node>> getNodeTypes() {
return Set.of(IndentedCodeBlock.class);
}
@Override
public void render(Node node) {
IndentedCodeBlock block = (IndentedCodeBlock) node;
html.line();
html.raw(block.getLiteral());
html.line();
}
}
public static OptionalInt toInt(String s) {
if (isBlank(s)) return OptionalInt.empty();
try {
return OptionalInt.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return OptionalInt.empty();
}
}
public static class LevCalculator {
private int[][] lev;
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.io;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import kala.encdet.EncodingDetector;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -38,6 +39,8 @@ import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -235,6 +238,22 @@ public final class FileUtils {
}
}
public static String readTextMaybeNativeEncoding(Path file) throws IOException {
byte[] bytes = Files.readAllBytes(file);
if (OperatingSystem.NATIVE_CHARSET == UTF_8)
return new String(bytes, UTF_8);
EncodingDetector detector = EncodingDetector.MODERN_WEB;
EncodingDetector.@Nullable Encoding bestEncoding = detector.detect(bytes).bestEncoding();
@Nullable Charset detectedCharset = bestEncoding != null ? bestEncoding.approximateCharset() : null;
if (detectedCharset != null && (detectedCharset == UTF_8 || detectedCharset == US_ASCII))
return new String(bytes, UTF_8);
else
return new String(bytes, OperatingSystem.NATIVE_CHARSET);
}
public static void deleteDirectory(Path directory) throws IOException {
if (!Files.exists(directory))
return;
@@ -17,11 +17,20 @@
*/
package org.jackhuang.hmcl.util.io;
import kala.encdet.EncodingDetector;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.GZIPInputStream;
import static java.nio.charset.StandardCharsets.*;
import static org.jackhuang.hmcl.util.platform.OperatingSystem.NATIVE_CHARSET;
/**
* This utility class consists of some util methods operating on InputStream/OutputStream.
@@ -35,6 +44,44 @@ public final class IOUtils {
public static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
public static BufferedReader newBufferedReaderMaybeNativeEncoding(Path file) throws IOException {
if (NATIVE_CHARSET == UTF_8)
return Files.newBufferedReader(file);
FileChannel channel = FileChannel.open(file);
try {
long oldPosition = channel.position();
long size = channel.size();
EncodingDetector detector = EncodingDetector.MODERN_WEB;
int bufferSize = (int) Math.max(Math.min(size - oldPosition, detector.maxBytes()), 8192L);
ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
//noinspection StatementWithEmptyBody
while (buffer.hasRemaining() && channel.read(buffer) > 0) {
// do nothing
}
buffer.flip();
Charset charset;
if (buffer.remaining() == 0) {
charset = UTF_8;
} else {
EncodingDetector.@Nullable Encoding encoding = detector.detect(buffer).bestEncoding();
Charset detectedCharset = encoding != null ? encoding.approximateCharset() : null;
charset = detectedCharset != null && (detectedCharset == UTF_8 || detectedCharset == US_ASCII)
? UTF_8
: NATIVE_CHARSET;
}
channel.position(oldPosition);
return new BufferedReader(new InputStreamReader(Channels.newInputStream(channel), charset));
} catch (Throwable e) {
closeQuietly(channel, e);
throw e;
}
}
public static byte[] readFully(InputStream stream) throws IOException {
try (stream) {
return stream.readAllBytes();
@@ -20,12 +20,15 @@ package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
@@ -47,6 +50,70 @@ public class TaskTest {
assertInstanceOf(Error.class, throwable.get(), "Error has not been thrown to uncaught exception handler");
}
/// Verifies that OOM completes the task lifecycle and still reaches the global handler.
@Test
public void testOutOfMemoryErrorLifecycle() {
OutOfMemoryError normalTaskError = new OutOfMemoryError("normal task");
assertOutOfMemoryErrorLifecycle(Task.runAsync(() -> {
throw normalTaskError;
}), normalTaskError);
OutOfMemoryError futureTaskError = new OutOfMemoryError("completable future task");
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(futureTaskError);
assertOutOfMemoryErrorLifecycle(Task.fromCompletableFuture(future), futureTaskError);
}
/// Checks the lifecycle events and recorded exceptions for one OOM task.
private void assertOutOfMemoryErrorLifecycle(Task<?> task, OutOfMemoryError error) {
AtomicInteger doneCount = new AtomicInteger();
AtomicReference<@Nullable Boolean> doneFailed = new AtomicReference<>();
AtomicInteger failedCount = new AtomicInteger();
AtomicReference<@Nullable Throwable> failedThrowable = new AtomicReference<>();
AtomicInteger stopCount = new AtomicInteger();
AtomicReference<@Nullable Boolean> stopSuccess = new AtomicReference<>();
AtomicReference<@Nullable Throwable> uncaught = new AtomicReference<>();
task.onDone().register(event -> {
doneCount.incrementAndGet();
doneFailed.set(event.isFailed());
});
TaskExecutor executor = task.executor(new TaskListener() {
@Override
public void onFailed(Task<?> failedTask, Throwable throwable) {
failedCount.incrementAndGet();
failedThrowable.set(throwable);
}
@Override
public void onStop(boolean success, TaskExecutor taskExecutor) {
stopCount.incrementAndGet();
stopSuccess.set(success);
}
});
@Nullable Thread.UncaughtExceptionHandler previousHandler = Thread.getDefaultUncaughtExceptionHandler();
try {
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> uncaught.set(throwable));
assertFalse(executor.test());
} finally {
Thread.setDefaultUncaughtExceptionHandler(previousHandler);
}
assertEquals(1, doneCount.get());
assertEquals(Boolean.TRUE, doneFailed.get());
assertEquals(1, failedCount.get());
assertSame(error, failedThrowable.get());
assertEquals(1, stopCount.get());
assertEquals(Boolean.FALSE, stopSuccess.get());
assertEquals(Task.TaskState.FAILED, task.getState());
assertNotNull(task.getException());
assertSame(error, task.getException().getCause());
assertNotNull(executor.getException());
assertSame(error, executor.getException().getCause());
assertSame(error, uncaught.get());
}
/**
*
*/
+6
View File
@@ -25,6 +25,7 @@ nayuki-qrcodegen = "1.8.0"
jwebp = "0.2.0"
weburl = "0.2.0"
uuid-tools = "0.2.0"
commonmark = "0.27.1"
# testing
junit = "6.0.1"
@@ -62,6 +63,11 @@ nayuki-qrcodegen = { module = "io.nayuki:qrcodegen", version.ref = "nayuki-qrcod
jwebp = { module = "org.glavo:webp", version.ref = "jwebp" }
weburl = { module = "org.glavo:weburl", version.ref = "weburl" }
uuid-tools = { module = "org.glavo:uuid-tools", version.ref = "uuid-tools" }
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
commonmark-underline = { module = "org.commonmark:commonmark-ext-ins", version.ref = "commonmark" }
commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
commonmark-table = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
# testing
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }