Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf21cf4a3c | ||
|
|
a0d1fa8f38 | ||
|
|
78b8d37891 | ||
|
|
0807c04572 | ||
|
|
8eec18450e | ||
|
|
15d4874366 | ||
|
|
c149401cf3 | ||
|
|
ff459a5bc4 | ||
|
|
5796dad18c | ||
|
|
0443ff7cd1 | ||
|
|
4c6fe865f5 | ||
|
|
1e54482f3b | ||
|
|
c329f0d2e8 | ||
|
|
af4d9feea5 | ||
|
|
af71c71663 | ||
|
|
4675442ecd | ||
|
|
c8929c80d9 | ||
|
|
81634b14df | ||
|
|
ccca396d91 |
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
|
||||
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;
|
||||
}
|
||||
|
||||
+9
-24
@@ -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,
|
||||
|
||||
+12
-1
@@ -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'/>"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+93
-19
@@ -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 -> {
|
||||
|
||||
+80
-26
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user