Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662a82f21f | ||
|
|
3f0fcc1382 | ||
|
|
258556b22c | ||
|
|
d7e3cc4473 | ||
|
|
93d4489b94 | ||
|
|
77eee17d36 | ||
|
|
4118fa9729 | ||
|
|
83ffc91a88 | ||
|
|
fdf883d09d | ||
|
|
342bcc5438 | ||
|
|
59bcc7fe62 | ||
|
|
77b42b6f99 |
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"));
|
||||
|
||||
+13
-12
@@ -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)));
|
||||
|
||||
+19
-26
@@ -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);
|
||||
}
|
||||
|
||||
+46
-16
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+29
-33
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+42
-46
@@ -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));
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+5
-5
@@ -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();
|
||||
}
|
||||
|
||||
+13
-13
@@ -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 <access token>
|
||||
*
|
||||
* 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 <access token>
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
+7
-11
@@ -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 {
|
||||
|
||||
+41
-5
@@ -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
|
||||
|
||||
-69
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
-10
@@ -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()));
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
|
||||
+10
-11
@@ -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
Reference in new issue
Block a user