Compare commits

..
176 changed files with 8377 additions and 4796 deletions

No files matched your search

@@ -38,10 +38,8 @@ import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
@@ -142,12 +140,8 @@ public final class Launcher extends Application {
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
Themes.applyNativeDarkMode(primaryStage);
UpdateChecker.init();
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage);
primaryStage.show();
});
} catch (Throwable e) {
@@ -0,0 +1,296 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.game.AssetObject;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.*;
/// @author Glavo
@NotNullByDefault
public final class HMCLDownloadProvider extends DownloadProvider {
private volatile DownloadSource versionListSource = DownloadSource.DEFAULT;
private volatile DownloadSource fileSource = DownloadSource.DEFAULT;
private static DownloadCandidates getCandidates(
DownloadSource source,
WebURL defaultUrl, WebURL bmclapiUrl
) {
if (LocaleUtils.IS_CHINA_MAINLAND) {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
} else {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
}
}
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
if (urls.isEmpty()) {
throw new IllegalArgumentException("urls cannot be empty");
}
// true means prefer mirror, false means prefer official, null means do not use mirror
@Nullable Boolean mirrorFirst;
if (LocaleUtils.IS_CHINA_MAINLAND) {
mirrorFirst = switch (source) {
case DEFAULT, MIRROR -> true;
case OFFICIAL -> false;
};
} else {
mirrorFirst = switch (source) {
case DEFAULT, OFFICIAL -> null;
case MIRROR -> true;
};
}
if (mirrorFirst == null) {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 2);
for (String url : urls) {
DownloadCandidate candidate = DownloadCandidate.of(url);
if (candidate.url() == null) {
// Invalid URL, just add it to the list and let the download task handle it.
candidates.add(candidate);
continue;
}
@Nullable DownloadCandidate mirrorCandidate = null;
boolean fallback = false;
for (MirrorRule rule : rules) {
if (url.startsWith(rule.source)) {
mirrorCandidate = DownloadCandidate.of(rule.target + url.substring(rule.source.length()));
fallback = rule.fallback;
break;
}
}
if (mirrorCandidate == null) {
candidates.add(candidate);
} else if (mirrorFirst && !fallback) {
candidates.add(mirrorCandidate);
candidates.add(candidate);
} else {
candidates.add(candidate);
candidates.add(mirrorCandidate);
}
}
return DownloadCandidates.of(List.copyOf(candidates));
}
private final String bmclRoot;
private final List<MirrorRule> rules;
public HMCLDownloadProvider(String bmclRoot) {
this.bmclRoot = bmclRoot;
this.rules = List.of(
new MirrorRule("https://bmclapi2.bangbang93.com", bmclRoot),
new MirrorRule("https://launchermeta.mojang.com", bmclRoot),
new MirrorRule("https://piston-meta.mojang.com", bmclRoot),
new MirrorRule("https://piston-data.mojang.com", bmclRoot),
new MirrorRule("https://launcher.mojang.com", bmclRoot),
new MirrorRule("https://libraries.minecraft.net", bmclRoot + "/libraries"),
new MirrorRule("http://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new MirrorRule("https://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new MirrorRule("https://maven.minecraftforge.net", bmclRoot + "/maven"),
new MirrorRule("https://maven.neoforged.net/releases/", bmclRoot + "/maven/"),
new MirrorRule("http://dl.liteloader.com/versions", bmclRoot + "/maven"),
new MirrorRule("https://dl.liteloader.com/versions", bmclRoot + "/maven"),
new MirrorRule("https://meta.fabricmc.net", bmclRoot + "/fabric-meta"),
new MirrorRule("https://maven.fabricmc.net", bmclRoot + "/maven"),
new MirrorRule("https://authlib-injector.yushi.moe", bmclRoot + "/mirrors/authlib-injector"),
new MirrorRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new MirrorRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new MirrorRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
new MirrorRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
new MirrorRule("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
new MirrorRule("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth", true),
new MirrorRule("https://cdn.modrinth.com", "https://mod.mcimirror.top", true),
new MirrorRule("https://api.curseforge.com", "https://mod.mcimirror.top/curseforge", true),
new MirrorRule("https://edge.forgecdn.net", "https://mod.mcimirror.top", true)
);
}
@Override
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
assert (gameVersion == null) == (type == GameComponentType.GAME);
switch (type) {
case FORGE -> {
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case NEO_FORGE -> {
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR || source == DownloadSource.DEFAULT) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case OPTIFINE -> {
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
}
}
return super.fetchVersionsAsync(type, gameVersion);
}
public void setFileSource(DownloadSource fileSource) {
this.fileSource = Objects.requireNonNull(fileSource);
}
public void setVersionListSource(DownloadSource versionListSource) {
this.versionListSource = Objects.requireNonNull(versionListSource);
}
@Override
public HMCLCacheRepository getCacheRepository() {
return HMCLCacheRepository.REPOSITORY;
}
@Override
public DownloadCandidates getGameVersionListCandidates() {
return getCandidates(
versionListSource,
GameRemoteVersion.VERSION_MANIFEST_URL,
WebURL.parse(bmclRoot + "/mc/game/version_manifest.json")
);
}
@Override
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
DownloadSource source = versionListSource;
return new FabricLikeVersionListCandidates(
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/game"))
);
}
@Override
public DownloadCandidates getCleanroomVersionListCandidates() {
return LocaleUtils.IS_CHINA_MAINLAND
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
}
@Override
public DownloadCandidates getDownloadCandidates(List<String> urls) {
return getCandidates(fileSource, rules, urls);
}
@Override
public DownloadCandidates getVersionListCandidates(List<String> urls) {
return getCandidates(versionListSource, rules, urls);
}
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + assetObject.getLocation()),
WebURL.parse(bmclRoot + "/assets/" + assetObject.getLocation())
);
}
@NotNullByDefault
private static final class FallbackTask<T> extends Task<T> {
private final Task<? extends T> task;
private final Task<? extends T> fallback;
private List<Task<?>> dependencies = List.of();
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
this.task = task;
this.fallback = fallback;
}
@Override
public Collection<? extends Task<?>> getDependents() {
return List.of(task);
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
setResult(task.getResult());
} else {
dependencies = List.of(fallback);
fallback.storeTo(this::setResult);
}
}
@Override
public List<Task<?>> getDependencies() {
return dependencies;
}
}
private record MirrorRule(String source, String target, boolean fallback) {
public MirrorRule(String source, String target) {
this(source, target, false);
}
}
}
@@ -32,15 +32,14 @@ import org.jackhuang.hmcl.launch.*;
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
import org.jackhuang.hmcl.util.*;
@@ -57,7 +56,6 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Path;
import java.time.Instant;
@@ -356,26 +354,26 @@ public final class LauncherHelper {
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
if (ex.getCause() instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException) {
URI uri = ((DownloadException) ex).getUri();
} else if (ex instanceof DownloadException de) {
String url = de.getUrl();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", uri);
message = i18n("install.failed.downloading.timeout", url);
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -385,11 +383,11 @@ public final class LauncherHelper {
message = i18n("account.failed.character_deleted");
} else if (ex instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message = i18n("download.code.404", uri);
message = i18n("download.code.404", url);
else
message = i18n("download.failed", uri, responseCode);
message = i18n("download.failed", url, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
@@ -744,7 +742,7 @@ public final class LauncherHelper {
Throwable resolvedException = resolveException(exception);
LOG.warning("Failed to download java", exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
future.completeExceptionally(new CancellationException());
}
@@ -50,15 +50,15 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(getType()).searchMod(searchFilter)) {
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(type).searchMod(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
@@ -69,7 +69,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
@@ -78,7 +78,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
SearchResult searchResult = null;
List<RemoteAddon> remoteAddons = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
remoteAddons = searchResult.results().toList();
if (!remoteAddons.isEmpty()) {
break;
@@ -91,7 +91,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
continue;
}
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteAddon.slug());
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteAddon;
} else {
@@ -103,7 +103,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteMod.slug());
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
@@ -122,8 +122,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public Stream<Category> getCategories() throws IOException {
return getBackedRemoteModRepository().getCategories();
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
return getBackedRemoteModRepository().getCategories(type);
}
@Override
@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.Skin;
import org.jackhuang.hmcl.auth.yggdrasil.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -99,7 +100,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(texture.url(), file).run();
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
LOG.info("Texture downloaded: " + texture.url());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -26,15 +26,11 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.game.JavaVersionConstraint;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -444,7 +440,7 @@ public final class JavaManager {
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
}
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
@@ -18,44 +18,47 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.List;
import java.util.concurrent.CancellationException;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DownloadProviders {
private DownloadProviders() {
}
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
private static final DownloadProvider MOJANG_PROVIDER;
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
private static final DownloadProvider DEFAULT_PROVIDER;
private static final String DEFAULT_BMCLAPI_ROOT = "https://bmclapi2.bangbang93.com";
private static final String BMCLAPI_ROOT;
static {
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
MOJANG_PROVIDER = new MojangDownloadProvider();
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
@Nullable String bmclOverride = System.getProperty("hmcl.bmclapi.override");
if (StringUtils.isBlank(bmclOverride)) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
WebURL url;
try {
url = WebURL.parseBrowserInput(bmclOverride);
} catch (Exception e) {
url = null;
LOG.warning("Invalid BMCLAPI override URL: " + bmclOverride, e);
}
if (url == null) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
BMCLAPI_ROOT = StringUtils.removeSuffix(url.toString(), "/");
}
}
}
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(BMCLAPI_ROOT);
/// Initializes download provider settings and synchronizes download thread settings.
public static void init() {
InvalidationListener onChangeDownloadThreads = observable -> {
@@ -67,73 +70,15 @@ public final class DownloadProviders {
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
onChangeDownloadThreads.invalidated(null);
InvalidationListener onChangeDownloadSource = observable -> {
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
settings().versionListSourceProperty().get(),
settings().fileDownloadSourceProperty().get()));
};
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
}
/// Creates a download provider with independent version-list and file download preferences.
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
return new AutoDownloadProvider(
getCandidates(versionListSource),
getCandidates(fileDownloadSource));
}
/// Returns provider candidates ordered by the given source preference.
private static List<DownloadProvider> getCandidates(DownloadSource source) {
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
return switch (normalized) {
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
case OFFICIAL -> List.of(MOJANG_PROVIDER);
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
};
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return PROVIDER_WRAPPER;
return PROVIDER;
}
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", uri);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", uri);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
} else if (exception instanceof CancellationException) {
return i18n("message.cancelled");
}
return StringUtils.getStackTrace(exception);
}
}
@@ -17,7 +17,10 @@
*/
package org.jackhuang.hmcl.setting;
import javafx.beans.property.*;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -33,13 +36,7 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnknownNullability;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.*;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
@@ -333,6 +330,7 @@ public final class GameDirectoryManager {
if (source == target) {
gameDirectory.setName(name);
gameDirectory.setPath(path);
rebuildGameDirectories();
return;
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
@@ -29,13 +30,12 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
@@ -50,13 +50,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaBundle {
private final Path root;
private final List<URI> links;
/// Candidate bundle URLs in attempt order.
private final List<WebURL> links;
private final FileDownloadTask.IntegrityCheck hash;
private final Map<String, FileDownloadTask.IntegrityCheck> files;
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
/// Creates a bundle using the supplied download candidates and archive/file integrity metadata.
/// The candidate list and file map are retained without copying.
public TerracottaBundle(Path root, List<WebURL> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
this.root = root;
this.links = links;
this.hash = hash;
@@ -22,6 +22,7 @@ import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.DownloadException;
@@ -42,7 +43,6 @@ import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -288,7 +288,7 @@ public final class TerracottaManager {
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
return new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR);
}
return Task.completed(null);
@@ -297,7 +297,7 @@ public final class TerracottaManager {
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return new TerracottaState.Waiting(-1, -1, null);
@@ -317,7 +317,7 @@ public final class TerracottaManager {
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
for (WebURL node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
@@ -338,7 +338,7 @@ public final class TerracottaManager {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
for (WebURL node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
@@ -35,10 +36,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -90,8 +91,8 @@ public final class TerracottaMetadata {
}
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> URI.create(options.replace(link)))
@Unmodifiable List<WebURL> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> WebURL.parse(options.replace(link)))
.toList();
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -26,9 +27,8 @@ import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -43,17 +43,19 @@ public final class TerracottaNodeList {
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
new URI(url);
} catch (URISyntaxException e) {
WebURL.parse(url);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
private static volatile List<URI> list;
/// Cached node URLs, or `null` before the first fetch completes.
private static volatile @Nullable @Unmodifiable List<WebURL> list;
public static List<URI> fetch() {
List<URI> list = TerracottaNodeList.list;
/// Returns the cached unmodifiable node URLs, fetching once and caching an empty list on failure.
public static @Unmodifiable List<WebURL> fetch() {
@Nullable @Unmodifiable List<WebURL> list = TerracottaNodeList.list;
if (list != null)
return list;
@@ -84,7 +86,7 @@ public final class TerracottaNodeList {
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
})
.map(it -> URI.create(it.url()))
.map(it -> WebURL.parse(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
@@ -26,6 +26,7 @@ import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorStyle;
import org.glavo.monetfx.Contrast;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.BackgroundType;
import org.jackhuang.hmcl.setting.LauncherSettings;
@@ -36,8 +37,8 @@ import org.jackhuang.hmcl.util.MathUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ContentEncoding;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNullByDefault;
@@ -48,7 +49,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
@@ -1497,28 +1497,28 @@ public final class ThemePackManager {
List<Path> temporaryFiles,
ResolvedBackground background) throws IOException {
String url = requireNonBlank(background.networkImageUrl(), "background.url");
URI uri = NetworkUtils.toURI(url);
if (!NetworkUtils.isHttpUri(uri)) {
WebURL webUrl = WebURL.parse(url);
if (!NetworkUtils.isHttpUri(webUrl)) {
throw new IOException("Theme background URL must be HTTP or HTTPS: " + url);
}
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(uri);
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(webUrl);
if (background.networkImageCachePolicy() == NetworkBackgroundImageCachePolicy.ENABLED) {
try {
@Nullable Path cachedFile = new CacheFileTask(uri).run();
@Nullable Path cachedFile = new CacheFileTask(webUrl).run();
if (cachedFile != null && Files.isRegularFile(cachedFile)) {
assets.add(new ThemePackAsset(cachedFile, entryName));
return new ThemeBackground.Image(entryName);
}
} catch (Exception e) {
LOG.warning("Failed to cache theme background, falling back to direct download: " + uri, e);
LOG.warning("Failed to cache theme background, falling back to direct download: " + webUrl, e);
}
}
Path temporaryFile = Files.createTempFile("hmcl-theme-background-", ".tmp");
boolean success = false;
try {
downloadNetworkBackground(uri, temporaryFile);
downloadNetworkBackground(webUrl, temporaryFile);
assets.add(new ThemePackAsset(temporaryFile, entryName));
temporaryFiles.add(temporaryFile);
success = true;
@@ -1535,12 +1535,12 @@ public final class ThemePackManager {
}
/// Downloads one network background into a temporary file without installing it into the persistent image cache.
private static void downloadNetworkBackground(URI uri, Path outputFile) throws IOException {
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(uri));
private static void downloadNetworkBackground(WebURL url, Path outputFile) throws IOException {
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(url));
try {
int responseCode = connection.getResponseCode();
if (responseCode / 100 != 2) {
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + uri);
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + url);
}
ContentEncoding contentEncoding = ContentEncoding.fromConnection(connection);
@@ -1554,8 +1554,8 @@ public final class ThemePackManager {
}
/// Returns a safe theme-pack asset file name for a downloaded network background.
private static String networkBackgroundAssetName(URI uri) {
String path = Objects.toString(uri.getPath(), "");
private static String networkBackgroundAssetName(WebURL url) {
String path = Objects.toString(url.getPath(), "");
@Nullable Path fileNamePath = path.isBlank() ? null : Path.of(path).getFileName();
String fileName = fileNamePath != null ? fileNamePath.toString() : "";
String sanitized = sanitizePathSegment(fileName);
@@ -91,6 +91,9 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Provides the current launcher MonetFX theme and derived color bindings.
@NotNullByDefault
public final class Themes {
/// The stage property retaining the listener for native appearance updates and marking completed registration.
private static final String NATIVE_DARK_MODE_LISTENER = "Themes.applyNativeDarkMode.listener";
/// The seed color extracted from the last loaded wallpaper image.
private static final ReadOnlyObjectWrapper<@Nullable ThemeColor> wallpaperThemeColor = new ReadOnlyObjectWrapper<>();
@@ -1148,8 +1151,17 @@ public final class Themes {
return autoBrightness.getReadOnlyProperty();
}
/// Applies native dark-mode integration to a JavaFX stage where the platform supports it.
/// Registers native dark-mode integration once per stage where the platform supports it.
///
/// Windows updates the stage's native frame; macOS updates the application appearance. The stage retains
/// its listener, which observes theme changes weakly. Repeated calls for an already registered stage do
/// nothing, including after hiding the stage or replacing its scene.
///
/// @param stage the stage retaining the registration, accessed on the JavaFX application thread
public static void applyNativeDarkMode(Stage stage) {
if (stage.getProperties().containsKey(NATIVE_DARK_MODE_LISTENER)) {
return;
}
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> {
if (stage.isShowing()) {
@@ -1166,7 +1178,7 @@ public final class Themes {
});
}
});
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
if (stage.isShowing()) {
listener.changed(null, false, Themes.darkModeProperty().get());
@@ -1183,7 +1195,7 @@ public final class Themes {
MacOSNativeUtils.setAppearance(darkModeProperty().get());
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance);
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
}
}
@@ -96,6 +96,7 @@ public final class Controllers {
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
/// The coordinator for the main window's scene graph and navigation stack.
private static @Nullable Decorator decorator;
private static DownloadPage downloadPage;
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
AccountListPage accountListPage = new AccountListPage();
@@ -210,6 +211,16 @@ public final class Controllers {
}
}
/// Configures the main stage's application identity, shutdown action, and taskbar relaunch information.
///
/// @param stage the unshown main stage
private static void configureMainStage(Stage stage) {
stage.setOnCloseRequest(event -> Launcher.stopApplication());
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(stage);
}
/// Initializes the main application stage, scene graph, and background services.
///
/// @param stage the primary application stage, which must not have been shown
@@ -230,9 +241,9 @@ public final class Controllers {
}
}
stage.setOnCloseRequest(e -> Launcher.stopApplication());
configureMainStage(stage);
decorator = new Decorator(getRootPage());
decorator = new Decorator(getRootPage(), Controllers::configureMainStage);
Scene mainScene = decorator.attachStage(stage);
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
getRootPage().getMainPage().showUpdateDialogProperty().bind(
@@ -251,9 +262,6 @@ public final class Controllers {
StyleSheets.init(mainScene);
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) {
Runnable continueAction = () -> {
UserState userState = userState();
@@ -606,20 +614,20 @@ public final class Controllers {
}
}
public static void openUriOrCopy(@Nullable URI uri) {
if (uri == null) return;
openUriOrCopy(uri.toString());
public static void openUriOrCopy(@Nullable URI url) {
if (url == null) return;
openUriOrCopy(url.toString());
}
public static void openUriOrCopy(@Nullable String uri) {
if (uri == null) return;
public static void openUriOrCopy(@Nullable String url) {
if (url == null) return;
var dialog = new MessageDialogPane.Builder(
i18n("web.open_in_browser", uri),
i18n("web.open_in_browser", url),
i18n("message.confirm"),
MessageDialogPane.MessageType.QUESTION
)
.addAction(i18n("button.copy"), () -> FXUtils.copyText(uri))
.yesOrNo(() -> FXUtils.openLink(uri), null)
.addAction(i18n("button.copy"), () -> FXUtils.copyText(url))
.yesOrNo(() -> FXUtils.openLink(url), null)
.build();
dialog(dialog);
}
@@ -71,7 +71,7 @@ public final class DialogUtils {
private static void showInDecorator(Decorator decorator, StackPane dialogContainer, Node content) {
show(dialogContainer, content, dialog -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingWindow(dialog);
decorator.capableDraggingOverlay(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(dialogContainer);
});
@@ -62,6 +62,7 @@ import javafx.stage.*;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
@@ -613,21 +614,21 @@ public final class FXUtils {
if (link == null)
return;
String uri = NetworkUtils.encodeLocation(link);
String url = NetworkUtils.encodeLocation(link);
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri});
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", url});
return;
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Runtime.getRuntime().exec(new String[]{"open", uri});
Runtime.getRuntime().exec(new String[]{"open", url});
return;
} else {
for (String browser : linuxBrowsers) {
Path path = SystemUtils.which(browser);
if (path != null) {
try {
Runtime.getRuntime().exec(new String[]{path.toString(), uri});
Runtime.getRuntime().exec(new String[]{path.toString(), url});
return;
} catch (Throwable ignored) {
}
@@ -640,7 +641,7 @@ public final class FXUtils {
}
try {
java.awt.Desktop.getDesktop().browse(new URI(uri));
java.awt.Desktop.getDesktop().browse(new URI(url));
} catch (Throwable e) {
LOG.warning("Failed to open link: " + link, e);
}
@@ -1001,8 +1002,9 @@ public final class FXUtils {
.setSignificance(Task.TaskSignificance.MINOR);
}
public static Task<Image> getRemoteImageTask(List<URI> uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uris)
/// Creates a task that caches an image from HTTP(S) candidates and loads it with the requested sizing options.
public static Task<Image> getRemoteImageTask(DownloadCandidates candidates, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(candidates)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
@@ -23,6 +23,7 @@ import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
@@ -61,6 +62,7 @@ import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogUtils;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -78,6 +80,9 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
/// Coordinates the main window's visual tree, navigation, dialogs, and native window behavior.
@@ -129,6 +134,29 @@ public final class Decorator {
/// The navigation stack rendered in the main window.
private final Navigator navigator = new Navigator();
/// Reflective access to system decoration, or `null` on unsupported runtimes.
private final @Nullable NativeWindowDecoration nativeDecoration = NativeWindowDecoration.create();
/// Whether the attached stage delegates window decoration and gestures to the platform.
private boolean nativeDecorationEnabled;
/// Installs application-specific identity and lifecycle integration on replacement stages before attachment.
private final Consumer<Stage> stageInitializer;
/// Whether an appearance change has already queued a window-style check.
private boolean windowStyleUpdateQueued;
/// Coalesces appearance changes so stage replacement runs after theme bindings have updated.
private final ChangeListener<Boolean> windowStyleListener = (observable, oldValue, newValue) -> {
if (!windowStyleUpdateQueued) {
windowStyleUpdateQueued = true;
Platform.runLater(() -> {
windowStyleUpdateQueued = false;
updateWindowStyle();
});
}
};
/// The clipped main-window content hosted inside [#shadowContainer].
private final MainWindowPane mainWindowPane;
@@ -141,15 +169,18 @@ public final class Decorator {
/// Whether the navigation close action should use the home icon.
private final BooleanProperty showCloseAsHome = new SimpleBooleanProperty(this, "showCloseAsHome");
/// The width of the visible window content, excluding custom decoration insets.
/// The width of the visible window content, excluding native borders and custom shadow padding.
private final DoubleProperty contentWidth = new SimpleDoubleProperty(this, "contentWidth");
/// The height of the visible window content, excluding custom decoration insets.
/// The height of the visible window content, excluding native borders and custom shadow padding.
private final DoubleProperty contentHeight = new SimpleDoubleProperty(this, "contentHeight");
/// The stage currently controlled by this decorator, or `null` while detached.
private @Nullable Stage stage;
/// Tracks the attached stage's content geometry, or `null` while detached.
private @Nullable WindowBounds windowBounds;
/// The title-bar state supplied by the current page, or `null` before navigation is initialized.
private final ObjectProperty<DecoratorPage.@Nullable State> state = new SimpleObjectProperty<>(this, "state");
@@ -198,49 +229,7 @@ public final class Decorator {
private final InvalidationListener stageDecorationListener = observable -> {
@Nullable Stage currentStage = stage;
if (currentStage != null) {
updateWindowDecoration(currentStage.isMaximized() || currentStage.isFullScreen());
}
};
/// Updates the changed visible-content bound and persists normal, non-iconified stage bounds.
private final InvalidationListener stageBoundsListener = observable -> {
@Nullable Stage currentStage = stage;
if (currentStage == null) {
return;
}
Insets insets = getWindowInsets();
boolean saveBounds = !currentStage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|| !currentStage.isFullScreen() && !currentStage.isMaximized());
if (observable == currentStage.xProperty()) {
if (saveBounds) {
double currentContentX = currentStage.getX() + insets.getLeft();
SettingsManager.state().setX(currentContentX / PRIMARY_SCREEN_BOUNDS.getWidth());
}
} else if (observable == currentStage.yProperty()) {
if (saveBounds) {
double currentContentY = currentStage.getY() + insets.getTop();
SettingsManager.state().setY(currentContentY / PRIMARY_SCREEN_BOUNDS.getHeight());
}
} else if (observable == currentStage.widthProperty()) {
double currentContentWidth = Math.max(
MIN_CONTENT_WIDTH,
currentStage.getWidth() - insets.getLeft() - insets.getRight());
contentWidth.set(currentContentWidth);
if (saveBounds) {
SettingsManager.state().setWidth(currentContentWidth);
}
} else if (observable == currentStage.heightProperty()) {
double currentContentHeight = Math.max(
MIN_CONTENT_HEIGHT,
currentStage.getHeight() - insets.getTop() - insets.getBottom());
contentHeight.set(currentContentHeight);
if (saveBounds) {
SettingsManager.state().setHeight(currentContentHeight);
}
updateWindowDecoration(WindowState.isMaximized(currentStage) || currentStage.isFullScreen());
}
};
@@ -251,9 +240,13 @@ public final class Decorator {
///
/// The decorator initially enables its custom window shadow. No scene is created until
/// [#attachStage(Stage)] is called or [#getRoot()] is otherwise installed in a scene.
/// Appearance changes replace the attached stage when required, preserving its retained scene and window state.
///
/// @param mainPage the permanent root page of the navigation stack
public Decorator(Node mainPage) {
/// @param stageInitializer configures each replacement stage on the JavaFX application thread before attachment;
/// it must not show the stage and is not called for stages supplied to [#attachStage(Stage)]
public Decorator(Node mainPage, Consumer<Stage> stageInitializer) {
this.stageInitializer = Objects.requireNonNull(stageInitializer);
navigator.setOnNavigated(this::onNavigated);
mainWindowPane = new MainWindowPane(this);
@@ -285,21 +278,21 @@ public final class Decorator {
return root;
}
/// Returns the insets reserved outside the main-window content.
/// Returns the space reserved inside the scene for custom window shadows.
///
/// @return the root's shadow insets for a normal window, or [Insets#EMPTY] while maximized or full-screen
/// @return the custom shadow insets, or [Insets#EMPTY] with native decoration, supported maximization, or full-screen
public Insets getWindowInsets() {
return root.getPadding();
}
/// Returns the visible window-content width property, excluding custom decoration insets.
/// Returns the visible window-content width property, excluding native borders and custom shadow padding.
///
/// @return the current content-width property
public ReadOnlyDoubleProperty contentWidthProperty() {
return contentWidth;
}
/// Returns the visible window-content height property, excluding custom decoration insets.
/// Returns the visible window-content height property, excluding native borders and custom shadow padding.
///
/// @return the current content-height property
public ReadOnlyDoubleProperty contentHeightProperty() {
@@ -316,11 +309,12 @@ public final class Decorator {
/// Updates the shadow, outer insets, and content corner shape for an edge-to-edge window state.
///
/// @param edgeToEdge whether the attached stage is maximized or full-screen
/// @param edgeToEdge whether the attached stage is maximized under [WindowState]'s policy or full-screen
private void updateWindowDecoration(boolean edgeToEdge) {
root.setPadding(edgeToEdge ? Insets.EMPTY : SHADOW_INSETS);
shadowContainer.setEffect(edgeToEdge ? null : windowShadow);
mainWindowPane.setWindowEdgeToEdge(edgeToEdge);
boolean customDecoration = !nativeDecorationEnabled && !edgeToEdge;
root.setPadding(customDecoration ? SHADOW_INSETS : Insets.EMPTY);
shadowContainer.setEffect(customDecoration ? windowShadow : null);
mainWindowPane.setWindowCornersRounded(customDecoration);
}
/// Returns the pane on which application dialogs are stacked.
@@ -405,7 +399,14 @@ public final class Decorator {
///
/// @param node the drag-enabled node
public void capableDraggingWindow(Node node) {
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> allowMove = true);
if (nativeDecoration != null) {
nativeDecoration.setDraggable(node, true);
}
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
if (!nativeDecorationEnabled) {
allowMove = true;
}
});
node.addEventHandler(MouseEvent.MOUSE_EXITED, event -> {
if (!dragging) {
allowMove = false;
@@ -413,6 +414,19 @@ public final class Decorator {
});
}
/// Configures window dragging through an overlay while retaining ordinary mouse picking.
///
/// Native decoration permits dragging only over underlying draggable header areas. Custom decoration
/// permits dragging from the overlay itself. Interactive content must be excluded with [#forbidDraggingWindow(Node)].
///
/// @param overlay the overlay above the window content and title bar
public void capableDraggingOverlay(Node overlay) {
capableDraggingWindow(overlay);
if (nativeDecoration != null) {
nativeDecoration.setDragTransparent(overlay);
}
}
/// Registers `node` as the title-bar area used to restore and maximize the attached stage.
///
/// @param node the title-bar node
@@ -425,9 +439,14 @@ public final class Decorator {
///
/// @param node the node that must consume drag eligibility
public void forbidDraggingWindow(Node node) {
if (nativeDecoration != null) {
nativeDecoration.setDraggable(node, false);
}
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
allowMove = false;
event.consume();
if (!nativeDecorationEnabled) {
allowMove = false;
event.consume();
}
});
}
@@ -436,14 +455,15 @@ public final class Decorator {
/// @param event the title-bar click event
private void onTitleBarDoubleClick(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
if (nativeDecorationEnabled
|| currentStage == null
|| !WindowState.supportsMaximization(currentStage)
|| event.getButton() != MouseButton.PRIMARY
|| event.getClickCount() != 2) {
return;
}
currentStage.setMaximized(!currentStage.isMaximized());
currentStage.setMaximized(!WindowState.isMaximized(currentStage));
event.consume();
}
@@ -452,7 +472,7 @@ public final class Decorator {
/// @param event the title-bar drag event
private void onTitleBarDragged(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (currentStage == null || dragging || !currentStage.isMaximized()) {
if (nativeDecorationEnabled || currentStage == null || dragging || !WindowState.isMaximized(currentStage)) {
return;
}
@@ -531,10 +551,10 @@ public final class Decorator {
/// @param event the pointer movement event
private void onMouseMoved(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (currentStage == null
if (nativeDecorationEnabled || currentStage == null
|| currentStage.isIconified()
|| currentStage.isFullScreen()
|| currentStage.isMaximized()
|| WindowState.isMaximized(currentStage)
|| !currentStage.isResizable()) {
root.setCursor(Cursor.DEFAULT);
return;
@@ -597,10 +617,10 @@ public final class Decorator {
/// @param event the primary-button drag event
private void onMouseDragged(MouseEvent event) {
@Nullable Stage currentStage = stage;
if (currentStage == null
if (nativeDecorationEnabled || currentStage == null
|| currentStage.isIconified()
|| currentStage.isFullScreen()
|| currentStage.isMaximized()
|| WindowState.isMaximized(currentStage)
|| !event.isPrimaryButtonDown()
|| event.isStillSincePress()) {
return;
@@ -673,13 +693,12 @@ public final class Decorator {
return root.getScene();
}
/// Applies the persisted content bounds and current decoration insets to `targetStage`.
/// Returns the persisted normal content rectangle without native borders or custom shadow padding.
///
/// An off-screen persisted position is replaced with a position centered on the primary screen.
///
/// @param targetStage the stage receiving the initial outer bounds and minimum dimensions
private void initializeStageBounds(Stage targetStage) {
Insets insets = getWindowInsets();
/// @return the initial content bounds in JavaFX screen coordinates
private Rectangle2D initialContentBounds() {
double initialContentWidth = Math.max(MIN_CONTENT_WIDTH, SettingsManager.state().getWidth());
double initialContentHeight = Math.max(MIN_CONTENT_HEIGHT, SettingsManager.state().getHeight());
double initialContentX = SettingsManager.state().getX() * PRIMARY_SCREEN_BOUNDS.getWidth();
@@ -702,14 +721,68 @@ public final class Decorator {
initialContentY = (PRIMARY_SCREEN_BOUNDS.getHeight() - initialContentHeight) / 2;
}
targetStage.setX(initialContentX - insets.getLeft());
targetStage.setY(initialContentY - insets.getTop());
targetStage.setWidth(initialContentWidth + insets.getLeft() + insets.getRight());
targetStage.setHeight(initialContentHeight + insets.getTop() + insets.getBottom());
targetStage.setMinWidth(MIN_CONTENT_WIDTH + insets.getLeft() + insets.getRight());
targetStage.setMinHeight(MIN_CONTENT_HEIGHT + insets.getTop() + insets.getBottom());
contentWidth.set(initialContentWidth);
contentHeight.set(initialContentHeight);
return new Rectangle2D(initialContentX, initialContentY, initialContentWidth, initialContentHeight);
}
/// Persists a normal content rectangle independently of the window's decoration style.
///
/// @param bounds the content bounds in JavaFX screen coordinates
private void saveContentBounds(Rectangle2D bounds) {
SettingsManager.state().setX(bounds.getMinX() / PRIMARY_SCREEN_BOUNDS.getWidth());
SettingsManager.state().setY(bounds.getMinY() / PRIMARY_SCREEN_BOUNDS.getHeight());
SettingsManager.state().setWidth(bounds.getWidth());
SettingsManager.state().setHeight(bounds.getHeight());
}
/// Returns the preferred style for the configured native-decoration policy and current theme.
///
/// @return the system style when available and permitted by the policy; otherwise the custom transparent style
private StageStyle preferredStageStyle() {
return nativeDecoration != null && nativeDecoration.isPreferred(
Themes.windowTransparentProperty().get(), Themes.darkModeProperty().get())
? nativeDecoration.style : StageStyle.TRANSPARENT;
}
/// Replaces the attached stage when transparency or theme brightness requires a different decoration style.
///
/// Preserves the scene, navigation, dialogs, normal bounds, supported window states, and focus owner.
/// Hidden windows remain hidden. Does nothing while detached or when the current style is still suitable.
/// Must run on the JavaFX application thread after theme bindings have updated.
private void updateWindowStyle() {
FXUtils.checkFxUserThread();
@Nullable Stage previousStage = stage;
if (previousStage == null || previousStage.getStyle() == preferredStageStyle()) {
return;
}
boolean showing = previousStage.isShowing();
boolean maximized = WindowState.isMaximized(previousStage);
boolean fullScreen = previousStage.isFullScreen();
boolean iconified = previousStage.isIconified();
@Nullable Node focusOwner = previousStage.getScene().getFocusOwner();
Stage replacement = new Stage();
stageInitializer.accept(replacement);
replacement.setTitle(previousStage.getTitle());
replacement.getIcons().setAll(previousStage.getIcons());
replacement.setOnCloseRequest(previousStage.getOnCloseRequest());
replacement.setResizable(previousStage.isResizable());
replacement.setAlwaysOnTop(previousStage.isAlwaysOnTop());
replacement.setFullScreenExitHint(previousStage.getFullScreenExitHint());
replacement.setFullScreenExitKeyCombination(previousStage.getFullScreenExitKeyCombination());
detachStage();
previousStage.hide();
attachStage(replacement);
replacement.setMaximized(maximized && WindowState.supportsMaximization(replacement));
replacement.setFullScreen(fullScreen);
replacement.setIconified(iconified);
if (showing) {
replacement.show();
}
if (focusOwner != null) {
focusOwner.requestFocus();
}
}
/// Attaches the retained scene and native window behavior to `newStage`.
@@ -717,23 +790,40 @@ public final class Decorator {
/// If the root has no scene, this method reuses `newStage`'s scene and replaces its root, or creates a
/// transparent scene when the stage has none. If the retained scene belongs to another stage, it is detached
/// from that stage before being installed on `newStage`. A newly attached stage receives the persisted normal
/// content bounds and minimum size adjusted for the normal decoration insets. The stage and retained scene use
/// transparent styles required by the custom decoration. Any active window animation is cancelled and reset.
/// When window animations are enabled, each subsequent showing starts the opening animation. This method does
/// not show the stage.
/// content bounds. Native frame offsets and minimum size are resolved when the stage is shown.
/// The native-decoration policy selects the system or custom transparent decoration. Automatic mode uses
/// system decoration for supported opaque windows, except for dark windows on Windows; explicit enablement
/// bypasses the brightness exclusion. Transparent windows always use custom decoration.
/// Any active window animation is cancelled and reset. Custom window
/// animations run only with custom decoration. Window-style listeners remain active until [#detachStage()].
/// On macOS, native application appearance updates are installed once per stage and retained across detachment.
/// This method does not show the stage or invoke the replacement-stage initializer.
///
/// @param newStage the stage to attach, which must be accessed on the JavaFX application thread
/// @return the scene installed on `newStage`
/// @throws IllegalStateException if `newStage` has already been shown with a non-transparent style, or if the
/// @throws IllegalStateException if `newStage` has already been shown with a different style, or if the
/// retained root or scene cannot be transferred from its current owner
public Scene attachStage(Stage newStage) {
FXUtils.checkFxUserThread();
stopWindowAnimation();
if (newStage.getStyle() != StageStyle.TRANSPARENT) {
newStage.initStyle(StageStyle.TRANSPARENT);
if (stage != null && stage != newStage) {
detachStage();
}
StageStyle style = preferredStageStyle();
if (newStage.getStyle() != style) {
newStage.initStyle(style);
}
nativeDecorationEnabled = nativeDecoration != null && style == nativeDecoration.style;
if (nativeDecoration != null) {
nativeDecoration.setContent(null);
if (nativeDecorationEnabled) {
nativeDecoration.configureStage(newStage);
}
}
mainWindowPane.setNativeDecoration(nativeDecorationEnabled ? nativeDecoration : null);
@Nullable Scene retainedScene = root.getScene();
Scene scene;
if (retainedScene == null) {
@@ -759,37 +849,37 @@ public final class Decorator {
}
}
scene.setFill(Color.TRANSPARENT);
if (nativeDecorationEnabled) {
scene.fillProperty().bind(Themes.colorSchemeProperty().getSurfaceContainer());
} else {
scene.fillProperty().unbind();
scene.setFill(Color.TRANSPARENT);
}
if (newStage.getScene() != scene) {
newStage.setScene(scene);
}
if (stage != newStage) {
playRestoreMinimizeAnimation = false;
if (stage != null) {
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
stage.iconifiedProperty().removeListener(iconifiedListener);
stage.maximizedProperty().removeListener(stageDecorationListener);
stage.fullScreenProperty().removeListener(stageDecorationListener);
stage.xProperty().removeListener(stageBoundsListener);
stage.yProperty().removeListener(stageBoundsListener);
stage.widthProperty().removeListener(stageBoundsListener);
stage.heightProperty().removeListener(stageBoundsListener);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Themes.applyNativeDarkMode(newStage);
}
playRestoreMinimizeAnimation = false;
updateWindowDecoration(false);
initializeStageBounds(newStage);
stage = newStage;
updateWindowDecoration(newStage.isMaximized() || newStage.isFullScreen());
newStage.addEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
newStage.iconifiedProperty().addListener(iconifiedListener);
newStage.maximizedProperty().addListener(stageDecorationListener);
newStage.fullScreenProperty().addListener(stageDecorationListener);
newStage.xProperty().addListener(stageBoundsListener);
newStage.yProperty().addListener(stageBoundsListener);
newStage.widthProperty().addListener(stageBoundsListener);
newStage.heightProperty().addListener(stageBoundsListener);
windowBounds = new WindowBounds(newStage, root, initialContentBounds(),
MIN_CONTENT_WIDTH, MIN_CONTENT_HEIGHT, this::saveContentBounds);
contentWidth.bind(windowBounds.contentWidthProperty());
contentHeight.bind(windowBounds.contentHeightProperty());
updateWindowDecoration(WindowState.isMaximized(newStage) || newStage.isFullScreen());
Themes.windowTransparentProperty().addListener(windowStyleListener);
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Themes.darkModeProperty().addListener(windowStyleListener);
}
}
root.setCursor(Cursor.DEFAULT);
dragging = false;
@@ -801,20 +891,27 @@ public final class Decorator {
///
/// The root, scene, navigation state, dialogs, and snackbar remain owned by this decorator and may subsequently
/// be attached to another stage. Calling this method while detached has no effect beyond resetting root transforms.
/// Removes window-style listeners; a queued style check does nothing unless another stage has been attached.
/// Hiding a stage that will later be shown again does not require detachment.
public void detachStage() {
FXUtils.checkFxUserThread();
stopWindowAnimation();
if (windowBounds != null) {
windowBounds.close();
contentWidth.unbind();
contentHeight.unbind();
windowBounds = null;
}
if (stage != null) {
Themes.windowTransparentProperty().removeListener(windowStyleListener);
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Themes.darkModeProperty().removeListener(windowStyleListener);
}
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
stage.iconifiedProperty().removeListener(iconifiedListener);
stage.maximizedProperty().removeListener(stageDecorationListener);
stage.fullScreenProperty().removeListener(stageDecorationListener);
stage.xProperty().removeListener(stageBoundsListener);
stage.yProperty().removeListener(stageBoundsListener);
stage.widthProperty().removeListener(stageBoundsListener);
stage.heightProperty().removeListener(stageBoundsListener);
if (stage.getScene() == root.getScene()) {
stage.setScene(null);
}
@@ -880,7 +977,7 @@ public final class Decorator {
/// Calling this method replaces any active minimize, restore, close, or earlier opening animation. If window
/// animations are disabled, it restores the stable root transform without starting an animation.
private void playOpenAnimation() {
if (!AnimationUtils.playWindowAnimation()) {
if (nativeDecorationEnabled || !AnimationUtils.playWindowAnimation()) {
stopWindowAnimation();
Controllers.trimHeap();
return;
@@ -915,7 +1012,7 @@ public final class Decorator {
return;
}
if (AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
@@ -946,7 +1043,7 @@ public final class Decorator {
/// Closes the application, using the configured window animation when enabled.
void closeWindow() {
if (AnimationUtils.playWindowAnimation()) {
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation()) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
@@ -1074,6 +1171,11 @@ public final class Decorator {
/// Restores the root node's transform after an animated minimization.
private void playRestoreAnimation() {
if (nativeDecorationEnabled) {
stopWindowAnimation();
return;
}
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(root.opacityProperty(), 0, Motion.EASE),
@@ -63,7 +63,7 @@ final class MainWindowPane extends StackPane {
/// The decorator whose state and actions are represented by this pane.
private final Decorator decorator;
/// The clip that rounds normal window corners and becomes square while the window fills the screen.
/// The content clip, rounded only for normal windows with custom decoration.
private final Rectangle clip = new Rectangle();
/// The frame containing the title bar and current navigation page.
@@ -72,6 +72,9 @@ final class MainWindowPane extends StackPane {
/// The title bar containing page navigation state.
private final BorderPane titleBar;
/// The custom help, minimize, and close buttons shared by both decoration styles.
private final HBox windowButtons;
/// The transition container used when the title-bar state changes.
private final TransitionPane navBarPane;
@@ -112,14 +115,15 @@ final class MainWindowPane extends StackPane {
center.getChildren().setAll(decorator.getNavigator());
frame.setCenter(center);
HBox rightButtonsContainer = createWindowButtons();
windowButtons = createWindowButtons();
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(rightButtonsContainer);
titleBar.setRight(windowButtons);
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
decorator.capableDraggingWindow(navBarPane);
frame.setTop(titleBar);
updateTitleBarBackground();
@@ -136,11 +140,27 @@ final class MainWindowPane extends StackPane {
getChildren().setAll(backgroundNode, frame);
}
/// Updates the content-corner shape for an edge-to-edge window state.
/// Moves the custom title bar into or out of the native dragging header.
///
/// @param edgeToEdge whether the attached window is maximized or full-screen
void setWindowEdgeToEdge(boolean edgeToEdge) {
double arc = edgeToEdge ? 0.0 : ARC;
/// @param decoration the native header support, or `null` for custom decoration
void setNativeDecoration(@Nullable NativeWindowDecoration decoration) {
frame.setTop(null);
if (decoration == null) {
frame.setTop(titleBar);
} else {
decoration.setContent(titleBar);
decoration.headerBar.backgroundProperty().bind(titleBar.backgroundProperty());
frame.setTop(decoration.headerBar);
}
}
/// Enables or disables custom rounding of the content corners.
///
/// Native decoration uses square content bounds so the platform can shape the window outline.
///
/// @param rounded whether to round the content corners
void setWindowCornersRounded(boolean rounded) {
double arc = rounded ? ARC : 0.0;
clip.setArcWidth(arc);
clip.setArcHeight(arc);
}
@@ -171,6 +191,7 @@ final class MainWindowPane extends StackPane {
private HBox createWindowButtons() {
HBox buttons = new HBox();
buttons.setAlignment(Pos.TOP_RIGHT);
decorator.capableDraggingWindow(buttons);
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton helpButton = new JFXButton();
@@ -247,6 +268,7 @@ final class MainWindowPane extends StackPane {
private Node createNavBar(DecoratorPage.State state) {
HBox navBar = new HBox();
navBar.setAlignment(Pos.CENTER_LEFT);
decorator.capableDraggingWindow(navBar);
// Left navigation buttons
if (state.backable()) {
@@ -275,6 +297,7 @@ final class MainWindowPane extends StackPane {
StackPane titleArea = new StackPane();
titleArea.setAlignment(Pos.CENTER_LEFT);
titleArea.setMinWidth(0);
decorator.capableDraggingWindow(titleArea);
HBox.setHgrow(titleArea, Priority.ALWAYS);
if (state.titleNode() != null) {
titleArea.getChildren().setAll(state.titleNode());
@@ -284,6 +307,7 @@ final class MainWindowPane extends StackPane {
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
titleLabel.getStyleClass().add("jfx-decorator-title");
titleLabel.setMinWidth(0);
titleLabel.setMouseTransparent(true);
titleArea.getChildren().setAll(titleLabel);
}
@@ -0,0 +1,245 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.collections.ListChangeListener;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.layout.Region;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Accesses the JavaFX 27 extended-window APIs without linking them on older runtimes.
@NotNullByDefault
final class NativeWindowDecoration {
/// Marks nodes whose title-content descendants are already observed for native hit testing.
private static final Object DRAG_CONFIGURED = new Object();
/// Whether the user explicitly enabled native decoration regardless of automatic platform or brightness exclusions.
private final boolean forced;
/// The platform-supported extended stage style.
final StageStyle style;
/// The header bar that supplies native dragging around the custom title content.
final Region headerBar;
/// Assigns the header bar's center content.
private final MethodHandle setCenter;
/// Assigns a node's native header hit-test behavior.
private final MethodHandle setDragType;
/// Reads explicit native drag exclusions on title nodes.
private final MethodHandle getDragType;
/// Sets the system button height to zero to hide the platform-provided buttons.
private final MethodHandle setSystemButtonHeight;
/// Marks only the specified node as draggable, preserving child control interaction.
private final Object draggable;
/// Excludes a node and its descendants from native dragging.
private final Object notDraggable;
/// Ignores an overlay and its descendants when testing underlying native header areas.
private final Object transparentSubtree;
/// Resolves all required public APIs and creates a detached header bar.
///
/// @param forced whether to bypass the automatic theme brightness exclusion
/// @throws Throwable if a required API cannot be resolved or invoked
private NativeWindowDecoration(boolean forced) throws Throwable {
this.forced = forced;
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
style = (StageStyle) lookup.findStaticGetter(StageStyle.class, "EXTENDED", StageStyle.class).invokeExact();
Class<?> headerClass = Class.forName("javafx.scene.layout.HeaderBar");
Class<?> dragClass = Class.forName("javafx.scene.layout.HeaderDragType");
// Adapt runtime-only types once so cached handles can be invoked with exact, statically known signatures.
setCenter = lookup.findVirtual(headerClass, "setCenter", MethodType.methodType(void.class, Node.class))
.asType(MethodType.methodType(void.class, Region.class, Node.class));
setDragType = lookup.findStatic(headerClass, "setDragType", MethodType.methodType(void.class, Node.class, dragClass))
.asType(MethodType.methodType(void.class, Node.class, Object.class));
getDragType = lookup.findStatic(headerClass, "getDragType", MethodType.methodType(dragClass, Node.class))
.asType(MethodType.methodType(Object.class, Node.class));
setSystemButtonHeight = lookup.findStatic(headerClass, "setSystemButtonHeight",
MethodType.methodType(void.class, Stage.class, double.class));
draggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "DRAGGABLE", dragClass).invoke());
notDraggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "NONE", dragClass).invoke());
transparentSubtree = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "TRANSPARENT_SUBTREE", dragClass).invoke());
headerBar = (Region) lookup.findConstructor(headerClass, MethodType.methodType(void.class)).invoke();
}
/// Creates native decoration support according to the configured preference and runtime capabilities.
///
/// The `hmcl.nativeDecoration` system property takes precedence over `HMCL_NATIVE_DECORATION`.
/// Values are case-insensitive and trimmed: `false` disables native decoration, `true` enables it on
/// any supported platform, and `auto` enables it only on macOS or Windows 11 and later. Missing or
/// unrecognized values use `auto`. All modes require JavaFX 27 or later and extended-window support.
/// The preference is read when this method is called and retained by the returned instance.
///
/// @return the resolved support, or `null` when disabled by policy or unavailable on the runtime
static @Nullable NativeWindowDecoration create() {
@Nullable String preference = System.getProperty("hmcl.nativeDecoration", System.getenv("HMCL_NATIVE_DECORATION"));
if (preference != null) {
preference = preference.trim();
}
if ("false".equalsIgnoreCase(preference)) {
return null;
}
boolean forced = "true".equalsIgnoreCase(preference);
if (!forced && preference != null && !"auto".equalsIgnoreCase(preference)) {
LOG.warning("Invalid native decoration preference: " + preference + "; using auto");
}
if (!forced && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS
&& !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11)) {
return null;
}
try {
int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]);
if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) {
return null;
}
return new NativeWindowDecoration(forced);
} catch (Throwable e) {
LOG.warning("Native window decoration is unavailable; using custom decoration", e);
return null;
}
}
/// Returns whether to use native decoration with the current transparency and theme brightness.
///
/// Transparent windows always use custom decoration. Automatic mode also excludes dark windows on Windows;
/// explicitly enabling native decoration bypasses only that brightness exclusion.
///
/// @param transparent whether the theme requests window transparency
/// @param dark whether the current theme is dark
/// @return whether this instance permits native decoration for the supplied theme settings
boolean isPreferred(boolean transparent, boolean dark) {
return !transparent && (forced || !(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && dark));
}
/// Sets or removes the header content; the caller must first detach it from any other parent.
///
/// Configures native dragging on non-interactive descendants and observes later child additions.
/// Controls other than labels and nodes explicitly excluded from dragging retain ordinary input handling.
///
/// @param content the title-bar content, or `null` to detach it
void setContent(@Nullable Node content) {
try {
if (content != null) {
configureTitleDragging(content);
}
setCenter.invokeExact(headerBar, content);
} catch (Throwable e) {
throw new IllegalStateException("Cannot update native header content", e);
}
}
/// Marks a node as draggable or explicitly non-draggable during native header hit testing.
///
/// Draggability does not extend to descendants, so interactive title content retains its behavior.
///
/// @param node the node to configure
/// @param enabled whether the node itself may move the window
void setDraggable(Node node, boolean enabled) {
try {
setDragType.invokeExact(node, enabled ? draggable : notDraggable);
} catch (Throwable e) {
throw new IllegalStateException("Cannot configure native header dragging", e);
}
}
/// Lets native header hit testing pass through an overlay and its descendants.
///
/// Ordinary mouse picking is unchanged. This does not create draggable areas outside the underlying
/// header. Descendants excluded with [#setDraggable(Node, boolean)] block native dragging instead.
///
/// @param overlay the overlay above the header
void setDragTransparent(Node overlay) {
try {
setDragType.invokeExact(overlay, transparentSubtree);
} catch (Throwable e) {
throw new IllegalStateException("Cannot configure native header overlay", e);
}
}
/// Configures native hit testing for title content, including subsequently added descendants.
///
/// Containers and label graphics may move the window. Other controls and nodes explicitly excluded with
/// [#setDraggable(Node, boolean)] retain ordinary input handling, including all of their descendants.
///
/// @param node the title content to configure
private void configureTitleDragging(Node node) {
if (node.getProperties().putIfAbsent(DRAG_CONFIGURED, Boolean.TRUE) != null) {
return;
}
try {
if ((Object) getDragType.invokeExact(node) == notDraggable) {
return;
}
} catch (Throwable e) {
throw new IllegalStateException("Cannot read native header dragging", e);
}
if (node instanceof Control && !(node instanceof Label)) {
setDraggable(node, false);
return;
}
// Native hit testing starts at the deepest picked node; marking only its parent is insufficient.
setDraggable(node, true);
if (node instanceof Parent parent) {
parent.getChildrenUnmodifiable().addListener((ListChangeListener<Node>) change -> {
while (change.next()) {
for (Node child : change.getAddedSubList()) {
configureTitleDragging(child);
}
}
});
for (Node child : parent.getChildrenUnmodifiable()) {
configureTitleDragging(child);
}
}
}
/// Hides platform-provided window buttons.
///
/// @param stage the extended stage to configure
void configureStage(Stage stage) {
try {
setSystemButtonHeight.invokeExact(stage, 0.0);
} catch (Throwable e) {
throw new IllegalStateException("Cannot hide native window buttons", e);
}
}
}
@@ -0,0 +1,307 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.layout.Region;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.WindowEvent;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Objects;
import java.util.function.Consumer;
/// Tracks content bounds independently of the stage's native frame and the root's shadow padding.
///
/// All access must occur on the JavaFX application thread. The scene and root must remain attached to the stage
/// until [#close()] is called. Normal bounds are sampled after layout, and once more before hiding or detaching.
/// Maximized bounds under [WindowState]'s policy, full-screen bounds, and iconified bounds are not reported
/// as normal bounds. Transparent macOS windows retain normal bounds despite native zoom notifications.
/// Minimum sizes are configured separately after showing or a display-scale or resizability change;
/// ordinary geometry tracking does not write window constraints.
@NotNullByDefault
final class WindowBounds implements AutoCloseable {
/// The stage whose outer geometry is managed.
private final Stage stage;
/// The scene providing client dimensions and offsets within the native window.
private final Scene scene;
/// The root whose padding reserves space for custom shadows.
private final Region root;
/// The padding used in the normal window state, even when maximized padding is empty.
private final Insets normalInsets;
/// The minimum content width in JavaFX screen coordinates.
private final double minWidth;
/// The minimum content height in JavaFX screen coordinates.
private final double minHeight;
/// Receives normal content bounds in JavaFX screen coordinates.
private final Consumer<Rectangle2D> saveBounds;
/// The latest normal content rectangle, retained through hiding and non-normal window states.
private Rectangle2D normalBounds;
/// The content width excluding both native borders and custom shadow padding.
private final ReadOnlyDoubleWrapper contentWidth = new ReadOnlyDoubleWrapper();
/// The content height excluding both native borders and custom shadow padding.
private final ReadOnlyDoubleWrapper contentHeight = new ReadOnlyDoubleWrapper();
/// Whether the current show operation is still resolving native client offsets.
private boolean restoringPosition;
/// Whether native geometry can be sampled, including during WINDOW_HIDING.
private boolean shown;
/// Whether geometry must be sampled after the next layout pulse.
private boolean dirty;
/// Whether the native minimum needs configuration once normal geometry has stopped changing between pulses.
private boolean minimumSizePending;
/// Whether this tracker has released its listeners.
private boolean closed;
/// Marks geometry for sampling after layout without assuming an order for stage and scene notifications.
private final ChangeListener<Object> boundsListener = (observable, oldValue, newValue) -> invalidate();
/// Compensates for native offsets discovered while creating the window peer.
private final InvalidationListener offsetListener = observable -> restorePosition();
/// Requests new native minimum dimensions when the display scale or resizability changes.
private final ChangeListener<Object> minimumSizeListener = (observable, oldValue, newValue) -> invalidateMinimumSize();
/// Establishes the normal restore rectangle before the native peer becomes visible.
private final EventHandler<WindowEvent> showingHandler = event -> restoreBounds();
/// Completes initial positioning and enables geometry sampling.
private final EventHandler<WindowEvent> shownHandler = event -> {
restorePosition();
restoringPosition = false;
shown = true;
invalidateMinimumSize();
};
/// Samples geometry before JavaFX clears the hidden scene's native offsets.
private final EventHandler<WindowEvent> hidingHandler = event -> {
updateBounds();
shown = false;
restoringPosition = false;
};
/// Samples geometry and separately processes pending minimum-size configuration after layout.
private final Runnable pulseListener = this::onPulse;
/// The properties observed for geometry, state, and padding changes.
private final ObservableValue<?> @Unmodifiable [] observed;
/// The properties that can change the normal native frame dimensions.
private final ObservableValue<?> @Unmodifiable [] minimumSizeObserved;
/// Initializes client sizing and installs listeners on an attached stage and scene.
///
/// The root's current padding must be its normal-state shadow padding. Initial bounds exclude that padding
/// and native decoration. [Stage#sizeToScene()] lets JavaFX determine the required native outer size.
///
/// @param stage the stage with `root` installed as its scene root
/// @param root the scene root, whose minimum and preferred sizes are managed by this tracker
/// @param initialBounds the normal content rectangle to restore
/// @param minWidth the minimum content width
/// @param minHeight the minimum content height
/// @param saveBounds the callback receiving sampled normal content bounds
WindowBounds(Stage stage, Region root, Rectangle2D initialBounds,
double minWidth, double minHeight, Consumer<Rectangle2D> saveBounds) {
this.stage = stage;
this.scene = Objects.requireNonNull(stage.getScene());
this.root = root;
this.normalInsets = root.getPadding();
this.normalBounds = initialBounds;
this.minWidth = minWidth;
this.minHeight = minHeight;
this.saveBounds = saveBounds;
// Enforce minimum content size on the stage, independently of the current page's layout minimum.
root.setMinSize(0, 0);
contentWidth.set(initialBounds.getWidth());
contentHeight.set(initialBounds.getHeight());
observed = new ObservableValue<?>[] {
stage.xProperty(), stage.yProperty(), stage.widthProperty(), stage.heightProperty(),
stage.maximizedProperty(), stage.fullScreenProperty(), stage.iconifiedProperty(),
scene.xProperty(), scene.yProperty(), scene.widthProperty(), scene.heightProperty(),
root.paddingProperty()
};
for (ObservableValue<?> observable : observed) {
observable.addListener(boundsListener);
}
scene.xProperty().addListener(offsetListener);
scene.yProperty().addListener(offsetListener);
minimumSizeObserved = new ObservableValue<?>[] {
stage.outputScaleXProperty(), stage.outputScaleYProperty(), stage.resizableProperty()
};
for (ObservableValue<?> observable : minimumSizeObserved) {
observable.addListener(minimumSizeListener);
}
scene.addPostLayoutPulseListener(pulseListener);
stage.addEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
stage.addEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
stage.addEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight());
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom());
restoreBounds();
if (stage.isShowing()) {
restoringPosition = false;
shown = true;
invalidateMinimumSize();
}
}
/// Returns the content width, excluding native borders and the root's current padding.
ReadOnlyDoubleProperty contentWidthProperty() {
return contentWidth.getReadOnlyProperty();
}
/// Returns the content height, excluding native borders and the root's current padding.
ReadOnlyDoubleProperty contentHeightProperty() {
return contentHeight.getReadOnlyProperty();
}
/// Requests one layout pulse to coalesce geometry notifications.
private void invalidate() {
dirty = true;
Platform.requestNextPulse();
}
/// Schedules native minimum-size configuration independently of ordinary geometry sampling.
private void invalidateMinimumSize() {
minimumSizePending = true;
invalidate();
}
/// Samples dirty geometry, then configures pending minimums on a subsequent quiet layout pulse.
private void onPulse() {
if (closed) {
return;
}
if (dirty) {
updateBounds();
if (minimumSizePending && isNormal()) {
Platform.requestNextPulse();
}
} else if (minimumSizePending && isNormal()) {
updateMinimumSize();
}
}
/// Returns whether the visible stage can supply normal window geometry.
private boolean isNormal() {
return shown && !restoringPosition && !stage.isIconified()
&& !WindowState.isMaximized(stage) && !stage.isFullScreen();
}
/// Requests the normal client size and starts compensating for native client offsets.
private void restoreBounds() {
restoringPosition = true;
root.setPrefSize(normalBounds.getWidth() + normalInsets.getLeft() + normalInsets.getRight(),
normalBounds.getHeight() + normalInsets.getTop() + normalInsets.getBottom());
restorePosition();
stage.sizeToScene();
}
/// Aligns the content origin while the native peer is being initialized.
private void restorePosition() {
if (restoringPosition) {
stage.setX(normalBounds.getMinX() - scene.getX() - normalInsets.getLeft());
stage.setY(normalBounds.getMinY() - scene.getY() - normalInsets.getTop());
}
}
/// Updates content dimensions and saves normal bounds when native geometry is available.
private void updateBounds() {
dirty = false;
if (!shown || restoringPosition || stage.isIconified()) {
return;
}
Insets insets = root.getPadding();
double width = scene.getWidth() - insets.getLeft() - insets.getRight();
double height = scene.getHeight() - insets.getTop() - insets.getBottom();
if (width <= 0 || height <= 0) {
return;
}
contentWidth.set(width);
contentHeight.set(height);
if (WindowState.isMaximized(stage) || stage.isFullScreen()) {
return;
}
normalBounds = new Rectangle2D(
stage.getX() + scene.getX() + insets.getLeft(),
stage.getY() + scene.getY() + insets.getTop(),
Math.max(minWidth, width), Math.max(minHeight, height));
saveBounds.accept(normalBounds);
}
/// Measures the normal native frame and applies content minimums including frame and shadow padding.
private void updateMinimumSize() {
// Stage and Scene sizes arrive separately. Only measure after pending geometry notifications
// have been processed, never from an individual size listener or an ordinary resize pulse.
boolean hasFrame = stage.getStyle() != StageStyle.TRANSPARENT && stage.getStyle() != StageStyle.UNDECORATED;
double frameWidth = hasFrame ? Math.max(0, stage.getWidth() - scene.getWidth()) : 0;
double frameHeight = hasFrame ? Math.max(0, stage.getHeight() - scene.getHeight()) : 0;
minimumSizePending = false;
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight() + frameWidth);
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom() + frameHeight);
}
/// Saves the last available bounds and removes all listeners without hiding or detaching the stage.
/// Repeated calls have no effect.
@Override
public void close() {
if (closed) {
return;
}
updateBounds();
closed = true;
for (ObservableValue<?> observable : observed) {
observable.removeListener(boundsListener);
}
scene.xProperty().removeListener(offsetListener);
scene.yProperty().removeListener(offsetListener);
for (ObservableValue<?> observable : minimumSizeObserved) {
observable.removeListener(minimumSizeListener);
}
scene.removePostLayoutPulseListener(pulseListener);
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
stage.removeEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
stage.removeEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
}
}
@@ -0,0 +1,53 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
/// Applies the main window's platform-specific maximization policy.
/// All methods must be called on the JavaFX application thread.
@NotNullByDefault
public final class WindowState {
/// Prevents instantiation of this utility class.
private WindowState() {
}
/// Returns whether the launcher honors maximization for the stage's platform and style.
///
/// Transparent windows on macOS do not support maximization in the launcher: native zoom notifications
/// can mark an ordinary borderless window as maximized, including during initialization.
/// This policy does not suppress full-screen or iconified states.
///
/// @param stage the main stage to inspect
/// @return `false` for transparent stages on macOS; `true` otherwise
public static boolean supportsMaximization(Stage stage) {
// https://github.com/HMCL-dev/HMCL/issues/4290
return OperatingSystem.CURRENT_OS != OperatingSystem.MACOS || stage.getStyle() != StageStyle.TRANSPARENT;
}
/// Returns whether the stage is maximized under the launcher's platform policy.
///
/// @param stage the main stage to inspect
/// @return `true` if maximization is supported and the stage reports that it is maximized
public static boolean isMaximized(Stage stage) {
return supportsMaximization(stage) && stage.isMaximized();
}
}
@@ -41,11 +41,7 @@ import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
import org.jackhuang.hmcl.ui.construct.LineToggleButton;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.PortablePath;
@@ -248,21 +244,25 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
SettingsManager.forceOverwriteUserGameDirectories();
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
} else {
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
SettingsManager.forceOverwriteLocalGameDirectories();
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
}
}
@@ -56,9 +56,9 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
protected BooleanProperty installable = new SimpleBooleanProperty();
public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) {
public AbstractInstallersPage(WizardController controller, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
this.controller = controller;
this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(gameVersion), getInstallerItemStyle());
this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle());
for (InstallerItem component : group.getComponents()) {
GameComponentType type = component.getComponentType();
@@ -29,6 +29,7 @@ import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.Optional;
@@ -36,11 +37,11 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
class AdditionalInstallersPage extends AbstractInstallersPage {
protected final BooleanProperty compatible = new SimpleBooleanProperty();
protected final String gameVersion;
protected final GameVersionNumber gameVersion;
protected final GameInstanceManifest manifest;
protected final HMCLGameInstance instance;
public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, WizardController controller, DownloadProvider downloadProvider) {
public AdditionalInstallersPage(HMCLGameInstance instance, GameVersionNumber gameVersion, WizardController controller, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
this.instance = instance;
this.gameVersion = gameVersion;
@@ -56,7 +56,9 @@ import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
@@ -94,7 +96,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
public DownloadPage(GameInstanceID uploadInstance) {
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), GameVersionNumber.unknown(), DownloadProviders.getDownloadProvider(),
GameComponentType.GAME, versionPageNavigator::onGameSelected)));
modpackTab.setNodeSupplier(loadVersionFor(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
@@ -110,7 +112,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteAddonRepository.WORLDS)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance())));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
GameDirectoryManager.registerVersionsListener(this::loadVersions);
@@ -162,13 +164,13 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Path dest = targetPath.resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.file().url()), dest);
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
task.setName(file.name());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
if (!(exception instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
}
} else {
Controllers.showToast(i18n("install.success"));
@@ -313,7 +315,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) {
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion());
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion().toString());
settings.asStringMap().forEach((key, value) -> {
if (!GameComponentType.GAME.getPatchId().equals(key)
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -38,7 +39,7 @@ public class InstallersPage extends AbstractInstallersPage {
private boolean isNameModifiedByUser = false;
public InstallersPage(WizardController controller, HMCLGameRepository repository, String gameVersion, DownloadProvider downloadProvider) {
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
txtName.getValidators().addAll(
@@ -52,7 +53,7 @@ public class InstallersPage extends AbstractInstallersPage {
@Override
public String getTitle() {
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion();
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
}
private String getVersion(String id) {
@@ -27,6 +27,7 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.SVGPath;
import javafx.stage.FileChooser;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
@@ -125,7 +126,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
try {
if (url.endsWith("server-manifest.json")) {
// if urlString ends with .json, we assume that the url is server-manifest.json
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
Controllers.taskDialog(new GetTask(WebURL.parse(url)).whenComplete(Schedulers.javafx(), (result, e) -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
handler.reject(i18n("modpack.type.server.malformed"));
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.game.LibraryDownloadException;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceManifest;
@@ -35,11 +36,11 @@ import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.util.ArrayList;
import java.util.concurrent.CancellationException;
import java.util.zip.ZipException;
@@ -104,11 +105,11 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + componentType.getPatchId())), gameInstance.getVersion().toString(), downloadProvider, componentType, () -> {
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + componentType.getPatchId())), gameInstance.getVersion(), downloadProvider, componentType, () -> {
if (oldLibraryVersion == null) {
controller.onFinish();
} else if (componentType == GameComponentType.GAME) {
String newGameVersion = ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion();
GameVersionNumber newGameVersion = ((GameRemoteVersion) settings.get(componentType.getPatchId())).getGameVersion();
controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider));
} else {
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType.getPatchId()), oldLibraryVersion, ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()),
@@ -137,27 +138,27 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().name()) + "\n";
if (exception.getCause() instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(exception.getCause());
}
Controllers.dialog(message, i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
} else if (exception instanceof DownloadException de) {
String url = de.getUrl();
if (exception.getCause() instanceof SocketTimeoutException) {
Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.timeout", url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception.getCause() instanceof ResponseCodeException responseCodeException) {
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else if (exception instanceof UnsupportedInstallationException unsupportedInstallationException) {
switch (unsupportedInstallationException.getReason()) {
@@ -32,7 +32,6 @@ import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
@@ -66,6 +65,7 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.regex.Pattern;
@@ -77,31 +77,29 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class VersionsPage extends Control implements WizardPage, Refreshable {
private final String gameVersion;
private final @Nullable GameVersionNumber gameVersion;
private final GameComponentType componentType;
private final String title;
private final Navigation navigation;
private final DownloadProvider downloadProvider;
private final ComponentVersionList<?> versionList;
private final Runnable callback;
private final ObservableList<ComponentRemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
public VersionsPage(Navigation navigation,
String title, String gameVersion,
String title, GameVersionNumber gameVersion,
DownloadProvider downloadProvider,
GameComponentType componentType,
Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.gameVersion = componentType == GameComponentType.GAME ? null : gameVersion;
this.componentType = componentType;
this.navigation = navigation;
this.downloadProvider = downloadProvider;
this.versionList = downloadProvider.getVersionList(componentType);
this.callback = callback;
refresh();
load(false);
}
@Override
@@ -111,9 +109,12 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
@Override
public void refresh() {
load(true);
}
private void load(boolean refresh) {
status.set(Status.LOADING);
Task<?> task = versionList.refreshAsync(gameVersion)
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
Task<?> task = downloadProvider.getVersionsAsync(componentType, gameVersion, refresh)
.whenComplete(Schedulers.javafx(), (items, exception) -> {
if (exception == null) {
versions.setAll(items);
@@ -240,7 +241,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
if (remoteVersion instanceof GameRemoteVersion) {
ComponentRemoteVersion.Type versionType = remoteVersion.getVersionType();
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
GameVersionNumber gameVersion = remoteVersion.getGameVersion();
switch (versionType) {
case RELEASE -> {
@@ -248,8 +249,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
}
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT && gameVersion.isAprilFools()) {
twoLineListItem.addTag(i18n("instance.game.april_fools"));
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
} else {
@@ -289,7 +289,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
iconType = GameInstanceIconType.COMMAND;
imageView.setImage(iconType.getIcon());
String displayGameVersion = I18n.getDisplayVersion(GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()));
String displayGameVersion = I18n.getDisplayVersion(remoteVersion.getGameVersion());
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(displayGameVersion);
@@ -326,7 +326,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
column2.setMaxWidth(150);
ColumnConstraints column3 = new ColumnConstraints();
if (control.versionList.hasType())
if (control.downloadProvider.hasType(control.componentType))
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
else
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
@@ -365,7 +365,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
refreshButton.setOnAction(event -> control.onRefresh());
if (control.versionList.hasType()) {
if (control.downloadProvider.hasType(control.componentType)) {
searchPane.addRow(rowIndex++,
new Label(i18n("instance.search")), nameField,
new Label(i18n("instance.game.type")), categoryField,
@@ -470,7 +470,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|| versionType == ComponentRemoteVersion.Type.PENDING
|| versionType == ComponentRemoteVersion.Type.UNOBFUSCATED;
case APRIL_FOOLS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
&& it.getGameVersion().isAprilFools();
case OLD -> versionType == ComponentRemoteVersion.Type.OLD;
// case ALL,
default -> true;
@@ -829,10 +829,14 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
nativeLibrarySettings.getContent().add(nativesDirPane);
nativesDirPane.setTitle(i18n("settings.advanced.natives_directory"));
{
var txtNativesDir = new JFXTextField();
txtNativesDir.setPrefWidth(400);
nativesDirPane.setRight(txtNativesDir);
bindIndependentTextField(nativesDirPane, txtNativesDir, GameSettings::nativesDirectoryProperty);
var nativesDirSelector = new FileSelector()
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
nativesDirSelector.setPrefWidth(400);
nativesDirPane.setRight(nativesDirSelector);
bindInheritableStringProperty(
nativesDirPane,
nativesDirSelector.valueProperty(),
GameSettings::nativesDirectoryProperty);
}
var noNativesPatchPane = createIndependentBooleanButton(GameSettings::notPatchNativesProperty);
@@ -342,7 +342,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
RemoteAddonRepository repo = object.data.source().getRepository();
Task.supplyAsync(() -> {
if (object.changelog != null) {
return object.changelog;
@@ -368,7 +368,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
Task.supplyAsync(() -> {
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
RemoteAddonRepository repo = object.data.source().getRepository();
return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion());
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
@@ -37,11 +37,12 @@ import javafx.scene.image.Image;
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.*;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -57,7 +58,6 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.util.*;
import java.util.stream.Collectors;
@@ -85,16 +85,18 @@ public class DownloadListPage extends Control implements DecoratorPage {
protected final StringProperty downloadSource = new SimpleStringProperty();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private int searchID = 0;
protected RemoteAddonRepository repository;
protected final RemoteAddonRepository repository;
protected final @NotNull RemoteAddon.Type type;
private final DownloadProvider downloadProvider;
private Runnable retrySearch;
public DownloadListPage(RemoteAddonRepository repository) {
this(repository, null, false);
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository) {
this(type, repository, null, false);
}
public DownloadListPage(RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
this.type = Objects.requireNonNull(type);
this.repository = repository;
this.callback = callback;
this.instanceSelection = instanceSelection;
@@ -178,7 +180,7 @@ public class DownloadListPage extends Control implements DecoratorPage {
return version != GameVersionNumber.unknown() ? version.toString() : "";
}
}).thenApplyAsync(
gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
gameVersion -> repository.search(downloadProvider, type, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (searchID != currentSearchID) {
return;
@@ -252,8 +254,8 @@ public class DownloadListPage extends Control implements DecoratorPage {
iconLoader = new RemoteImageLoader(control.downloadProvider) {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris) {
return FXUtils.getRemoteImageTask(uris, 80, 80, true, true);
protected @NotNull Task<Image> createLoadTask(@NotNull DownloadCandidates urls) {
return FXUtils.getRemoteImageTask(urls, 80, 80, true, true);
}
};
@@ -354,7 +356,7 @@ public class DownloadListPage extends Control implements DecoratorPage {
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryComboBox.getSelectionModel().select(0);
Task.supplyAsync(() -> getSkinnable().repository.getCategories())
Task.supplyAsync(() -> getSkinnable().repository.getCategories(getSkinnable().type))
.thenAcceptAsync(Schedulers.javafx(), categories -> {
if (!Objects.equals(getSkinnable().downloadSource.get(), downloadSource)) {
return;
@@ -604,7 +606,7 @@ public class DownloadListPage extends Control implements DecoratorPage {
: PADDING
);
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().type).getModByCurseForgeId(item.slug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.title());
String description = item.description();
if (description != null) {
@@ -79,7 +79,7 @@ public class DownloadPage extends Control implements DecoratorPage {
this.page = page;
this.repository = page.repository;
this.addon = addon;
this.type = Objects.requireNonNullElse(addon.type(), repository.getType());
this.type = Objects.requireNonNullElse(addon.type(), page.type);
this.translations = ModTranslations.getTranslationsByAddonType(this.type);
this.mod = translations.getModByCurseForgeId(addon.slug());
this.instanceReference = instanceReference;
@@ -605,7 +605,7 @@ public class DownloadPage extends Control implements DecoratorPage {
return Task.allOf(queue).thenSupplyAsync(() -> {
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
if (types.value().isEmpty()) return Stream.of();
if (types.value().isEmpty()) return Stream.empty();
return Stream.concat(
Stream.of(types.key()),
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
@@ -17,6 +17,8 @@
*/
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
@@ -32,35 +34,34 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
public static DownloadListPage ofMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, true, true);
}
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, false, true);
}
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, true, true);
}
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, true, true);
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, true, true);
page.supportChinese.set(false);
return page;
}
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
super(null, callback, instanceSelection);
repository = new Repository(type, curseForge, modrinth);
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, boolean curseForge, boolean modrinth) {
super(type, new Repository(), callback, instanceSelection);
((Repository) repository).downloadSource.bind(downloadSource);
supportChinese.set(true);
boolean supportedCurseForge = CurseForgeRemoteAddonRepository.isAvailable() && curseForge != null;
boolean supportedCurseForge = curseForge && CurseForgeRemoteAddonRepository.isAvailable();
downloadSources.setAll("addon.modrinth");
if (supportedCurseForge) {
@@ -70,13 +71,13 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
if ("curseforge".equalsIgnoreCase(settings().defaultAddonSourceProperty().get())) {
if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
} else if (modrinth != null) {
} else if (modrinth) {
downloadSource.set("addon.modrinth");
} else {
throw new AssertionError("Should not be here.");
}
} else {
if (modrinth != null) {
if (modrinth) {
downloadSource.set("addon.modrinth");
} else if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
@@ -86,30 +87,22 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
}
}
private class Repository extends LocalizedRemoteAddonRepository {
private final RemoteAddon.Type type;
private final CurseForgeRemoteAddonRepository curseForge;
private final ModrinthRemoteAddonRepository modrinth;
private static class Repository extends LocalizedRemoteAddonRepository {
public Repository(RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
this.type = type;
this.curseForge = curseForge;
this.modrinth = modrinth;
public Repository() {
}
public final StringProperty downloadSource = new SimpleStringProperty();
@Override
protected RemoteAddonRepository getBackedRemoteModRepository() {
if ("addon.modrinth".equals(downloadSource.get())) {
return modrinth;
return ModrinthRemoteAddonRepository.getInstance();
} else {
return curseForge;
return CurseForgeRemoteAddonRepository.getInstance();
}
}
@Override
public RemoteAddon.Type getType() {
return type;
}
}
@Override
@@ -24,6 +24,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
@@ -46,15 +47,14 @@ import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
@@ -81,9 +81,9 @@ public final class Instances {
public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGameRepository repository, GameInstanceID instanceId, RemoteAddon mod, RemoteAddon.Version file) {
Path modpack;
List<URI> downloadURLs;
DownloadCandidates downloadURLs;
try {
downloadURLs = downloadProvider.injectURLWithCandidates(file.file().url());
downloadURLs = downloadProvider.getDownloadCandidates(file.file().url());
modpack = Files.createTempFile("modpack", ".zip");
} catch (IOException | IllegalArgumentException e) {
Controllers.dialog(
@@ -235,7 +235,7 @@ public final class Instances {
repository.setSelectedInstance(repository.getInstance(instanceId));
} else {
Controllers.dialog(
DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
I18n.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
}
}), i18n("install.new_game"), TaskCancellationAction.NORMAL);
}, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName)));
@@ -657,8 +657,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.MODS),
pair("addon.modrinth", ModrinthRemoteAddonRepository.MODS)
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
@@ -662,8 +662,8 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
setBody(descriptionPane);
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.RESOURCE_PACKS),
pair("addon.modrinth", ModrinthRemoteAddonRepository.RESOURCE_PACKS)
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
@@ -87,7 +87,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
private static final SynchronizedExceptionalLazy<RemoteAddon> litematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
try {
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "litematica");
return ModrinthRemoteAddonRepository.getInstance().getAddonById(DownloadProviders.getDownloadProvider(), "litematica");
} catch (IOException e) {
LOG.warning("Failed to fetch litematica", e);
throw e;
@@ -95,7 +95,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
});
private static final SynchronizedExceptionalLazy<RemoteAddon> forgematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
try {
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "forgematica");
return ModrinthRemoteAddonRepository.getInstance().getAddonById(DownloadProviders.getDownloadProvider(), "forgematica");
} catch (IOException e) {
LOG.warning("Failed to fetch forgematica", e);
throw e;
@@ -742,10 +742,10 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
{
var fo = SVG.FOLDER_OPEN.createIcon();
var f = SVG.FOLDER.createIcon();
btnReveal.graphicProperty().bind(isDirectoryProperty.map(b -> b ? fo : f));
btnReveal.graphicProperty().bind(Bindings.when(isDirectoryProperty).then(fo).otherwise(f));
var tooltip = new Tooltip();
tooltip.textProperty().bind(isDirectoryProperty.map(b -> b ? i18n("button.reveal_dir") : i18n("reveal.in_file_manager")));
tooltip.textProperty().bind(Bindings.when(isDirectoryProperty).then(i18n("button.reveal_dir")).otherwise(i18n("reveal.in_file_manager")));
FXUtils.installFastTooltip(btnReveal, tooltip);
}
btnReveal.setOnAction(event -> {
@@ -57,6 +57,7 @@ import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
@@ -200,7 +201,7 @@ public final class JavaDownloadDialog extends StackPane {
if (exceptionToDisplay != null) {
LOG.warning("Failed to download java", exceptionToDisplay);
Controllers.dialog(DownloadProviders.localizeErrorMessage(exceptionToDisplay), i18n("install.failed"));
Controllers.dialog(I18n.localizeErrorMessage(exceptionToDisplay), i18n("install.failed"));
}
});
@@ -372,7 +373,7 @@ public final class JavaDownloadDialog extends StackPane {
if (version == null)
return;
Controllers.taskDialog(new GetTask(downloadProvider.injectURLWithCandidates(version.getLinks().pkgInfoUri()))
Controllers.taskDialog(new GetTask(downloadProvider.getDownloadCandidates(version.getLinks().pkgInfoUri()))
.setExecutor(Schedulers.io())
.thenComposeAsync(json -> {
DiscoResult<DiscoRemoteFileInfo> result = JsonUtils.fromNonNullJson(json, DiscoResult.typeOf(DiscoRemoteFileInfo.class));
@@ -392,8 +393,8 @@ public final class JavaDownloadDialog extends StackPane {
Task<FileDownloadTask.IntegrityCheck> getIntegrityCheck;
if (StringUtils.isNotBlank(fileInfo.checksum()))
getIntegrityCheck = Task.completed(new FileDownloadTask.IntegrityCheck(fileInfo.checksumType(), fileInfo.checksum()));
else if (StringUtils.isNotBlank(fileInfo.checksumUri()))
getIntegrityCheck = new GetTask(downloadProvider.injectURLWithCandidates(fileInfo.checksumUri()))
else if (StringUtils.isNotBlank(fileInfo.checksumUri())) {
getIntegrityCheck = new GetTask(downloadProvider.getDownloadCandidates(fileInfo.checksumUri()))
.thenApplyAsync(checksum -> {
checksum = checksum.trim();
@@ -403,13 +404,16 @@ public final class JavaDownloadDialog extends StackPane {
return new FileDownloadTask.IntegrityCheck(fileInfo.checksumType(), checksum);
});
}
else
getIntegrityCheck = Task.completed(null);
return getIntegrityCheck
.thenComposeAsync(integrityCheck ->
new FileDownloadTask(downloadProvider.injectURLWithCandidates(fileInfo.directDownloadUri()),
targetFile, integrityCheck).setName(fileInfo.fileName()))
{
return new FileDownloadTask(downloadProvider.getDownloadCandidates(fileInfo.directDownloadUri()),
targetFile, integrityCheck).setName(fileInfo.fileName());
})
.thenSupplyAsync(() -> targetFile);
})
.whenComplete(Schedulers.javafx(), ((result, exception) -> {
@@ -432,7 +436,7 @@ public final class JavaDownloadDialog extends StackPane {
LOG.warning("Failed to download java", exception);
Throwable resolvedException = resolveException(exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
}
})), i18n("java.download"), TaskCancellationAction.NORMAL);
@@ -45,7 +45,6 @@ import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -71,7 +70,6 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
@@ -360,13 +358,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
private void launchNoGame() {
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
ComponentVersionList<?> versionList = downloadProvider.getVersionList(GameComponentType.GAME);
Holder<GameInstanceID> instanceHolder = new Holder<>();
Task<?> task = versionList.refreshAsync("")
.thenSupplyAsync(() -> versionList.getVersions("").stream()
Task<?> task = downloadProvider.getVersionsAsync(GameComponentType.GAME, null, false)
.thenApplyAsync(versions -> versions.stream()
.filter(it -> it.getVersionType() == RELEASE)
.filter(it -> NativePatcher.checkSupportedStatus(GameVersionNumber.asGameVersion(it.getGameVersion()), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
.filter(it -> NativePatcher.checkSupportedStatus(it.getGameVersion(), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
.sorted()
.findFirst()
.orElseThrow(() -> new IOException("No versions found")))
@@ -374,7 +371,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
HMCLDependencyManager dependency = repository.getDependency();
String gameVersion = version.getGameVersion();
String gameVersion = version.getGameVersion().toString();
GameInstanceID instanceId = new GameInstanceID(gameVersion);
instanceHolder.value = instanceId;
@@ -37,9 +37,9 @@ public final class ChunkBaseApp {
};
public static final String[] STRONGHOLD_FINDER_GAME_VERSIONS = {
"26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2",
"1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15",
"1.14", "1.13", "1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
"26.3", "26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4",
"1.21.2", "1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16",
"1.15", "1.14", "1.13", "1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
};
public static final String[] NETHER_FORTRESS_GAME_VERSIONS = {
@@ -49,9 +49,9 @@ public final class ChunkBaseApp {
};
public static final String[] END_CITY_GAME_VERSIONS = {
"26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2",
"1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15",
"1.14", "1.13"
"26.3", "26.2", "26.1", "1.21.9", "1.21.6", "1.21.5", "1.21.4",
"1.21.2", "1.21", "1.20", "1.19.3", "1.19", "1.18", "1.17", "1.16",
"1.15", "1.14", "1.13"
};
public static boolean isSupported(@NotNull World world) {
@@ -19,6 +19,8 @@ package org.jackhuang.hmcl.util;
import javafx.beans.value.WritableValue;
import javafx.scene.image.Image;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -27,7 +29,6 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -35,9 +36,12 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public abstract class RemoteImageLoader {
private final DownloadProvider downloadProvider;
private final Map<URI, WeakReference<Image>> cache = new HashMap<>();
private final Map<URI, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
private final WeakHashMap<WritableValue<Image>, URI> reverseLookup = new WeakHashMap<>();
/// Loaded images indexed by normalized URL; image references do not prevent garbage collection.
private final Map<WebURL, WeakReference<@Nullable Image>> cache = new HashMap<>();
/// Image targets awaiting each URL's active load task.
private final Map<WebURL, List<WeakReference<WritableValue<Image>>>> pendingRequests = new HashMap<>();
/// Current requested URL for each target, used to discard obsolete load completions.
private final WeakHashMap<WritableValue<Image>, WebURL> reverseLookup = new WeakHashMap<>();
public RemoteImageLoader(DownloadProvider downloadProvider) {
this.downloadProvider = downloadProvider;
@@ -47,59 +51,61 @@ public abstract class RemoteImageLoader {
return null;
}
protected abstract @NotNull Task<Image> createLoadTask(@NotNull List<URI> uris);
/// Creates a task that loads an image from candidate URLs in attempt order.
protected abstract @NotNull Task<Image> createLoadTask(DownloadCandidates candidates);
/// Loads an image or assigns the placeholder when the URL is absent or invalid.
@FXThread
public void load(@NotNull WritableValue<Image> writableValue, String url) {
URI uri = NetworkUtils.toURIOrNull(url);
if (uri == null) {
public void load(@NotNull WritableValue<Image> writableValue, @Nullable String url) {
@Nullable WebURL webUrl = NetworkUtils.toWebURLOrNull(url);
if (webUrl == null) {
reverseLookup.remove(writableValue);
writableValue.setValue(getPlaceholder());
return;
}
WeakReference<Image> reference = cache.get(uri);
@Nullable WeakReference<@Nullable Image> reference = cache.get(webUrl);
if (reference != null) {
Image image = reference.get();
@Nullable Image image = reference.get();
if (image != null) {
reverseLookup.remove(writableValue);
writableValue.setValue(image);
return;
}
cache.remove(uri);
cache.remove(webUrl);
}
writableValue.setValue(getPlaceholder());
{
List<WeakReference<WritableValue<Image>>> list = pendingRequests.get(uri);
@Nullable List<WeakReference<WritableValue<Image>>> list = pendingRequests.get(webUrl);
if (list != null) {
list.add(new WeakReference<>(writableValue));
reverseLookup.put(writableValue, uri);
reverseLookup.put(writableValue, webUrl);
return;
} else {
list = new ArrayList<>(1);
list.add(new WeakReference<>(writableValue));
pendingRequests.put(uri, list);
reverseLookup.put(writableValue, uri);
pendingRequests.put(webUrl, list);
reverseLookup.put(writableValue, webUrl);
}
}
createLoadTask(downloadProvider.injectURLWithCandidates(url)).whenComplete(Schedulers.javafx(), (result, exception) -> {
Image image;
createLoadTask(downloadProvider.getDownloadCandidates(url)).whenComplete(Schedulers.javafx(), (result, exception) -> {
@Nullable Image image;
if (exception == null) {
image = result;
} else {
LOG.warning("Failed to load image from " + uri, exception);
LOG.warning("Failed to load image from " + webUrl, exception);
image = getPlaceholder();
}
cache.put(uri, new WeakReference<>(image));
List<WeakReference<WritableValue<Image>>> list = pendingRequests.remove(uri);
cache.put(webUrl, new WeakReference<>(image));
@Nullable List<WeakReference<WritableValue<Image>>> list = pendingRequests.remove(webUrl);
if (list != null) {
for (WeakReference<WritableValue<Image>> ref : list) {
WritableValue<Image> target = ref.get();
if (target != null && uri.equals(reverseLookup.get(target))) {
@Nullable WritableValue<Image> target = ref.get();
if (target != null && webUrl.equals(reverseLookup.get(target))) {
reverseLookup.remove(target);
target.setValue(image);
}
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonLoaderType;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
@@ -53,18 +54,25 @@ import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackLocalInstallTask;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.translator.Translator;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.PropertyKey;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.file.AccessDeniedException;
import java.time.temporal.TemporalAccessor;
import java.util.*;
import java.util.concurrent.CancellationException;
public final class I18n {
@@ -238,6 +246,39 @@ public final class I18n {
return null;
}
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException de) {
String url = de.getUrl();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", url);
} else if (exception.getCause() instanceof ResponseCodeException responseCodeException) {
if (hasKey("download.code." + responseCodeException.getResponseCode())) {
return i18n("download.code." + responseCodeException.getResponseCode(), url);
} else {
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", url);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
} else if (exception instanceof CancellationException) {
return i18n("message.cancelled");
}
return StringUtils.getStackTrace(exception);
}
public static boolean hasKey(String key) {
return getResourceBundle().containsKey(key);
}
@@ -17,45 +17,65 @@
*/
package org.jackhuang.hmcl.util.url.data;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.Charset;
import java.util.Base64;
/**
* @author Glavo
*/
public final class DataUri {
/// Parses a data URL's media type, charset, and percent-decoded payload.
/// Query content is included in the payload; the fragment is excluded.
/// Percent escapes are decoded as UTF-8 before the declared charset is applied by the read methods.
///
/// @author Glavo
public final class DataURL {
/// Scheme identifying inline data URLs.
public static final String SCHEME = "data";
private static IllegalArgumentException invalidUri(URI uri) {
return new IllegalArgumentException("Invalid data URI: " + uri);
/// Creates a parsing failure for a data URL without a metadata/payload separator.
private static IllegalArgumentException invalidUri(WebURL url) {
return new IllegalArgumentException("Invalid data URL: " + url);
}
public static boolean isDataUri(URI uri) {
return uri != null && SCHEME.equals(uri.getScheme());
/// Returns whether the URL uses the data scheme, or `false` for `null`.
public static boolean isDataUri(@Nullable WebURL url) {
return url != null && SCHEME.equals(url.getScheme());
}
/// Trimmed media type and parameters, excluding the Base64 marker.
private final @NotNull String mediaType;
/// Declared charset, or UTF-8 when absent or unrecognized.
private final @NotNull Charset charset;
/// Whether the metadata ends with the case-sensitive Base64 marker.
private final boolean base64;
/// Percent-decoded payload before optional Base64 decoding.
private final @NotNull String rawData;
public DataUri(URI uri) {
if (!uri.getScheme().equals(SCHEME)) {
throw new IllegalArgumentException("URI scheme must be " + SCHEME);
/// Parses metadata and payload without decoding Base64 content.
///
/// @param url the data URL
/// @throws IllegalArgumentException if the scheme is not data or the decoded content has no comma
public DataURL(WebURL url) {
if (!SCHEME.equals(url.getScheme())) {
throw new IllegalArgumentException("URL scheme must be " + SCHEME);
}
String schemeSpecificPart = uri.getSchemeSpecificPart();
if (schemeSpecificPart == null)
throw invalidUri(uri);
String schemeSpecificPart = url.getPath();
@Nullable String authority = url.getAuthority();
if (authority != null)
schemeSpecificPart = "//" + authority + schemeSpecificPart;
// The query delimiter and content belong to the data, while the fragment does not.
@Nullable String query = url.getQuery();
if (query != null)
schemeSpecificPart += "?" + query;
int comma = schemeSpecificPart.indexOf(',');
if (comma < 0)
throw invalidUri(uri);
throw invalidUri(url);
String mediaType = schemeSpecificPart.substring(0, comma);
boolean base64 = mediaType.endsWith(";base64");
@@ -68,22 +88,29 @@ public final class DataUri {
this.rawData = schemeSpecificPart.substring(comma + 1);
}
/// Returns the trimmed media type and parameters without the Base64 marker; may be empty.
public @NotNull String getMediaType() {
return mediaType;
}
/// Returns the declared charset, or UTF-8 when absent or unrecognized.
public @NotNull Charset getCharset() {
return charset;
}
/// Returns whether the payload is marked for Base64 decoding.
public boolean isBase64() {
return base64;
}
/// Returns the percent-decoded payload before optional Base64 decoding, preserving literal plus signs.
public @NotNull String getRawData() {
return rawData;
}
/// Returns a new byte array by decoding Base64 or encoding the text payload with [#getCharset()].
///
/// @throws IOException if a Base64 payload cannot be decoded
public byte[] readBytes() throws IOException {
if (base64) {
try {
@@ -96,6 +123,9 @@ public final class DataUri {
}
}
/// Returns the text payload, decoding Base64 bytes with [#getCharset()] when marked as Base64.
///
/// @throws IOException if a Base64 payload cannot be decoded
public String readString() throws IOException {
if (base64) {
try {
@@ -27,20 +27,20 @@ import java.net.URLConnection;
* @author Glavo
*/
public final class DataURLConnection extends URLConnection {
private final DataUri dataUri;
private final DataURL dataUrl;
private byte[] data;
private InputStream inputStream;
DataURLConnection(URL url, DataUri dataUri) {
DataURLConnection(URL url, DataURL dataUrl) {
super(url);
this.dataUri = dataUri;
this.dataUrl = dataUrl;
}
@Override
public void connect() throws IOException {
if (!connected) {
connected = true;
data = dataUri.readBytes();
data = dataUrl.readBytes();
inputStream = new ByteArrayInputStream(this.data);
}
}
@@ -53,7 +53,7 @@ public final class DataURLConnection extends URLConnection {
@Override
public String getContentType() {
return dataUri.getMediaType();
return dataUrl.getMediaType();
}
@Override
@@ -17,23 +17,26 @@
*/
package org.jackhuang.hmcl.util.url.data;
import org.glavo.url.WebURL;
import java.io.IOException;
import java.net.*;
/**
* @author Glavo
*/
/// Opens data URL connections using [WebURL] to parse the address.
///
/// @author Glavo
public final class DataURLHandle extends URLStreamHandler {
/// Creates a connection without decoding its Base64 payload.
///
/// @throws MalformedURLException if the address cannot be parsed as a data URL
@Override
protected URLConnection openConnection(URL u) throws IOException {
try {
URI uri = u.toURI();
if (!DataUri.isDataUri(uri))
throw new MalformedURLException("URI is not a data URI: " + u);
return new DataURLConnection(u, new DataUri(uri));
} catch (URISyntaxException e) {
throw new MalformedURLException(e.getMessage());
return new DataURLConnection(u, new DataURL(WebURL.of(u)));
} catch (IllegalArgumentException e) {
MalformedURLException exception = new MalformedURLException(e.getMessage());
exception.initCause(e);
throw exception;
}
}
}
File diff suppressed because it is too large. Load diff
+89 -23
View File
@@ -40,7 +40,7 @@ gregblock;32;;格雷空岛;Gregblock;
sevtech-ages;33;;赛文科技;SevTech: Ages;STA
;34;;伊卡洛斯号迫降;Icarus Landing;IL
greedycraft;35;;贪婪整合包;GreedyCraft;GC
stoneblock;36;;StoneBlock;;
stoneblock;36;;StoneBlock;;SB
ftb-presents-skyfactory-3;37;;天空工厂3;FTB Presents SkyFactory 3;SF3
space-astronomy;38;;Space Astronomy;;
world-of-dragons;39;;World of Dragons;;
@@ -52,7 +52,7 @@ farming-valley-lite;44;;Farming Valley - Lite;;
atm-all-the-magic;45;;All the Mods: All the Magic;;
hr-new-beginnings;46;;HR: New Beginnings;;
valhelsia-2;47;;Valhelsia 2;;
ftb-presents-stoneblock-2;48;;FTB Presents Stoneblock 2;;
ftb-presents-stoneblock-2;48;;FTB Presents Stoneblock 2;;SB2
ftb-sky-odyssey;49;;天空奥德赛;FTB Sky Odyssey;
all-of-fabric-3;50;;All of Fabric 3;;AOF3
terrafirma-rescue;51;;群峦:救援;Terrafirma:Rescue;TFR
@@ -515,7 +515,7 @@ tankoptimization;521;;坦坦优化;Tankoptimization;
pick-the-stars;523;;摘星;Pick The Stars;PTS
earth-technology;524;;Earth Technology;;
;525;;核乐不为;;ZMC
ftb-stoneblock-3;526;;FTB StoneBlock 3;;
ftb-stoneblock-3;526;;FTB StoneBlock 3;;SB3
create-mod-plus;527;;Create+;;
multiplayer-optimization;528;;RawDiamondMC的优化包;RawDiamondMC's optimization pack;MPO
chroma-endless;529;;Chroma Endless;;
@@ -701,13 +701,12 @@ create-perfect-world;711;;Create: Perfect World;;
new-simple-mods;712;;New Simple Mods - Easy to Understand;;
;713;;灾变录;Catastrophe Record;CR
sky-greg;714;;Sky Greg;;
deceasedcraft;715;;亡者世界;DeceasedCraft - Urban Zombie Apocalypse;
deceasedcraft;715;;亡者世界;DeceasedCraft - Urban Zombie Apocalypse;DC
;716;;生欲:求生;Vivere The Zombie Apocalypse;VZA
create-planetary;717;;Create Planetary;;
master-mabaoguo;718;;Master mabaoguo;;
gregorian-nightmares;719;;Gregorian Nightmares Industrially Fabricated;;
;720;;史诗的地下城;Dungeons Of Fantasy;
;721;;Emotional Damaged Minecraft;;EmDM
;722;;脆骨症;No Flesh Within Chest;NFWC
all-in-one-create;723;;All in One: Create;;AIOC
the-backrooms-survival;724;;The Backrooms Survival;;
@@ -754,7 +753,7 @@ modecube;765;;ModéCube;;
electrictechnocraft;766;;ElectricTechnoCraft;;
create-2-mekanism;767;;Create 2 Mekanism;;C2M
randblock;768;;随机空岛;RandBlock;RD
;769;;格雷科技休闲版;GregTech Leisure;GTL
gregtech-leisure;769;;格雷科技休闲版;GregTech Leisure;GTL
frozenopolis;770;;Frozenopolis;;
infernal-rpg;771;;Infernal Origins;;
;772;;天狼星;Sirius;
@@ -793,7 +792,7 @@ chemillas-reborn;804;;化学:重生;Chemilla's Reborn;CR
milkyway;806;;Create: Milkyway;;
farming-crossing-4;807;;农业森友会 4;Farming Crossing 4;
hardrock-terrafirmacraft-4-realistic-extreme;808;;HardRock TerraFirmaCraft;;TFCH/HTFC
terrafirma-rebirth;809;;群峦:重生;TerraFirma: Rebirth;TFCR
terrafirma-rebirth;809;;群峦重生2;TerraFirma Rebirth 2;TFCR2
create-live-5-skyblock;810;;Create Live 5;;
;811;;万象征程;Cosmos of Endless Allegory;CEA
;812;;远古之遗;;
@@ -867,7 +866,7 @@ aeon-automata;881;;Aeon Automata;;AA
;882;;被恐惧支配2:收容失效;Dominated by Fear 2 Containment Breach;DoF2
;883;;剑拔弩张之时;;TC
project-architect-2;884;;Project Architect 2;;
;885;;接入铁路网络:我的世界铁路;MTR Railway Network Access;RNA-MTR
block-railway-engineering;885;;像素铁路工程;Block Railway Engineering;BRE
create-mekanized;886;;Create: Mekanized;;
create-perfect-world-2;887;;Create: Perfect World 2;;
create-stellar;888;;Create Stellar;;
@@ -1037,7 +1036,7 @@ minecraft-legendary-edition;1050;;Legendary Edition;;
forge-frontier;1055;;Create - Forge Frontier;;
create-workshop-uwu;1056;;Create: Workshop UwU Edition;;
;1057;;古钠重铸;Oldium Optimized;OO
;1058;;越寰;Leaping across the world;LTW
;1058;;越渡;Leaping across the world;LTW
ftb-oceanblock-2;1059;;FTB OceanBlock 2;;
;1060;;伊甸空天2;Eden Aerospace 2;EAS2
create-infinity-mechanism;1061;;机械动力:无限构件;Create:Infinity Mechanism;CIM
@@ -1073,7 +1072,7 @@ roguelike-adventures-and-dungeons-3;1090;;冒险与地牢3;Roguelike Adventures
wheelchair-gregtech;1093;;Wheelchair Gregtech;;
egged-up;1094;;Egged Up;;
;1095;;香草纪元:食旅纪行;VanillaEra: FaresChron;VEFC
the-fool;1096;;愚者;The Fool;TF
;1096;;愚者;The Fool;TF
;1097;;时间之书2:星梦指南;BookofTime II Stardream Compass;BKSC
;1098;;群峦之上:无垠伊土;EndlessFirma;EnF
;1099;;血月低语:暗夜惊魂;Bloodmoon's Whisper;BW
@@ -1117,12 +1116,12 @@ life-in-the-village-4;1136;;Life in the village 4;;LITV4
real-island-2-modular-craft;1137;;真实空岛2:模块化之旅;Real Island 2 - Modular Craft;RI2
;1138;;奥术咖啡馆;Arcane Cofferia;
unyielding;1139;;Unyielding;;
;1140;;剑与王国;;sword_and_kingdom
;1140;;剑与王国;Sword and Kingdom;
the-fungal-infection-coop;1141;;The Fungal Infection | Co-Op Base Defense;;
mc-eternal-2;1142;;永恒的MC 2;MC Eternal 2;MCE2
solo-leveling-reawakening;1143;;Solo Leveling - Reawakening;;
create-ultimate-selection;1144;;Create: Ultimate Selection;;
;1145;;元素觉醒;Elemental Awakening;
;1145;;元素觉醒;Elemental Awakening;ELA
endless-horror-the-ultimate-horror-modpack;1146;;Endless Horror - The ULTIMATE Horror Experience;;
;1147;;锻造之旅;;
picky;1148;;Picky;;
@@ -1238,7 +1237,7 @@ skyopolis-evolved;1255;;Skyopolis: Evolved;;
;1260;;登神者:天阶咏叹;The God Ascender;TGA
;1261;;虚饰作品;Fictional;
;1262;;无尽工坊:奇境传说;Infinity Workshop: Legends of Wonderland;IWLoW
;1263;;大群方舟;Arknights: The Great Tide;AGT
arknights-the-great-tide;1263;;大群方舟;Arknights: The Great Tide;AGT
arcadia-rpg;1264;;Arcadia[RPG];;
ozone-skyblock-reborn-2;1265;;Ozone Skyblock Reborn 2;;
witarcland;1266;;维塔克兰;Witarcland;WTL
@@ -1267,7 +1266,6 @@ ultramodern-optimization;1289;;超现代优化;Ultramodern optimization;UO
reverie-foundry;1290;;遐想铸造厂;Reverie Foundry;RF
;1291;;神秘时代4·全家桶;All the thaumcraft's mod;AllTC
over-stars;1292;;Over Stars;;
pandemonium-nexus-anarchic-aeon;1293;;万象枢纽:无序纪元;Pandemonium Nexus: Anarchic Aeon;PNAA
;1294;;菌染亵渎;Fungal Infection Blasphemy;FIB
;1295;;Ice and M:Sky;;IAMS
;1296;;TUFF PERFORMANCE;;
@@ -1299,12 +1297,12 @@ chosens-create-integrated;1320;;Chosen's Create: Integrated;;
;1322;;MineZ v.无尽苦寒之地;MineZ v.I.C.E;
;1323;;森语田园;;
;1324;;子弹呼啸;Bullet Whizz;BW
forgemechtrek;1325;;锻械游行;ForgeMechTrek;FMT
;1325;;锻械游行;ForgeMechTrek;FMT
;1326;;生电与优化整合包;Electricity Generation Integration Package;EGIP
create-perfect-world-post-production;1327;;Create - Perfect World Post Production;;
terrafirmagreg-modern;1328;;群峦格雷:现代版;TerraFirmaGreg: Modern;TFG
terrafirmagreg-modern;1328;;群峦格雷:现代版;TerraFirmaGreg-Modern;TFGM
nanomagic;1329;;NanoMagic;;
;1330;;接入铁路网络:沉浸铁路;IR Railway Network Access (Immersive Railroading);RNA-IR
;1330;;接入铁路网络:沉浸铁路;IR Railway Network Access;rRNA
biohazard-project-genesis;1331;;Biohazard: Project Genesis - The Ultimate Apocalypse;;
;1332;;齐柏林的启示录;Apocalypse of Zeppelin;AoZ
;1333;;遗尘往事;Story of Forgotten Land;SOFL
@@ -1331,13 +1329,13 @@ the-last-world-shines;1352;;末世生辉;The Last World Shines;TLWS
journey-to-infinity;1354;;Journey To Infinity;;
;1355;;混元登仙;;HATI
flying-path;1356;;飞舞之路;Flying Path;
create-craft-quiet;1357;;机械动力:匠途闲境;Create:Craft&Quiet;ccq
create-craft-quiet;1357;;机械动力:匠途闲境;Create : Craft & Quiet;CCQ
sky-new-horizons;1358;;Sky New Horizons;;SkyNH
;1359;;天启之路;The Path Of Revelation;TPOR
mana-and-mechanisms;1360;;Mana & Mechanisms;;
;1361;;胶兽异界之旅Ⅱ;;
mechanical-dragon;1362;;龙之机械;Dragon create;DC
;1363;;香草精华;;VE
;1363;;香草精华;Vanilla Essence;VE
submerged-2;1364;;Seaopolis Submerged 2;;
;1365;;天煞孤星;A STAR DOOMED TO LONELINESS;ALONG
;1366;;全亡灵启示录;All Undead Apocalypse;AUA
@@ -1352,14 +1350,14 @@ mysticalblockreloaded;1374;;Mystical Block Reloaded;;
cozy-mizuno-minecraft;1375;;A Cozy Mizuno World;;
;1376;;新生异土;Tensura Neo Otherworld;
extraordinary-energy-modern;1377;;Extraordinary Energy : Modern;;
;1378;;铁砧工艺-七天创世;;
;1378;;铁砧工艺-归途;;
avaritia-project;1379;;Avaritia Project;;
after-the-nukes;1380;;After The Nukes;;
abyssal-ascent;1381;;Abyssal Ascent;;
desolate-planet;1382;;Desolate Planet;;
;1383;;德雷马尔:神化;Drehmal: Apotheosis;DHA
;1383;;德雷马尔:神化;Drehmal: Apotheosis;
project-arc-light-the-hanging-pavilion;1384;;弧光计划:空中楼阁;Project Arc Light: The Hanging Pavilion;PAHP
;1385;;文明:创世;Civilization Isolaria;
civilization-isolaria;1385;;文明:创世;Civilization Isolaria;
blockitecture;1386;;方块建筑学;Blockitecture (Block Architecture);
;1388;;曙光空岛;;
;1389;;千界一梦;The Oneiropolis;TOP
@@ -1399,7 +1397,7 @@ enigmatic-skies;1419;;Enigmatic Skies;;
cook-in-skyland-recaked;1423;;天空厨房:重制版;Cook in Skyland Recaked;CiSR
rlcraft-fanmade;1424;;RLCraft Fanmade;;
unaltered;1425;;Unaltered+;;
sendims-banforges;1426;;千界万锻;Myriad Dimensional Forge;SDBF
sendims-banforges;1426;;千界※万锻;Myriad Dimensional Forge;SDBF
;1427;;超级优化;Super Optimized;SO
society-sunlit-cobblemon;1428;;Society: Sunlit Cobblemon;;
create-aeronautica;1429;;Create Aeronautica;;
@@ -1432,3 +1430,71 @@ dungeon-train;1455;;地牢列车;Dungeon train;
project-arc-light-dimensional-rift;1456;;弧光计划:维度裂隙;Project Arc Light - Dimensional Rift;PA2
;1457;;恶意世界;HostileCraft;HC
adj;1458;;A Distant Journey;;ADJ
;1459;;枪械:先锋;TACZ: Pioneer;
;1460;;莱特兰:魂影与恶域;L2Land: Souls & Evil Domains;
;1461;;终末幸存者;Last One;LO
soa3-f;1462;;Slow Start AoA3 [FORGE];;SoA3-f
;1463;;KessokuTeaTime Bingopack;;KTTBP
;1464;;更多模组的Bingo;More Mods Bingo;MMB
blast-off-reborn;1465;;Blast off-Reborn;;
new-age-branch-line;1466;;新时代支线;new age branch line;NABL
;1467;;工业溯源;Industrial Genesis;
sky-of-grind;1468;;Sky of Grind;;SoG
ntnewhorizons;1469;;Nuclear Tech New Horizons;;NTNH
;1470;;秘法漫纪;Chronicles of Mystic Arcana;CMA
;1471;;爱之诗;Poem Amour;POA
;1472;;从一无所有到所有;Nothing To All;
;1473;;剑痕纪元;;
;1474;;魔女之旅;Wondering Witch:The Journey of Elaina;WWTJE
;1475;;丧尸危机:生存;Zombie Crisis: Survival;ZCS
;1476;;星之谷;StarValley;
journey-through-middle-earth;1477;;Journey Through Middle-Earth;;
;1478;;魔法金属优化美化包;;
;1479;;锈蚀天穹;;
xiaozi-craft;1480;;xiaozi craft;;XC
;1481;;渎圣织罪;;BLSW
;1482;;无限模板;;IT
flat-factory-f2;1483;;平原化工厂;Flat Factory;F2
;1484;;下界潜兵2:自由升级;Netherdivers™ 2 Escalation of Freedom;ND2
;1485;;落雪长冬;The Winter;
;1486;;空中厕所2026;PoopSky2026;
;1487;;通天之路;;
;1488;;钻石大陆加豪版;Diamond Continent — Plus&Plus Edition;DC++
;1489;;地下酒吧;Underground Speakeasy;
all-of-mcreator-6-aomc6;1490;;All of Mcreator 6;;AOMC6
;1491;;宝可梦:轻快之旅;Pokémon: Journey of Agility;PJA
;1492;;直至永恒;Until Eternity;UE
;1493;;猪咪大冒险;;
;1494;;诡厄使徒:诸神黄昏;;GK
;1495;;永恒恶意;Eternal Hostility;EH
vault-hunters-third-edition-remastered;1496;;Vault Hunters Third Edition - Remastered;;
;1497;;万般闲暇;All The Leisures;ATL
minecraft-or-not;1498;;Minecraft or not;;MON
;1499;;忆婷乐章;;
;1500;;格雷笙息空岛;;GTSK
things-addonpack;1501;;Things/All Of The Addons;;
;1502;;旅行时光:饥饿;Traveltime: Hunger;TTH
;1503;;脑叶世界:收容重置;LobotoCraft Recontainment;LCR
;1504;;闲居物语:重制版;The Serene Homestead 2;TSH2
i-wanna-make-a-sink;1505;;I wanna make a sink;;IWMS
;1506;;神之征伐:涤罪;Apothic Craft;ATC
shitblock;1507;;ShitBlock;;
;1508;;麦神之旅;Journey of Wheat;JoW
;1509;;永无止境:超越无界;Create: Above and Beyond Community Refresh-Ex;CABREx
;1510;;炒饭食堂;Skyblock:Fried Rice;
;1511;;幻梦境;The Dreamlands;TD
;1512;;摄影奇境;Photo Wanderer;
;1513;;登神者·登神之长阶;;TLA
;1514;;越渡 贰;Leaping across the world 2;LTW2
arknights-lucky-block;1515;;明日方舟幸运方块;Arknights Lucky Block;ALB
donnas-odyssey-fantasy-rpg;1516;;Donna's Odyssey Fantasy RPG;;
matrix-of-materia;1517;;Matrix of Materia;;
on-ancient-ground;1518;;On Ancient Ground;;
;1519;;末地颂歌;;TEO
;1520;;荒垣兴筑:启垣;BUILDING A ROUGH WRLL:STARTING THE WALL;BARW
;1521;;工业复兴;;
statech-industry-2;1522;;StaTech Industry 2;;
;1523;;魔女之泉:归寂;;
create-village-to-sky;1524;;机械动力:下村上天;Create Village to Sky;
essence-odyssey;1525;;本源远征;Essence Odyssey;
ftb-presents-architects-exodus;1526;;FTB Presents Architect's Exodus;;
@@ -0,0 +1,282 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXDialog;
import javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.input.PickResult;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import org.jackhuang.hmcl.JavaFXLauncher;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.api.parallel.Resources;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/// Exercises JavaFX's native header hit testing without requiring a desktop window manager.
@NotNullByDefault
@EnabledIf("supportsHeaderBar")
final class NativeWindowDecorationTest {
/// Requires a running toolkit and the header API, but not platform support for extended windows.
static boolean supportsHeaderBar() {
try {
Class.forName("javafx.scene.layout.HeaderBar");
return JavaFXLauncher.isStarted()
&& Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]) >= 27;
} catch (ClassNotFoundException e) {
return false;
}
}
/// Verifies explicit preferences and automatic platform restrictions against the runtime's capabilities.
@Test
@ResourceLock(Resources.SYSTEM_PROPERTIES)
void honorsConfiguredPreference() throws Exception {
onFxThread(() -> {
@Nullable String previous = System.getProperty("hmcl.nativeDecoration");
try {
System.setProperty("hmcl.nativeDecoration", " FALSE ");
assertNull(NativeWindowDecoration.create());
boolean supported = Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"));
System.setProperty("hmcl.nativeDecoration", " TRUE ");
@Nullable NativeWindowDecoration forced = NativeWindowDecoration.create();
assertEquals(supported, forced != null);
if (forced != null) {
assertTrue(forced.isPreferred(false, true));
assertFalse(forced.isPreferred(true, false));
assertFalse(forced.isPreferred(true, true));
}
boolean automatic = supported && (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|| OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11));
System.setProperty("hmcl.nativeDecoration", " AUTO ");
@Nullable NativeWindowDecoration decoration = NativeWindowDecoration.create();
assertEquals(automatic, decoration != null);
if (decoration != null) {
assertFalse(decoration.isPreferred(true, false));
}
System.clearProperty("hmcl.nativeDecoration");
@Nullable String environment = System.getenv("HMCL_NATIVE_DECORATION");
if (environment != null) {
environment = environment.trim();
}
boolean expected = "true".equalsIgnoreCase(environment) ? supported
: !"false".equalsIgnoreCase(environment) && automatic;
assertEquals(expected, NativeWindowDecoration.create() != null);
System.setProperty("hmcl.nativeDecoration", "invalid");
assertEquals(automatic, NativeWindowDecoration.create() != null);
} finally {
if (previous == null) {
System.clearProperty("hmcl.nativeDecoration");
} else {
System.setProperty("hmcl.nativeDecoration", previous);
}
}
return null;
});
}
/// Verifies automatic mode preserves transparency and Windows dark-mode exclusions.
@Test
void retainsAutomaticThemeExclusions() throws Exception {
onFxThread(() -> {
NativeWindowDecoration decoration = createDecoration();
assertTrue(decoration.isPreferred(false, false));
assertFalse(decoration.isPreferred(true, false));
assertFalse(decoration.isPreferred(true, true));
assertEquals(OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS, decoration.isPreferred(false, true));
return null;
});
}
/// Verifies nested title content and label skins are draggable while interactive controls are excluded.
@Test
void picksNestedTitleContentAndControls() throws Exception {
onFxThread(() -> {
NativeWindowDecoration decoration = createDecoration();
Rectangle icon = new Rectangle(20, 20);
Label title = new Label("Launcher title");
HBox customTitle = new HBox(icon, title);
Button action = new Button("Action");
Rectangle buttonGraphic = new Rectangle(12, 12);
action.setGraphic(buttonGraphic);
BorderPane content = new BorderPane(new StackPane(customTitle), null, action, null, null);
decoration.setContent(content);
StackPane root = new StackPane(decoration.headerBar);
Scene scene = new Scene(root, 800, 40);
layout(root);
assertEquals("DRAGBAR", pick(scene, customTitle));
assertEquals("DRAGBAR", pick(scene, icon));
assertEquals("DRAGBAR", pick(scene, title));
assertNotEquals("DRAGBAR", pick(scene, action));
assertNotEquals("DRAGBAR", pick(scene, buttonGraphic));
// Reattachment and later content changes must preserve the hit-test policy.
decoration.setContent(null);
decoration.setContent(content);
Label addedTitle = new Label("Updated title");
TextField input = new TextField("Editable title");
StackPane excluded = new StackPane(new Label("Excluded"));
decoration.setDraggable(excluded, false);
customTitle.getChildren().setAll(addedTitle, input, excluded);
layout(root);
assertEquals("DRAGBAR", pick(scene, addedTitle));
assertNotEquals("DRAGBAR", pick(scene, input));
assertNotEquals("DRAGBAR", pick(scene, excluded));
return null;
});
}
/// Verifies dialog overlays preserve native header dragging without allowing ordinary mouse clicks through.
@Test
void picksHeaderThroughDialogOverlay() throws Exception {
onFxThread(() -> {
NativeWindowDecoration decoration = createDecoration();
Rectangle title = new Rectangle(20, 20);
Button headerAction = new Button("Header action");
decoration.setContent(new BorderPane(new StackPane(title), null, headerAction, null, null));
decoration.headerBar.setPrefHeight(40);
StackPane root = new StackPane(new BorderPane(new StackPane(), decoration.headerBar, null, null, null));
Scene scene = new Scene(root, 800, 400);
Button dialogAction = new Button("Dialog action");
JFXDialogPane pane = new JFXDialogPane();
pane.push(dialogAction);
JFXDialog dialog = new JFXDialog(root, pane, JFXDialog.DialogTransition.NONE, false);
// A nested overlay node must inherit transparency for header hit testing only.
Region shade = new Region();
dialog.getChildren().add(0, shade);
decoration.setDraggable(pane, false);
dialog.show();
root.resize(800, 400);
root.applyCss();
root.layout();
assertNotEquals("DRAGBAR", pick(scene, title));
decoration.setDragTransparent(dialog);
assertEquals("DRAGBAR", pick(scene, title));
assertNotEquals("DRAGBAR", pick(scene, headerAction));
assertNotEquals("DRAGBAR", pick(scene, 20, 200));
assertSame(shade, pickMouse(scene, title.localToScene(10, 10)).getIntersectedNode());
// Dialog controls must still block native dragging even when they overlap the header.
Point2D actionCenter = dialogAction.localToScene(dialogAction.getWidth() / 2, dialogAction.getHeight() / 2);
pane.getParent().setTranslateY(20 - actionCenter.getY());
assertNotEquals("DRAGBAR", pick(scene, dialogAction));
dialog.close();
assertEquals("DRAGBAR", pick(scene, title));
return null;
});
}
/// Verifies each newly attached stage opts out of the system-provided window buttons.
@Test
void hidesSystemButtons() throws Exception {
onFxThread(() -> {
NativeWindowDecoration decoration = createDecoration();
Stage first = new Stage();
Stage replacement = new Stage();
decoration.configureStage(first);
decoration.configureStage(replacement);
Method getter = decoration.headerBar.getClass().getMethod("getSystemButtonHeight", Stage.class);
assertEquals(0.0, getter.invoke(null, first));
assertEquals(0.0, getter.invoke(null, replacement));
return null;
});
}
/// Resolves the real APIs even when the headless toolkit reports no native window decorations.
private static NativeWindowDecoration createDecoration() throws ReflectiveOperationException {
Constructor<NativeWindowDecoration> constructor = NativeWindowDecoration.class.getDeclaredConstructor(boolean.class);
constructor.setAccessible(true);
return constructor.newInstance(false);
}
/// Applies CSS so hit testing includes control skins, then lays out the header at a fixed size.
private static void layout(StackPane root) {
root.resize(800, 40);
root.applyCss();
root.layout();
}
/// Uses the same header-area picker that the platform window toolkit calls.
///
/// @return the native hit-test category at the node's center, or `null` for an ordinary client-area hit
private static @Nullable String pick(Scene scene, Node node) throws ReflectiveOperationException {
Point2D point = node.localToScene(node.getLayoutBounds().getWidth() / 2, node.getLayoutBounds().getHeight() / 2);
return pick(scene, point.getX(), point.getY());
}
/// Returns the native header hit-test category at scene coordinates, or `null` for an ordinary client-area hit.
private static @Nullable String pick(Scene scene, double x, double y) throws ReflectiveOperationException {
Class<?> listenerClass = Class.forName("javafx.scene.Scene$ScenePeerListener");
Constructor<?> constructor = listenerClass.getDeclaredConstructor(Scene.class);
constructor.setAccessible(true);
Object listener = constructor.newInstance(scene);
Method pick = listenerClass.getDeclaredMethod("pickHeaderArea", double.class, double.class);
pick.setAccessible(true);
@Nullable Object result = pick.invoke(listener, x, y);
return result == null ? null : result.toString();
}
/// Performs ordinary mouse picking to verify the modal overlay still intercepts input.
private static PickResult pickMouse(Scene scene, Point2D point) throws ReflectiveOperationException {
Method pick = Scene.class.getDeclaredMethod("pick", double.class, double.class);
pick.setAccessible(true);
return (PickResult) pick.invoke(scene, point.getX(), point.getY());
}
/// Runs a checked test action on the JavaFX application thread and propagates failures.
private static void onFxThread(Callable<@Nullable Void> action) throws Exception {
FutureTask<@Nullable Void> task = new FutureTask<>(action);
Platform.runLater(task);
task.get(10, TimeUnit.SECONDS);
}
}
@@ -0,0 +1,425 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.decorator;
import javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.jackhuang.hmcl.JavaFXLauncher;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/// Exercises real window-manager geometry; enable with `hmcl.test.nativeWindows=true` in the test JVM.
/// These tests require a desktop session and intentionally show, maximize, and hide windows.
@NotNullByDefault
@EnabledIfSystemProperty(named = "hmcl.test.nativeWindows", matches = "true")
final class WindowBoundsTest {
/// The normal content rectangle used across window styles.
private static final Rectangle2D INITIAL = new Rectangle2D(300, 200, 920, 560);
/// Starts the toolkit and keeps it alive between test windows.
@BeforeAll
static void startToolkit() throws Exception {
assertTrue(JavaFXLauncher.isStarted());
onFxThread(() -> {
Platform.setImplicitExit(false);
return null;
});
}
/// Verifies Scene reuse, repeated style changes, and hiding do not accumulate frame or shadow offsets.
@Test
void preservesContentAcrossStylesAndHiding() throws Exception {
Fixture fixture = onFxThread(Fixture::new);
try {
for (int i = 0; i < 6; i++) {
StageStyle style = i % 2 == 0 ? StageStyle.TRANSPARENT : nativeStyle();
onFxThread(() -> {
fixture.replace(style, false);
return null;
});
awaitContent(fixture, INITIAL);
}
onFxThread(() -> {
fixture.stage.hide();
assertRectangle(INITIAL, fixture.saved);
fixture.stage.show();
return null;
});
awaitContent(fixture, INITIAL);
} finally {
onFxThread(() -> {
fixture.close();
return null;
});
}
}
/// Verifies native and custom minimum sizes, resizing, and position persistence use content coordinates.
@Test
void tracksResizingAndMinimumContentSize() throws Exception {
Fixture fixture = onFxThread(Fixture::new);
try {
for (StageStyle style : new StageStyle[] {StageStyle.TRANSPARENT, nativeStyle()}) {
onFxThread(() -> {
fixture.replace(style, false);
return null;
});
awaitContent(fixture, INITIAL);
onFxThread(() -> {
fixture.stage.setWidth(fixture.stage.getMinWidth());
fixture.stage.setHeight(fixture.stage.getMinHeight());
fixture.stage.setX(fixture.stage.getX() + 40);
fixture.stage.setY(fixture.stage.getY() + 20);
return null;
});
await(() -> {
assertTrue(fixture.content.getWidth() >= 800 && fixture.content.getWidth() < 802,
() -> "Content width: " + fixture.content.getWidth() + ", scene: " + fixture.scene.getWidth()
+ ", stage: " + fixture.stage.getWidth() + ", minimum: " + fixture.stage.getMinWidth());
assertTrue(fixture.content.getHeight() >= 490 && fixture.content.getHeight() < 492,
() -> "Content height: " + fixture.content.getHeight());
assertEquals(fixture.content.getWidth(), fixture.saved.getWidth(), 0.01);
assertEquals(fixture.content.getHeight(), fixture.saved.getHeight(), 0.01);
assertEquals(340, fixture.saved.getMinX(), 0.01);
assertEquals(220, fixture.saved.getMinY(), 0.01);
return true;
});
// Native minimum sizes are rounded to whole logical units and then to physical pixels.
// Test fractional-size preservation above that constraint, where both styles can fit exactly.
Rectangle2D resized = onFxThread(() -> {
Rectangle2D expected = new Rectangle2D(340, 220,
fixture.saved.getWidth() + 24, fixture.saved.getHeight() + 24);
fixture.stage.setWidth(fixture.stage.getWidth() + 24);
fixture.stage.setHeight(fixture.stage.getHeight() + 24);
return expected;
});
awaitContent(fixture, resized);
for (int i = 0; i < 4; i++) {
StageStyle replacementStyle = i % 2 == 0 ? StageStyle.TRANSPARENT : nativeStyle();
onFxThread(() -> {
fixture.replace(replacementStyle, false);
return null;
});
awaitContent(fixture, resized);
}
onFxThread(() -> {
fixture.close();
fixture.saved = INITIAL;
return null;
});
}
} finally {
onFxThread(() -> {
fixture.close();
return null;
});
}
}
/// Verifies saving bounds on detach cannot turn overlapping size notifications into new window minimums.
@Test
void keepsMinimumSizeStableWhenDetachedDuringResize() throws Exception {
Fixture fixture = onFxThread(Fixture::new);
AtomicBoolean resizeAgain = new AtomicBoolean();
AtomicBoolean detached = new AtomicBoolean();
InvalidationListener clientSizeListener = observable -> {
if (resizeAgain.getAndSet(false)) {
// Leave a second outer-size request pending while the first client notification is delivered.
fixture.stage.setWidth(fixture.stage.getWidth() + 60);
}
};
ChangeListener<Number> detachListener = (observable, oldValue, newValue) -> {
if (detached.compareAndSet(false, true)) {
Objects.requireNonNull(fixture.bounds).close();
}
};
try {
onFxThread(() -> {
fixture.scene.widthProperty().addListener(clientSizeListener);
return null;
});
for (StageStyle style : new StageStyle[] {StageStyle.TRANSPARENT, nativeStyle()}) {
onFxThread(() -> {
fixture.replace(style, false);
return null;
});
awaitContent(fixture, INITIAL);
List<Double> minimumChanges = new ArrayList<>();
onFxThread(() -> {
fixture.stage.minWidthProperty().addListener((observable, oldValue, newValue) ->
minimumChanges.add(newValue.doubleValue()));
fixture.stage.minHeightProperty().addListener((observable, oldValue, newValue) ->
minimumChanges.add(newValue.doubleValue()));
detached.set(false);
fixture.scene.widthProperty().addListener(detachListener);
resizeAgain.set(true);
fixture.stage.setWidth(fixture.stage.getWidth() + 20);
fixture.stage.setHeight(fixture.stage.getHeight() + 20);
return null;
});
await(detached::get);
onFxThread(() -> {
fixture.scene.widthProperty().removeListener(detachListener);
fixture.close();
assertTrue(minimumChanges.isEmpty(), () -> "Minimum sizes changed during resizing: " + minimumChanges);
fixture.saved = INITIAL;
return null;
});
}
} finally {
onFxThread(() -> {
fixture.scene.widthProperty().removeListener(clientSizeListener);
fixture.scene.widthProperty().removeListener(detachListener);
fixture.close();
return null;
});
}
}
/// Verifies a style switch while maximized retains the normal rectangle for subsequent restoration.
@Test
void preservesNormalBoundsWhenMaximized() throws Exception {
Fixture fixture = onFxThread(Fixture::new);
try {
onFxThread(() -> {
fixture.replace(OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
? nativeStyle() : StageStyle.TRANSPARENT, false);
return null;
});
awaitContent(fixture, INITIAL);
onFxThread(() -> {
fixture.stage.setMaximized(true);
return null;
});
await(() -> fixture.stage.isMaximized() && fixture.content.getWidth() > INITIAL.getWidth());
onFxThread(() -> {
assertRectangle(INITIAL, fixture.saved);
fixture.replace(nativeStyle(), true);
return null;
});
await(() -> fixture.stage.isMaximized() && fixture.content.getWidth() > INITIAL.getWidth());
onFxThread(() -> {
assertRectangle(INITIAL, fixture.saved);
fixture.stage.hide();
assertRectangle(INITIAL, fixture.saved);
fixture.stage.show();
return null;
});
await(() -> fixture.stage.isMaximized() && fixture.content.getWidth() > INITIAL.getWidth());
onFxThread(() -> {
assertRectangle(INITIAL, fixture.saved);
fixture.stage.setMaximized(false);
return null;
});
awaitContent(fixture, INITIAL);
} finally {
onFxThread(() -> {
fixture.close();
return null;
});
}
}
/// Verifies full-screen and iconified states do not replace the saved normal rectangle.
@Test
void preservesNormalBoundsThroughFullScreenAndMinimization() throws Exception {
Fixture fixture = onFxThread(Fixture::new);
try {
for (StageStyle style : new StageStyle[] {StageStyle.TRANSPARENT, nativeStyle()}) {
onFxThread(() -> {
fixture.replace(style, false);
fixture.stage.setFullScreenExitHint("");
return null;
});
awaitContent(fixture, INITIAL);
onFxThread(() -> {
fixture.stage.setFullScreen(true);
return null;
});
await(() -> fixture.stage.isFullScreen() && fixture.content.getWidth() > INITIAL.getWidth());
onFxThread(() -> {
assertRectangle(INITIAL, fixture.saved);
fixture.stage.setFullScreen(false);
return null;
});
awaitContent(fixture, INITIAL);
onFxThread(() -> {
fixture.stage.setIconified(true);
return null;
});
await(() -> fixture.stage.isIconified());
onFxThread(() -> {
assertRectangle(INITIAL, fixture.saved);
fixture.stage.setIconified(false);
return null;
});
awaitContent(fixture, INITIAL);
}
} finally {
onFxThread(() -> {
fixture.close();
return null;
});
}
}
/// Returns EXTENDED on JavaFX 27 or later when supported, otherwise the traditional native frame.
///
/// Queries capabilities on the JavaFX application thread, waiting for the result when called elsewhere.
private static StageStyle nativeStyle() throws Exception {
if (!Platform.isFxApplicationThread()) {
return onFxThread(WindowBoundsTest::nativeStyle);
}
try {
int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]);
if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) {
return StageStyle.DECORATED;
}
return StageStyle.valueOf("EXTENDED");
} catch (IllegalArgumentException e) {
return StageStyle.DECORATED;
}
}
/// Waits for actual node geometry and persisted bounds to agree with the expected rectangle.
private static void awaitContent(Fixture fixture, Rectangle2D expected) throws Exception {
await(() -> {
Point2D origin = Objects.requireNonNull(fixture.content.localToScreen(0, 0));
assertRectangle(expected, new Rectangle2D(origin.getX(), origin.getY(),
fixture.content.getWidth(), fixture.content.getHeight()));
assertRectangle(expected, fixture.saved);
assertEquals(expected.getWidth(), fixture.bounds.contentWidthProperty().get(), 0.01);
assertEquals(expected.getHeight(), fixture.bounds.contentHeightProperty().get(), 0.01);
assertEquals(800 + fixture.stage.getWidth() - fixture.content.getWidth(), fixture.stage.getMinWidth(), 0.01);
assertEquals(490 + fixture.stage.getHeight() - fixture.content.getHeight(), fixture.stage.getMinHeight(), 0.01);
return true;
});
}
/// Compares logical screen coordinates, allowing floating-point conversion error.
private static void assertRectangle(Rectangle2D expected, Rectangle2D actual) {
assertEquals(expected.getMinX(), actual.getMinX(), 0.01, "content x");
assertEquals(expected.getMinY(), actual.getMinY(), 0.01, "content y");
assertEquals(expected.getWidth(), actual.getWidth(), 0.01, "content width");
assertEquals(expected.getHeight(), actual.getHeight(), 0.01, "content height");
}
/// Retries assertions while asynchronous native events and layout pulses are being delivered.
private static void await(Callable<Boolean> check) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (true) {
try {
if (onFxThread(check)) {
return;
}
} catch (java.util.concurrent.ExecutionException e) {
if (!(e.getCause() instanceof AssertionError) || System.nanoTime() >= deadline) {
throw e;
}
}
assertTrue(System.nanoTime() < deadline, "Native window did not reach the expected state");
Thread.sleep(50);
}
}
/// Runs an action on the JavaFX application thread and propagates its result or failure.
private static <T extends @Nullable Object> T onFxThread(Callable<T> action) throws Exception {
FutureTask<T> task = new FutureTask<>(action);
Platform.runLater(task);
return task.get(10, TimeUnit.SECONDS);
}
/// Owns a retained scene and mirrors the decorator's shadow and stage-replacement lifecycle.
@NotNullByDefault
private static final class Fixture {
/// The node whose on-screen content geometry is checked.
final Region content = new Region();
/// The retained scene root providing custom shadow padding.
final StackPane root = new StackPane(content);
/// The scene transferred between stages.
final Scene scene = new Scene(root);
/// The last normal bounds reported by the tracker.
Rectangle2D saved = INITIAL;
/// The current stage, initially an unshown placeholder.
Stage stage = new Stage();
/// The current tracker, absent before the first replacement or after closing.
@Nullable WindowBounds bounds;
/// Creates a scene without showing a window.
Fixture() {
// Measure client geometry independently of StackPane's outward pixel snapping during layout.
root.setSnapToPixel(false);
}
/// Transfers the retained scene to a new style and optionally starts maximized.
void replace(StageStyle style, boolean maximized) {
close();
Insets normalInsets = style == StageStyle.TRANSPARENT ? new Insets(8) : Insets.EMPTY;
root.setPadding(normalInsets);
stage = new Stage(style);
stage.setScene(scene);
javafx.beans.InvalidationListener decorationListener = o ->
root.setPadding(WindowState.isMaximized(stage) || stage.isFullScreen() ? Insets.EMPTY : normalInsets);
stage.maximizedProperty().addListener(decorationListener);
stage.fullScreenProperty().addListener(decorationListener);
bounds = new WindowBounds(stage, root, saved, 800, 490, value -> saved = value);
stage.setMaximized(maximized && WindowState.supportsMaximization(stage));
stage.show();
}
/// Releases listeners before removing the scene and hiding its previous stage.
void close() {
if (bounds != null) {
bounds.close();
bounds = null;
}
stage.setScene(null);
stage.hide();
}
}
}
@@ -0,0 +1,110 @@
/*
* 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.url.data;
import org.glavo.url.WebURL;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.*;
/// Tests data URL parsing and decoding through both WebURL and the Java URL handler.
@NotNullByDefault
public final class DataURLTest {
/// Parses a data URL and reads its text payload.
private static String readString(String url) throws IOException {
return new DataURL(WebURL.parse(url)).readString();
}
/// Parses a data URL and reads its byte payload.
private static byte[] readBytes(String url) throws IOException {
return new DataURL(WebURL.parse(url)).readBytes();
}
/// Ensures percent-encoded and Base64 text payloads are decoded.
@Test
public void testReadString() throws IOException {
assertEquals("Hello, World!", readString("data:,Hello%2C%20World%21"));
assertEquals("Hello, World!", readString("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="));
assertEquals("<h1>Hello, World!</h1>", readString("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E"));
assertEquals("<script>alert('hi');</script>", readString("data:text/html,%3Cscript%3Ealert%28%27hi%27%29%3B%3C%2Fscript%3E"));
}
/// Ensures queries remain part of the payload, fragments are excluded, and escapes are decoded once.
@Test
public void testPayloadDelimiters() throws IOException {
assertEquals("Hello?name=a+b&path=/#end", readString("data:,Hello?name=a+b&path=%2F%23end#ignored"));
assertEquals("Hello?", readString("data:,Hello?#ignored"));
assertEquals("?a+b#c%23", readString("data:,%3Fa%2Bb%23c%2523#ignored"));
assertEquals("a,b", readString("data:,a,b"));
assertEquals("", readString("data:,"));
}
/// Ensures WebURL normalization accepts uppercase schemes and unescaped spaces.
@Test
public void testNormalizedInput() throws IOException {
assertEquals("Hello, World!", readString("DATA:text/plain,Hello, World!"));
assertTrue(DataURL.isDataUri(WebURL.parse("DATA:,value")));
assertFalse(DataURL.isDataUri(WebURL.parse("https://example.com/")));
assertFalse(DataURL.isDataUri(null));
}
/// Ensures payload bytes, Base64 escapes, charset metadata, and literal plus signs are retained.
@Test
public void testReadBytesAndMetadata() throws IOException {
assertArrayEquals("Hello + World!".getBytes(UTF_8), readBytes("data:,Hello%20+%20World!"));
assertArrayEquals(new byte[]{0, (byte) 0xFF, (byte) 0x80}, readBytes("data:application/octet-stream;base64,AP+A"));
assertEquals("Hello", readString("data:;base64,SGVsbG8%3D#ignored"));
DataURL data = new DataURL(WebURL.parse("data:text/plain;charset=ISO-8859-1;base64,Y2Fm6Q=="));
assertEquals("text/plain;charset=ISO-8859-1", data.getMediaType());
assertEquals("ISO-8859-1", data.getCharset().name());
assertTrue(data.isBase64());
assertEquals("Y2Fm6Q==", data.getRawData());
assertEquals("caf\u00E9", data.readString());
}
/// Ensures invalid schemes, missing separators, and invalid Base64 are reported at the appropriate stage.
@Test
public void testInvalidData() {
assertThrows(IllegalArgumentException.class, () -> new DataURL(WebURL.parse("https://example.com/")));
assertThrows(IllegalArgumentException.class, () -> new DataURL(WebURL.parse("data:text/plain")));
assertThrows(IOException.class, () -> readBytes("data:;base64,invalid!"));
assertThrows(IOException.class, () -> readString("data:;base64,invalid!"));
}
/// Ensures Java URL connections use the WebURL parser and expose decoded payload bytes.
@Test
public void testURLHandler() throws IOException {
URLConnection connection = new URL(null, "data:text/plain,Hello world?name=a+b%23c#ignored", new DataURLHandle())
.openConnection();
assertEquals("text/plain", connection.getContentType());
try (var input = connection.getInputStream()) {
assertEquals("Hello world?name=a+b#c", new String(input.readAllBytes(), UTF_8));
}
assertThrows(MalformedURLException.class,
() -> new URL(null, "data:text/plain", new DataURLHandle()).openConnection());
}
}
@@ -1,44 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.url.data;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class DataUriTest {
private static String readString(String uri) throws IOException {
return new DataUri(URI.create(uri)).readString();
}
private static byte[] readBytes(String uri) throws IOException {
return new DataUri(URI.create(uri)).readBytes();
}
@Test
public void testReadString() throws IOException {
assertEquals("Hello, World!", readString("data:,Hello%2C%20World%21"));
assertEquals("Hello, World!", readString("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="));
assertEquals("<h1>Hello, World!</h1>", readString("data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E"));
assertEquals("<script>alert('hi');</script>", readString("data:text/html,%3Cscript%3Ealert%28%27hi%27%29%3B%3C%2Fscript%3E"));
}
}
+6
View File
@@ -53,3 +53,9 @@ tasks.processResources {
}
}
}
tasks.register("updateLiteLoaderVersionList", org.jackhuang.hmcl.gradle.component.UpdateLiteLoaderVersionList::class) {
description = "Update LiteLoader version list"
outputFile.set(projectDir.resolve("src/main/resources/assets/liteloader/versions.json"))
}
@@ -112,7 +112,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
if (this.type == DependencyType.BROKEN) {
this.remoteAddon = RemoteAddon.BROKEN;
} else {
this.remoteAddon = this.source.getCommonRepo().resolveDependency(downloadProvider, this.id);
this.remoteAddon = this.source.getRepository().resolveDependency(downloadProvider, this.id);
}
}
return this.remoteAddon;
@@ -140,65 +140,17 @@ public record RemoteAddon(String slug, String author, String title, String descr
}
public enum Source {
CURSEFORGE(
CurseForgeRemoteAddonRepository.COMMON,
CurseForgeRemoteAddonRepository.MODS,
CurseForgeRemoteAddonRepository.RESOURCE_PACKS,
CurseForgeRemoteAddonRepository.SHADERS,
CurseForgeRemoteAddonRepository.WORLDS,
CurseForgeRemoteAddonRepository.MODPACKS,
CurseForgeRemoteAddonRepository.CUSTOMIZATIONS
),
MODRINTH(
ModrinthRemoteAddonRepository.COMMON,
ModrinthRemoteAddonRepository.MODS,
ModrinthRemoteAddonRepository.RESOURCE_PACKS,
ModrinthRemoteAddonRepository.SHADER_PACKS,
null,
ModrinthRemoteAddonRepository.MODPACKS,
null
);
CURSEFORGE(CurseForgeRemoteAddonRepository.getInstance()),
MODRINTH(ModrinthRemoteAddonRepository.getInstance());
private final RemoteAddonRepository commonRepo;
private final RemoteAddonRepository modRepo;
private final RemoteAddonRepository resourcePackRepo;
private final RemoteAddonRepository shaderPackRepo;
private final RemoteAddonRepository worldRepo;
private final RemoteAddonRepository modpackRepo;
private final RemoteAddonRepository customizationRepo;
private final RemoteAddonRepository repo;
@Nullable
public RemoteAddonRepository getRepoForType(Type type) {
return switch (type) {
case MOD -> modRepo;
case RESOURCE_PACK -> resourcePackRepo;
case SHADER_PACK -> shaderPackRepo;
case WORLD -> worldRepo;
case MODPACK -> modpackRepo;
case CUSTOMIZATION -> customizationRepo;
};
public RemoteAddonRepository getRepository() {
return repo;
}
public RemoteAddonRepository getCommonRepo() {
return commonRepo;
}
Source(
RemoteAddonRepository commonRepo,
RemoteAddonRepository modRepo,
RemoteAddonRepository resourcePackRepo,
RemoteAddonRepository shaderPackRepo,
RemoteAddonRepository worldRepo,
RemoteAddonRepository modpackRepo,
RemoteAddonRepository customizationRepo
) {
this.commonRepo = commonRepo;
this.modRepo = modRepo;
this.resourcePackRepo = resourcePackRepo;
this.shaderPackRepo = shaderPackRepo;
this.worldRepo = worldRepo;
this.modpackRepo = modpackRepo;
this.customizationRepo = customizationRepo;
Source(RemoteAddonRepository repo) {
this.repo = repo;
}
}
@@ -29,13 +29,11 @@ import java.util.stream.Stream;
public interface RemoteAddonRepository {
RemoteAddon.Type getType();
String getApiBaseUrl();
String getBaseUrl();
SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder)
SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, @Nullable Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder)
throws IOException;
Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException;
@@ -56,7 +54,7 @@ public interface RemoteAddonRepository {
@NotNull
String getVersionPageUrl(RemoteAddon.Version version) throws IOException;
Stream<Category> getCategories() throws IOException;
Stream<Category> getCategories(RemoteAddon.Type type) throws IOException;
record Category(Object self, String id, List<Category> subcategories) {
}
@@ -75,5 +73,9 @@ public interface RemoteAddonRepository {
}
record SearchResult(Stream<RemoteAddon> results, int totalPages) {
public static SearchResult empty() {
return new SearchResult(Stream.empty(), 0);
}
}
}
@@ -196,7 +196,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
RemoteAddonRepository repository = source.getRepoForType(RemoteAddon.Type.MOD);
RemoteAddonRepository repository = source.getRepository();
if (repository == null) return null;
Optional<RemoteAddon.Version> currentVersion = repository.getRemoteVersionByLocalFile(file);
if (currentVersion.isEmpty()) return null;
@@ -21,6 +21,8 @@ import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.download.DownloadCandidate;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.MurmurHash2;
@@ -32,7 +34,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
@@ -71,24 +72,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
return !API_KEY.isEmpty();
}
private final @Nullable RemoteAddon.Type type;
private final int section;
public CurseForgeRemoteAddonRepository() {
this.type = null;
this.section = -1;
}
public CurseForgeRemoteAddonRepository(int section) {
this.type = toAddonType(section);
if (type == null) throw new IllegalArgumentException("Unsupported CurseForge section id: " + section);
this.section = section;
}
@Override
public RemoteAddon.Type getType() {
if (type == null) throw new UnsupportedOperationException();
return type;
}
@Override
@@ -130,8 +114,9 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
/// @throws NoCandidatesException if no response is obtained and no I/O failure was recorded
/// @throws IOException if all candidate requests fail with I/O errors
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteAddonRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
if (type == null) throw new UnsupportedOperationException();
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, @Nullable RemoteAddonRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
int section = toSectionId(type);
if (section < 0) return SearchResult.empty();
SEMAPHORE.acquireUninterruptibly();
try {
int categoryId = 0;
@@ -154,17 +139,17 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
@Nullable Response<List<CurseAddon>> response = null;
@Nullable IOException exception = null;
List<URI> candidates = downloadProvider.injectURLWithCandidates(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", query));
for (URI candidate : candidates) {
DownloadCandidates candidates = downloadProvider.getDownloadCandidates(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", query));
for (DownloadCandidate candidate : candidates.getCandidates()) {
LOG.info("Fetching " + candidate);
try {
response = withApiKey(HttpRequest.GET(candidate.toString()))
response = withApiKey(HttpRequest.GET(candidate.displayUrl()))
.retry(DEFAULT_RETRY_COUNT)
.getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
break;
} catch (IOException e) {
LOG.warning("Failed to search addons: " + candidate, e);
if (candidates.size() == 1) {
if (candidates.getCandidates().size() == 1) {
exception = e;
} else {
if (exception == null) {
@@ -359,8 +344,9 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
}
@Override
public Stream<RemoteAddonRepository.Category> getCategories() throws IOException {
if (type == null) throw new UnsupportedOperationException();
public Stream<RemoteAddonRepository.Category> getCategories(RemoteAddon.Type type) throws IOException {
int section = toSectionId(type);
if (section < 0) return Stream.empty();
SEMAPHORE.acquireUninterruptibly();
try {
Response<List<Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
@@ -407,13 +393,11 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
public static final int SECTION_UNKNOWN2 = 4979;
public static final int SECTION_UNKNOWN3 = 4984;
public static final CurseForgeRemoteAddonRepository COMMON = new CurseForgeRemoteAddonRepository();
public static final CurseForgeRemoteAddonRepository MODS = new CurseForgeRemoteAddonRepository(SECTION_MOD);
public static final CurseForgeRemoteAddonRepository MODPACKS = new CurseForgeRemoteAddonRepository(SECTION_MODPACK);
public static final CurseForgeRemoteAddonRepository RESOURCE_PACKS = new CurseForgeRemoteAddonRepository(SECTION_RESOURCE_PACK);
public static final CurseForgeRemoteAddonRepository WORLDS = new CurseForgeRemoteAddonRepository(SECTION_WORLD);
public static final CurseForgeRemoteAddonRepository CUSTOMIZATIONS = new CurseForgeRemoteAddonRepository(SECTION_CUSTOMIZATION);
public static final CurseForgeRemoteAddonRepository SHADERS = new CurseForgeRemoteAddonRepository(SECTION_SHADER);
private static final CurseForgeRemoteAddonRepository INSTANCE = new CurseForgeRemoteAddonRepository();
public static CurseForgeRemoteAddonRepository getInstance() {
return INSTANCE;
}
@Nullable
private static RemoteAddon.Type toAddonType(int classId) {
@@ -428,6 +412,18 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
};
}
private static int toSectionId(RemoteAddon.Type type) {
return switch (type) {
case MOD -> SECTION_MOD;
case MODPACK -> SECTION_MODPACK;
case RESOURCE_PACK -> SECTION_RESOURCE_PACK;
case SHADER_PACK -> SECTION_SHADER;
case WORLD -> SECTION_WORLD;
case CUSTOMIZATION -> SECTION_CUSTOMIZATION;
default -> -1;
};
}
public record Pagination(int index, int pageSize, int resultCount, int totalCount) {
}
@@ -22,6 +22,8 @@ import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.download.DownloadCandidate;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.Immutable;
@@ -39,7 +41,6 @@ import org.jetbrains.annotations.Unmodifiable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.Instant;
@@ -55,11 +56,12 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @see <a href="https://docs.modrinth.com/api">Modrinth API Doc</a>
public final class ModrinthRemoteAddonRepository implements RemoteAddonRepository {
public static final ModrinthRemoteAddonRepository COMMON = new ModrinthRemoteAddonRepository();
public static final ModrinthRemoteAddonRepository MODS = new ModrinthRemoteAddonRepository("mod");
public static final ModrinthRemoteAddonRepository MODPACKS = new ModrinthRemoteAddonRepository("modpack");
public static final ModrinthRemoteAddonRepository RESOURCE_PACKS = new ModrinthRemoteAddonRepository("resourcepack");
public static final ModrinthRemoteAddonRepository SHADER_PACKS = new ModrinthRemoteAddonRepository("shader");
private static final ModrinthRemoteAddonRepository INSTANCE = new ModrinthRemoteAddonRepository();
public static ModrinthRemoteAddonRepository getInstance() {
return INSTANCE;
}
private static final Comparator<String> TAG_COMPARATOR = PriorityComparator.of(
List.of("babric",
@@ -107,31 +109,23 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
};
}
private static @Nullable String toProjectType(RemoteAddon.Type type) {
return switch (type) {
case MOD -> "mod";
case MODPACK -> "modpack";
case RESOURCE_PACK -> "resourcepack";
case SHADER_PACK -> "shader";
default -> null;
};
}
private static final Semaphore SEMAPHORE = new Semaphore(16);
private static final String PREFIX = "https://api.modrinth.com";
private static final String BASE = "https://modrinth.com";
private final @Nullable String projectType;
private final @Nullable RemoteAddon.Type type;
private ModrinthRemoteAddonRepository() {
this.projectType = null;
this.type = null;
}
private ModrinthRemoteAddonRepository(@NotNull String projectType) {
this.projectType = projectType;
this.type = toAddonType(projectType);
if (type == null) throw new IllegalArgumentException("Unsupported Modrinth project type: " + projectType);
}
@Override
public RemoteAddon.Type getType() {
if (type == null) throw new UnsupportedOperationException();
return this.type;
}
@Override
@@ -162,8 +156,9 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
}
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteAddonRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (projectType == null) throw new UnsupportedOperationException();
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, @Nullable RemoteAddonRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
String projectType = toProjectType(type);
if (projectType == null) return SearchResult.empty();
SEMAPHORE.acquireUninterruptibly();
try {
List<List<String>> facets = new ArrayList<>();
@@ -182,19 +177,19 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
pair("index", convertSortType(sort))
);
List<URI> candidates = downloadProvider.injectURLWithCandidates(NetworkUtils.withQuery(PREFIX + "/v2/search", query));
DownloadCandidates candidates = downloadProvider.getVersionListCandidates(NetworkUtils.withQuery(PREFIX + "/v2/search", query));
IOException exception = null;
for (URI candidate : candidates) {
for (DownloadCandidate candidate : candidates.getCandidates()) {
try {
LOG.info("Fetching " + candidate);
Response<ProjectSearchResult> response = HttpRequest.GET(candidate.toString())
LOG.info("Fetching " + candidate.displayUrl());
Response<ProjectSearchResult> response = HttpRequest.GET(candidate.displayUrl())
.getJson(Response.typeOf(ProjectSearchResult.class));
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toAddon), (int) Math.ceil((double) response.totalHits / pageSize));
} catch (IOException e) {
LOG.warning("Failed to search addons: " + candidate, e);
IOException wrapper = new IOException("Failed to search addons: " + candidate, e);
if (candidates.size() == 1) {
if (candidates.getCandidates().size() == 1) {
exception = wrapper;
} else {
if (exception == null) {
@@ -246,16 +241,16 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
SEMAPHORE.acquireUninterruptibly();
try {
id = StringUtils.removePrefix(id, "local-");
List<URI> candidates = downloadProvider.injectURLWithCandidates(PREFIX + "/v2/project/" + id);
DownloadCandidates candidates = downloadProvider.getVersionListCandidates(PREFIX + "/v2/project/" + id);
IOException exception = null;
for (URI candidate : candidates) {
for (DownloadCandidate candidate : candidates.getCandidates()) {
try {
Project project = HttpRequest.GET(candidate.toString()).getJson(Project.class);
Project project = HttpRequest.GET(candidate.displayUrl()).getJson(Project.class);
return project.toAddon();
} catch (IOException e) {
IOException wrapper = new IOException("Failed to get addon: " + candidate, e);
if (candidates.size() == 1) {
if (candidates.getCandidates().size() == 1) {
exception = wrapper;
} else {
if (exception == null) {
@@ -303,17 +298,17 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
try {
id = StringUtils.removePrefix(id, "local-");
List<URI> candidates = downloadProvider.injectURLWithCandidates(PREFIX + "/v2/project/" + id + "/version?include_changelog=false");
DownloadCandidates candidates = downloadProvider.getVersionListCandidates(PREFIX + "/v2/project/" + id + "/version?include_changelog=false");
IOException exception = null;
for (URI candidate : candidates) {
for (DownloadCandidate candidate : candidates.getCandidates()) {
try {
List<ProjectVersion> versions = HttpRequest.GET(candidate.toString())
List<ProjectVersion> versions = HttpRequest.GET(candidate.displayUrl())
.getJson(listTypeOf(ProjectVersion.class));
return versions.stream().map(ProjectVersion::toVersion).flatMap(Optional::stream);
} catch (IOException e) {
IOException wrapper = new IOException("Failed to get remote versions: " + candidate, e);
if (candidates.size() == 1) {
if (candidates.getCandidates().size() == 1) {
exception = wrapper;
} else {
if (exception == null) {
@@ -334,16 +329,16 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
List<URI> candidates = downloadProvider.injectURLWithCandidates(PREFIX + "/v2/version/" + versionId);
DownloadCandidates candidates = downloadProvider.getVersionListCandidates(PREFIX + "/v2/version/" + versionId);
IOException exception = null;
for (URI uri : candidates) {
for (DownloadCandidate candidate : candidates.getCandidates()) {
try {
ProjectVersion version = HttpRequest.GET(uri.toString()).getJson(ProjectVersion.class);
ProjectVersion version = HttpRequest.GET(candidate.displayUrl()).getJson(ProjectVersion.class);
return version.changelog();
} catch (IOException e) {
IOException wrapper = new IOException("Failed to get addon changelog: " + uri, e);
if (candidates.size() == 1) {
IOException wrapper = new IOException("Failed to get addon changelog: " + candidate.displayUrl(), e);
if (candidates.getCandidates().size() == 1) {
exception = wrapper;
} else {
if (exception == null) {
@@ -366,8 +361,9 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
}
@Override
public Stream<RemoteAddonRepository.Category> getCategories() throws IOException {
if (projectType == null) throw new UnsupportedOperationException();
public Stream<RemoteAddonRepository.Category> getCategories(RemoteAddon.Type type) throws IOException {
String projectType = toProjectType(type);
if (projectType == null) return Stream.empty();
SEMAPHORE.acquireUninterruptibly();
try {
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class));
@@ -86,7 +86,7 @@ final class ResourcePackZipFile extends ResourcePackFile {
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException {
RemoteAddonRepository repository = source.getRepoForType(RemoteAddon.Type.RESOURCE_PACK);
RemoteAddonRepository repository = source.getRepository();
if (repository == null) return null;
Optional<RemoteAddon.Version> currentVersion = repository.getRemoteVersionByLocalFile(file);
if (currentVersion.isEmpty()) return null;
@@ -39,12 +39,12 @@ public final class AuthlibInjectorDnD {
private AuthlibInjectorDnD() {}
public static Optional<String> parseUrlFromDragboard(Dragboard dragboard) {
String uri = dragboard.getString();
if (uri == null) return Optional.empty();
String url = dragboard.getString();
if (url == null) return Optional.empty();
String[] uriElements = uri.split(":");
if (uriElements.length == 3 && SCHEME.equals(uriElements[0]) && PATH_YGGDRASIL_SERVER.equals(uriElements[1])) {
return Optional.of(decodeURL(uriElements[2]));
String[] urlElements = url.split(":");
if (urlElements.length == 3 && SCHEME.equals(urlElements[0]) && PATH_YGGDRASIL_SERVER.equals(urlElements[1])) {
return Optional.of(decodeURL(urlElements[2]));
}
return Optional.empty();
}
@@ -17,11 +17,11 @@
*/
package org.jackhuang.hmcl.auth.authlibinjector;
import org.glavo.url.WebURL;
import org.glavo.uuid.UUIDs;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilProvider;
import java.net.URI;
import java.util.UUID;
public class AuthlibInjectorProvider implements YggdrasilProvider {
@@ -33,33 +33,33 @@ public class AuthlibInjectorProvider implements YggdrasilProvider {
}
@Override
public URI getAuthenticationURL() throws AuthenticationException {
return URI.create(apiRoot + "authserver/authenticate");
public WebURL getAuthenticationURL() throws AuthenticationException {
return WebURL.parse(apiRoot + "authserver/authenticate");
}
@Override
public URI getRefreshmentURL() throws AuthenticationException {
return URI.create(apiRoot + "authserver/refresh");
public WebURL getRefreshmentURL() throws AuthenticationException {
return WebURL.parse(apiRoot + "authserver/refresh");
}
@Override
public URI getValidationURL() throws AuthenticationException {
return URI.create(apiRoot + "authserver/validate");
public WebURL getValidationURL() throws AuthenticationException {
return WebURL.parse(apiRoot + "authserver/validate");
}
@Override
public URI getInvalidationURL() throws AuthenticationException {
return URI.create(apiRoot + "authserver/invalidate");
public WebURL getInvalidationURL() throws AuthenticationException {
return WebURL.parse(apiRoot + "authserver/invalidate");
}
@Override
public URI getSkinUploadURL(UUID uuid) throws UnsupportedOperationException {
return URI.create(apiRoot + "api/user/profile/" + UUIDs.toCompactString(uuid) + "/skin");
public WebURL getSkinUploadURL(UUID uuid) throws UnsupportedOperationException {
return WebURL.parse(apiRoot + "api/user/profile/" + UUIDs.toCompactString(uuid) + "/skin");
}
@Override
public URI getProfilePropertiesURL(UUID uuid) throws AuthenticationException {
return URI.create(apiRoot + "sessionserver/session/minecraft/profile/" + UUIDs.toCompactString(uuid));
public WebURL getProfilePropertiesURL(UUID uuid) throws AuthenticationException {
return WebURL.parse(apiRoot + "sessionserver/session/minecraft/profile/" + UUIDs.toCompactString(uuid));
}
@Override
@@ -22,6 +22,7 @@ import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import org.glavo.url.WebURL;
import org.glavo.uuid.UUIDs;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth;
@@ -39,7 +40,6 @@ import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -290,13 +290,13 @@ public class MicrosoftService {
String response = NetworkUtils.readFullyAsString(con);
if (StringUtils.isBlank(response)) {
if (con.getResponseCode() / 100 != 2)
throw new ResponseCodeException(con.getURL().toURI(), con.getResponseCode());
throw new ResponseCodeException(WebURL.of(con.getURL()), con.getResponseCode());
} else {
MinecraftErrorResponse profileResponse = GSON.fromJson(response, MinecraftErrorResponse.class);
if (StringUtils.isNotBlank(profileResponse.errorMessage) || con.getResponseCode() / 100 != 2)
throw new AuthenticationException("Failed to upload skin, response code: " + con.getResponseCode() + ", response: " + response);
}
} catch (IOException | JsonParseException | URISyntaxException e) {
} catch (IOException | JsonParseException e) {
throw new AuthenticationException(e);
}
}
@@ -306,7 +306,7 @@ public class MicrosoftService {
if (payload == null)
return NetworkUtils.doGet(url);
else
return NetworkUtils.doPost(NetworkUtils.toURI(url), payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
return NetworkUtils.doPost(WebURL.parse(url), payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
} catch (IOException e) {
throw new ServerDisconnectException(e);
}
@@ -20,7 +20,9 @@ package org.jackhuang.hmcl.auth.offline;
import com.google.gson.JsonObject;
import com.google.gson.annotations.SerializedName;
import javafx.scene.image.Image;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.auth.yggdrasil.TextureModel;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
@@ -37,7 +39,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@@ -121,7 +122,7 @@ public record Skin(Type type, String cslApi, TextureModel textureModel, String l
String realCslApi = type == Type.LITTLE_SKIN
? "https://littleskin.cn/csl"
: NetworkUtils.addHttpsIfMissing(StringUtils.removeSuffix(Objects.requireNonNullElse(cslApi, ""), "/"));
return Task.composeAsync(() -> new GetTask(String.format("%s/%s.json", realCslApi, username)))
return Task.composeAsync(() -> new GetTask(WebURL.parse("%s/%s.json".formatted(realCslApi, username))))
.thenComposeAsync(json -> {
SkinJson result = JsonUtils.GSON.fromJson(json, SkinJson.class);
@@ -185,8 +186,8 @@ public record Skin(Type type, String cslApi, TextureModel textureModel, String l
private static class FetchBytesTask extends FetchTask<InputStream> {
public FetchBytesTask(String uri) {
super(List.of(NetworkUtils.toURI(uri)));
public FetchBytesTask(String url) {
super(DownloadCandidates.of(url));
}
@Override
@@ -17,42 +17,45 @@
*/
package org.jackhuang.hmcl.auth.yggdrasil;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.auth.AuthenticationException;
import java.net.URI;
import java.util.UUID;
/**
* @see <a href="http://wiki.vg">http://wiki.vg</a>
*/
/// Supplies the endpoints used by [YggdrasilService] for authentication and profile operations.
///
/// @see <a href="http://wiki.vg">http://wiki.vg</a>
public interface YggdrasilProvider {
URI getAuthenticationURL() throws AuthenticationException;
/// Returns the endpoint for authenticating credentials.
WebURL getAuthenticationURL() throws AuthenticationException;
URI getRefreshmentURL() throws AuthenticationException;
/// Returns the endpoint for refreshing an access token.
WebURL getRefreshmentURL() throws AuthenticationException;
URI getValidationURL() throws AuthenticationException;
/// Returns the endpoint for validating an access token.
WebURL getValidationURL() throws AuthenticationException;
URI getInvalidationURL() throws AuthenticationException;
/// Returns the endpoint for invalidating an access token.
WebURL getInvalidationURL() throws AuthenticationException;
/**
* URL to upload skin.
*
* Headers:
* Authentication: Bearer &lt;access token&gt;
*
* Payload:
* The payload for this API consists of multipart form data. There are two parts (order does not matter b/c of boundary):
* model: Empty string for the default model and "slim" for the slim model
* file: Raw image file data
*
* @see <a href="https://wiki.vg/Mojang_API#Upload_Skin">https://wiki.vg/Mojang_API#Upload_Skin</a>
* @return url to upload skin
* @throws AuthenticationException if url cannot be generated. e.g. some parameter or query is malformed.
* @throws UnsupportedOperationException if the Yggdrasil provider does not support third-party skin uploading.
*/
URI getSkinUploadURL(UUID uuid) throws AuthenticationException, UnsupportedOperationException;
/// URL to upload skin.
///
/// Headers:
/// Authentication: Bearer &lt;access token&gt;
///
/// Payload:
/// The payload for this API consists of multipart form data. There are two parts (order does not matter b/c of boundary):
/// model: Empty string for the default model and "slim" for the slim model
/// file: Raw image file data
///
/// @see <a href="https://wiki.vg/Mojang_API#Upload_Skin">https://wiki.vg/Mojang_API#Upload_Skin</a>
/// @return url to upload skin
/// @throws AuthenticationException if url cannot be generated. e.g. some parameter or query is malformed.
/// @throws UnsupportedOperationException if the Yggdrasil provider does not support third-party skin uploading.
WebURL getSkinUploadURL(UUID uuid) throws AuthenticationException, UnsupportedOperationException;
URI getProfilePropertiesURL(UUID uuid) throws AuthenticationException;
/// Returns the endpoint for reading the profile properties of the given player UUID.
WebURL getProfilePropertiesURL(UUID uuid) throws AuthenticationException;
}
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.auth.yggdrasil;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import org.glavo.url.WebURL;
import org.glavo.uuid.UUIDs;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.ServerDisconnectException;
@@ -30,11 +31,11 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpMultipartRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -227,12 +228,13 @@ public class YggdrasilService {
}
}
private static String request(URI uri, Object payload) throws AuthenticationException {
/// Performs a GET when the payload is null, or posts the payload as JSON otherwise.
private static String request(WebURL url, @Nullable Object payload) throws AuthenticationException {
try {
if (payload == null)
return NetworkUtils.doGet(uri);
return NetworkUtils.doGet(url);
else
return NetworkUtils.doPost(uri, payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
return NetworkUtils.doPost(url, payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
} catch (IOException e) {
throw new ServerDisconnectException(e);
}
@@ -17,8 +17,6 @@
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.game.GameComponentType;
/**
*
* @author huangyuhui
@@ -27,11 +25,6 @@ public abstract class AbstractDependencyManager implements DependencyManager {
public abstract DownloadProvider getDownloadProvider();
@Override
public abstract DefaultCacheRepository getCacheRepository();
@Override
public ComponentVersionList<?> getVersionList(GameComponentType componentType) {
return getDownloadProvider().getVersionList(componentType);
}
}
@@ -1,209 +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.download;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.task.Task;
import java.net.URI;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author huangyuhui
public final class AutoDownloadProvider implements DownloadProvider {
private final List<DownloadProvider> versionListProviders;
private final List<DownloadProvider> fileProviders;
private final ConcurrentMap<GameComponentType, ComponentVersionList<?>> versionLists = new ConcurrentHashMap<>();
public AutoDownloadProvider(
List<DownloadProvider> versionListProviders,
List<DownloadProvider> fileProviders) {
if (versionListProviders == null || versionListProviders.isEmpty()) {
throw new IllegalArgumentException("versionListProviders must not be null or empty");
}
if (fileProviders == null || fileProviders.isEmpty()) {
throw new IllegalArgumentException("fileProviders must not be null or empty");
}
this.versionListProviders = versionListProviders;
this.fileProviders = fileProviders;
}
public AutoDownloadProvider(DownloadProvider... downloadProviderCandidate) {
if (downloadProviderCandidate.length == 0) {
throw new IllegalArgumentException("Download provider must have at least one download provider");
}
this.versionListProviders = List.of(downloadProviderCandidate);
this.fileProviders = versionListProviders;
}
private DownloadProvider getPreferredDownloadProvider() {
return fileProviders.get(0);
}
private static List<URI> getAll(
List<DownloadProvider> providers,
Function<DownloadProvider, List<URI>> function) {
LinkedHashSet<URI> result = new LinkedHashSet<>();
for (DownloadProvider provider : providers) {
result.addAll(function.apply(provider));
}
return List.copyOf(result);
}
@Override
public List<URI> getVersionListURLs() {
return getAll(versionListProviders, DownloadProvider::getVersionListURLs);
}
@Override
public String injectURL(String baseURL) {
return getPreferredDownloadProvider().injectURL(baseURL);
}
@Override
public List<URI> getAssetObjectCandidates(String assetObjectLocation) {
return getAll(fileProviders, provider -> provider.getAssetObjectCandidates(assetObjectLocation));
}
@Override
public List<URI> injectURLWithCandidates(String baseURL) {
return getAll(fileProviders, provider -> provider.injectURLWithCandidates(baseURL));
}
@Override
public List<URI> injectURLsWithCandidates(List<String> urls) {
return getAll(fileProviders, provider -> provider.injectURLsWithCandidates(urls));
}
@Override
public ComponentVersionList<?> getVersionList(GameComponentType componentType) {
return versionLists.computeIfAbsent(componentType, value -> {
ComponentVersionList<?>[] lists = new ComponentVersionList<?>[versionListProviders.size()];
for (int i = 0; i < versionListProviders.size(); i++) {
lists[i] = versionListProviders.get(i).getVersionList(value);
}
return new MultipleSourceVersionList(lists);
});
}
@Override
public int getConcurrency() {
return getPreferredDownloadProvider().getConcurrency();
}
@Override
public String toString() {
return "AutoDownloadProvider[versionListProviders=%s, fileProviders=%s]".formatted(versionListProviders, fileProviders);
}
private static final class MultipleSourceVersionList extends ComponentVersionList<ComponentRemoteVersion> {
private final ComponentVersionList<?>[] backends;
MultipleSourceVersionList(ComponentVersionList<?>[] backends) {
this.backends = backends;
assert (backends.length >= 1);
}
@Override
public boolean hasType() {
boolean hasType = backends[0].hasType();
assert (Arrays.stream(backends).allMatch(versionList -> versionList.hasType() == hasType));
return hasType;
}
@Override
public Task<?> refreshAsync() {
throw new UnsupportedOperationException("MultipleSourceVersionList does not support loading the entire remote version list.");
}
private Task<?> refreshAsync(String gameVersion, int sourceIndex) {
ComponentVersionList<?> versionList = backends[sourceIndex];
Task<?> refreshTask = versionList.refreshAsync(gameVersion);
return new Task<>() {
private Task<?> nextTask = null;
{
setSignificance(TaskSignificance.MODERATE);
setName("MultipleSourceVersionList.refreshAsync(task=%s, index=%d, all=%d)".formatted(
refreshTask.getName(), sourceIndex, backends.length)
);
}
@Override
public Collection<Task<?>> getDependents() {
return List.of(refreshTask);
}
@Override
public Collection<? extends Task<?>> getDependencies() {
return nextTask != null ? List.of(nextTask) : List.of();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
lock.writeLock().lock();
try {
versions.putAll(gameVersion, versionList.getVersions(gameVersion));
} finally {
lock.writeLock().unlock();
}
setResult(refreshTask.getResult());
} else {
Exception exception = refreshTask.getException();
assert exception != null;
if (sourceIndex == backends.length - 1) {
LOG.warning("Failed to fetch versions list from all sources", exception);
setSignificance(TaskSignificance.MINOR);
throw exception;
} else {
LOG.warning("Failed to fetch versions list and try to fetch from other source", exception);
nextTask = refreshAsync(gameVersion, sourceIndex + 1);
nextTask.storeTo(this::setResult);
}
}
}
};
}
@Override
public Task<?> refreshAsync(String gameVersion) {
versions.clear(gameVersion);
return refreshAsync(gameVersion, 0);
}
}
}
@@ -1,177 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.download.cleanroom.CleanroomVersionList;
import org.jackhuang.hmcl.download.fabric.FabricAPIVersionList;
import org.jackhuang.hmcl.download.fabric.FabricVersionList;
import org.jackhuang.hmcl.download.forge.ForgeBMCLVersionList;
import org.jackhuang.hmcl.download.game.GameVersionList;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricAPIVersionList;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricVersionList;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderBMCLVersionList;
import org.jackhuang.hmcl.download.neoforge.NeoForgeBMCLVersionList;
import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList;
import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList;
import org.jackhuang.hmcl.download.quilt.QuiltVersionList;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.net.URI;
import java.util.List;
import static org.jackhuang.hmcl.util.Pair.pair;
/**
*
* @author huang
*/
public final class BMCLAPIDownloadProvider implements DownloadProvider {
private final String apiRoot;
private final GameVersionList game;
private final FabricVersionList fabric;
private final FabricAPIVersionList fabricApi;
private final ForgeBMCLVersionList forge;
private final CleanroomVersionList cleanroom;
private final LegacyFabricVersionList legacyFabric;
private final LegacyFabricAPIVersionList legacyFabricApi;
private final NeoForgeBMCLVersionList neoforge;
private final LiteLoaderBMCLVersionList liteLoader;
private final OptiFineBMCLVersionList optifine;
private final QuiltVersionList quilt;
private final QuiltAPIVersionList quiltApi;
private final List<Pair<String, String>> replacement;
private final List<Pair<String, String>> fallbackReplacement;
public BMCLAPIDownloadProvider(String apiRoot) {
this.apiRoot = apiRoot;
this.game = new GameVersionList(this);
this.fabric = new FabricVersionList(this);
this.fabricApi = new FabricAPIVersionList(this);
this.forge = new ForgeBMCLVersionList(apiRoot);
this.cleanroom = new CleanroomVersionList(this);
this.neoforge = new NeoForgeBMCLVersionList(apiRoot);
this.liteLoader = new LiteLoaderBMCLVersionList(this);
this.optifine = new OptiFineBMCLVersionList(apiRoot);
this.quilt = new QuiltVersionList(this);
this.quiltApi = new QuiltAPIVersionList(this);
this.legacyFabric = new LegacyFabricVersionList(this);
this.legacyFabricApi = new LegacyFabricAPIVersionList(this);
this.replacement = List.of(
pair("https://bmclapi2.bangbang93.com", apiRoot),
pair("https://launchermeta.mojang.com", apiRoot),
pair("https://piston-meta.mojang.com", apiRoot),
pair("https://piston-data.mojang.com", apiRoot),
pair("https://launcher.mojang.com", apiRoot),
pair("https://libraries.minecraft.net", apiRoot + "/libraries"),
pair("http://files.minecraftforge.net/maven", apiRoot + "/maven"),
pair("https://files.minecraftforge.net/maven", apiRoot + "/maven"),
pair("https://maven.minecraftforge.net", apiRoot + "/maven"),
pair("https://maven.neoforged.net/releases/", apiRoot + "/maven/"),
pair("http://dl.liteloader.com/versions/versions.json", apiRoot + "/maven/com/mumfrey/liteloader/versions.json"),
pair("http://dl.liteloader.com/versions", apiRoot + "/maven"),
pair("https://meta.fabricmc.net", apiRoot + "/fabric-meta"),
pair("https://maven.fabricmc.net", apiRoot + "/maven"),
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://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
pair("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
pair("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
pair("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto")
);
this.fallbackReplacement = List.of(
// 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")
);
}
public String getApiRoot() {
return apiRoot;
}
@Override
public List<URI> getVersionListURLs() {
return List.of(URI.create(apiRoot + "/mc/game/version_manifest.json"));
}
@Override
public List<URI> getAssetObjectCandidates(String assetObjectLocation) {
return List.of(NetworkUtils.toURI(apiRoot + "/assets/" + assetObjectLocation));
}
@Override
public ComponentVersionList<?> getVersionList(GameComponentType componentType) {
return switch (componentType) {
case GAME -> game;
case FABRIC -> fabric;
case FABRIC_API -> fabricApi;
case FORGE -> forge;
case CLEANROOM -> cleanroom;
case NEO_FORGE -> neoforge;
case LITELOADER -> liteLoader;
case OPTIFINE -> optifine;
case QUILT -> quilt;
case QUILT_API -> quiltApi;
case LEGACY_FABRIC -> legacyFabric;
case LEGACY_FABRIC_API -> legacyFabricApi;
};
}
private static String injectURL(List<Pair<String, String>> replacement, String baseURL) {
for (Pair<String, String> pair : replacement) {
if (baseURL.startsWith(pair.getKey())) {
return pair.getValue() + baseURL.substring(pair.getKey().length());
}
}
return baseURL;
}
@Override
public String injectURL(String baseURL) {
return injectURL(replacement, baseURL);
}
@Override
public List<URI> injectURLWithCandidates(String baseURL) {
String injected = injectURL(replacement, baseURL);
if (injected.equals(baseURL)) {
String fallbackInjected = injectURL(fallbackReplacement, baseURL);
if (fallbackInjected.equals(baseURL)) {
return List.of(NetworkUtils.toURI(baseURL));
} else {
return List.of(
NetworkUtils.toURI(baseURL),
NetworkUtils.toURI(fallbackInjected)
);
}
} else {
return List.of(NetworkUtils.toURI(injected));
}
}
@Override
public int getConcurrency() {
return Math.max(Runtime.getRuntime().availableProcessors() * 2, 6);
}
}
@@ -22,7 +22,11 @@ import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameInstancePatch;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.nio.file.Path;
import java.time.Instant;
@@ -32,35 +36,31 @@ import java.util.Objects;
/// The remote version.
///
/// @author huangyuhui
@NotNullByDefault
public abstract class ComponentRemoteVersion implements Comparable<ComponentRemoteVersion> {
private final GameComponentType componentType;
private final String gameVersion;
private final GameVersionNumber gameVersion;
private final String selfVersion;
private final Instant releaseDate;
private final List<String> urls;
private final @Nullable Instant releaseDate;
private final @Unmodifiable List<String> urls;
private final Type type;
/// Constructor.
///
/// @param gameVersion the Minecraft version that this remote version suits.
/// @param selfVersion the version string of the remote version.
/// @param urls the installer or universal jar original URL.
public ComponentRemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, List<String> urls) {
this(componentType, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, urls);
}
/// Constructor.
///
/// @param gameVersion the Minecraft version that this remote version suits.
/// @param selfVersion the version string of the remote version.
/// @param urls the installer or universal jar URL.
public ComponentRemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, Type type, List<String> urls) {
public ComponentRemoteVersion(
GameComponentType componentType,
GameVersionNumber gameVersion,
String selfVersion,
@Nullable Instant releaseDate, Type type, List<String> urls) {
this.componentType = Objects.requireNonNull(componentType);
this.gameVersion = Objects.requireNonNull(gameVersion);
this.selfVersion = Objects.requireNonNull(selfVersion);
this.releaseDate = releaseDate;
this.urls = Objects.requireNonNull(urls);
this.urls = List.copyOf(urls);
this.type = Objects.requireNonNull(type);
}
@@ -68,7 +68,7 @@ public abstract class ComponentRemoteVersion implements Comparable<ComponentRemo
return componentType;
}
public String getGameVersion() {
public GameVersionNumber getGameVersion() {
return gameVersion;
}
@@ -80,11 +80,11 @@ public abstract class ComponentRemoteVersion implements Comparable<ComponentRemo
return getSelfVersion();
}
public Instant getReleaseDate() {
public @Nullable Instant getReleaseDate() {
return releaseDate;
}
public List<String> getUrls() {
public @Unmodifiable List<String> getUrls() {
return urls;
}
@@ -106,7 +106,7 @@ public abstract class ComponentRemoteVersion implements Comparable<ComponentRemo
@Override
public boolean equals(Object obj) {
return obj instanceof ComponentRemoteVersion && Objects.equals(selfVersion, ((ComponentRemoteVersion) obj).selfVersion);
return obj instanceof ComponentRemoteVersion that && Objects.equals(selfVersion, that.selfVersion);
}
@Override
@@ -124,6 +124,10 @@ public abstract class ComponentRemoteVersion implements Comparable<ComponentRemo
@Override
public int compareTo(ComponentRemoteVersion o) {
if (this.getComponentType() != o.getComponentType()) {
return this.getComponentType().compareTo(o.getComponentType());
}
// newer versions are smaller than older versions
return VersionNumber.asVersion(o.selfVersion).compareTo(VersionNumber.asVersion(selfVersion));
}
@@ -0,0 +1,77 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.AbstractList;
import java.util.SortedSet;
@NotNullByDefault
public final class ComponentRemoteVersionList<V extends ComponentRemoteVersion> extends AbstractList<V> {
private static final ComponentRemoteVersionList<?>[] EMPTY_LISTS = new ComponentRemoteVersionList<?>[GameComponentType.ALL.size()];
private static final ComponentRemoteVersion[] EMPTY_VERSIONS_ARRAY = new ComponentRemoteVersion[0];
static {
for (GameComponentType type : GameComponentType.ALL) {
EMPTY_LISTS[type.ordinal()] = new ComponentRemoteVersionList<>(type, EMPTY_VERSIONS_ARRAY);
}
}
public static <V extends ComponentRemoteVersion> ComponentRemoteVersionList<V> of(GameComponentType type) {
@SuppressWarnings("unchecked")
ComponentRemoteVersionList<V> list = (ComponentRemoteVersionList<V>) EMPTY_LISTS[type.ordinal()];
return list;
}
@SuppressWarnings("unchecked")
public static <V extends ComponentRemoteVersion> ComponentRemoteVersionList<V> of(GameComponentType type, SortedSet<? extends V> elements) {
return new ComponentRemoteVersionList<>(type, elements.toArray((V[]) EMPTY_VERSIONS_ARRAY));
}
private final GameComponentType type;
private final V[] elements;
private ComponentRemoteVersionList(GameComponentType type, V[] elements) {
this.elements = elements;
this.type = type;
}
@Override
public V get(int index) {
return elements[index];
}
public @Nullable V getRemoteVersion(String remoteVersion) {
if (type == GameComponentType.FORGE)
remoteVersion = StringUtils.substringAfter(remoteVersion, "-", remoteVersion);
for (V it : elements)
if (remoteVersion.equals(it.getSelfVersion()) || remoteVersion.equals(it.getFullVersion()))
return it;
return null;
}
@Override
public int size() {
return elements.length;
}
}
@@ -1,126 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.SimpleMultimap;
import java.util.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/// The remote version list.
///
/// @param <V> the type of ComponentRemoteVersion.
/// @author huangyuhui
public abstract class ComponentVersionList<V extends ComponentRemoteVersion> {
/**
* the remote version list.
* key: game version.
* values: corresponding remote versions.
*/
protected final SimpleMultimap<String, V, TreeSet<V>> versions = new SimpleMultimap<>(HashMap::new, TreeSet::new);
/**
* True if the version list has been loaded.
*/
public boolean isLoaded() {
return !versions.isEmpty();
}
/**
* True if the version list that contains the remote versions which depends on the specific game version has been loaded.
*
* @param gameVersion the remote version depends on
*/
public boolean isLoaded(String gameVersion) {
return !versions.get(gameVersion).isEmpty();
}
public abstract boolean hasType();
protected final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
/**
* @return the task to reload the remote version list.
*/
public abstract Task<?> refreshAsync();
/**
* @param gameVersion the remote version depends on
* @return the task to reload the remote version list.
*/
public Task<?> refreshAsync(String gameVersion) {
return refreshAsync();
}
public Task<?> loadAsync(String gameVersion) {
return Task.composeAsync(() -> {
lock.readLock().lock();
try {
return isLoaded(gameVersion) ? null : refreshAsync(gameVersion);
} finally {
lock.readLock().unlock();
}
});
}
protected Collection<V> getVersionsImpl(String gameVersion) {
return versions.get(gameVersion);
}
/**
* Get the remote versions that specifics Minecraft version.
*
* @param gameVersion the Minecraft version that remote versions belong to
* @return the collection of specific remote versions
*/
public final Collection<V> getVersions(String gameVersion) {
lock.readLock().lock();
try {
return Collections.unmodifiableCollection(new ArrayList<>(getVersionsImpl(gameVersion)));
} finally {
lock.readLock().unlock();
}
}
/**
* Get the specific remote version.
*
* @param gameVersion the Minecraft version that remote versions belong to
* @param remoteVersion the version of the remote version.
* @return the specific remote version, null if it is not found.
*/
public Optional<V> getVersion(String gameVersion, String remoteVersion) {
lock.readLock().lock();
try {
V result = null;
TreeSet<V> remoteVersions = versions.get(gameVersion);
for (V it : remoteVersions)
if (remoteVersion.equals(it.getSelfVersion()))
result = it;
if (result == null)
for (V it : remoteVersions)
if (remoteVersion.equals(it.getFullVersion()))
result = it;
return Optional.ofNullable(result);
} finally {
lock.readLock().unlock();
}
}
}
@@ -38,6 +38,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
/// Provides downloads and game-component installation for one game repository.
@@ -355,12 +356,11 @@ public class DefaultDependencyManager extends AbstractDependencyManager {
String gameVersion,
GameComponentType componentType,
String componentVersion) {
ComponentVersionList<?> versionList = getVersionList(componentType);
return versionList.loadAsync(gameVersion)
.thenComposeAsync(() -> installUnpublishedComponentAsync(
return downloadProvider.getVersionsAsync(componentType, componentType == GameComponentType.GAME ? null : GameVersionNumber.asGameVersion(gameVersion), false)
.thenComposeAsync(versions -> installUnpublishedComponentAsync(
baseManifest,
modsDirectory,
versionList.getVersion(gameVersion, componentVersion)
Optional.ofNullable(versions.getRemoteVersion(componentVersion))
.orElseThrow(() -> new IOException(
"Remote component " + componentType + " has no version " + componentVersion))))
.withStage("hmcl.install.%s:%s".formatted(componentType, componentVersion));
@@ -411,12 +411,11 @@ public class DefaultDependencyManager extends AbstractDependencyManager {
throw new IllegalArgumentException("baseManifest id does not match instance");
}
ComponentVersionList<?> versionList = getVersionList(componentType);
return versionList.loadAsync(gameVersion)
.thenComposeAsync(() -> installComponentRemoteAsync(
return downloadProvider.getVersionsAsync(componentType, componentType == GameComponentType.GAME ? null : GameVersionNumber.asGameVersion(gameVersion), false)
.thenComposeAsync(versions -> installComponentRemoteAsync(
instance,
baseManifest,
versionList.getVersion(gameVersion, componentVersion)
Optional.ofNullable(versions.getRemoteVersion(componentVersion))
.orElseThrow(() -> new IOException(
"Remote component " + componentType + " has no version " + componentVersion))))
.withStage("hmcl.install.%s:%s".formatted(componentType, componentVersion));
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstance;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameInstanceManifest;
@@ -99,10 +98,4 @@ public interface DependencyManager {
/// another repository draft is open
GameBuilder newGameBuilder(GameInstance instance);
/// Returns a registered remote-version list.
///
/// @param componentType the component type, such as `game`, `forge`, or `optifine`
/// @return the registered version list
/// @throws IllegalArgumentException if no list is registered for `id`
ComponentVersionList<?> getVersionList(GameComponentType componentType);
}
@@ -0,0 +1,49 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.glavo.url.WebURL;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
/// @author Glavo
public record DownloadCandidate(
@Nullable WebURL url,
@Nullable String rawUrl,
int retry,
@Nullable Duration connectTimeout,
@Nullable Duration readTimeout
) {
public static DownloadCandidate of(WebURL url) {
return new DownloadCandidate(url, null, -1, null, null);
}
public static DownloadCandidate of(String url) {
return new DownloadCandidate(WebURL.tryParse(url), url, -1, null, null);
}
public DownloadCandidate {
assert url != null || rawUrl != null;
}
public String displayUrl() {
return url != null ? url.href() : rawUrl;
}
}
@@ -0,0 +1,78 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.glavo.url.WebURL;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import java.util.stream.Stream;
@NotNullByDefault
public final class DownloadCandidates {
public static DownloadCandidates of(String url) {
return new DownloadCandidates(List.of(DownloadCandidate.of(url)));
}
public static DownloadCandidates of(WebURL url) {
return new DownloadCandidates(List.of(DownloadCandidate.of(url)));
}
public static DownloadCandidates of(DownloadCandidate candidate) {
return new DownloadCandidates(List.of(candidate));
}
public static DownloadCandidates of(List<DownloadCandidate> candidates) {
return new DownloadCandidates(candidates);
}
public static DownloadCandidates of(String... urls) {
return new DownloadCandidates(Stream.of(urls).map(DownloadCandidate::of).toList());
}
public static DownloadCandidates of(WebURL... urls) {
return new DownloadCandidates(Stream.of(urls).map(DownloadCandidate::of).toList());
}
public static DownloadCandidates of(DownloadCandidate... candidates) {
return new DownloadCandidates(List.of(candidates));
}
public static DownloadCandidates ofUrls(List<WebURL> urls) {
return new DownloadCandidates(urls.stream().map(DownloadCandidate::of).toList());
}
private final @Unmodifiable List<DownloadCandidate> candidates;
private DownloadCandidates(@Unmodifiable List<DownloadCandidate> candidates) {
if (candidates.isEmpty()) {
throw new IllegalArgumentException("Candidates must not be empty");
}
this.candidates = List.copyOf(candidates);
}
public DownloadCandidate getPrimaryCandidate() {
return candidates.get(0);
}
public @Unmodifiable List<DownloadCandidate> getCandidates() {
return candidates;
}
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,59 +17,333 @@
*/
package org.jackhuang.hmcl.download;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricRemoteVersion;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderRemoteVersion;
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.game.AssetObject;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.net.URI;
import java.util.LinkedHashSet;
import java.util.List;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.lang.ref.SoftReference;
import java.util.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
/// The service provider that provides Minecraft online file downloads.
///
/// @author huangyuhui
public interface DownloadProvider {
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
List<URI> getVersionListURLs();
@NotNullByDefault
public class DownloadProvider {
List<URI> getAssetObjectCandidates(String assetObjectLocation);
private static final VarHandle VERSION_LIST_STATES_HANDLE = MethodHandles.arrayElementVarHandle(VersionListState[].class);
private final @Nullable VersionListState[] versionListStates = new VersionListState[GameComponentType.ALL.size()];
/// Inject into original URL provided by Mojang and Forge.
///
/// Since there are many provided URLs that are written in JSONs and are unmodifiable,
/// this method provides a way to change them.
///
/// @param baseURL original URL provided by Mojang and Forge.
/// @return the URL that is equivalent to `baseURL``, but belongs to your own service provider.
String injectURL(String baseURL);
/// Inject into original URL provided by Mojang and Forge.
///
/// Since there are many provided URLs that are written in JSONs and are unmodifiable,
/// this method provides a way to change them.
///
/// @param baseURL original URL provided by Mojang and Forge.
/// @return the URL that is equivalent to `baseURL`, but belongs to your own service provider.
default List<URI> injectURLWithCandidates(String baseURL) {
return List.of(NetworkUtils.toURI(injectURL(baseURL)));
public boolean hasType(GameComponentType type) {
return switch (type) {
case GAME, OPTIFINE, NEO_FORGE, LITELOADER -> true;
case FABRIC, FABRIC_API, FORGE, CLEANROOM, LEGACY_FABRIC, LEGACY_FABRIC_API, QUILT, QUILT_API -> false;
};
}
default List<URI> injectURLsWithCandidates(List<String> urls) {
LinkedHashSet<URI> result = new LinkedHashSet<>();
for (String url : urls) {
result.addAll(injectURLWithCandidates(url));
public @Unmodifiable Task<ComponentRemoteVersionList<?>> getVersionsAsync(
GameComponentType type,
@Nullable GameVersionNumber gameVersion,
boolean refresh) {
assert (type == GameComponentType.GAME) == (gameVersion == null);
final VersionListState state = getState(type);
if (!refresh) {
state.lock.readLock().lock();
try {
@Unmodifiable ComponentRemoteVersionList<?> result = state.tryGet(gameVersion);
if (result != null) {
return Task.completed(result);
}
} finally {
state.lock.readLock().unlock();
}
}
return List.copyOf(result);
var task = fetchVersionsAsync(type, gameVersion);
return task.thenApplyAsync(result -> {
state.lock.writeLock().lockInterruptibly();
try {
state.put(gameVersion, result);
} finally {
state.lock.writeLock().unlock();
}
return result;
});
}
/// the specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine"
///
/// @param componentType the component type of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine"
/// @return the version list
/// @throws IllegalArgumentException if the version list does not exist
ComponentVersionList<?> getVersionList(GameComponentType componentType);
private VersionListState getState(GameComponentType type) {
@Nullable VersionListState currentState = versionListStates[type.ordinal()];
if (currentState != null) {
return currentState;
}
/// The maximum download concurrency that this download provider supports.
///
/// @return the maximum download concurrency.
int getConcurrency();
VersionListState state = new VersionListState(type);
if (VERSION_LIST_STATES_HANDLE.compareAndSet(versionListStates, type.ordinal(), null, state)) {
return state;
} else {
state = (VersionListState) VERSION_LIST_STATES_HANDLE.getVolatile(versionListStates, type.ordinal());
Objects.requireNonNull(state, "VersionListState should not be null after compareAndSet failure");
return state;
}
}
private <V extends ComponentRemoteVersion> Task<ComponentRemoteVersionList<V>> fetchFabricVersionsAsync(
GameComponentType type,
GameVersionNumber gameVersion,
FabricLikeVersionListCandidates candidates,
BiFunction<String, String, V> function
) {
return Task.combine(
new GetTask(candidates.loaderMetaCandidates),
new GetTask(candidates.gameMetaCandidates)
).thenApplyAsync(pair -> {
@JsonSerializable
record GameVersion(String version, String maven, boolean stable) {
}
TypeToken<List<GameVersion>> gameVersionsType = listTypeOf(GameVersion.class);
List<GameVersion> gameVersions = JsonUtils.fromNonNullJson(pair.getValue(), gameVersionsType);
Optional<GameVersion> metaGameVersion = gameVersions.stream()
.filter(it -> gameVersion.equals(GameVersionNumber.asGameVersion(it.version)))
.findFirst();
if (metaGameVersion.isEmpty()) {
return ComponentRemoteVersionList.of(type);
}
SortedSet<V> versions = new TreeSet<>();
List<GameVersion> loaderVersions = JsonUtils.fromNonNullJson(pair.getKey(), gameVersionsType);
for (GameVersion loaderVersion : loaderVersions) {
versions.add(function.apply(metaGameVersion.get().version, loaderVersion.version));
}
return ComponentRemoteVersionList.of(type, versions);
});
}
private <V extends ComponentRemoteVersion> Task<ComponentRemoteVersionList<V>> fetchModrinthVersionsAsync(
GameComponentType type,
String modId,
GameVersionNumber gameVersion,
Function<RemoteAddon.Version, V> mapper
) {
return Task.supplyAsync(Schedulers.io(), () -> ComponentRemoteVersionList.of(type, ModrinthRemoteAddonRepository.getInstance().getRemoteVersionsById(this, modId)
.filter(it -> {
for (String supportedGameVersion : it.gameVersions()) {
if (GameVersionNumber.asGameVersion(supportedGameVersion).equals(gameVersion)) {
return true;
}
}
return false;
})
.map(mapper)
.collect(Collectors.toCollection(() -> (SortedSet<V>) new TreeSet<V>()))));
}
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(
GameComponentType type, @Nullable GameVersionNumber gameVersion
) {
assert (type == GameComponentType.GAME) == (gameVersion == null);
return switch (type) {
case GAME -> GameRemoteVersion.fetchAsync(this);
case FABRIC -> fetchFabricVersionsAsync(
type,
gameVersion,
getFabricVersionListCandidates(),
(metaGameVersion, loaderVersion) -> new FabricRemoteVersion(
gameVersion, loaderVersion,
List.of("%s/%s/%s".formatted(FabricRemoteVersion.LOADER_META_URL, metaGameVersion, loaderVersion)))
);
case FABRIC_API -> fetchModrinthVersionsAsync(
type,
FabricAPIRemoteVersion.MODRINTH_ID,
gameVersion,
it -> new FabricAPIRemoteVersion(
gameVersion,
it.version(),
it.name(),
it.datePublished(),
it,
List.of(it.file().url()))
);
case LEGACY_FABRIC -> fetchFabricVersionsAsync(
type,
gameVersion,
getLegacyFabricVersionListCandidates(),
(metaGameVersion, loaderVersion) -> new LegacyFabricRemoteVersion(
gameVersion, loaderVersion,
List.of("%s/%s/%s".formatted(LegacyFabricRemoteVersion.LOADER_META_URL, metaGameVersion, loaderVersion)))
);
case LEGACY_FABRIC_API -> fetchModrinthVersionsAsync(
type,
LegacyFabricAPIRemoteVersion.MODRINTH_ID,
gameVersion,
it -> new LegacyFabricAPIRemoteVersion(
gameVersion,
it.version(),
it.name(),
it.datePublished(),
it,
List.of(it.file().url()))
);
case QUILT -> fetchFabricVersionsAsync(
type,
gameVersion,
getQuiltVersionListCandidates(),
(metaGameVersion, loaderVersion) -> new QuiltRemoteVersion(
gameVersion, loaderVersion,
List.of("%s/%s/%s".formatted(QuiltRemoteVersion.LOADER_META_URL, metaGameVersion, loaderVersion)))
);
case QUILT_API -> fetchModrinthVersionsAsync(
type,
QuiltAPIRemoteVersion.MODRINTH_ID,
gameVersion,
it -> new QuiltAPIRemoteVersion(
gameVersion,
it.version(),
it.name(),
it.datePublished(),
it,
List.of(it.file().url()))
);
case FORGE -> ForgeRemoteVersion.fetchAsync(this, gameVersion);
case NEO_FORGE -> NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
case CLEANROOM -> CleanroomRemoteVersion.fetchAsync(this, gameVersion);
case LITELOADER -> LiteLoaderRemoteVersion.fetchAsync(gameVersion);
case OPTIFINE -> Task.supplyAsync(() -> {
throw new UnsupportedOperationException("OptiFine version list fetching is not supported in this DownloadProvider.");
});
};
}
public CacheRepository getCacheRepository() {
return CacheRepository.getInstance();
}
public DownloadCandidates getGameVersionListCandidates() {
return DownloadCandidates.of("https://piston-meta.mojang.com/mc/game/version_manifest.json");
}
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
return new FabricLikeVersionListCandidates(
DownloadCandidates.of(FabricRemoteVersion.LOADER_META_URL),
DownloadCandidates.of(FabricRemoteVersion.GAME_META_URL)
);
}
public FabricLikeVersionListCandidates getLegacyFabricVersionListCandidates() {
return new FabricLikeVersionListCandidates(
DownloadCandidates.of(LegacyFabricRemoteVersion.LOADER_META_URL),
DownloadCandidates.of(LegacyFabricRemoteVersion.GAME_META_URL)
);
}
public FabricLikeVersionListCandidates getQuiltVersionListCandidates() {
return new FabricLikeVersionListCandidates(
DownloadCandidates.of(QuiltRemoteVersion.LOADER_META_URL),
DownloadCandidates.of(QuiltRemoteVersion.GAME_META_URL)
);
}
public DownloadCandidates getForgeVersionListCandidates() {
return DownloadCandidates.of(ForgeRemoteVersion.FORGE_LIST);
}
public DownloadCandidates getNeoForgeVersionListCandidates() {
return DownloadCandidates.of(NeoForgeRemoteVersion.META_URL);
}
public DownloadCandidates getNeoForgeOldVersionListCandidates() {
return DownloadCandidates.of(NeoForgeRemoteVersion.OLD_URL);
}
public DownloadCandidates getCleanroomVersionListCandidates() {
return DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
}
public DownloadCandidates getDownloadCandidates(String baseURL) {
return getDownloadCandidates(List.of(baseURL));
}
public DownloadCandidates getDownloadCandidates(ComponentRemoteVersion remoteVersion) {
return getDownloadCandidates(remoteVersion.getUrls());
}
public DownloadCandidates getDownloadCandidates(List<String> urls) {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
public DownloadCandidates getVersionListCandidates(String url) {
return getVersionListCandidates(List.of(url));
}
public DownloadCandidates getVersionListCandidates(List<String> urls) {
return getDownloadCandidates(urls);
}
/// Returns unmodifiable candidate URLs for an asset's relative object location, in attempt order.
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
return getDownloadCandidates("https://resources.download.minecraft.net/" + assetObject.getLocation());
}
public record FabricLikeVersionListCandidates(
DownloadCandidates loaderMetaCandidates, DownloadCandidates gameMetaCandidates
) {
}
private static final class VersionListState {
private final GameComponentType type;
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Map<@Nullable GameVersionNumber, SoftReference<ComponentRemoteVersionList<?>>> versions = new HashMap<>();
private VersionListState(GameComponentType type) {
this.type = type;
}
@Unmodifiable
@Nullable ComponentRemoteVersionList<?> tryGet(@Nullable GameVersionNumber gameVersion) {
assert (type == GameComponentType.GAME) == (gameVersion == null);
@Nullable SoftReference<ComponentRemoteVersionList<?>> resultRef = versions.get(gameVersion);
return resultRef != null ? resultRef.get() : null;
}
void put(@Nullable GameVersionNumber gameVersion, ComponentRemoteVersionList<?> componentRemoteVersions) {
assert (type == GameComponentType.GAME) == (gameVersion == null);
versions.put(gameVersion, new SoftReference<>(componentRemoteVersions));
}
}
}
@@ -1,109 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.task.Task;
import java.net.URI;
import java.util.List;
import java.util.Objects;
/**
* @author Glavo
*/
public final class DownloadProviderWrapper implements DownloadProvider {
private volatile DownloadProvider provider;
public DownloadProviderWrapper(DownloadProvider provider) {
this.provider = provider;
}
public DownloadProvider getProvider() {
return this.provider;
}
public void setProvider(DownloadProvider provider) {
this.provider = Objects.requireNonNull(provider);
}
@Override
public List<URI> getAssetObjectCandidates(String assetObjectLocation) {
return getProvider().getAssetObjectCandidates(assetObjectLocation);
}
@Override
public List<URI> getVersionListURLs() {
return getProvider().getVersionListURLs();
}
@Override
public String injectURL(String baseURL) {
return getProvider().injectURL(baseURL);
}
@Override
public List<URI> injectURLWithCandidates(String baseURL) {
return getProvider().injectURLWithCandidates(baseURL);
}
@Override
public List<URI> injectURLsWithCandidates(List<String> urls) {
return getProvider().injectURLsWithCandidates(urls);
}
@Override
public ComponentVersionList<?> getVersionList(GameComponentType componentType) {
return new ComponentVersionList<>() {
@Override
public boolean hasType() {
return getProvider().getVersionList(componentType).hasType();
}
@Override
public Task<?> refreshAsync() {
throw new UnsupportedOperationException();
}
@Override
public Task<?> refreshAsync(String gameVersion) {
return getProvider().getVersionList(componentType).refreshAsync(gameVersion)
.thenComposeAsync(() -> {
lock.writeLock().lock();
try {
versions.putAll(gameVersion, getProvider().getVersionList(componentType).getVersions(gameVersion));
} finally {
lock.writeLock().unlock();
}
return null;
});
}
};
}
@Override
public int getConcurrency() {
return getProvider().getConcurrency();
}
@Override
public String toString() {
return "DownloadProviderWrapper[provider=%s]".formatted(provider);
}
}
@@ -1,111 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.download.cleanroom.CleanroomVersionList;
import org.jackhuang.hmcl.download.fabric.FabricAPIVersionList;
import org.jackhuang.hmcl.download.fabric.FabricVersionList;
import org.jackhuang.hmcl.download.forge.ForgeVersionList;
import org.jackhuang.hmcl.download.game.GameVersionList;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricAPIVersionList;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricVersionList;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderVersionList;
import org.jackhuang.hmcl.download.neoforge.NeoForgeOfficialVersionList;
import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList;
import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList;
import org.jackhuang.hmcl.download.quilt.QuiltVersionList;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.net.URI;
import java.util.List;
/**
* @author huangyuhui
* @see <a href="http://wiki.vg">http://wiki.vg</a>
*/
public class MojangDownloadProvider implements DownloadProvider {
private final GameVersionList game;
private final FabricVersionList fabric;
private final FabricAPIVersionList fabricApi;
private final ForgeVersionList forge;
private final NeoForgeOfficialVersionList neoforge;
private final CleanroomVersionList cleanroom;
private final LiteLoaderVersionList liteLoader;
private final OptiFineBMCLVersionList optifine;
private final QuiltVersionList quilt;
private final QuiltAPIVersionList quiltApi;
private final LegacyFabricVersionList legacyFabric;
private final LegacyFabricAPIVersionList legacyFabricApi;
public MojangDownloadProvider() {
// If there is no official download channel available, fallback to BMCLAPI.
String apiRoot = "https://bmclapi2.bangbang93.com";
this.game = new GameVersionList(this);
this.fabric = new FabricVersionList(this);
this.fabricApi = new FabricAPIVersionList(this);
this.forge = new ForgeVersionList(this);
this.neoforge = new NeoForgeOfficialVersionList(this);
this.cleanroom = new CleanroomVersionList(this);
this.liteLoader = new LiteLoaderVersionList(this);
this.optifine = new OptiFineBMCLVersionList(apiRoot);
this.quilt = new QuiltVersionList(this);
this.quiltApi = new QuiltAPIVersionList(this);
this.legacyFabric = new LegacyFabricVersionList(this);
this.legacyFabricApi = new LegacyFabricAPIVersionList(this);
}
@Override
public List<URI> getVersionListURLs() {
return List.of(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest.json"));
}
@Override
public List<URI> getAssetObjectCandidates(String assetObjectLocation) {
return List.of(NetworkUtils.toURI("https://resources.download.minecraft.net/" + assetObjectLocation));
}
@Override
public ComponentVersionList<?> getVersionList(GameComponentType componentType) {
return switch (componentType) {
case GAME -> game;
case FABRIC -> fabric;
case FABRIC_API -> fabricApi;
case FORGE -> forge;
case CLEANROOM -> cleanroom;
case NEO_FORGE -> neoforge;
case LITELOADER -> liteLoader;
case OPTIFINE -> optifine;
case QUILT -> quilt;
case QUILT_API -> quiltApi;
case LEGACY_FABRIC -> legacyFabric;
case LEGACY_FABRIC_API -> legacyFabricApi;
};
}
@Override
public String injectURL(String baseURL) {
return baseURL;
}
@Override
public int getConcurrency() {
return 6;
}
}
@@ -17,9 +17,7 @@
*/
package org.jackhuang.hmcl.download.cleanroom;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.UnsupportedInstallationException;
import org.jackhuang.hmcl.download.VersionMismatchException;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
@@ -38,10 +36,7 @@ import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.*;
import static org.jackhuang.hmcl.download.UnsupportedInstallationException.CLEANROOM_NOT_COMPATIBLE_WITH_FORGE;
@@ -50,7 +45,7 @@ public final class CleanroomInstallTask extends Task<GameInstancePatch> {
private final DefaultDependencyManager dependencyManager;
private final GameInstanceManifest manifest;
/// Minecraft version whose vanilla client JAR is required by the installer processors.
private final String gameVersion;
private final GameVersionNumber gameVersion;
private final @Nullable CleanroomRemoteVersion remote;
private @Nullable Path installer;
private @Nullable FileDownloadTask dependent;
@@ -84,7 +79,7 @@ public final class CleanroomInstallTask extends Task<GameInstancePatch> {
public CleanroomInstallTask(
DefaultDependencyManager dependencyManager,
GameInstanceManifest manifest,
String gameVersion,
GameVersionNumber gameVersion,
String selfVersion,
Path installer) {
this.dependencyManager = dependencyManager;
@@ -108,8 +103,9 @@ public final class CleanroomInstallTask extends Task<GameInstancePatch> {
installer = Files.createTempFile("cleanroom-installer", ".jar");
CleanroomRemoteVersion remoteVersion = Objects.requireNonNull(remote);
DownloadProvider downloadProvider = dependencyManager.getDownloadProvider();
dependent = new FileDownloadTask(
dependencyManager.getDownloadProvider().injectURLsWithCandidates(remoteVersion.getUrls()),
downloadProvider.getDownloadCandidates(remoteVersion),
installer, null);
dependent.setCacheRepository(dependencyManager.getCacheRepository());
dependent.setCaching(true);
@@ -191,7 +187,7 @@ public final class CleanroomInstallTask extends Task<GameInstancePatch> {
return new CleanroomInstallTask(
dependencyManager,
manifest,
gameVersion,
GameVersionNumber.asGameVersion(gameVersion),
modifyVersion(profile.getVersion()),
installer);
} else {
@@ -17,20 +17,56 @@
*/
package org.jackhuang.hmcl.download.cleanroom;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import com.google.gson.annotations.SerializedName;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameInstancePatch;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.TreeSet;
public class CleanroomRemoteVersion extends ComponentRemoteVersion {
public CleanroomRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List<String> url) {
super(GameComponentType.CLEANROOM, gameVersion, selfVersion, releaseDate, url);
@NotNullByDefault
public final class CleanroomRemoteVersion extends ComponentRemoteVersion {
private static final GameVersionNumber GAME_VERSION_1_12_2 = GameVersionNumber.asGameVersion("1.12.2");
public static final WebURL LOADER_LIST_URL = WebURL.parse("https://hmcl.glavo.site/metadata/cleanroom/index.json");
public static Task<ComponentRemoteVersionList<CleanroomRemoteVersion>> fetchAsync(
DownloadProvider downloadProvider, GameVersionNumber gameVersion) {
if (!gameVersion.equals(GAME_VERSION_1_12_2)) {
return Task.completed(ComponentRemoteVersionList.of(GameComponentType.CLEANROOM));
}
@JsonSerializable
record ReleaseResult(String name, @SerializedName("created_at") String createdAt) {
}
return new GetTask(downloadProvider.getCleanroomVersionListCandidates()).thenApplyAsync(result -> {
var results = JsonUtils.fromNonNullJson(result, JsonUtils.listTypeOf(ReleaseResult.class));
var versions = new TreeSet<CleanroomRemoteVersion>();
for (ReleaseResult version : results) {
versions.add(new CleanroomRemoteVersion(
GAME_VERSION_1_12_2, version.name, Instant.parse(version.createdAt),
List.of("https://hmcl.glavo.site/metadata/cleanroom/files/cleanroom-%s-installer.jar".formatted(version.name))
));
}
return ComponentRemoteVersionList.of(GameComponentType.CLEANROOM, versions);
});
}
public CleanroomRemoteVersion(GameVersionNumber gameVersion, String selfVersion, Instant releaseDate, List<String> url) {
super(GameComponentType.CLEANROOM, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, url);
}
@Override
@@ -1,69 +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.download.cleanroom;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import java.time.Instant;
import java.util.Collections;
public final class CleanroomVersionList extends ComponentVersionList<CleanroomRemoteVersion> {
private final DownloadProvider downloadProvider;
private static final String LOADER_LIST_URL = "https://hmcl.glavo.site/metadata/cleanroom/index.json";
private static final String INSTALLER_URL = "https://hmcl.glavo.site/metadata/cleanroom/files/cleanroom-%s-installer.jar";
public CleanroomVersionList(DownloadProvider downloadProvider) {
this.downloadProvider = downloadProvider;
}
@Override
public boolean hasType() {
return false;
}
@Override
public Task<?> refreshAsync() {
return Task.allOf(
new GetTask(downloadProvider.injectURLWithCandidates(LOADER_LIST_URL)).thenGetJsonAsync(ReleaseResult[].class)
).thenAcceptAsync(results -> {
lock.writeLock().lock();
try {
versions.clear();
for (ReleaseResult version : results.get(0)) {
versions.put("1.12.2", new CleanroomRemoteVersion(
"1.12.2", version.name, Instant.parse(version.created_at),
Collections.singletonList(
String.format(INSTALLER_URL, version.name)
)
));
}
} finally {
lock.writeLock().unlock();
}
});
}
private final static class ReleaseResult {
String name;
String created_at;
}
}
@@ -24,24 +24,29 @@ import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameInstancePatch;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
public class FabricAPIRemoteVersion extends ComponentRemoteVersion {
@NotNullByDefault
public final class FabricAPIRemoteVersion extends ComponentRemoteVersion {
/// @see <a href="https://modrinth.com/mod/fabric-api">Fabric API - Modrinth</a>
public static final String MODRINTH_ID = "fabric-api";
private final String fullVersion;
private final RemoteAddon.Version version;
/**
* Constructor.
*
* @param gameVersion the Minecraft version that this remote version suits.
* @param selfVersion the version string of the remote version.
* @param urls the installer or universal jar original URL.
*/
FabricAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List<String> urls) {
super(GameComponentType.FABRIC_API, gameVersion, selfVersion, datePublished, urls);
/// Constructor.
///
/// @param gameVersion the Minecraft version that this remote version suits.
/// @param selfVersion the version string of the remote version.
/// @param urls the installer or universal jar original URL.
public FabricAPIRemoteVersion(GameVersionNumber gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List<String> urls) {
super(GameComponentType.FABRIC_API, gameVersion, selfVersion, datePublished, Type.UNCATEGORIZED, urls);
this.fullVersion = fullVersion;
this.version = version;
@@ -1,53 +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.download.fabric;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import java.util.Collections;
public class FabricAPIVersionList extends ComponentVersionList<FabricAPIRemoteVersion> {
private final DownloadProvider downloadProvider;
public FabricAPIVersionList(DownloadProvider downloadProvider) {
this.downloadProvider = downloadProvider;
}
@Override
public boolean hasType() {
return false;
}
@Override
public Task<?> refreshAsync() {
return Task.runAsync(() -> {
for (RemoteAddon.Version modVersion : Lang.toIterable(ModrinthRemoteAddonRepository.MODS.getRemoteVersionsById(downloadProvider, "P7dR8mSH"))) {
for (String gameVersion : modVersion.gameVersions()) {
versions.put(gameVersion, new FabricAPIRemoteVersion(gameVersion, modVersion.version(), modVersion.name(), modVersion.datePublished(), modVersion,
Collections.singletonList(modVersion.file().url())));
}
}
});
}
}
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.download.fabric;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.UnsupportedInstallationException;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
@@ -27,6 +28,7 @@ import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
import java.util.*;
@@ -55,7 +57,8 @@ public final class FabricInstallTask extends Task<GameInstancePatch> {
this.manifest = manifest;
this.remote = remoteVersion;
launchMetaTask = new GetTask(dependencyManager.getDownloadProvider().injectURLsWithCandidates(remoteVersion.getUrls()));
DownloadProvider downloadProvider = dependencyManager.getDownloadProvider();
launchMetaTask = new GetTask(downloadProvider.getDownloadCandidates(remoteVersion));
launchMetaTask.setCacheRepository(dependencyManager.getCacheRepository());
}
@@ -96,7 +99,7 @@ public final class FabricInstallTask extends Task<GameInstancePatch> {
dependencies.add(new GameLibrariesTask(dependencyManager, manifest, true, getResult().getLibraries()));
}
private GameInstancePatch getPatch(FabricInfo fabricInfo, String gameVersion, String loaderVersion) {
private GameInstancePatch getPatch(FabricInfo fabricInfo, GameVersionNumber gameVersion, String loaderVersion) {
JsonObject launcherMeta = fabricInfo.launcherMeta;
Arguments arguments = new Arguments();
@@ -17,17 +17,24 @@
*/
package org.jackhuang.hmcl.download.fabric;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameInstancePatch;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
import java.util.List;
public class FabricRemoteVersion extends ComponentRemoteVersion {
@NotNullByDefault
public final class FabricRemoteVersion extends ComponentRemoteVersion {
public static final WebURL LOADER_META_URL = WebURL.parse("https://meta.fabricmc.net/v2/versions/loader");
public static final WebURL GAME_META_URL = WebURL.parse("https://meta.fabricmc.net/v2/versions/game");
/**
* Constructor.
*
@@ -35,8 +42,8 @@ public class FabricRemoteVersion extends ComponentRemoteVersion {
* @param selfVersion the version string of the remote version.
* @param urls the installer or universal jar original URL.
*/
FabricRemoteVersion(String gameVersion, String selfVersion, List<String> urls) {
super(GameComponentType.FABRIC, gameVersion, selfVersion, null, urls);
public FabricRemoteVersion(GameVersionNumber gameVersion, String selfVersion, List<String> urls) {
super(GameComponentType.FABRIC, gameVersion, selfVersion, null, Type.UNCATEGORIZED, urls);
}
@Override
@@ -1,81 +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.download.fabric;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
public final class FabricVersionList extends ComponentVersionList<FabricRemoteVersion> {
private final DownloadProvider downloadProvider;
public FabricVersionList(DownloadProvider downloadProvider) {
this.downloadProvider = downloadProvider;
}
@Override
public boolean hasType() {
return false;
}
@Override
public Task<?> refreshAsync() {
return Task.runAsync(() -> {
List<String> gameVersions = getGameVersions(GAME_META_URL);
List<String> loaderVersions = getGameVersions(LOADER_META_URL);
lock.writeLock().lock();
try {
for (String gameVersion : gameVersions)
for (String loaderVersion : loaderVersions)
versions.put(gameVersion, new FabricRemoteVersion(gameVersion, loaderVersion,
Collections.singletonList(getLaunchMetaUrl(gameVersion, loaderVersion))));
} finally {
lock.writeLock().unlock();
}
});
}
private static final String LOADER_META_URL = "https://meta.fabricmc.net/v2/versions/loader";
private static final String GAME_META_URL = "https://meta.fabricmc.net/v2/versions/game";
private List<String> getGameVersions(String metaUrl) throws IOException {
String json = NetworkUtils.doGet(downloadProvider.injectURLWithCandidates(metaUrl));
return JsonUtils.GSON.fromJson(json, listTypeOf(GameVersion.class))
.stream().map(GameVersion::version).collect(Collectors.toList());
}
private static String getLaunchMetaUrl(String gameVersion, String loaderVersion) {
return String.format("https://meta.fabricmc.net/v2/versions/loader/%s/%s", gameVersion, loaderVersion);
}
@JsonSerializable
private record GameVersion(String version, String maven, boolean stable) {
}
}
@@ -0,0 +1,46 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download.forge;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Objects;
@NotNullByDefault
@JsonSerializable
record ForgeBMCLVersion(
@Nullable String branch,
int build,
String mcversion,
@Nullable String modified,
String version,
List<File> files) {
public ForgeBMCLVersion {
Objects.requireNonNull(files, "files");
Objects.requireNonNull(mcversion, "mcversion");
Objects.requireNonNull(version, "version");
}
@JsonSerializable
public record File(String format, String category, String hash) {
}
}
@@ -1,229 +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.download.forge;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.*;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ForgeBMCLVersionList extends ComponentVersionList<ForgeRemoteVersion> {
private final String apiRoot;
/**
* @param apiRoot API Root of BMCLAPI implementations
*/
public ForgeBMCLVersionList(String apiRoot) {
this.apiRoot = apiRoot;
}
@Override
public boolean hasType() {
return false;
}
@Override
public Task<?> refreshAsync() {
throw new UnsupportedOperationException("ForgeBMCLVersionList does not support loading the entire Forge remote version list.");
}
private static String toLookupVersion(String gameVersion) {
return "1.7.10-pre4".equals(gameVersion) ? "1.7.10_pre4" : gameVersion;
}
private static String fromLookupVersion(String lookupVersion) {
return "1.7.10_pre4".equals(lookupVersion) ? "1.7.10-pre4" : lookupVersion;
}
private static String toLookupBranch(String gameVersion, String branch) {
if ("1.7.10-pre4".equals(gameVersion)) {
return "prerelease";
}
return Objects.requireNonNullElse(branch, "");
}
@Override
public Task<?> refreshAsync(String gameVersion) {
String lookupVersion = toLookupVersion(gameVersion);
return new GetTask(apiRoot + "/forge/minecraft/" + lookupVersion).thenGetJsonAsync(listTypeOf(ForgeVersion.class))
.thenAcceptAsync(forgeVersions -> {
lock.writeLock().lock();
try {
versions.clear(gameVersion);
if (forgeVersions == null) return;
for (ForgeVersion version : forgeVersions) {
if (version == null)
continue;
List<String> urls = new ArrayList<>();
for (ForgeVersion.File file : version.getFiles())
if ("installer".equals(file.getCategory()) && "jar".equals(file.getFormat())) {
String branch = toLookupBranch(gameVersion, version.getBranch());
String classifier = lookupVersion + "-" + version.getVersion() + (branch.isEmpty() ? "" : '-' + branch);
String fileName1 = "forge-" + classifier + "-" + file.getCategory() + "." + file.getFormat();
String fileName2 = "forge-" + classifier + "-" + lookupVersion + "-" + file.getCategory() + "." + file.getFormat();
urls.add("https://files.minecraftforge.net/maven/net/minecraftforge/forge/" + classifier + "/" + fileName1);
urls.add("https://files.minecraftforge.net/maven/net/minecraftforge/forge/" + classifier + "-" + lookupVersion + "/" + fileName2);
urls.add(NetworkUtils.withQuery("https://bmclapi2.bangbang93.com/forge/download", mapOf(
pair("mcversion", version.getGameVersion()),
pair("version", version.getVersion()),
pair("branch", branch),
pair("category", file.getCategory()),
pair("format", file.getFormat())
)));
}
if (urls.isEmpty())
continue;
Instant releaseDate = null;
if (version.getModified() != null) {
try {
releaseDate = Instant.parse(version.getModified());
} catch (DateTimeParseException e) {
LOG.warning("Failed to parse instant " + version.getModified(), e);
}
}
versions.put(gameVersion, new ForgeRemoteVersion(
fromLookupVersion(version.getGameVersion()), version.getVersion(), releaseDate, urls));
}
} finally {
lock.writeLock().unlock();
}
});
}
@Override
public Optional<ForgeRemoteVersion> getVersion(String gameVersion, String remoteVersion) {
remoteVersion = StringUtils.substringAfter(remoteVersion, "-", remoteVersion);
return super.getVersion(gameVersion, remoteVersion);
}
@Immutable
public static final class ForgeVersion implements Validation {
private final String branch;
private final int build;
private final String mcversion;
private final String modified;
private final String version;
private final List<File> files;
/**
* No-arg constructor for Gson.
*/
@SuppressWarnings("unused")
public ForgeVersion() {
this(null, 0, "", null, "", Collections.emptyList());
}
public ForgeVersion(String branch, int build, String mcversion, String modified, String version, List<File> files) {
this.branch = branch;
this.build = build;
this.mcversion = mcversion;
this.modified = modified;
this.version = version;
this.files = files;
}
@Nullable
public String getBranch() {
return branch;
}
public int getBuild() {
return build;
}
@NotNull
public String getGameVersion() {
return mcversion;
}
@Nullable
public String getModified() {
return modified;
}
@NotNull
public String getVersion() {
return version;
}
@NotNull
public List<File> getFiles() {
return files;
}
@Override
public void validate() throws JsonParseException {
if (files == null)
throw new JsonParseException("ForgeVersion files cannot be null");
if (version == null)
throw new JsonParseException("ForgeVersion version cannot be null");
if (mcversion == null)
throw new JsonParseException("ForgeVersion mcversion cannot be null");
}
@Immutable
public static final class File {
private final String format;
private final String category;
private final String hash;
public File() {
this("", "", "");
}
public File(String format, String category, String hash) {
this.format = format;
this.category = category;
this.hash = hash;
}
public String getFormat() {
return format;
}
public String getCategory() {
return category;
}
public String getHash() {
return hash;
}
}
}
}
@@ -75,8 +75,9 @@ public final class ForgeInstallTask extends Task<GameInstancePatch> {
public void preExecute() throws Exception {
installer = Files.createTempFile("forge-installer", ".jar");
DownloadProvider downloadProvider = dependencyManager.getDownloadProvider();
dependent = new FileDownloadTask(
dependencyManager.getDownloadProvider().injectURLsWithCandidates(remote.getUrls()),
downloadProvider.getDownloadCandidates(remote),
installer, null);
dependent.setCacheRepository(dependencyManager.getCacheRepository());
dependent.setCaching(true);
@@ -107,7 +108,7 @@ public final class ForgeInstallTask extends Task<GameInstancePatch> {
@Override
public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException {
String originalMainClass = manifest.mainClass();
if (GameVersionNumber.compare("1.13", remote.getGameVersion()) <= 0) {
if (GameVersionNumber.asGameVersion("1.13").compareTo(remote.getGameVersion()) <= 0) {
// Forge 1.13 is not compatible with fabric.
if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass))
throw new UnsupportedInstallationException(UNSUPPORTED_LAUNCH_WRAPPER);
@@ -134,19 +135,19 @@ public final class ForgeInstallTask extends Task<GameInstancePatch> {
/// @throws IOException if the installer profile is missing, malformed, or
/// unsupported
/// @throws VersionMismatchException if the installer targets another Minecraft version
public static boolean detectForgeInstallerType(String gameVersion, Path installer) throws IOException, VersionMismatchException {
public static boolean detectForgeInstallerType(GameVersionNumber gameVersion, Path installer) throws IOException, VersionMismatchException {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) {
String installProfileText = Files.readString(fs.getPath("install_profile.json"));
Map<?, ?> installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class);
if (installProfile.containsKey("spec")) {
ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class);
if (!gameVersion.equals(profile.getMinecraft()))
throw new VersionMismatchException(profile.getMinecraft(), gameVersion);
if (!gameVersion.equals(GameVersionNumber.asGameVersion(profile.getMinecraft())))
throw new VersionMismatchException(profile.getMinecraft(), gameVersion.toString());
return true;
} else if (installProfile.containsKey("install") && installProfile.containsKey("versionInfo")) {
ForgeInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeInstallProfile.class);
if (!gameVersion.equals(profile.install().getMinecraft()))
throw new VersionMismatchException(profile.install().getMinecraft(), gameVersion);
if (!gameVersion.equals(GameVersionNumber.asGameVersion(profile.install().getMinecraft())))
throw new VersionMismatchException(profile.install().getMinecraft(), gameVersion.toString());
return false;
} else {
throw new IOException();
@@ -19,10 +19,13 @@ package org.jackhuang.hmcl.download.forge;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile.Processor;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.download.game.GameInstanceJsonDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.DigestUtils;
@@ -34,7 +37,6 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.NotNull;
@@ -42,7 +44,6 @@ import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -371,8 +372,8 @@ public class ForgeNewInstallTask extends Task<GameInstancePatch> {
throw new Exception("client_mappings download info not found");
}
List<URI> mappingsUrl = dependencyManager.getDownloadProvider()
.injectURLWithCandidates(mappings.getUrl());
DownloadProvider downloadProvider = dependencyManager.getDownloadProvider();
DownloadCandidates mappingsUrl = downloadProvider.getDownloadCandidates(mappings.getUrl());
var mappingsTask = new FileDownloadTask(
mappingsUrl,
Path.of(output),
@@ -17,18 +17,144 @@
*/
package org.jackhuang.hmcl.download.forge;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.game.GameInstancePatch;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeSet;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
public final class ForgeRemoteVersion extends ComponentRemoteVersion {
public static final WebURL FORGE_LIST = WebURL.parse("https://hmcl.glavo.site/metadata/forge/");
private static String toLookupVersion(String gameVersion) {
return "1.7.10-pre4".equals(gameVersion) ? "1.7.10_pre4" : gameVersion;
}
private static String fromLookupVersion(String lookupVersion) {
return "1.7.10_pre4".equals(lookupVersion) ? "1.7.10-pre4" : lookupVersion;
}
private static String toLookupBranch(String gameVersion, @Nullable String branch) {
if ("1.7.10-pre4".equals(gameVersion)) {
return "prerelease";
}
return Objects.requireNonNullElse(branch, "");
}
public static Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchAsync(DownloadProvider downloadProvider, GameVersionNumber gameVersion) {
return new GetTask(downloadProvider.getForgeVersionListCandidates()).thenApplyAsync(result -> {
ForgeVersionRoot root = JsonUtils.GSON.fromJson(result, ForgeVersionRoot.class);
TreeSet<ForgeRemoteVersion> versions = new TreeSet<>();
for (Map.Entry<String, int[]> entry : root.mcversion().entrySet()) {
if (gameVersion.equals(GameVersionNumber.asGameVersion(fromLookupVersion(entry.getKey())))) {
for (int v : entry.getValue()) {
ForgeVersion version = root.number().get(v);
if (version == null)
continue;
String jar = null;
for (String[] file : version.getFiles())
if (file.length > 1 && "installer".equals(file[1])) {
String classifier = version.getGameVersion() + "-" + version.getVersion()
+ (StringUtils.isNotBlank(version.getBranch()) ? "-" + version.getBranch() : "");
String fileName = root.artifact() + "-" + classifier + "-" + file[1] + "." + file[0];
jar = root.webpath() + classifier + "/" + fileName;
}
if (jar == null)
continue;
versions.add(new ForgeRemoteVersion(
gameVersion,
version.getVersion(),
version.getModified() > 0 ? Instant.ofEpochSecond(version.getModified()) : null,
List.of(jar)
));
}
break;
}
}
return ComponentRemoteVersionList.of(GameComponentType.FORGE, versions);
});
}
public static Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCLAsync(
DownloadProvider downloadProvider, String bmclRoot, GameVersionNumber gameVersion) {
String lookupVersion = toLookupVersion(gameVersion.toString());
return new GetTask(DownloadCandidates.of(bmclRoot + "/forge/minecraft/" + lookupVersion)).thenApplyAsync(result -> {
List<ForgeBMCLVersion> forgeVersions = JsonUtils.fromNonNullJson(result, listTypeOf(ForgeBMCLVersion.class));
TreeSet<ForgeRemoteVersion> versions = new TreeSet<>();
for (ForgeBMCLVersion version : forgeVersions) {
//noinspection ConstantValue
if (version == null)
continue;
List<String> urls = new ArrayList<>();
for (ForgeBMCLVersion.File file : version.files())
if ("installer".equals(file.category()) && "jar".equals(file.format())) {
String branch = toLookupBranch(gameVersion.toString(), version.branch());
String classifier = lookupVersion + "-" + version.version() + (branch.isEmpty() ? "" : '-' + branch);
String fileName1 = "forge-" + classifier + "-" + file.category() + "." + file.format();
String fileName2 = "forge-" + classifier + "-" + lookupVersion + "-" + file.category() + "." + file.format();
urls.add("https://files.minecraftforge.net/maven/net/minecraftforge/forge/" + classifier + "/" + fileName1);
urls.add("https://files.minecraftforge.net/maven/net/minecraftforge/forge/" + classifier + "-" + lookupVersion + "/" + fileName2);
urls.add(NetworkUtils.withQuery(bmclRoot + "/forge/download", mapOf(
pair("mcversion", version.mcversion()),
pair("version", version.version()),
pair("branch", branch),
pair("category", file.category()),
pair("format", file.format())
)));
}
if (urls.isEmpty())
continue;
Instant releaseDate = null;
if (version.modified() != null) {
try {
releaseDate = Instant.parse(version.modified());
} catch (DateTimeParseException e) {
LOG.warning("Failed to parse instant " + version.modified(), e);
}
}
versions.add(new ForgeRemoteVersion(gameVersion, version.version(), releaseDate, urls));
}
return ComponentRemoteVersionList.of(GameComponentType.FORGE, versions);
});
}
public class ForgeRemoteVersion extends ComponentRemoteVersion {
/**
* Constructor.
*
@@ -36,8 +162,8 @@ public class ForgeRemoteVersion extends ComponentRemoteVersion {
* @param selfVersion the version string of the remote version.
* @param url the installer or universal jar original URL.
*/
public ForgeRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List<String> url) {
super(GameComponentType.FORGE, gameVersion, selfVersion, releaseDate, url);
public ForgeRemoteVersion(GameVersionNumber gameVersion, String selfVersion, @Nullable Instant releaseDate, List<String> url) {
super(GameComponentType.FORGE, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, url);
}
@Override
@@ -26,7 +26,7 @@ import org.jackhuang.hmcl.util.gson.Validation;
* @author huangyuhui
*/
@Immutable
public final class ForgeVersion implements Validation {
final class ForgeVersion implements Validation {
private final String branch;
private final String mcversion;
@@ -1,100 +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.download.forge;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.net.URI;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
/**
*
* @author huangyuhui
*/
public final class ForgeVersionList extends ComponentVersionList<ForgeRemoteVersion> {
private final DownloadProvider downloadProvider;
public ForgeVersionList(DownloadProvider downloadProvider) {
this.downloadProvider = downloadProvider;
}
@Override
public boolean hasType() {
return false;
}
private static String toLookupVersion(String gameVersion) {
return "1.7.10-pre4".equals(gameVersion) ? "1.7.10_pre4" : gameVersion;
}
private static String fromLookupVersion(String lookupVersion) {
return "1.7.10_pre4".equals(lookupVersion) ? "1.7.10-pre4" : lookupVersion;
}
@Override
public Task<?> refreshAsync() {
return new GetTask(FORGE_LIST).thenGetJsonAsync(ForgeVersionRoot.class)
.thenAcceptAsync(root -> {
lock.writeLock().lock();
try {
if (root == null)
return;
versions.clear();
for (Map.Entry<String, int[]> entry : root.getGameVersions().entrySet()) {
String gameVersion = fromLookupVersion(VersionNumber.normalize(entry.getKey()));
for (int v : entry.getValue()) {
ForgeVersion version = root.getNumber().get(v);
if (version == null)
continue;
String jar = null;
for (String[] file : version.getFiles())
if (file.length > 1 && "installer".equals(file[1])) {
String classifier = version.getGameVersion() + "-" + version.getVersion()
+ (StringUtils.isNotBlank(version.getBranch()) ? "-" + version.getBranch() : "");
String fileName = root.getArtifact() + "-" + classifier + "-" + file[1] + "." + file[0];
jar = root.getWebPath() + classifier + "/" + fileName;
}
if (jar == null)
continue;
versions.put(gameVersion, new ForgeRemoteVersion(
toLookupVersion(version.getGameVersion()),
version.getVersion(),
version.getModified() > 0 ? Instant.ofEpochSecond(version.getModified()) : null,
Collections.singletonList(jar)
));
}
}
} finally {
lock.writeLock().unlock();
}
});
}
public static final URI FORGE_LIST = URI.create("https://hmcl.glavo.site/metadata/forge/");
}
@@ -17,91 +17,27 @@
*/
package org.jackhuang.hmcl.download.forge;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.Objects;
/**
*
* @author huangyuhui
*/
@Immutable
public final class ForgeVersionRoot implements Validation {
/// @author huangyuhui
@NotNullByDefault
record ForgeVersionRoot(String artifact,
String webpath,
@Nullable String adfly,
@Nullable String homepage,
@Nullable String name,
@Nullable Map<String, int[]> branches,
Map<String, int[]> mcversion,
@Nullable Map<String, Integer> promos,
Map<Integer, ForgeVersion> number) {
private final String artifact;
private final String webpath;
private final String adfly;
private final String homepage;
private final String name;
private final Map<String, int[]> branches;
private final Map<String, int[]> mcversion;
private final Map<String, Integer> promos;
private final Map<Integer, ForgeVersion> number;
/**
* No-arg constructor for Gson.
*/
@SuppressWarnings("unused")
public ForgeVersionRoot() {
this(null, null, null, null, null, null, null, null, null);
}
public ForgeVersionRoot(String artifact, String webpath, String adfly, String homepage, String name, Map<String, int[]> branches, Map<String, int[]> mcversion, Map<String, Integer> promos, Map<Integer, ForgeVersion> number) {
this.artifact = artifact;
this.webpath = webpath;
this.adfly = adfly;
this.homepage = homepage;
this.name = name;
this.branches = branches;
this.mcversion = mcversion;
this.promos = promos;
this.number = number;
}
public String getArtifact() {
return artifact;
}
public String getWebPath() {
return webpath;
}
public String getAdfly() {
return adfly;
}
public String getHomePage() {
return homepage;
}
public String getName() {
return name;
}
public Map<String, int[]> getBranches() {
return branches;
}
public Map<String, int[]> getGameVersions() {
return mcversion;
}
public Map<String, Integer> getPromos() {
return promos;
}
public Map<Integer, ForgeVersion> getNumber() {
return number;
}
@Override
public void validate() throws JsonParseException {
if (number == null)
throw new JsonParseException("ForgeVersionRoot number cannot be null");
if (mcversion == null)
throw new JsonParseException("ForgeVersionRoot mcversion cannot be null");
public ForgeVersionRoot {
Objects.requireNonNull(number, "number");
Objects.requireNonNull(mcversion, "mcversion");
}
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.download.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.AbstractDependencyManager;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
@@ -26,7 +27,6 @@ import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -100,9 +100,8 @@ public final class GameAssetDownloadTask extends Task<Void> {
LOG.warning("Unable to calc hash value of file " + file, e);
}
if (download) {
List<URI> uris = dependencyManager.getDownloadProvider().getAssetObjectCandidates(assetObject.getLocation());
var task = new FileDownloadTask(uris, file, new FileDownloadTask.IntegrityCheck("SHA-1", assetObject.hash()));
DownloadCandidates candidates = dependencyManager.getDownloadProvider().getAssetObjectCandidates(assetObject);
var task = new FileDownloadTask(candidates, file, new FileDownloadTask.IntegrityCheck("SHA-1", assetObject.hash()));
task.setName(assetObject.hash());
task.setCandidate(dependencyManager.getCacheRepository().getCommonDirectory()
.resolve("assets").resolve("objects").resolve(assetObject.getLocation()));
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.download.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.AbstractDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
@@ -94,8 +95,9 @@ public final class GameAssetIndexDownloadTask extends Task<Void> {
// We should not check the hash code of asset index file since this file is not consistent
// And Mojang will modify this file anytime. So assetIndex.hash might be outdated.
DownloadProvider downloadProvider = dependencyManager.getDownloadProvider();
var task = new FileDownloadTask(
dependencyManager.getDownloadProvider().injectURLWithCandidates(assetIndexInfo.getUrl()),
downloadProvider.getDownloadCandidates(assetIndexInfo.getUrl()),
assetIndexFile,
verifyHashCode ? new FileDownloadTask.IntegrityCheck("SHA-1", assetIndexInfo.getSha1()) : null
);
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.download.game;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.DownloadInfo;
import org.jackhuang.hmcl.game.GameInstanceManifest;
import org.jackhuang.hmcl.task.CacheFileTask;
@@ -71,14 +72,17 @@ public final class GameDownloadTask extends Task<Path> {
public void execute() {
DownloadInfo downloadInfo = manifest.getDownloadInfo();
@Nullable String sha1 = downloadInfo.getSha1();
CacheFileTask cacheTask = sha1 != null
? new CacheFileTask(
dependencyManager.getDownloadProvider()
.injectURLWithCandidates(downloadInfo.getUrl()),
sha1)
: new CacheFileTask(
dependencyManager.getDownloadProvider()
.injectURLWithCandidates(downloadInfo.getUrl()));
CacheFileTask cacheTask;
if (sha1 != null) {
cacheTask = new CacheFileTask(
dependencyManager.getDownloadProvider()
.getDownloadCandidates(downloadInfo.getUrl()),
sha1);
} else {
DownloadProvider downloadProvider = dependencyManager.getDownloadProvider();
cacheTask = new CacheFileTask(
downloadProvider.getDownloadCandidates(downloadInfo.getUrl()));
}
cacheTask.setCacheRepository(dependencyManager.getCacheRepository());
cacheTask.storeTo(this::setResult);
dependencies.add(cacheTask);
@@ -62,7 +62,7 @@ public class GameInstallTask extends Task<GameInstancePatch> {
this.dependencyManager = dependencyManager;
this.manifest = manifest;
this.remote = remoteVersion;
this.downloadTask = new GameInstanceJsonDownloadTask(remoteVersion.getGameVersion(), dependencyManager);
this.downloadTask = new GameInstanceJsonDownloadTask(remoteVersion.getGameVersion().toString(), dependencyManager);
}
/// {@inheritDoc}
@@ -89,7 +89,7 @@ public class GameInstallTask extends Task<GameInstancePatch> {
GameInstancePatch patch = GameInstancePatch.fromManifest(
JsonUtils.fromNonNullJson(downloadTask.getResult(), GameInstanceManifest.class),
GameComponentType.GAME.getPatchId(),
remote.getGameVersion(),
remote.getGameVersion().toString(),
GameInstancePatch.PRIORITY_MC).withJar(null);
setResult(patch);
@@ -17,9 +17,7 @@
*/
package org.jackhuang.hmcl.download.game;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.ComponentVersionList;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
@@ -36,16 +34,15 @@ import java.util.List;
public final class GameInstanceJsonDownloadTask extends Task<String> {
private final String gameVersion;
private final DefaultDependencyManager dependencyManager;
private final List<Task<?>> dependents = new ArrayList<>(1);
private final Task<ComponentRemoteVersionList<?>> getGameVersionsTask;
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final ComponentVersionList<?> gameVersionList;
public GameInstanceJsonDownloadTask(String gameVersion, DefaultDependencyManager dependencyManager) {
this.gameVersion = gameVersion;
this.dependencyManager = dependencyManager;
this.gameVersionList = dependencyManager.getVersionList(GameComponentType.GAME);
dependents.add(gameVersionList.loadAsync(gameVersion));
getGameVersionsTask = dependencyManager.getDownloadProvider()
.getVersionsAsync(GameComponentType.GAME, null, false);
setSignificance(TaskSignificance.MODERATE);
}
@@ -57,13 +54,15 @@ public final class GameInstanceJsonDownloadTask extends Task<String> {
@Override
public Collection<Task<?>> getDependents() {
return dependents;
return List.of(getGameVersionsTask);
}
@Override
public void execute() throws IOException {
ComponentRemoteVersion remoteVersion = gameVersionList.getVersion(gameVersion, gameVersion)
.orElseThrow(() -> new IOException("Cannot find specific version " + gameVersion + " in remote repository"));
dependencies.add(new GetTask(dependencyManager.getDownloadProvider().injectURLsWithCandidates(remoteVersion.getUrls())).storeTo(this::setResult));
ComponentRemoteVersion remoteVersion = getGameVersionsTask.getResult().getRemoteVersion(gameVersion);
if (remoteVersion == null)
throw new IOException(new IOException("Cannot find specific version " + gameVersion + " in remote repository"));
DownloadProvider downloadProvider = dependencyManager.getDownloadProvider();
dependencies.add(new GetTask(downloadProvider.getDownloadCandidates(remoteVersion)).storeTo(this::setResult));
}
}
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more