Compare commits

...
27 Commits
Author SHA1 Message Date
Glavo 7035767828 修复 pending 类型的游戏版本被错误识别为愚人节版的问题 (#4273) 2025-08-16 16:08:58 +08:00
Wulian233andGlavo 13a2777c87 为 NeoForge 下载添加正式版/快照分类 (#4268)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-16 15:58:31 +08:00
Glavo a4f9438d9e 移除 Pack200 支持 (#4267) 2025-08-16 15:45:12 +08:00
Glavo 4445021d66 Fix #4061: 在 Windows 7/8/8.1 上使用 llvmpipe-loader 加载 mesa (#4224) 2025-08-14 23:07:23 +08:00
Glavo f6a3b76a0b 暂时禁用 MCIM 加速 (#4265) 2025-08-14 20:20:46 +08:00
Glavo 42bb602ff5 优化游戏下载页 (#4264) 2025-08-14 20:14:40 +08:00
Glavo 4547448718 优化游戏窗口大小选择功能 (#4234) 2025-08-14 16:32:14 +08:00
Glavo 10bda7e6f4 转义 URI 中的中括号和大括号 (#4261) 2025-08-14 16:16:43 +08:00
Glavo f239140055 Update amdgpu.ids (#4262) 2025-08-14 15:54:30 +08:00
Glavo 6f4b557f63 支持最大化和全屏模式 (#4260) 2025-08-14 15:10:02 +08:00
Glavo 5118ce4e62 Fix #4256: 修复游戏下载页面可能崩溃的问题 (#4257) 2025-08-13 16:14:26 +08:00
ZkiteflyandGlavo a705b7cf95 Fix 3955: 启动时不应补全服务端自动更新整合包中已禁用的 mod (#3956)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-13 16:03:53 +08:00
GlavoandCopilot 0d5d1ae4e6 将 authlib-injector 嵌入启动器 (#4246)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 15:37:09 +08:00
Glavo b57547415b 在未安装游戏版本时支持一键下载启动游戏 (#4248) 2025-08-13 15:36:53 +08:00
Wulian233andGlavo b60d889bf7 为愚人节分类添加图标 (#4249)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-13 15:17:47 +08:00
Glavo 2f463c1eda 在 ./.hmcl/java 中搜索 Java (#4179) 2025-08-11 20:59:30 +08:00
Wulian233 d1e006ed29 修复版本列表排序错误的问题 (#4244) 2025-08-11 20:57:05 +08:00
GlavoandCopilot ba58905c9c 优化游戏下载页版本分类功能 (#4141)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-11 16:52:03 +08:00
Glavo de221b9dae 修复非 Windows 平台上游戏进程的 user.home 可能被设置为 null 的问题 (#4241) 2025-08-11 16:08:27 +08:00
Glavo 0913c5e710 支持通过 MCIM 加速模组搜索 (#4225) 2025-08-11 16:06:59 +08:00
ZkiteflyandGlavo 41fd38d8d1 导出日志时附带最近日志 (#4051)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-10 21:55:01 +08:00
ENC_EuphonyandGlavo 7ec880852f 修复部分版本 wiki 链接不正确的问题 (#4239)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-10 21:41:44 +08:00
Glavo 9213b7fb2e Fix 4232: 修复初始化 CacheRepository 导致启动器崩溃的问题 (#4235) 2025-08-10 21:41:10 +08:00
Glavo 80f1367d9a Fix #4233: 修复 RemoteMod::getIntegrityCheck 未选择正确的算法的问题 (#4238) 2025-08-10 15:39:13 +08:00
Glavo f521a041a9 Bump Shadow Plugin to 9.0.1 (#4220) 2025-08-10 15:23:44 +08:00
be9efbe6ee 修复版本 Wiki 映射 (#4211)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-10 15:06:31 +08:00
辞庐and3gf8jv4dv 8f3c716ca7 Fix #4127: 修改整合包下载页面描述 (#4200)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-10 15:05:57 +08:00
56 changed files with 1092 additions and 645 deletions

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ hs_err_pid*
/externalgames
NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
# gradle build
/build/
+9 -1
View File
@@ -37,6 +37,8 @@ val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
version = "$versionRoot.$buildNumber"
val embedResources by configurations.registering
dependencies {
implementation(project(":HMCLCore"))
implementation(project(":HMCLBoot"))
@@ -47,6 +49,8 @@ dependencies {
if (launcherExe == null) {
implementation(libs.hmclauncher)
}
embedResources(libs.authlib.injector)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -132,6 +136,10 @@ tasks.shadowJar {
exclude(project(":HMCLBoot"))
}
into("assets/") {
from(embedResources)
}
manifest {
attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
@@ -141,8 +149,8 @@ tasks.shadowJar {
"Microsoft-Auth-Id" to microsoftAuthId,
"Microsoft-Auth-Secret" to microsoftAuthSecret,
"CurseForge-Api-Key" to curseForgeApiKey,
"Authlib-Injector-Version" to libs.authlib.injector.get().version!!,
"Build-Channel" to versionType,
"Class-Path" to "pack200.jar",
"Add-Opens" to listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -160,8 +160,10 @@ public final class LauncherHelper {
}),
Task.composeAsync(() -> {
Renderer renderer = setting.getRenderer();
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Library lib = NativePatcher.getMesaLoader(java);
if (renderer != Renderer.DEFAULT
&& OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& OperatingSystem.WINDOWS_VERSION != null) {
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.WINDOWS_VERSION);
if (lib == null)
return null;
File file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
@@ -41,9 +42,9 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public SearchResult search(String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
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)) {
return getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
@@ -65,7 +66,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
@@ -56,6 +56,7 @@ public final class JavaManager {
}
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
public static String getMojangJavaPlatform(Platform platform) {
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
@@ -571,6 +572,10 @@ public final class JavaManager {
for (JavaRuntime java : REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
}
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, boolean verify) {
@@ -36,6 +36,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import javax.net.ssl.SSLException;
@@ -68,18 +69,6 @@ public final class Accounts {
private static final AuthlibInjectorArtifactProvider AUTHLIB_INJECTOR_DOWNLOADER = createAuthlibInjectorArtifactProvider();
private static void triggerAuthlibInjectorUpdateCheck() {
if (AUTHLIB_INJECTOR_DOWNLOADER instanceof AuthlibInjectorDownloader) {
Schedulers.io().execute(() -> {
try {
((AuthlibInjectorDownloader) AUTHLIB_INJECTOR_DOWNLOADER).checkUpdate();
} catch (IOException e) {
LOG.warning("Failed to check update for authlib-injector", e);
}
});
}
}
public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory();
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
@@ -337,8 +326,6 @@ public final class Accounts {
});
}
triggerAuthlibInjectorUpdateCheck();
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
continue;
@@ -371,24 +358,18 @@ public final class Accounts {
// ==== authlib-injector ====
private static AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProvider() {
String authlibinjectorLocation = System.getProperty("hmcl.authlibinjector.location");
if (authlibinjectorLocation == null) {
return new AuthlibInjectorDownloader(
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve("authlib-injector.jar"),
DownloadProviders::getDownloadProvider) {
@Override
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
Optional<AuthlibInjectorArtifactInfo> local = super.getArtifactInfoImmediately();
if (local.isPresent()) {
return local;
}
// search authlib-injector.jar in current directory, it's used as a fallback
return parseArtifact(Metadata.CURRENT_DIRECTORY.resolve("authlib-injector.jar"));
}
};
} else {
if (authlibinjectorLocation != null) {
LOG.info("Using specified authlib-injector: " + authlibinjectorLocation);
return new SimpleAuthlibInjectorArtifactProvider(Paths.get(authlibinjectorLocation));
}
String authlibInjectorVersion = JarUtils.getManifestAttribute("Authlib-Injector-Version", null);
if (authlibInjectorVersion == null)
throw new AssertionError("Missing Authlib-Injector-Version");
String authlibInjectorFileName = "authlib-injector-" + authlibInjectorVersion + ".jar";
return new AuthlibInjectorExtractor(Accounts.class.getResource("/assets/" + authlibInjectorFileName),
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve(authlibInjectorFileName));
}
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
@@ -29,6 +29,7 @@ public enum VersionIconType {
COMMAND("/assets/img/command.png"),
OPTIFINE("/assets/img/optifine.png"),
CRAFT_TABLE("/assets/img/craft_table.png"),
APRIL_FOOLS("/assets/img/april_fools.png"),
FABRIC("/assets/img/fabric.png"),
FORGE("/assets/img/forge.png"),
NEO_FORGE("/assets/img/neoforge.png"),
@@ -216,7 +216,9 @@ public final class Controllers {
if (targetProperty != null
&& Controllers.stage != null
&& !Controllers.stage.isIconified()) {
&& !Controllers.stage.isIconified()
&& !Controllers.stage.isFullScreen()
&& !Controllers.stage.isMaximized()) {
targetProperty.set(sourceProperty.get());
}
};
@@ -25,10 +25,7 @@ import javafx.beans.Observable;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.WeakListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.beans.value.*;
import javafx.collections.ObservableMap;
import javafx.event.Event;
@@ -832,10 +829,144 @@ public final class FXUtils {
property.addListener(binding);
}
public static void unbindColorPicker(ColorPicker colorPicker, Property<Paint> property) {
PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property);
colorPicker.valueProperty().removeListener(binding);
property.removeListener(binding);
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<String>> comboBoxRef;
private final WeakReference<IntegerProperty> widthPropertyRef;
private final WeakReference<IntegerProperty> heightPropertyRef;
private final int hashCode;
private boolean updating = false;
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
IntegerProperty widthProperty,
IntegerProperty heightProperty) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.widthPropertyRef = new WeakReference<>(widthProperty);
this.heightPropertyRef = new WeakReference<>(heightProperty);
this.hashCode = System.identityHashCode(comboBox)
^ System.identityHashCode(widthProperty)
^ System.identityHashCode(heightProperty);
}
@Override
public void invalidated(Observable observable) {
if (!updating) {
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null) {
if (comboBox != null) {
comboBox.focusedProperty().removeListener(this);
comboBox.sceneProperty().removeListener(this);
}
if (widthProperty != null)
widthProperty.removeListener(this);
if (heightProperty != null)
heightProperty.removeListener(this);
} else {
updating = true;
try {
int width = widthProperty.get();
int height = heightProperty.get();
if (observable instanceof ReadOnlyProperty<?>
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
String value = comboBox.valueProperty().get();
if (value == null)
value = "";
int idx = value.indexOf('x');
if (idx < 0)
idx = value.indexOf('*');
if (idx < 0) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
String widthStr = value.substring(0, idx).trim();
String heightStr = value.substring(idx + 1).trim();
int newWidth;
int newHeight;
try {
newWidth = Integer.parseInt(widthStr);
newHeight = Integer.parseInt(heightStr);
} catch (NumberFormatException e) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
widthProperty.set(newWidth);
heightProperty.set(newHeight);
} else {
comboBox.setValue(width + "x" + height);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return this.comboBoxRef.get() == null
|| this.widthPropertyRef.get() == null
|| this.heightPropertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof WindowsSizeBidirectionalBinding))
return false;
var that = (WindowsSizeBidirectionalBinding) obj;
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
var thatComboBox = that.comboBoxRef.get();
var thatWidthProperty = that.widthPropertyRef.get();
var thatHeightProperty = that.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
return false;
}
return comboBox == thatComboBox
&& widthProperty == thatWidthProperty
&& heightProperty == thatHeightProperty;
}
}
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().addListener(binding);
comboBox.sceneProperty().addListener(binding);
widthProperty.addListener(binding);
heightProperty.addListener(binding);
}
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().removeListener(binding);
comboBox.sceneProperty().removeListener(binding);
widthProperty.removeListener(binding);
heightProperty.removeListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
@@ -34,10 +34,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.DragEvent;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
@@ -167,6 +164,14 @@ public class DecoratorController {
// press ESC to go back
onEscPressed(navigator, this::back);
// press F11 to toggle full screen
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.F11) {
stage.setFullScreen(!stage.isFullScreen());
e.consume();
}
});
try {
// For JavaFX 12+
MouseButton button = MouseButton.valueOf("BACK");
@@ -18,8 +18,11 @@
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -29,6 +32,7 @@ import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
@@ -50,6 +54,8 @@ public class DecoratorSkin extends SkinBase<Decorator> {
private final Stage primaryStage;
private final TransitionPane navBarPane;
private final InvalidationListener onWindowsStatusChange;
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
/**
@@ -80,9 +86,25 @@ public class DecoratorSkin extends SkinBase<Decorator> {
skinnable.getSnackbar().registerSnackbarContainer(parent);
root.addEventFilter(MouseEvent.MOUSE_RELEASED, this::onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
onWindowsStatusChange = observable -> {
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
} else {
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
};
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
onWindowsStatusChange.invalidated(null);
shadowContainer.getChildren().setAll(parent);
root.getChildren().setAll(shadowContainer);
@@ -155,6 +177,12 @@ public class DecoratorSkin extends SkinBase<Decorator> {
BorderPane titleBar = new BorderPane();
titleContainer.getChildren().add(titleBar);
titleBar.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
primaryStage.setMaximized(!primaryStage.isMaximized());
event.consume();
}
});
Rectangle buttonsContainerPlaceHolder = new Rectangle();
{
@@ -17,24 +17,17 @@
*/
package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSpinner;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import com.jfoenix.controls.*;
import javafx.beans.InvalidationListener;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.VersionList;
@@ -55,291 +48,67 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.wrap;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class VersionsPage extends BorderPane implements WizardPage, Refreshable {
public final class VersionsPage extends Control implements WizardPage, Refreshable {
private final String gameVersion;
private final String libraryId;
private final String title;
private final Navigation navigation;
private final JFXListView<RemoteVersion> list;
private final JFXSpinner spinner;
private final StackPane failedPane;
private final StackPane emptyPane;
private final TransitionPane root;
private final JFXCheckBox chkRelease;
private final JFXCheckBox chkSnapshot;
private final JFXCheckBox chkOld;
private final ComponentList centrePane;
private final StackPane center;
private final VersionList<?> versionList;
private Task<?> executor;
private final Runnable callback;
private final HBox searchBar;
private final StringProperty queryString = new SimpleStringProperty();
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, String libraryId, Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.navigation = navigation;
HintPane hintPane = new HintPane();
hintPane.setText(i18n("sponsor.bmclapi"));
hintPane.getStyleClass().add("sponsor-pane");
FXUtils.onClicked(hintPane, this::onSponsor);
BorderPane.setMargin(hintPane, new Insets(10, 10, 0, 10));
this.setTop(hintPane);
root = new TransitionPane();
BorderPane toolbarPane = new BorderPane();
JFXButton btnRefresh;
{
spinner = new JFXSpinner();
center = new StackPane();
center.setStyle("-fx-padding: 10;");
{
centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
HBox checkPane = new HBox();
checkPane.setSpacing(10);
{
chkRelease = new JFXCheckBox(i18n("version.game.releases"));
chkRelease.setSelected(true);
HBox.setMargin(chkRelease, new Insets(10, 0, 10, 0));
chkSnapshot = new JFXCheckBox(i18n("version.game.snapshots"));
HBox.setMargin(chkSnapshot, new Insets(10, 0, 10, 0));
chkOld = new JFXCheckBox(i18n("version.game.old"));
HBox.setMargin(chkOld, new Insets(10, 0, 10, 0));
checkPane.getChildren().setAll(chkRelease, chkSnapshot, chkOld);
}
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
TransitionPane rightToolbarPane = new TransitionPane();
{
HBox refreshPane = new HBox();
refreshPane.setAlignment(Pos.CENTER_RIGHT);
btnRefresh = new JFXButton(i18n("button.refresh"));
btnRefresh.getStyleClass().add("jfx-tool-bar-button");
btnRefresh.setOnAction(e -> onRefresh());
JFXButton btnSearch = new JFXButton(i18n("search"));
btnSearch.getStyleClass().add("jfx-tool-bar-button");
btnSearch.setGraphic(wrap(SVG.SEARCH.createIcon(Theme.blackFill(), -1)));
searchBar = new HBox();
{
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 0));
JFXTextField searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
JFXButton closeSearchBar = new JFXButton();
closeSearchBar.getStyleClass().add("jfx-tool-bar-button");
closeSearchBar.setGraphic(wrap(SVG.CLOSE.createIcon(Theme.blackFill(), -1)));
closeSearchBar.setOnAction(e -> {
searchField.clear();
rightToolbarPane.setContent(refreshPane, ContainerAnimations.FADE);
});
onEscPressed(searchField, closeSearchBar::fire);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> queryString.set(searchField.getText()));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
});
searchBar.getChildren().setAll(searchField, closeSearchBar);
btnSearch.setOnAction(e -> {
rightToolbarPane.setContent(searchBar, ContainerAnimations.FADE);
searchField.requestFocus();
});
}
refreshPane.getChildren().setAll(new HBox(btnSearch, btnRefresh));
rightToolbarPane.setContent(refreshPane, ContainerAnimations.NONE);
}
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
toolbarPane.setLeft(checkPane);
toolbarPane.setRight(rightToolbarPane);
centrePane.getContent().setAll(toolbarPane, list);
}
center.getChildren().setAll(centrePane);
}
failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, this::onRefresh);
failedPane.getChildren().setAll(label);
}
emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, this::onBack);
emptyPane.getChildren().setAll(label);
}
}
this.setCenter(root);
versionList = downloadProvider.getVersionListById(libraryId);
boolean hasType = versionList.hasType();
chkRelease.setManaged(hasType);
chkRelease.setVisible(hasType);
chkSnapshot.setManaged(hasType);
chkSnapshot.setVisible(hasType);
chkOld.setManaged(hasType);
chkOld.setVisible(hasType);
if (hasType) {
centrePane.getContent().setAll(toolbarPane, list);
} else {
centrePane.getContent().setAll(list);
}
ComponentList.setVgrow(list, Priority.ALWAYS);
InvalidationListener listener = o -> {
List<RemoteVersion> versions = loadVersions();
String query = queryString.get();
if (!StringUtils.isBlank(query)) {
Predicate<RemoteVersion> predicate;
if (query.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(query.substring("regex:".length()));
predicate = it -> pattern.matcher(it.getSelfVersion()).find();
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
} else {
String lowerQueryString = query.toLowerCase(Locale.ROOT);
predicate = it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString);
}
versions = versions.stream().filter(predicate).collect(Collectors.toList());
}
list.getItems().setAll(versions);
};
chkRelease.selectedProperty().addListener(listener);
chkSnapshot.selectedProperty().addListener(listener);
chkOld.selectedProperty().addListener(listener);
queryString.addListener(listener);
btnRefresh.setGraphic(wrap(SVG.REFRESH.createIcon(Theme.blackFill(), -1)));
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
navigation.getSettings().put(libraryId, list.getSelectionModel().getSelectedItem());
callback.run();
});
this.versionList = downloadProvider.getVersionListById(libraryId);
this.callback = callback;
refresh();
}
private List<RemoteVersion> loadVersions() {
return versionList.getVersions(gameVersion).stream()
.filter(it -> {
switch (it.getVersionType()) {
case RELEASE:
return chkRelease.isSelected();
case PENDING:
case SNAPSHOT:
return chkSnapshot.isSelected();
case OLD:
return chkOld.isSelected();
default:
return true;
}
})
.sorted().collect(Collectors.toList());
@Override
protected Skin<?> createDefaultSkin() {
return new VersionsPageSkin(this);
}
@Override
public void refresh() {
VersionList<?> currentVersionList = versionList;
root.setContent(spinner, ContainerAnimations.FADE);
executor = currentVersionList.refreshAsync(gameVersion).whenComplete(Schedulers.defaultScheduler(), (result, exception) -> {
if (exception == null) {
List<RemoteVersion> items = loadVersions();
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
if (currentVersionList.getVersions(gameVersion).isEmpty()) {
root.setContent(emptyPane, ContainerAnimations.FADE);
status.set(Status.LOADING);
Task<?> task = versionList.refreshAsync(gameVersion)
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
.whenComplete(Schedulers.javafx(), (items, exception) -> {
if (exception == null) {
versions.setAll(items);
status.set(Status.SUCCESS);
} else {
if (items.isEmpty()) {
chkRelease.setSelected(true);
chkSnapshot.setSelected(true);
chkOld.setSelected(true);
} else {
list.getItems().setAll(items);
}
root.setContent(center, ContainerAnimations.FADE);
LOG.warning("Failed to fetch versions list", exception);
status.set(Status.FAILED);
}
});
} else {
LOG.warning("Failed to fetch versions list", exception);
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
root.setContent(failedPane, ContainerAnimations.FADE);
});
}
// https://github.com/HMCL-dev/HMCL/issues/938
System.gc();
});
executor.start();
task.start();
}
@Override
@@ -350,9 +119,6 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
@Override
public void cleanup(Map<String, Object> settings) {
settings.remove(libraryId);
// fixme
// if (executor != null)
// executor.cancel(true);
}
private void onRefresh() {
@@ -367,10 +133,24 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
FXUtils.openLink("https://bmclapidoc.bangbang93.com");
}
private enum Status {
LOADING,
FAILED,
SUCCESS,
}
private enum VersionTypeFilter {
ALL,
RELEASE,
SNAPSHOTS,
APRIL_FOOLS,
OLD
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
final IconedTwoLineListItem content = new IconedTwoLineListItem();
final RipplerContainer ripplerContainer = new RipplerContainer(content);
final StackPane pane = new StackPane();
private final IconedTwoLineListItem content = new IconedTwoLineListItem();
private final RipplerContainer ripplerContainer = new RipplerContainer(content);
private final StackPane pane = new StackPane();
private final Holder<RemoteVersionListCell> lastCell;
@@ -409,22 +189,30 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
}
if (remoteVersion instanceof GameRemoteVersion) {
switch (remoteVersion.getVersionType()) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
String wikiSuffix = getWikiUrlSuffix(remoteVersion.getGameVersion());
switch (versionType) {
case RELEASE:
content.getTags().setAll(i18n("version.game.release"));
content.setImage(VersionIconType.GRASS.getIcon());
content.setExternalLink(i18n("wiki.version.game.release", remoteVersion.getGameVersion()));
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
break;
case PENDING:
case SNAPSHOT:
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
content.setExternalLink(i18n("wiki.version.game.snapshot", remoteVersion.getGameVersion()));
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isSpecial()) {
content.getTags().setAll(i18n("version.game.april_fools"));
content.setImage(VersionIconType.APRIL_FOOLS.getIcon());
} else {
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
}
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
break;
default:
content.getTags().setAll(i18n("version.game.old"));
content.setImage(VersionIconType.CRAFT_TABLE.getIcon());
content.setExternalLink(null);
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
break;
}
} else {
@@ -452,5 +240,285 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
content.setExternalLink(null);
}
}
private String getWikiUrlSuffix(String gameVersion) {
String id = gameVersion.toLowerCase(Locale.ROOT);
switch (id) {
case "0.30-1":
case "0.30-2":
case "c0.30_01c":
return i18n("wiki.version.game.search", "Classic_0.30");
case "in-20100206-2103":
return i18n("wiki.version.game.search", "Indev_20100206");
case "inf-20100630-1":
return i18n("wiki.version.game.search", "Infdev_20100630");
case "inf-20100630-2":
return i18n("wiki.version.game.search", "Alpha_v1.0.0");
case "1.19_deep_dark_experimental_snapshot-1":
return "1.19-exp1";
case "in-20100130":
return i18n("wiki.version.game.search", "Indev_0.31_20100130");
case "b1.6-tb3":
return i18n("wiki.version.game.search", "Beta_1.6_Test_Build_3");
case "1.14_combat-212796":
return i18n("wiki.version.game.search", "1.14.3_-_Combat_Test");
case "1.14_combat-0":
return i18n("wiki.version.game.search", "Combat_Test_2");
case "1.14_combat-3":
return i18n("wiki.version.game.search", "Combat_Test_3");
case "1_15_combat-1":
return i18n("wiki.version.game.search", "Combat_Test_4");
case "1_15_combat-6":
return i18n("wiki.version.game.search", "Combat_Test_5");
case "1_16_combat-0":
return i18n("wiki.version.game.search", "Combat_Test_6");
case "1_16_combat-1":
return i18n("wiki.version.game.search", "Combat_Test_7");
case "1_16_combat-2":
return i18n("wiki.version.game.search", "Combat_Test_7b");
case "1_16_combat-3":
return i18n("wiki.version.game.search", "Combat_Test_7c");
case "1_16_combat-4":
return i18n("wiki.version.game.search", "Combat_Test_8");
case "1_16_combat-5":
return i18n("wiki.version.game.search", "Combat_Test_8b");
case "1_16_combat-6":
return i18n("wiki.version.game.search", "Combat_Test_8c");
}
if (id.startsWith("1.0.0-rc2")) return "RC2";
if (id.startsWith("2.0")) return i18n("wiki.version.game.search", "2.0");
if (id.startsWith("b1.8-pre1")) return "Beta_1.8-pre1";
if (id.startsWith("b1.1-")) return i18n("wiki.version.game.search", "Beta_1.1");
if (id.startsWith("a1.1.0")) return "Alpha_v1.1.0";
if (id.startsWith("a1.0.14")) return "Alpha_v1.0.14";
if (id.startsWith("a1.0.13_01")) return "Alpha_v1.0.13_01";
if (id.startsWith("in-20100214")) return i18n("wiki.version.game.search", "Indev_20100214");
if (id.contains("experimental-snapshot")) {
return id.replace("_experimental-snapshot-", "-exp");
}
if (id.startsWith("inf-")) return id.replace("inf-", "Infdev_");
if (id.startsWith("in-")) return id.replace("in-", "Indev_");
if (id.startsWith("rd-")) return "pre-Classic_" + id;
if (id.startsWith("b")) return id.replace("b", "Beta_");
if (id.startsWith("a")) return id.replace("a", "Alpha_v");
if (id.startsWith("c")) return id.replace("c", "Classic_").replace("st", "SURVIVAL_TEST");
return i18n("wiki.version.game.search", id);
}
}
private static final class VersionsPageSkin extends SkinBase<VersionsPage> {
private final JFXListView<RemoteVersion> list;
private final TransitionPane transitionPane;
private final JFXSpinner spinner;
private final JFXTextField nameField;
private final JFXComboBox<VersionTypeFilter> categoryField = new JFXComboBox<>();
VersionsPageSkin(VersionsPage control) {
super(control);
BorderPane root = new BorderPane();
GridPane searchPane = new GridPane();
root.setTop(searchPane);
searchPane.getStyleClass().addAll("card");
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
ColumnConstraints nameColumn = new ColumnConstraints();
nameColumn.setMinWidth(USE_PREF_SIZE);
ColumnConstraints column1 = new ColumnConstraints();
column1.setHgrow(Priority.ALWAYS);
ColumnConstraints column2 = new ColumnConstraints();
column2.setMaxWidth(150);
ColumnConstraints column3 = new ColumnConstraints();
if (control.versionList.hasType())
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
else
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
searchPane.setHgap(16);
searchPane.setVgap(10);
{
int rowIndex = 0;
{
nameField = new JFXTextField();
nameField.setPromptText(i18n("version.search.prompt"));
nameField.textProperty().addListener(o -> updateList());
if ("game".equals(control.libraryId)) {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS,
VersionTypeFilter.APRIL_FOOLS,
VersionTypeFilter.OLD
);
categoryField.getSelectionModel().select(VersionTypeFilter.RELEASE);
} else {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS
);
categoryField.getSelectionModel().select(VersionTypeFilter.ALL);
}
categoryField.setConverter(stringConverter(type -> i18n("version.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.getSelectionModel().selectedItemProperty().addListener(o -> updateList());
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
refreshButton.setOnAction(event -> control.onRefresh());
if (control.versionList.hasType()) {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("version.game.type")), categoryField,
refreshButton
);
} else {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
refreshButton
);
}
}
// {
// HBox actionsBox = new HBox(8);
// GridPane.setColumnSpan(actionsBox, 4);
// actionsBox.setAlignment(Pos.CENTER_RIGHT);
//
// JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
// refreshButton.setOnAction(event -> control.onRefresh());
//
// actionsBox.getChildren().setAll(refreshButton);
//
// searchPane.addRow(rowIndex++, actionsBox);
// }
}
{
SpinnerPane spinnerPane = new SpinnerPane();
root.setCenter(spinnerPane);
transitionPane = new TransitionPane();
spinner = new JFXSpinner();
StackPane centerWrapper = new StackPane();
centerWrapper.setStyle("-fx-padding: 10;");
{
ComponentList centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
control.versions.addListener((InvalidationListener) o -> updateList());
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control.libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
control.navigation.getSettings().put(control.libraryId, list.getSelectionModel().getSelectedItem());
control.callback.run();
});
ComponentList.setVgrow(list, Priority.ALWAYS);
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
centrePane.getContent().setAll(list);
}
centerWrapper.getChildren().setAll(centrePane);
}
StackPane failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, control::onRefresh);
failedPane.getChildren().setAll(label);
}
StackPane emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, control::onBack);
emptyPane.getChildren().setAll(label);
}
FXUtils.onChangeAndOperate(control.status, status -> {
if (status == Status.LOADING)
transitionPane.setContent(spinner, ContainerAnimations.FADE);
else if (status == Status.SUCCESS)
transitionPane.setContent(centerWrapper, ContainerAnimations.FADE);
else // if (status == Status.FAILED)
transitionPane.setContent(failedPane, ContainerAnimations.FADE);
});
root.setCenter(transitionPane);
}
this.getChildren().setAll(root);
}
private void updateList() {
Stream<RemoteVersion> versions = getSkinnable().versions.stream();
VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem();
if (filter != null)
versions = versions.filter(it -> {
RemoteVersion.Type versionType = it.getVersionType();
switch (filter) {
case RELEASE:
return versionType == RemoteVersion.Type.RELEASE;
case SNAPSHOTS:
return versionType == RemoteVersion.Type.SNAPSHOT
|| versionType == RemoteVersion.Type.PENDING;
case APRIL_FOOLS:
return versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isSpecial();
case OLD:
return versionType == RemoteVersion.Type.OLD;
case ALL:
default:
return true;
}
});
String nameQuery = nameField.getText();
if (!StringUtils.isBlank(nameQuery)) {
if (nameQuery.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(nameQuery.substring("regex:".length()));
versions = versions.filter(it -> pattern.matcher(it.getSelfVersion()).find());
} catch (Throwable e) {
LOG.warning("Illegal regular expression: " + nameQuery, e);
}
} else {
String lowerQueryString = nameQuery.toLowerCase(Locale.ROOT);
versions = versions.filter(it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString));
}
}
//noinspection DataFlowIssue
list.getItems().setAll(versions.collect(Collectors.toList()));
list.scrollTo(0);
}
}
}
@@ -31,6 +31,7 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
@@ -42,10 +43,16 @@ import javafx.scene.shape.Rectangle;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.VersionList;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -61,16 +68,24 @@ import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
import java.util.stream.IntStream;
import static org.jackhuang.hmcl.download.RemoteVersion.Type.RELEASE;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.SINE;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MainPage extends StackPane implements DecoratorPage {
private static final String ANNOUNCEMENT = "announcement";
@@ -211,7 +226,6 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchButton.setPrefWidth(230);
launchButton.setPrefHeight(55);
//launchButton.setButtonType(JFXButton.ButtonType.RAISED);
launchButton.setOnAction(e -> launch());
launchButton.setDefaultButton(true);
launchButton.setClip(new Rectangle(-100, -100, 310, 200));
{
@@ -219,18 +233,34 @@ public final class MainPage extends StackPane implements DecoratorPage {
graphic.setAlignment(Pos.CENTER);
graphic.setTranslateX(-7);
graphic.setMaxWidth(200);
Label launchLabel = new Label(i18n("version.launch"));
Label launchLabel = new Label();
launchLabel.setStyle("-fx-font-size: 16px;");
Label currentLabel = new Label();
currentLabel.setStyle("-fx-font-size: 12px;");
currentLabel.textProperty().bind(Bindings.createStringBinding(() -> {
if (getCurrentGame() == null) {
return i18n("version.empty");
} else {
return getCurrentGame();
FXUtils.onChangeAndOperate(currentGameProperty(), new Consumer<>() {
private Tooltip tooltip;
@Override
public void accept(String currentGame) {
if (currentGame == null) {
launchLabel.setText(i18n("version.launch.empty"));
currentLabel.setText(null);
graphic.getChildren().setAll(launchLabel);
launchButton.setOnAction(e -> MainPage.this.launchNoGame());
if (tooltip == null)
tooltip = new Tooltip(i18n("version.launch.empty.tooltip"));
FXUtils.installFastTooltip(launchButton, tooltip);
} else {
launchLabel.setText(i18n("version.launch"));
currentLabel.setText(currentGame);
graphic.getChildren().setAll(launchLabel, currentLabel);
launchButton.setOnAction(e -> MainPage.this.launch());
if (tooltip != null)
Tooltip.uninstall(launchButton, tooltip);
}
}
}, currentGameProperty()));
graphic.getChildren().setAll(launchLabel, currentLabel);
});
launchButton.setGraphic(graphic);
}
@@ -319,7 +349,46 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
private void launch() {
Versions.launch(Profiles.getSelectedProfile());
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), null);
}
private void launchNoGame() {
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
VersionList<?> versionList = downloadProvider.getVersionListById("game");
Holder<String> gameVersionHolder = new Holder<>();
Task<?> task = versionList.refreshAsync("")
.thenSupplyAsync(() -> versionList.getVersions("").stream()
.filter(it -> it.getVersionType() == RELEASE)
.sorted()
.findFirst()
.orElseThrow(() -> new IOException("No versions found")))
.thenComposeAsync(version -> {
Profile profile = Profiles.getSelectedProfile();
DefaultDependencyManager dependency = profile.getDependency();
String gameVersion = gameVersionHolder.value = version.getGameVersion();
return dependency.gameBuilder()
.name(gameVersion)
.gameVersion(gameVersion)
.buildAsync();
})
.whenComplete(any -> profile.getRepository().refreshVersions())
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
profile.setSelectedVersion(gameVersionHolder.value);
launch();
} else if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else {
LOG.warning("Failed to install game", exception);
Controllers.dialog(StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.WARNING);
}
});
Controllers.taskDialog(task, i18n("version.launch.empty.installing"), TaskCancellationAction.NORMAL);
}
private void onMenu() {
@@ -23,6 +23,7 @@ import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.scene.control.ToggleGroup;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.Settings;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -40,10 +41,12 @@ import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.Lang.thread;
@@ -123,22 +126,46 @@ public final class SettingsPage extends SettingsView {
@Override
protected void onExportLogs() {
// We cannot determine which file is JUL using.
// So we write all the logs to a new file.
thread(() -> {
Path logFile = Paths.get("hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath();
String nameBase = "hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"));
List<Path> recentLogFiles = LOG.findRecentLogFiles(5);
LOG.info("Exporting logs to " + logFile);
try (OutputStream output = Files.newOutputStream(logFile)) {
LOG.exportLogs(output);
Path outputFile;
try {
if (recentLogFiles.isEmpty()) {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".log");
LOG.info("Exporting latest logs to " + outputFile);
try (OutputStream output = Files.newOutputStream(outputFile)) {
LOG.exportLogs(output);
}
} else {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".zip");
LOG.info("Exporting latest logs to " + outputFile);
try (var os = Files.newOutputStream(outputFile);
var zos = new ZipOutputStream(os)) {
for (Path path : recentLogFiles) {
String zipEntryName = path.getFileName().toString();
zos.putNextEntry(new ZipEntry(zipEntryName));
Files.copy(path, zos);
zos.closeEntry();
}
zos.putNextEntry(new ZipEntry("latest.log"));
LOG.exportLogs(zos);
zos.closeEntry();
}
}
} catch (IOException e) {
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(e), null, MessageType.ERROR));
LOG.warning("Failed to export logs", e);
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(e), null, MessageType.ERROR));
return;
}
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", logFile)));
FXUtils.showFileInExplorer(logFile);
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", outputFile)));
FXUtils.showFileInExplorer(outputFile);
});
}
@@ -39,10 +39,12 @@ import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -90,6 +92,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private int searchID = 0;
protected RemoteModRepository repository;
private final DownloadProvider downloadProvider;
private Runnable retrySearch;
@@ -101,6 +104,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
this.repository = repository;
this.callback = callback;
this.versionSelection = versionSelection;
this.downloadProvider = DownloadProviders.getDownloadProvider();
}
public ObservableList<Node> getActions() {
@@ -171,7 +175,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
: "";
}
}).thenApplyAsync(
gameVersion -> repository.search(gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteModRepository.SortOrder.DESC)
gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteModRepository.SortOrder.DESC)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (searchID != currentSearchID) {
return;
@@ -231,6 +235,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteMod> listView = new JFXListView<>();
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
@@ -60,6 +60,7 @@ public class VersionIconDialog extends DialogPane {
createIcon(VersionIconType.CHEST),
createIcon(VersionIconType.CHICKEN),
createIcon(VersionIconType.COMMAND),
createIcon(VersionIconType.APRIL_FOOLS),
createIcon(VersionIconType.OPTIFINE),
createIcon(VersionIconType.CRAFT_TABLE),
createIcon(VersionIconType.FABRIC),
@@ -93,8 +93,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
private String versionId;
private final VBox rootPane;
private final JFXTextField txtWidth;
private final JFXTextField txtHeight;
private final JFXComboBox<String> cboWindowsSize;
private final JFXTextField txtServerIP;
private final ComponentList componentList;
private final JFXComboBox<LauncherVisibility> cboLauncherVisibility;
@@ -389,29 +388,20 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
BorderPane right = new BorderPane();
dimensionPane.setRight(right);
{
HBox hbox = new HBox();
right.setLeft(hbox);
hbox.setPrefWidth(210);
hbox.setSpacing(3);
hbox.setAlignment(Pos.CENTER);
BorderPane.setAlignment(hbox, Pos.CENTER);
{
txtWidth = new JFXTextField();
txtWidth.setPromptText("800");
txtWidth.setPrefWidth(100);
FXUtils.setValidateWhileTextChanged(txtWidth, true);
txtWidth.getValidators().setAll(new NumberValidator(i18n("input.number"), false));
Label x = new Label("x");
txtHeight = new JFXTextField();
txtHeight.setPromptText("480");
txtHeight.setPrefWidth(100);
FXUtils.setValidateWhileTextChanged(txtHeight, true);
txtHeight.getValidators().setAll(new NumberValidator(i18n("input.number"), false));
hbox.getChildren().setAll(txtWidth, x, txtHeight);
}
cboWindowsSize = new JFXComboBox<>();
cboWindowsSize.setPrefWidth(150);
right.setLeft(cboWindowsSize);
cboWindowsSize.setEditable(true);
cboWindowsSize.setStyle("-fx-padding: 4 4 4 16");
cboWindowsSize.setPromptText("854x480");
cboWindowsSize.getItems().addAll(
"854x480",
"1280x720",
"1600x900",
"1920x1080",
"2560x1440",
"3840x2160"
);
chkFullscreen = new JFXCheckBox();
right.setRight(chkFullscreen);
@@ -419,6 +409,8 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
chkFullscreen.setAlignment(Pos.CENTER);
BorderPane.setAlignment(chkFullscreen, Pos.CENTER);
BorderPane.setMargin(chkFullscreen, new Insets(0, 0, 0, 7));
cboWindowsSize.disableProperty().bind(chkFullscreen.selectedProperty());
}
}
@@ -559,8 +551,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
// unbind data fields
if (lastVersionSetting != null) {
FXUtils.unbind(txtWidth, lastVersionSetting.widthProperty());
FXUtils.unbind(txtHeight, lastVersionSetting.heightProperty());
FXUtils.unbindWindowsSize(cboWindowsSize, lastVersionSetting.widthProperty(), lastVersionSetting.heightProperty());
maxMemory.unbindBidirectional(lastVersionSetting.maxMemoryProperty());
javaCustomOption.valueProperty().unbindBidirectional(lastVersionSetting.javaDirProperty());
gameDirCustomOption.valueProperty().unbindBidirectional(lastVersionSetting.gameDirProperty());
@@ -593,8 +584,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
javaVersionOption.valueProperty().unbind();
// bind new data fields
FXUtils.bindInt(txtWidth, versionSetting.widthProperty());
FXUtils.bindInt(txtHeight, versionSetting.heightProperty());
FXUtils.bindWindowsSize(cboWindowsSize, versionSetting.widthProperty(), versionSetting.heightProperty());
maxMemory.bindBidirectional(versionSetting.maxMemoryProperty());
javaCustomOption.bindBidirectional(versionSetting.javaDirProperty());
@@ -205,14 +205,6 @@ public final class Versions {
});
}
public static void launch(Profile profile) {
launch(profile, profile.getSelectedVersion());
}
public static void launch(Profile profile, String id) {
launch(profile, id, null);
}
public static void launch(Profile profile, String id, Consumer<LauncherHelper> injecter) {
if (!checkVersionForLaunching(profile, id))
return;
@@ -18,14 +18,9 @@
package org.jackhuang.hmcl.upgrade;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.util.Pack200Utils;
import org.tukaani.xz.XZInputStream;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.jar.JarOutputStream;
final class HMCLDownloadTask extends FileDownloadTask {
@@ -45,15 +40,6 @@ final class HMCLDownloadTask extends FileDownloadTask {
switch (archiveFormat) {
case JAR:
break;
case PACK_XZ:
byte[] raw = Files.readAllBytes(target);
try (InputStream in = new XZInputStream(new ByteArrayInputStream(raw));
JarOutputStream out = new JarOutputStream(Files.newOutputStream(target))) {
Pack200Utils.unpack(in, out);
}
break;
default:
throw new IllegalArgumentException("Unknown format: " + archiveFormat);
}
@@ -21,14 +21,13 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck;
import org.jackhuang.hmcl.util.Pack200Utils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.Optional;
public class RemoteVersion {
public final class RemoteVersion {
public static RemoteVersion fetch(UpdateChannel channel, String url) throws IOException {
try {
@@ -36,12 +35,8 @@ public class RemoteVersion {
String version = Optional.ofNullable(response.get("version")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("version is missing"));
String jarUrl = Optional.ofNullable(response.get("jar")).map(JsonElement::getAsString).orElse(null);
String jarHash = Optional.ofNullable(response.get("jarsha1")).map(JsonElement::getAsString).orElse(null);
String packXZUrl = Optional.ofNullable(response.get("packxz")).map(JsonElement::getAsString).orElse(null);
String packXZHash = Optional.ofNullable(response.get("packxzsha1")).map(JsonElement::getAsString).orElse(null);
boolean force = Optional.ofNullable(response.get("force")).map(JsonElement::getAsBoolean).orElse(false);
if (Pack200Utils.isSupported() && packXZUrl != null && packXZHash != null) {
return new RemoteVersion(channel, version, packXZUrl, Type.PACK_XZ, new IntegrityCheck("SHA-1", packXZHash), force);
} else if (jarUrl != null && jarHash != null) {
if (jarUrl != null && jarHash != null) {
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), force);
} else {
throw new IOException("No download url is available");
@@ -97,7 +92,6 @@ public class RemoteVersion {
}
public enum Type {
PACK_XZ,
JAR
}
}
@@ -26,7 +26,10 @@ import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.platform.windows.WindowsVersion;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStreamReader;
@@ -178,7 +181,19 @@ public final class NativePatcher {
return version.setLibraries(newLibraries);
}
public static Library getMesaLoader(JavaRuntime javaVersion) {
return getNatives(javaVersion.getPlatform()).get("mesa-loader");
public static @Nullable Library getWindowsMesaLoader(@NotNull JavaRuntime javaVersion, @NotNull Renderer renderer, @NotNull WindowsVersion windowsVersion) {
if (renderer == Renderer.DEFAULT)
return null;
if (windowsVersion.compareTo(WindowsVersion.WINDOWS_10) >= 0) {
return getNatives(javaVersion.getPlatform()).get("mesa-loader");
} else if (windowsVersion.compareTo(WindowsVersion.WINDOWS_7) >= 0) {
if (renderer == Renderer.LLVMPIPE)
return getNatives(javaVersion.getPlatform()).get("software-renderer-loader");
else
return null;
} else {
return null;
}
}
}
@@ -64,6 +64,12 @@
"titleLocalized" : "about.thanks_to.mcbbs",
"subtitleLocalized" : "about.thanks_to.mcbbs.statement"
},
{
"image" : "/assets/img/mcim.png",
"titleLocalized" : "about.thanks_to.mcim",
"subtitleLocalized" : "about.thanks_to.mcim.statement",
"externalLink" : "https://github.com/mcmod-info-mirror"
},
{
"image" : "/assets/img/github.png",
"titleLocalized" : "about.thanks_to.contributors",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

@@ -39,6 +39,8 @@ about.thanks_to.zekerzhayard.statement=Contribute a lot of technical support to
about.thanks_to.zkitefly.statement=Responsible for maintaining the documentation of HMCL.
about.thanks_to.mcbbs=MCBBS (Minecraft Chinese Forum)
about.thanks_to.mcbbs.statement=For providing the mcbbs.net download mirror for Chinese Mainland users. (No longer available)
about.thanks_to.mcim=mcmod-info-mirror
about.thanks_to.mcim.statement=Provides mod information cache acceleration service
about.thanks_to.mcmod=MCMod (mcmod.cn)
about.thanks_to.mcmod.statement=For providing the Simplified Chinese translations and wiki for various mods.
about.thanks_to.red_lnn.statement=For providing the default background image.
@@ -859,7 +861,7 @@ modpack.choose.local.detail=You can drag the modpack file here.
modpack.choose.remote=Download from URL
modpack.choose.remote.detail=A direct download link to the remote modpack file is required.
modpack.choose.repository=Download Modpack from CurseForge or Modrinth
modpack.choose.repository.detail=Remember to go back to this page and drop the modpack file here after the modpack is downloaded.
modpack.choose.repository.detail=You can choose the desired modpack on the next page.
modpack.choose.remote.tooltip=Please enter your modpack URL
modpack.completion=Downloading dependencies
modpack.desc=Describe your modpack, including an introduction and probably some changelog. Markdown and images from URL are currently supported.
@@ -1426,12 +1428,18 @@ version.empty=No Instances
version.empty.add=Add new instance
version.empty.launch=No available instances. Clicking "OK" will take you to the "Download" page.\n\nYou can also download the game or switch game directories via the "Download" or "All Instances" buttons on the HMCL homepage.
version.empty.hint=There are no Minecraft instances here.\nYou can try switching to another game directory or clicking here to download one.
version.game.all=All
version.game.april_fools=April Fools
version.game.old=Historical
version.game.release=Release
version.game.releases=Releases
version.game.snapshot=Snapshot
version.game.snapshots=Snapshots
version.game.type=Type
version.launch=Launch Game
version.launch.empty=Start Game
version.launch.empty.installing=Installing Game
version.launch.empty.tooltip=Install and launch the latest official release
version.launch.test=Test Launch
version.switch=Switch Instance
version.launch_script=Export Launch Script
@@ -1456,12 +1464,14 @@ version.manage.remove_libraries=Delete All Libraries
version.manage.rename=Rename Instance
version.manage.rename.message=Enter New Instance Name
version.manage.rename.fail=Failed to rename the instance. Some files might be in use, or the name contains an invalid character.
version.search=Name
version.search.prompt=Enter the version name to search
version.settings=Settings
version.update=Update Modpack
wiki.tooltip=Minecraft Wiki Page
wiki.version.game.release=https://minecraft.wiki/w/Java_Edition_%s
wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s
wiki.version.game=https://minecraft.wiki/w/Special:Search?search=%s
wiki.version.game.search=Java_Edition_%s
wizard.prev=< Prev
wizard.failed=Failed
@@ -865,7 +865,7 @@ modpack.choose.remote=Descargar desde Internet
modpack.choose.remote.detail=Se requiere un enlace de descarga directa al archivo modpack remoto
modpack.choose.remote.tooltip=Por favor, introduzca la URL de su modpack
modpack.choose.repository=Descargar Modpack de CurseForge o Modrinth
modpack.choose.repository.detail=Recuerda volver a esta página y soltar aquí el archivo del modpack una vez descargado.
modpack.choose.repository.detail=Puedes elegir el modpack que desees en la página siguiente.
modpack.completion=Descargando dependencias
modpack.desc=Describa su modpack, incluyendo una introducción y probablemente algún registro de cambios. Actualmente se admiten Markdown e imágenes desde URL.
modpack.description=Descripción
@@ -1460,8 +1460,8 @@ version.settings=Configuración
version.update=Actualizar Modpack
wiki.tooltip=Página de Minecraft Wiki
wiki.version.game.release=https://es.minecraft.wiki/w/Java_Edition_%s
wiki.version.game.snapshot=https://es.minecraft.wiki/w/Java_Edition_%s
wiki.version.game=https://es.minecraft.wiki/w/Special:Search?search=%s
wiki.version.game.search=Java_Edition_%s
wizard.prev=< Previo
wizard.failed=Falló
@@ -963,6 +963,9 @@ version.manage.rename.fail=バージョンの名前を変更できませんで
version.settings=設定
version.update=modpackを更新する
wiki.version.game=https://ja.minecraft.wiki/w/Special:Search?search=%s
wiki.version.game.search=Java_Edition_%s
wizard.prev=< 前へ
wizard.failed=失敗
wizard.finish=終了
@@ -864,7 +864,7 @@ modpack.choose.remote=Скачать
modpack.choose.remote.detail=Необходима прямая ссылка на скачивание модпака
modpack.choose.remote.tooltip=Введите URL-адрес модпака
modpack.choose.repository=Скачать модпак с CurseForge или Modrinth
modpack.choose.repository.detail=Не забудьте вернуться на эту страницу и перетащить файл модпака сюда после того, как модпак будет скачан.
modpack.choose.repository.detail=Вы сможете выбрать нужный вам модпак на следующей странице.
modpack.completion=Скачивание зависимостей
modpack.desc=Опишите модпак, включая введение и журнал изменений. Поддерживаются Markdown и изображения по URL-адресу.
modpack.description=Описание
@@ -1460,8 +1460,8 @@ version.settings=Настройки
version.update=Обновить модпак
wiki.tooltip=Страница Minecraft Wiki
wiki.version.game.release=https://ru.minecraft.wiki/w/%s_(Java_Edition)
wiki.version.game.snapshot=https://ru.minecraft.wiki/w/%s_(Java_Edition)
wiki.version.game=https://ru.minecraft.wiki/w/Special:Search?search=%s
wiki.version.game.search=%s_(Java_Edition)
wizard.prev=< Пред.
wizard.failed=Не удалось
@@ -38,6 +38,8 @@ about.thanks_to.zekerzhayard.statement=為 HMCL 貢獻許多技術支援
about.thanks_to.zkitefly.statement=負責維護 HMCL 的文件
about.thanks_to.mcbbs=MCBBS (我的世界中文論壇)
about.thanks_to.mcbbs.statement=提供 MCBBS 下載源 (現已停止服務)
about.thanks_to.mcim=mcmod-info-mirror
about.thanks_to.mcim.statement=提供 Mod 信息緩存加速服務
about.thanks_to.mcmod=MC 百科 (mcmod.cn)
about.thanks_to.mcmod.statement=提供模組簡體中文名映射表與模組百科
about.thanks_to.red_lnn.statement=提供預設背景圖
@@ -675,7 +677,7 @@ modpack.choose.local.detail=你可以直接將模組包檔案拖入本頁面以
modpack.choose.remote=從網路下載模組包
modpack.choose.remote.detail=需要提供模組包的下載連結
modpack.choose.repository=從 CurseForge/Modrinth 下載模組包
modpack.choose.repository.detail=下載後記得回到這個介面,把模組包拖進來哦
modpack.choose.repository.detail=可直接在跳轉到的頁面安裝模組包
modpack.choose.remote.tooltip=要下載的模組包的連結
modpack.completion=下載模組包相關檔案
modpack.desc=描述你要製作的模組包,比如模組包注意事項和更新紀錄。支援 Markdown (圖片請上傳至網路)。
@@ -1219,12 +1221,18 @@ version.empty=沒有遊戲實例
version.empty.add=進入下載頁安裝遊戲
version.empty.launch=沒有可啟動的遊戲。點擊「確定」將進入「下載」頁面。\n你也可以點擊 HMCL 主介面左側的「下載」按鈕安裝遊戲,或在「實例清單」切換遊戲目錄。
version.empty.hint=沒有已安裝的遊戲。\n你可以切換其他遊戲目錄,或者點擊此處進入遊戲下載頁面。
version.game.all=全部
version.game.april_fools=愚人節
version.game.old=遠古版
version.game.release=正式版
version.game.releases=正式版
version.game.snapshot=快照
version.game.snapshots=快照
version.game.type=版本類型
version.launch=啟動遊戲
version.launch.empty=開始遊戲
version.launch.empty.installing=安裝遊戲
version.launch.empty.tooltip=安裝並啟動最新正式版遊戲
version.launch.test=測試遊戲
version.switch=切換實例
version.launch_script=生成啟動指令碼
@@ -1249,12 +1257,14 @@ version.manage.remove_libraries=刪除所有支援庫檔案
version.manage.rename=重新命名該實例
version.manage.rename.message=請輸入新名稱
version.manage.rename.fail=重新命名實例失敗,可能檔案被佔用或者名稱有特殊字元。
version.search=名稱
version.search.prompt=輸入版本名稱進行搜尋
version.settings=遊戲設定
version.update=更新模組包
wiki.tooltip=Minecraft Wiki 頁面
wiki.version.game.release=https://zh.minecraft.wiki/w/Java%%E7%%89%%88%s?variant=zh-tw
wiki.version.game.snapshot=https://zh.minecraft.wiki/w/%s?variant=zh-tw
wiki.version.game=https://zh.minecraft.wiki/w/Special:Search?search=%s&variant=zh-tw
wiki.version.game.search=Java版%s
wizard.prev=< 上一步
wizard.failed=失敗
@@ -38,6 +38,8 @@ about.thanks_to.zekerzhayard.statement=为 HMCL 贡献许多技术支持
about.thanks_to.zkitefly.statement=负责维护 HMCL 的文档
about.thanks_to.mcbbs=MCBBS (我的世界中文论坛)
about.thanks_to.mcbbs.statement=提供 MCBBS 下载源 (现已停止服务)
about.thanks_to.mcim=mcmod-info-mirror
about.thanks_to.mcim.statement=提供 Mod 信息缓存加速服务
about.thanks_to.mcmod=MC 百科 (mcmod.cn)
about.thanks_to.mcmod.statement=提供模组简体中文名映射表与模组百科
about.thanks_to.red_lnn.statement=提供默认背景图
@@ -685,7 +687,7 @@ modpack.choose.local.detail=你可以直接将整合包文件拖入本页面以
modpack.choose.remote=从互联网下载整合包
modpack.choose.remote.detail=需要提供整合包的下载链接
modpack.choose.repository=从 CurseForge/Modrinth 下载整合包
modpack.choose.repository.detail=下载后记得回到这个界面,把整合包拖进来哦
modpack.choose.repository.detail=可直接在跳转到的页面安装整合包
modpack.choose.remote.tooltip=要下载的整合包的链接
modpack.completion=下载整合包相关文件
modpack.desc=描述你要制作的整合包,比如整合包注意事项和更新记录。支持 Markdown (图片请用网络链接)。
@@ -1229,12 +1231,18 @@ version.empty=没有游戏版本
version.empty.add=进入下载页安装游戏
version.empty.launch=没有可启动的游戏。点击“确定”将进入“下载”页面。\n你也可以点击 HMCL 主界面左侧的“下载”按钮安装游戏,或在“版本列表”切换游戏文件夹。
version.empty.hint=没有已安装的游戏。\n你可以切换其他游戏文件夹,或者点击此处进入游戏下载页面。
version.game.all=全部
version.game.april_fools=愚人节
version.game.old=远古版
version.game.release=正式版
version.game.releases=正式版
version.game.snapshot=快照
version.game.snapshots=快照
version.game.type=版本类型
version.launch=启动游戏
version.launch.empty=开始游戏
version.launch.empty.installing=安装游戏
version.launch.empty.tooltip=安装并启动最新正式版游戏
version.launch.test=测试游戏
version.switch=切换版本
version.launch_script=生成启动脚本
@@ -1259,12 +1267,14 @@ version.manage.remove_libraries=删除所有库文件
version.manage.rename=重命名该版本
version.manage.rename.message=请输入要修改的名称
version.manage.rename.fail=重命名版本失败,可能文件被占用或者名字有特殊字符。
version.search=名称
version.search.prompt=输入版本名称进行搜索
version.settings=游戏设置
version.update=更新整合包
wiki.tooltip=Minecraft Wiki 页面
wiki.version.game.release=https://zh.minecraft.wiki/w/Java%%E7%%89%%88%s?variant=zh-cn
wiki.version.game.snapshot=https://zh.minecraft.wiki/w/%s?variant=zh-cn
wiki.version.game=https://zh.minecraft.wiki/w/Special:Search?search=%s&variant=zh-cn
wiki.version.game.search=Java版%s
wizard.prev=< 上一步
wizard.failed=失败
@@ -4007,6 +4007,17 @@
"size": 27971214
}
}
},
"software-renderer-loader": {
"name": "org.glavo:llvmpipe-loader:1.0",
"downloads": {
"artifact": {
"path": "org/glavo/llvmpipe-loader/1.0/llvmpipe-loader-1.0.jar",
"url": "https://repo1.maven.org/maven2/org/glavo/llvmpipe-loader/1.0/llvmpipe-loader-1.0.jar",
"sha1": "ff255415e5c4b2a18970da0a8e552b557ca013ae",
"size": 12964773
}
}
}
},
"windows-x86": {
@@ -0,0 +1,78 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.auth.authlibinjector;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class AuthlibInjectorExtractor implements AuthlibInjectorArtifactProvider {
private final URL source;
private final Path artifactLocation;
public AuthlibInjectorExtractor(URL source, Path artifactLocation) {
if (source == null)
throw new IllegalArgumentException("Missing authlib injector");
this.source = source;
this.artifactLocation = artifactLocation;
}
@Override
public AuthlibInjectorArtifactInfo getArtifactInfo() throws IOException {
Optional<AuthlibInjectorArtifactInfo> cached = getArtifactInfoImmediately();
if (cached.isPresent())
return cached.get();
synchronized (this) {
cached = getArtifactInfoImmediately();
if (cached.isPresent())
return cached.get();
LOG.info("No local authlib-injector found, extracting");
Files.createDirectories(artifactLocation.getParent());
try (InputStream inputStream = source.openStream()) {
FileUtils.saveSafely(artifactLocation, inputStream::transferTo);
}
return getArtifactInfoImmediately().orElseThrow(() ->
new IOException("Failed to extract authlib-injector artifact"));
}
}
@Override
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
if (!Files.isRegularFile(artifactLocation))
return Optional.empty();
try {
return Optional.of(AuthlibInjectorArtifactInfo.from(artifactLocation));
} catch (IOException e) {
LOG.warning("Bad authlib-injector artifact", e);
return Optional.empty();
}
}
}
@@ -79,6 +79,12 @@ public final class BMCLAPIDownloadProvider implements DownloadProvider {
pair("https://authlib-injector.yushi.moe", apiRoot + "/mirrors/authlib-injector"),
pair("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
pair("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto")
// // https://github.com/mcmod-info-mirror/mcim-rust-api
// pair("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth"),
// pair("https://cdn.modrinth.com", "https://mod.mcimirror.top"),
// pair("https://api.curseforge.com", "https://mod.mcimirror.top/curseforge"),
// pair("https://edge.forgecdn.net", "https://mod.mcimirror.top"),
// pair("https://mediafilez.forgecdn.net", "https://mod.mcimirror.top")
);
}
@@ -71,7 +71,7 @@ public class DefaultCacheRepository extends CacheRepository {
} else {
index = new Index();
}
} catch (IOException | JsonParseException e) {
} catch (Exception e) {
LOG.warning("Unable to read index file", e);
index = new Index();
} finally {
@@ -54,15 +54,16 @@ public final class GameRemoteVersion extends RemoteVersion {
@Override
public int compareTo(RemoteVersion o) {
if (!(o instanceof GameRemoteVersion))
if (!(o instanceof GameRemoteVersion)) {
return 0;
}
int dateCompare = o.getReleaseDate().compareTo(getReleaseDate());
if (dateCompare != 0) {
return dateCompare;
}
return GameVersionNumber.compare(getSelfVersion(), o.getSelfVersion());
return GameVersionNumber.compare(o.getSelfVersion(), getSelfVersion());
}
private static Type getReleaseType(ReleaseType type) {
@@ -42,7 +42,7 @@ public final class NeoForgeBMCLVersionList extends VersionList<NeoForgeRemoteVer
@Override
public boolean hasType() {
return false;
return true;
}
@Override
@@ -20,7 +20,7 @@ public final class NeoForgeOfficialVersionList extends VersionList<NeoForgeRemot
@Override
public boolean hasType() {
return false;
return true;
}
private static final String OLD_URL = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/forge";
@@ -10,7 +10,7 @@ import java.util.List;
public class NeoForgeRemoteVersion extends RemoteVersion {
public NeoForgeRemoteVersion(String gameVersion, String selfVersion, List<String> urls) {
super(LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId(), gameVersion, selfVersion, null, urls);
super(LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId(), gameVersion, selfVersion, null, getType(selfVersion), urls);
}
@Override
@@ -18,6 +18,10 @@ public class NeoForgeRemoteVersion extends RemoteVersion {
return new NeoForgeInstallTask(dependencyManager, baseVersion, this);
}
private static Type getType(String version) {
return version.contains("beta") ? Type.SNAPSHOT : Type.RELEASE;
}
public static String normalize(String version) {
if (version.startsWith("1.20.1-")) {
if (version.startsWith("forge-", "1.20.1-".length())) {
@@ -168,7 +168,7 @@ public class DefaultLauncher extends Launcher {
}
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
res.addDefault("-Duser.home=", options.getGameDir().getParent());
res.addDefault("-Duser.home=", options.getGameDir().getAbsoluteFile().getParent());
Proxy.Type proxyType = options.getProxyType();
if (proxyType == null) {
@@ -300,11 +300,13 @@ public final class RemoteMod {
public FileDownloadTask.IntegrityCheck getIntegrityCheck() {
if (hashes.containsKey("md5")) {
return new FileDownloadTask.IntegrityCheck("MD5", hashes.get("sha1"));
return new FileDownloadTask.IntegrityCheck("MD5", hashes.get("md5"));
} else if (hashes.containsKey("sha1")) {
return new FileDownloadTask.IntegrityCheck("SHA-1", hashes.get("sha1"));
} else if (hashes.containsKey("sha256")) {
return new FileDownloadTask.IntegrityCheck("SHA-256", hashes.get("sha256"));
} else if (hashes.containsKey("sha512")) {
return new FileDownloadTask.IntegrityCheck("SHA-256", hashes.get("sha1"));
return new FileDownloadTask.IntegrityCheck("SHA-512", hashes.get("sha512"));
} else {
return null;
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.mod;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -83,7 +84,7 @@ public interface RemoteModRepository {
}
}
SearchResult search(String gameVersion, @Nullable Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder)
SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder)
throws IOException;
Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException;
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.mod.curse;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
@@ -26,6 +27,7 @@ import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayOutputStream;
@@ -47,6 +49,13 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
private static final int WORD_PERFECT_MATCH_WEIGHT = 5;
private static <R extends HttpRequest> R withApiKey(R request) {
if (request.getUrl().startsWith(PREFIX) && !apiKey.isEmpty()) {
request.header("X-API-KEY", apiKey);
}
return request;
}
public static boolean isAvailable() {
return !apiKey.isEmpty();
}
@@ -100,29 +109,28 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
}
@Override
public SearchResult search(String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
int categoryId = 0;
if (category != null && category.getSelf() instanceof CurseAddon.Category) {
categoryId = ((CurseAddon.Category) category.getSelf()).getId();
}
Response<List<CurseAddon>> response = HttpRequest.GET(PREFIX + "/v1/mods/search",
pair("gameId", "432"),
pair("classId", Integer.toString(section)),
pair("categoryId", Integer.toString(categoryId)),
pair("gameVersion", gameVersion),
pair("searchFilter", searchFilter),
pair("sortField", Integer.toString(toModsSearchSortField(sortType))),
pair("sortOrder", toSortOrder(sortOrder)),
pair("index", Integer.toString(pageOffset * pageSize)),
pair("pageSize", Integer.toString(pageSize)))
.header("X-API-KEY", apiKey)
Response<List<CurseAddon>> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", mapOf(
pair("gameId", "432"),
pair("classId", Integer.toString(section)),
pair("categoryId", Integer.toString(categoryId)),
pair("gameVersion", gameVersion),
pair("searchFilter", searchFilter),
pair("sortField", Integer.toString(toModsSearchSortField(sortType))),
pair("sortOrder", toSortOrder(sortOrder)),
pair("index", Integer.toString(pageOffset * pageSize)),
pair("pageSize", Integer.toString(pageSize)))))))
.getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
if (searchFilter.isEmpty()) {
return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
}
// https://github.com/HMCL-dev/HMCL/issues/1549
String lowerCaseSearchFilter = searchFilter.toLowerCase();
String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT);
Map<String, Integer> searchFilterWords = new HashMap<>();
for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) {
searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1);
@@ -162,9 +170,8 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1));
Response<FingerprintMatchesResult> response = HttpRequest.POST(PREFIX + "/v1/fingerprints/432")
Response<FingerprintMatchesResult> response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432"))
.json(mapOf(pair("fingerprints", Collections.singletonList(hash))))
.header("X-API-KEY", apiKey)
.getJson(Response.typeOf(FingerprintMatchesResult.class));
if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) {
@@ -176,32 +183,28 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
@Override
public RemoteMod getModById(String id) throws IOException {
Response<CurseAddon> response = HttpRequest.GET(PREFIX + "/v1/mods/" + id)
.header("X-API-KEY", apiKey)
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
.getJson(Response.typeOf(CurseAddon.class));
return response.data.toMod();
}
@Override
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
Response<CurseAddon.LatestFile> response = HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId))
.header("X-API-KEY", apiKey)
Response<CurseAddon.LatestFile> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId)))
.getJson(Response.typeOf(CurseAddon.LatestFile.class));
return response.getData().toVersion().getFile();
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
Response<List<CurseAddon.LatestFile>> response = HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
pair("pageSize", "10000"))
.header("X-API-KEY", apiKey)
Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
pair("pageSize", "10000")))
.getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class)));
return response.getData().stream().map(CurseAddon.LatestFile::toVersion);
}
public List<CurseAddon.Category> getCategoriesImpl() throws IOException {
Response<List<CurseAddon.Category>> categories = HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432"))
.header("X-API-KEY", apiKey)
Response<List<CurseAddon.Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
.getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class)));
return reorganizeCategories(categories.getData(), section);
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.mod.modrinth;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.RemoteMod;
@@ -79,7 +80,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
}
@Override
public SearchResult search(String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
List<List<String>> facets = new ArrayList<>();
facets.add(Collections.singletonList("project_type:" + projectType));
if (StringUtils.isNotBlank(gameVersion)) {
@@ -95,7 +96,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
pair("limit", Integer.toString(pageSize)),
pair("index", convertSortType(sort))
);
Response<ProjectSearchResult> response = HttpRequest.GET(NetworkUtils.withQuery(PREFIX + "/v2/search", query))
Response<ProjectSearchResult> response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query)))
.getJson(Response.typeOf(ProjectSearchResult.class));
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
}
@@ -21,6 +21,7 @@ import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.GameBuilder;
import org.jackhuang.hmcl.game.DefaultGameRepository;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.GetTask;
@@ -120,7 +121,7 @@ public class ServerModpackCompletionTask extends Task<Void> {
dependencies.add(builder.buildAsync());
}
Path rootPath = repository.getVersionRoot(version).toPath();
Path rootPath = repository.getVersionRoot(version).toPath().toAbsolutePath().normalize();
Map<String, ModpackConfiguration.FileInformation> files = manifest.getManifest().getFiles().stream()
.collect(Collectors.toMap(ModpackConfiguration.FileInformation::getPath,
Function.identity()));
@@ -128,12 +129,24 @@ public class ServerModpackCompletionTask extends Task<Void> {
Set<String> remoteFiles = remoteManifest.getFiles().stream().map(ModpackConfiguration.FileInformation::getPath)
.collect(Collectors.toSet());
Path runDirectory = repository.getRunDirectory(version).toPath().toAbsolutePath().normalize();
Path modsDirectory = runDirectory.resolve("mods");
int total = 0;
// for files in new modpack
for (ModpackConfiguration.FileInformation file : remoteManifest.getFiles()) {
Path actualPath = rootPath.resolve(file.getPath());
Path actualPath = rootPath.resolve(file.getPath()).toAbsolutePath().normalize();
String fileName = actualPath.getFileName().toString();
if (!actualPath.startsWith(rootPath)) {
throw new IOException("Unsecure path: " + file.getPath());
}
boolean download;
if (!files.containsKey(file.getPath())) {
if (!files.containsKey(file.getPath()) ||
modsDirectory.equals(actualPath.getParent())
&& Files.notExists(actualPath.resolveSibling(fileName + ModManager.DISABLED_EXTENSION))
&& Files.notExists(actualPath.resolveSibling(fileName + ModManager.OLD_EXTENSION))) {
// If old modpack does not have this entry, download it
download = true;
} else if (!Files.exists(actualPath)) {
@@ -38,11 +38,12 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class CacheFileTask extends FetchTask<Path> {
public CacheFileTask(@NotNull String uri) {
super(List.of(NetworkUtils.toURI(uri)));
this(NetworkUtils.toURI(uri));
}
public CacheFileTask(@NotNull URI uri) {
super(List.of(uri));
setName(uri.toString());
}
@Override
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.util;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
@@ -75,7 +74,7 @@ public class CacheRepository {
index = new LinkedHashMap<>();
indexFileLastModified = null;
}
} catch (IOException | JsonParseException e) {
} catch (Exception e) {
LOG.warning("Unable to read index file", e);
index = new LinkedHashMap<>();
indexFileLastModified = null;
@@ -354,7 +353,7 @@ public class CacheRepository {
}
public int compareTo(ETagItem other) {
if (!url.equals(other.url))
if (!url.equals(other.url) && !NetworkUtils.toURI(url).equals(NetworkUtils.toURI(other.url)))
throw new IllegalArgumentException();
ZonedDateTime thisTime = Lang.ignoringException(() -> ZonedDateTime.parse(remoteLastModified, DateTimeFormatter.RFC_1123_DATE_TIME), null);
@@ -1,122 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.jar.JarOutputStream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class Pack200Utils {
private Pack200Utils() {
}
private static final String[] IMPL_NAMES = {
"java.util.jar.Pack200",
"org.glavo.pack200.Pack200",
"io.pack200.Pack200"
};
private static final MethodHandle newUnpackerHandle;
private static final MethodHandle unpackHandle;
private static final MethodHandle unpackFileHandle;
static {
Class<?> pack200Class = null;
Class<?> unpackerClass = null;
for (String implName : IMPL_NAMES) {
try {
pack200Class = Class.forName(implName);
unpackerClass = Class.forName(implName + "$Unpacker");
break;
} catch (ClassNotFoundException ignored) {
}
}
if (pack200Class == null) {
LOG.warning("Pack200 not found");
newUnpackerHandle = null;
unpackHandle = null;
unpackFileHandle = null;
} else {
final MethodHandles.Lookup lookup = MethodHandles.publicLookup();
MethodHandle newUnpacker = null;
MethodHandle unpack = null;
MethodHandle unpackFile = null;
try {
newUnpacker = lookup.findStatic(pack200Class, "newUnpacker", MethodType.methodType(unpackerClass));
unpack = lookup.findVirtual(unpackerClass, "unpack", MethodType.methodType(void.class, InputStream.class, JarOutputStream.class));
unpackFile = lookup.findVirtual(unpackerClass, "unpack", MethodType.methodType(void.class, File.class, JarOutputStream.class));
} catch (Throwable e) {
LOG.warning("Failed to find pack200 methods", e);
}
if (newUnpacker != null) {
newUnpackerHandle = newUnpacker;
unpackHandle = unpack;
unpackFileHandle = unpackFile;
} else {
newUnpackerHandle = null;
unpackHandle = null;
unpackFileHandle = null;
}
}
}
public static boolean isSupported() {
return newUnpackerHandle != null;
}
public static void unpack(InputStream in, JarOutputStream out) throws IOException {
if (newUnpackerHandle == null) {
throw new UnsupportedOperationException("Pack200");
}
try {
unpackHandle.invoke(newUnpackerHandle.invoke(), in, out);
} catch (IOException | RuntimeException | Error e) {
throw e;
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
public static void unpack(File in, JarOutputStream out) throws IOException {
if (newUnpackerHandle == null) {
throw new UnsupportedOperationException("Pack200");
}
try {
unpackFileHandle.invoke(newUnpackerHandle.invoke(), in, out);
} catch (IOException | RuntimeException | Error e) {
throw e;
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
}
@@ -57,6 +57,10 @@ public abstract class HttpRequest {
this.method = method;
}
public String getUrl() {
return url;
}
public HttpRequest accept(String contentType) {
return header("Accept", contentType);
}
@@ -146,7 +146,10 @@ public final class NetworkUtils {
boolean left = true;
while (i < location.length()) {
char ch = location.charAt(i);
if (ch == ' ' || ch >= 0x80)
if (ch == ' '
|| ch == '[' || ch == ']'
|| ch == '{' || ch == '}'
|| ch >= 0x80)
break;
else if (ch == '?')
left = false;
@@ -163,22 +166,6 @@ public final class NetworkUtils {
for (; i < location.length(); i++) {
char ch = location.charAt(i);
if (Character.isSurrogate(ch)) {
if (Character.isHighSurrogate(ch) && i < location.length() - 1) {
char ch2 = location.charAt(i + 1);
if (Character.isLowSurrogate(ch2)) {
int codePoint = Character.toCodePoint(ch, ch2);
encodeCodePoint(builder, codePoint);
i++;
continue;
}
}
// Invalid surrogate pair, encode as '?'
builder.append("%3F");
continue;
}
if (ch == ' ') {
if (left)
builder.append("%20");
@@ -187,7 +174,23 @@ public final class NetworkUtils {
} else if (ch == '?') {
left = false;
builder.append('?');
} else if (ch >= 0x80) {
} else if (ch >= 0x80 || (left && (ch == '[' || ch == ']' || ch == '{' || ch == '}'))) {
if (Character.isSurrogate(ch)) {
if (Character.isHighSurrogate(ch) && i < location.length() - 1) {
char ch2 = location.charAt(i + 1);
if (Character.isLowSurrogate(ch2)) {
int codePoint = Character.toCodePoint(ch, ch2);
encodeCodePoint(builder, codePoint);
i++;
continue;
}
}
// Invalid surrogate pair, encode as '?'
builder.append("%3F");
continue;
}
encodeCodePoint(builder, ch);
} else {
builder.append(ch);
@@ -1,6 +1,24 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.logging;
import org.jackhuang.hmcl.util.Pair;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.tukaani.xz.LZMA2Options;
import org.tukaani.xz.XZOutputStream;
@@ -127,47 +145,10 @@ public final class Logger {
String caller = CLASS_NAME + ".onExit";
if (logRetention > 0 && logFile != null) {
List<Pair<Path, int[]>> list = new ArrayList<>();
Pattern fileNamePattern = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})T(?<hour>\\d{2})-(?<minute>\\d{2})-(?<second>\\d{2})(\\.(?<n>\\d+))?\\.log(\\.(gz|xz))?");
Path dir = logFile.getParent();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
for (Path path : stream) {
Matcher matcher = fileNamePattern.matcher(path.getFileName().toString());
if (matcher.matches() && Files.isRegularFile(path)) {
int year = Integer.parseInt(matcher.group("year"));
int month = Integer.parseInt(matcher.group("month"));
int day = Integer.parseInt(matcher.group("day"));
int hour = Integer.parseInt(matcher.group("hour"));
int minute = Integer.parseInt(matcher.group("minute"));
int second = Integer.parseInt(matcher.group("second"));
int n = Optional.ofNullable(matcher.group("n")).map(Integer::parseInt).orElse(0);
list.add(Pair.pair(path, new int[]{year, month, day, hour, minute, second, n}));
}
}
} catch (IOException e) {
log(Level.WARNING, caller, "Failed to list log files in " + dir, e);
}
var list = findRecentLogFiles(Integer.MAX_VALUE);
if (list.size() > logRetention) {
list.sort((a, b) -> {
int[] v1 = a.getValue();
int[] v2 = b.getValue();
assert v1.length == v2.length;
for (int i = 0; i < v1.length; i++) {
int c = Integer.compare(v1[i], v2[i]);
if (c != 0)
return c;
}
return 0;
});
for (int i = 0, end = list.size() - logRetention; i < end; i++) {
Path file = list.get(i).getKey();
Path file = list.get(i);
try {
if (!Files.isSameFile(file, logFile)) {
log(Level.INFO, caller, "Delete old log file " + file, null);
@@ -276,6 +257,40 @@ public final class Logger {
return logFile;
}
public @NotNull List<Path> findRecentLogFiles(int n) {
if (n <= 0 || logFile == null)
return List.of();
var currentLogFile = LogFile.ofFile(logFile);
Path logDir = logFile.getParent();
if (logDir == null || !Files.isDirectory(logDir))
return List.of();
var logFiles = new ArrayList<LogFile>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(logDir)) {
for (Path path : stream) {
LogFile item = LogFile.ofFile(path);
if (item != null && (currentLogFile == null || item.compareTo(currentLogFile) < 0)) {
logFiles.add(item);
}
}
} catch (IOException e) {
log(Level.WARNING, CLASS_NAME + ".findRecentLogFiles", "Failed to list log files in " + logDir, e);
return List.of();
}
logFiles.sort(Comparator.naturalOrder());
final int resultLength = Math.min(n, logFiles.size());
final int offset = logFiles.size() - resultLength;
var result = new Path[resultLength];
for (int i = 0; i < resultLength; i++) {
result[i] = logFiles.get(i + offset).file;
}
return List.of(result);
}
public void exportLogs(OutputStream output) throws IOException {
Objects.requireNonNull(output);
LogEvent.ExportLog event = new LogEvent.ExportLog(output);
@@ -352,4 +367,69 @@ public final class Logger {
public void trace(String msg, Throwable exception) {
log(Level.TRACE, CallerFinder.getCaller(), msg, exception);
}
private static final class LogFile implements Comparable<LogFile> {
private static final Pattern FILE_NAME_PATTERN = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})T(?<hour>\\d{2})-(?<minute>\\d{2})-(?<second>\\d{2})(\\.(?<n>\\d+))?\\.log(\\.(gz|xz))?");
private static @Nullable LogFile ofFile(Path file) {
if (!Files.isRegularFile(file))
return null;
Matcher matcher = FILE_NAME_PATTERN.matcher(file.getFileName().toString());
if (!matcher.matches())
return null;
int year = Integer.parseInt(matcher.group("year"));
int month = Integer.parseInt(matcher.group("month"));
int day = Integer.parseInt(matcher.group("day"));
int hour = Integer.parseInt(matcher.group("hour"));
int minute = Integer.parseInt(matcher.group("minute"));
int second = Integer.parseInt(matcher.group("second"));
int n = Optional.ofNullable(matcher.group("n")).map(Integer::parseInt).orElse(0);
return new LogFile(file, year, month, day, hour, minute, second, n);
}
private final Path file;
private final int year;
private final int month;
private final int day;
private final int hour;
private final int minute;
private final int second;
private final int n;
private LogFile(Path file, int year, int month, int day, int hour, int minute, int second, int n) {
this.file = file;
this.year = year;
this.month = month;
this.day = day;
this.hour = hour;
this.minute = minute;
this.second = second;
this.n = n;
}
@Override
public int compareTo(@NotNull Logger.LogFile that) {
if (this.year != that.year) return Integer.compare(this.year, that.year);
if (this.month != that.month) return Integer.compare(this.month, that.month);
if (this.day != that.day) return Integer.compare(this.day, that.day);
if (this.hour != that.hour) return Integer.compare(this.hour, that.hour);
if (this.minute != that.minute) return Integer.compare(this.minute, that.minute);
if (this.second != that.second) return Integer.compare(this.second, that.second);
if (this.n != that.n) return Integer.compare(this.n, that.n);
return 0;
}
@Override
public int hashCode() {
return Objects.hash(year, month, day, hour, minute, second, n);
}
@Override
public boolean equals(Object obj) {
return obj instanceof LogFile && compareTo((LogFile) obj) == 0;
}
}
}
@@ -98,6 +98,10 @@ public abstract class GameVersionNumber implements Comparable<GameVersionNumber>
this.value = value;
}
public boolean isSpecial() {
return this instanceof Special;
}
enum Type {
PRE_CLASSIC, CLASSIC, INFDEV, ALPHA, BETA, NEW
}
@@ -1,4 +1,4 @@
# https://gitlab.freedesktop.org/mesa/drm/-/blob/b65d6ede3ebe64dbde5805007ddef22f390f8050/data/amdgpu.ids
# https://gitlab.freedesktop.org/mesa/libdrm/-/blob/7d43d9b6fe5df4b234937fce9e51adb1b327ded1/data/amdgpu.ids
#
# List of AMDGPU IDs
#
@@ -591,7 +591,9 @@
74B6, 00, AMD Instinct MI308X
74BD, 00, AMD Instinct MI300X HF
7550, C0, AMD Radeon RX 9070 XT
7550, C2, AMD Radeon RX 9070 GRE
7550, C3, AMD Radeon RX 9070
7551, C0, AMD Radeon AI PRO R9700
9830, 00, AMD Radeon HD 8400 / R3 Series
9831, 00, AMD Radeon HD 8400E
9832, 00, AMD Radeon HD 8330
@@ -36,9 +36,12 @@ public class NetworkUtilsTest {
assertEquals("https://github.com/HMCL-dev/HMCL/commits?author=Glavo", encodeLocation("https://github.com/HMCL-dev/HMCL/commits?author=Glavo"));
assertEquals("https://www.example.com/file%20with%20space", encodeLocation("https://www.example.com/file with space"));
assertEquals("https://www.example.com/file%20with%20space", encodeLocation("https://www.example.com/file%20with%20space"));
assertEquals("https://www.example.com/%5Bfile%5D", encodeLocation("https://www.example.com/[file]"));
assertEquals("https://www.example.com/%7Bfile%7D", encodeLocation("https://www.example.com/{file}"));
assertEquals("https://www.example.com/%E6%B5%8B%E8%AF%95", encodeLocation("https://www.example.com/测试"));
assertEquals("https://www.example.com/%F0%9F%98%87", encodeLocation("https://www.example.com/\uD83D\uDE07"));
assertEquals("https://www.example.com/test?a=10+20", encodeLocation("https://www.example.com/test?a=10 20"));
assertEquals("https://www.example.com/test?a=10+20&b=[30]&c={40}", encodeLocation("https://www.example.com/test?a=10 20&b=[30]&c={40}"));
assertEquals("https://www.example.com/%E6%B5%8B%E8%AF%95?a=10+20", encodeLocation("https://www.example.com/测试?a=10 20"));
// Invalid surrogate pair
+3 -1
View File
@@ -16,12 +16,13 @@ twelvemonkeys = "3.12.0"
jna = "5.17.0"
pci-ids = "0.4.0"
java-info = "1.0"
authlib-injector = "1.2.5"
# testing
junit = "5.13.4"
# plugins
shadow = "8.3.6"
shadow = "9.0.1"
[libraries]
hmclauncher = { module = "org.glavo.hmcl:HMCLauncher", version.ref = "hmclauncher" }
@@ -43,6 +44,7 @@ jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
pci-ids = { module = "org.glavo:pci-ids", version.ref = "pci-ids" }
java-info = { module = "org.glavo:java-info", version.ref = "java-info" }
authlib-injector = { module = "org.glavo.hmcl:authlib-injector", version.ref = "authlib-injector" }
# testing
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }