Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662a82f21f | ||
|
|
3f0fcc1382 | ||
|
|
258556b22c | ||
|
|
d7e3cc4473 | ||
|
|
93d4489b94 | ||
|
|
77eee17d36 | ||
|
|
4118fa9729 | ||
|
|
83ffc91a88 | ||
|
|
fdf883d09d | ||
|
|
342bcc5438 | ||
|
|
59bcc7fe62 | ||
|
|
77b42b6f99 | ||
|
|
2f5594d8c1 | ||
|
|
13ad50208f | ||
|
|
13910c24b7 | ||
|
|
cc9bdb62ca | ||
|
|
baa7c816b7 | ||
|
|
090f0b9822 | ||
|
|
6d9ae24912 | ||
|
|
d03851b196 |
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
public final class HMCLModpackProvider implements ModpackProvider {
|
||||
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
|
||||
@@ -48,7 +49,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
|
||||
public Task<?> createUpdateTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
DefaultGameInstance instance,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
|
||||
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
|
||||
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
|
||||
|
||||
@@ -77,7 +83,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
|
||||
private final static class HMCLModpack extends Modpack {
|
||||
@Override
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) {
|
||||
public Task<?> getInstallTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,6 +56,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
@@ -196,6 +197,26 @@ public final class ModpackHelper {
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
|
||||
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
|
||||
}
|
||||
|
||||
/// Creates an install task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param instanceId the new instance id
|
||||
/// @param modpack the parsed modpack
|
||||
/// @param iconUrl the optional icon URL, or `null`
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the install task
|
||||
public static Task<?> getInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
Modpack modpack,
|
||||
@Nullable String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
repository.refresh();
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
@@ -209,17 +230,17 @@ public final class ModpackHelper {
|
||||
};
|
||||
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
@@ -240,17 +261,38 @@ public final class ModpackHelper {
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
return getUpdateTask(repository, zipFile, charset, instanceId, configuration, null);
|
||||
}
|
||||
|
||||
/// Creates an update task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param charset the archive encoding
|
||||
/// @param instanceId the instance to update
|
||||
/// @param configuration the existing modpack configuration
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the update task
|
||||
public static Task<?> getUpdateTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Charset charset,
|
||||
GameInstanceID instanceId,
|
||||
ModpackConfiguration<?> configuration,
|
||||
@Nullable Set<String> excludedFiles)
|
||||
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
if (provider == null) {
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack)
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
else
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack)
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,12 +21,14 @@ import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
public abstract class MDListCell<T> extends ListCell<T> {
|
||||
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
private static final PseudoClass LAST = PseudoClass.getPseudoClass("last");
|
||||
|
||||
private final StackPane container = new StackPane();
|
||||
private final StackPane root = new StackPane();
|
||||
@@ -58,7 +60,10 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
if (oldItem == item && oldEmpty == empty) {
|
||||
updateLastPseudoClass(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ripplerContainer.releaseRippleImmediately();
|
||||
|
||||
@@ -68,6 +73,19 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
} else {
|
||||
setGraphic(root);
|
||||
}
|
||||
updateLastPseudoClass(empty || item == null);
|
||||
}
|
||||
|
||||
/// Updates the `:last` pseudo-class so the trailing divider can be suppressed in CSS.
|
||||
///
|
||||
/// @param empty whether this cell is empty
|
||||
private void updateLastPseudoClass(boolean empty) {
|
||||
ListView<T> listView = getListView();
|
||||
boolean last = !empty
|
||||
&& listView != null
|
||||
&& getIndex() >= 0
|
||||
&& getIndex() == listView.getItems().size() - 1;
|
||||
root.pseudoClassStateChanged(LAST, last);
|
||||
}
|
||||
|
||||
protected StackPane getContainer() {
|
||||
@@ -80,5 +98,9 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
});
|
||||
}
|
||||
|
||||
protected void onClicked(Runnable action) {
|
||||
FXUtils.onClicked(ripplerContainer, action);
|
||||
}
|
||||
|
||||
protected abstract void updateControl(T item, boolean empty);
|
||||
}
|
||||
@@ -33,44 +33,16 @@ import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameInstallTask;
|
||||
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
|
||||
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
|
||||
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
|
||||
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltInstallTask;
|
||||
import org.jackhuang.hmcl.game.HMCLModpackInstallTask;
|
||||
import org.jackhuang.hmcl.java.JavaInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
|
||||
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.Task;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.task.TaskListener;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Collection;
|
||||
@@ -161,58 +133,16 @@ public final class TaskListPane extends StackPane {
|
||||
if (!task.getSignificance().shouldShow() || task.getName() == null)
|
||||
return;
|
||||
|
||||
if (task instanceof GameAssetDownloadTask) {
|
||||
task.setName(i18n("assets.download_all"));
|
||||
} else if (task instanceof GameInstallTask) {
|
||||
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
|
||||
return;
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
|
||||
} else if (task instanceof CleanroomInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
|
||||
} else if (task instanceof LegacyFabricInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric")));
|
||||
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
|
||||
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.neoforge")));
|
||||
} else if (task instanceof LiteLoaderInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.liteloader")));
|
||||
} else if (task instanceof OptiFineInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.optifine")));
|
||||
} else if (task instanceof FabricInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.fabric")));
|
||||
} else if (task instanceof FabricAPIInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.fabric-api")));
|
||||
} else if (task instanceof QuiltInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.quilt")));
|
||||
} else if (task instanceof QuiltAPIInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.quilt-api")));
|
||||
} else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) {
|
||||
task.setName(i18n("modpack.completion"));
|
||||
} else if (task instanceof ModpackInstallTask) {
|
||||
task.setName(i18n("modpack.installing"));
|
||||
} else if (task instanceof ModpackUpdateTask) {
|
||||
task.setName(i18n("modpack.update"));
|
||||
} else if (task instanceof CurseInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.curse")));
|
||||
} else if (task instanceof MultiMCModpackInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.multimc")));
|
||||
} else if (task instanceof ModrinthInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.modrinth")));
|
||||
} else if (task instanceof ServerModpackLocalInstallTask) {
|
||||
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
|
||||
} else if (task instanceof HMCLModpackInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
|
||||
} else if (task instanceof McbbsModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
|
||||
task.setName(i18n("modpack.export"));
|
||||
} else if (task instanceof MinecraftInstanceTask) {
|
||||
task.setName(i18n("modpack.scan"));
|
||||
} else if (task instanceof MojangJavaDownloadTask) {
|
||||
task.setName(i18n("download.java"));
|
||||
} else if (task instanceof JavaInstallTask) {
|
||||
task.setName(i18n("java.installing"));
|
||||
if (task instanceof GameInstallTask
|
||||
&& task.getInheritedStage() != null
|
||||
&& task.getInheritedStage().startsWith("hmcl.install.game")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable String localizedName = I18n.translateTaskName(task);
|
||||
if (localizedName != null)
|
||||
task.setName(localizedName);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
ProgressListNode node = new ProgressListNode(task);
|
||||
nodes.put(task, node);
|
||||
|
||||
@@ -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;
|
||||
@@ -125,10 +121,12 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
setMessage(i18n("game_directory.already_exists"));
|
||||
}
|
||||
|
||||
/// Rejects names used by other game directories, excluding the entry being edited.
|
||||
@Override
|
||||
protected void eval() {
|
||||
JFXTextField control = (JFXTextField) this.getSrcControl();
|
||||
hasErrors.set(GameDirectoryManager.getGameDirectories().stream()
|
||||
.filter(existingGameDirectory -> existingGameDirectory != gameDirectory)
|
||||
.anyMatch(existingGameDirectory -> Objects.equals(
|
||||
GameDirectoryManager.getGameDirectoryCustomName(existingGameDirectory), control.getText())));
|
||||
}
|
||||
@@ -170,43 +168,52 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
StackPane.setAlignment(saveButton, Pos.BOTTOM_RIGHT);
|
||||
saveButton.setPrefSize(100, 40);
|
||||
saveButton.setOnAction(e -> onSave());
|
||||
String initialLocation = getLocation();
|
||||
boolean initialUseRelativePath = toggleUseRelativePath.isSelected();
|
||||
saveButton.disableProperty().bind(Bindings.createBooleanBinding(
|
||||
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation()),
|
||||
txtGameDirectoryName.textProperty(), location));
|
||||
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation())
|
||||
|| (gameDirectory != null
|
||||
&& Objects.equals(txtGameDirectoryName.getText(), gameDirectoryDisplayName)
|
||||
&& Objects.equals(getLocation(), initialLocation)
|
||||
&& toggleUseRelativePath.isSelected() == initialUseRelativePath),
|
||||
txtGameDirectoryName.textProperty(), location, toggleUseRelativePath.selectedProperty()));
|
||||
}
|
||||
|
||||
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
|
||||
Path newPath;
|
||||
try {
|
||||
newPath = FileUtils.toAbsolute(Path.of(newValue));
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!".minecraft".equals(FileUtils.getName(newPath)))
|
||||
return;
|
||||
|
||||
Path parent = newPath.getParent();
|
||||
if (parent == null)
|
||||
return;
|
||||
|
||||
String suggestedName = FileUtils.getName(parent);
|
||||
if (!suggestedName.isBlank()) {
|
||||
txtGameDirectoryName.setText(suggestedName);
|
||||
}
|
||||
};
|
||||
locationChangeListener.changed(null, this.location.get(), this.location.get());
|
||||
locationProperty().addListener(locationChangeListener);
|
||||
|
||||
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
|
||||
if (txtGameDirectoryName.isFocused()) {
|
||||
txtGameDirectoryName.textProperty().removeListener(this);
|
||||
locationProperty().removeListener(locationChangeListener);
|
||||
if (gameDirectory == null) {
|
||||
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
|
||||
Path newPath;
|
||||
try {
|
||||
newPath = FileUtils.toAbsolute(Path.of(newValue));
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!".minecraft".equals(FileUtils.getName(newPath)))
|
||||
return;
|
||||
|
||||
@Nullable Path parent = newPath.getParent();
|
||||
if (parent == null)
|
||||
return;
|
||||
|
||||
String suggestedName = FileUtils.getName(parent);
|
||||
if (!suggestedName.isBlank()) {
|
||||
txtGameDirectoryName.setText(suggestedName);
|
||||
}
|
||||
};
|
||||
locationChangeListener.changed(locationProperty(), this.location.get(), this.location.get());
|
||||
locationProperty().addListener(locationChangeListener);
|
||||
|
||||
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
|
||||
/// Stops automatic name suggestions after the user edits the name.
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
|
||||
if (txtGameDirectoryName.isFocused()) {
|
||||
txtGameDirectoryName.textProperty().removeListener(this);
|
||||
locationProperty().removeListener(locationChangeListener);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@FXThread
|
||||
@@ -237,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) {
|
||||
|
||||
@@ -20,11 +20,18 @@ package org.jackhuang.hmcl.ui.download;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackFile;
|
||||
import org.jackhuang.hmcl.modpack.ModpackManifest;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -39,9 +46,15 @@ import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -51,10 +64,22 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
private final BooleanProperty installAsVersion = new SimpleBooleanProperty(true);
|
||||
private Modpack manifest = null;
|
||||
private Charset charset;
|
||||
private final ObservableList<ModpackFile> allFiles = FXCollections.observableList(new ArrayList<>());
|
||||
private final ObservableSet<String> excludedFiles = FXCollections.observableSet(new HashSet<>());
|
||||
private final BooleanProperty loadingOptionalFiles = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty loadedOptionalFiles = new SimpleBooleanProperty(true);
|
||||
|
||||
public LocalModpackPage(WizardController controller) {
|
||||
super(controller);
|
||||
|
||||
btnOptionalFiles.setOnAction(ev -> controller.onNext(new OptionalFilesPage(
|
||||
this::onInstall,
|
||||
this::loadOptionalFiles,
|
||||
loadingOptionalFiles,
|
||||
loadedOptionalFiles,
|
||||
new FilteredList<>(allFiles, ModpackFile::optional),
|
||||
excludedFiles)));
|
||||
|
||||
HMCLGameRepository repository = controller.getSettings().get(ModpackPage.REPOSITORY);
|
||||
|
||||
String name = controller.getSettings().get(MODPACK_NAME);
|
||||
@@ -103,8 +128,7 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile))
|
||||
.thenApplyAsync(encoding -> {
|
||||
charset = encoding;
|
||||
manifest = ModpackHelper.readModpackManifest(selectedFile, encoding);
|
||||
return manifest;
|
||||
return ModpackHelper.readModpackManifest(selectedFile, encoding);
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
|
||||
if (exception instanceof ManuallyCreatedModpackException) {
|
||||
@@ -126,7 +150,11 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
LOG.warning("Failed to read modpack manifest", exception);
|
||||
Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
Platform.runLater(controller::onEnd);
|
||||
} else if (manifest == null) {
|
||||
Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
Platform.runLater(controller::onEnd);
|
||||
} else {
|
||||
this.manifest = manifest;
|
||||
hideSpinner();
|
||||
controller.getSettings().put(MODPACK_MANIFEST, manifest);
|
||||
nameProperty.set(manifest.getName());
|
||||
@@ -139,6 +167,33 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
}
|
||||
|
||||
btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription()));
|
||||
|
||||
if (manifest.getManifest() instanceof ModpackManifest.SupportOptional supportOptional) {
|
||||
allFiles.setAll(supportOptional.getFiles());
|
||||
if (allFiles.stream().anyMatch(ModpackFile::optional)) {
|
||||
loadOptionalFiles();
|
||||
btnOptionalFiles.setVisible(true);
|
||||
btnOptionalFiles.setManaged(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadOptionalFiles() {
|
||||
Objects.requireNonNull(manifest);
|
||||
loadingOptionalFiles.set(true);
|
||||
loadedOptionalFiles.set(false);
|
||||
Task.supplyAsync(() -> manifest.getManifest().getProvider().loadFiles(DownloadProviders.getDownloadProvider(), manifest.getManifest()))
|
||||
.whenComplete(Schedulers.javafx(), (manifest1, exception) -> {
|
||||
loadingOptionalFiles.set(false);
|
||||
List<? extends ModpackFile> files = ((ModpackManifest.SupportOptional) manifest1).getFiles();
|
||||
manifest.setManifest(manifest1);
|
||||
allFiles.setAll(files);
|
||||
if (files.stream().anyMatch(s -> s.optional() && (!s.addonQueried() || s.fileName() == null))) {
|
||||
LOG.warning("Failed to load optional files");
|
||||
} else {
|
||||
loadedOptionalFiles.set(true);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
@@ -157,21 +212,28 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
i18n("install.name.invalid"),
|
||||
i18n("message.warning"),
|
||||
MessageDialogPane.MessageType.QUESTION)
|
||||
.yesOrNo(() -> {
|
||||
controller.getSettings().put(MODPACK_NAME, name);
|
||||
controller.getSettings().put(MODPACK_CHARSET, charset);
|
||||
controller.onFinish();
|
||||
}, () -> {
|
||||
.yesOrNo(() -> finishInstall(name), () -> {
|
||||
// The user selects Cancel and does nothing.
|
||||
})
|
||||
.build());
|
||||
} else {
|
||||
controller.getSettings().put(MODPACK_NAME, name);
|
||||
controller.getSettings().put(MODPACK_CHARSET, charset);
|
||||
controller.onFinish();
|
||||
finishInstall(name);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishInstall(String name) {
|
||||
controller.getSettings().put(MODPACK_NAME, name);
|
||||
controller.getSettings().put(MODPACK_CHARSET, charset);
|
||||
controller.getSettings().put(MODPACK_EXCLUDED_FILES, getExcludedFiles());
|
||||
controller.onFinish();
|
||||
}
|
||||
|
||||
private @Nullable Set<String> getExcludedFiles() {
|
||||
if (allFiles.isEmpty())
|
||||
return null;
|
||||
return Set.copyOf(excludedFiles);
|
||||
}
|
||||
|
||||
protected void onDescribe() {
|
||||
if (manifest != null)
|
||||
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
|
||||
@@ -183,4 +245,5 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
|
||||
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
|
||||
public static final SettingsMap.Key<String> MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL");
|
||||
public static final SettingsMap.Key<Set<String>> MODPACK_EXCLUDED_FILES = new SettingsMap.Key<>("MODPACK_EXCLUDED_FILES");
|
||||
}
|
||||
@@ -90,6 +90,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
String iconUrl = settings.get(LocalModpackPage.MODPACK_ICON_URL);
|
||||
Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET);
|
||||
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);
|
||||
var excludedFiles = settings.get(LocalModpackPage.MODPACK_EXCLUDED_FILES);
|
||||
|
||||
if (isManuallyCreated) {
|
||||
return ModpackHelper.getInstallManuallyCreatedModpackTask(selected, name, charset);
|
||||
@@ -108,7 +109,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
if (serverModpackManifest != null) {
|
||||
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)));
|
||||
} else {
|
||||
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)));
|
||||
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)), excludedFiles);
|
||||
}
|
||||
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
|
||||
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
|
||||
@@ -123,7 +124,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack)
|
||||
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||
} else {
|
||||
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl)
|
||||
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl, excludedFiles)
|
||||
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
@@ -49,6 +50,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
|
||||
protected final JFXTextField txtModpackName;
|
||||
protected final JFXButton btnInstall;
|
||||
protected final JFXButton btnDescription;
|
||||
protected final JFXButton btnOptionalFiles;
|
||||
|
||||
protected ModpackPage(WizardController controller) {
|
||||
this.controller = controller;
|
||||
@@ -66,7 +68,6 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
|
||||
|
||||
txtModpackName = new JFXTextField();
|
||||
txtModpackName.setPrefWidth(300);
|
||||
// BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32));
|
||||
BorderPane.setAlignment(txtModpackName, Pos.CENTER_RIGHT);
|
||||
archiveNamePane.setRight(txtModpackName);
|
||||
}
|
||||
@@ -95,10 +96,19 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
|
||||
btnDescription.setOnAction(e -> onDescribe());
|
||||
descriptionPane.setLeft(btnDescription);
|
||||
|
||||
var installHBox = new HBox(8);
|
||||
btnOptionalFiles = FXUtils.newRaisedButton(i18n("modpack.optional_files"));
|
||||
btnOptionalFiles.setVisible(false);
|
||||
btnOptionalFiles.setManaged(false);
|
||||
installHBox.getChildren().add(btnOptionalFiles);
|
||||
|
||||
btnInstall = FXUtils.newRaisedButton(i18n("button.install"));
|
||||
btnInstall.setOnAction(e -> onInstall());
|
||||
btnInstall.disableProperty().bind(createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty()));
|
||||
descriptionPane.setRight(btnInstall);
|
||||
var nameInvalid = createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty());
|
||||
btnInstall.disableProperty().bind(nameInvalid);
|
||||
btnOptionalFiles.disableProperty().bind(nameInvalid);
|
||||
installHBox.getChildren().add(btnInstall);
|
||||
descriptionPane.setRight(installHBox);
|
||||
}
|
||||
|
||||
componentList.getContent().setAll(
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* 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.download;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.collections.SetChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.modpack.ModpackFile;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
|
||||
import org.jackhuang.hmcl.ui.construct.MDListCell;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardPage;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Wizard page that lets the user choose which optional modpack files to install.
|
||||
@NotNullByDefault
|
||||
public class OptionalFilesPage extends SpinnerPane implements WizardPage {
|
||||
|
||||
/// Fixed row height used to size [#body] to its items.
|
||||
private static final double CELL_HEIGHT = 48;
|
||||
|
||||
/// Maximum list viewport height before scrolling.
|
||||
private static final double MAX_LIST_HEIGHT = 320;
|
||||
|
||||
/// Keys of files the user unchecked; empty means all optional files will be installed.
|
||||
private final ObservableSet<String> excludedFiles;
|
||||
|
||||
/// Per-file selected state mirrored from [#excludedFiles] (`true` means install / not excluded).
|
||||
private final Map<String, BooleanProperty> selectedByKey = new HashMap<>();
|
||||
|
||||
/// Whether [#selectedByKey] is being updated from an [#excludedFiles] set change.
|
||||
private boolean updatingFromSet;
|
||||
|
||||
/// List view showing optional files.
|
||||
private final JFXListView<ModpackFile> body = new JFXListView<>();
|
||||
|
||||
/// Creates the optional-files selection page.
|
||||
///
|
||||
/// @param install callback that finishes the install wizard
|
||||
/// @param retry callback that reloads optional-file metadata
|
||||
/// @param loading whether remote metadata is still loading
|
||||
/// @param successful whether remote metadata loaded successfully
|
||||
/// @param optionalFiles optional files to display
|
||||
/// @param excludedFiles mutable set of unchecked optional file keys
|
||||
public OptionalFilesPage(
|
||||
Runnable install,
|
||||
Runnable retry,
|
||||
ObservableBooleanValue loading,
|
||||
ObservableBooleanValue successful,
|
||||
ObservableList<ModpackFile> optionalFiles,
|
||||
ObservableSet<String> excludedFiles) {
|
||||
this.excludedFiles = excludedFiles;
|
||||
excludedFiles.addListener((SetChangeListener<String>) change -> {
|
||||
updatingFromSet = true;
|
||||
try {
|
||||
if (change.wasRemoved()) {
|
||||
BooleanProperty selected = selectedByKey.get(change.getElementRemoved());
|
||||
if (selected != null)
|
||||
selected.set(true);
|
||||
}
|
||||
if (change.wasAdded()) {
|
||||
BooleanProperty selected = selectedByKey.get(change.getElementAdded());
|
||||
if (selected != null)
|
||||
selected.set(false);
|
||||
}
|
||||
} finally {
|
||||
updatingFromSet = false;
|
||||
}
|
||||
});
|
||||
|
||||
VBox borderPane = new VBox();
|
||||
borderPane.setAlignment(Pos.CENTER);
|
||||
borderPane.setMaxHeight(Region.USE_PREF_SIZE);
|
||||
FXUtils.setLimitWidth(borderPane, 500);
|
||||
|
||||
ComponentList componentList = new ComponentList();
|
||||
|
||||
Label lblRetry = new Label(i18n("modpack.retry_optional_files"));
|
||||
lblRetry.setOnMouseClicked(e -> retry.run());
|
||||
|
||||
VBox tail = new VBox();
|
||||
{
|
||||
var descPane = new BorderPane();
|
||||
HBox selectionButtons = new HBox(8);
|
||||
JFXButton btnSelectAll = FXUtils.newBorderButton(i18n("button.select_all"));
|
||||
btnSelectAll.setOnAction(e -> excludedFiles.clear());
|
||||
JFXButton btnClear = FXUtils.newBorderButton(i18n("button.clear"));
|
||||
btnClear.setOnAction(e -> {
|
||||
excludedFiles.clear();
|
||||
for (ModpackFile file : optionalFiles) {
|
||||
excludedFiles.add(file.key());
|
||||
}
|
||||
});
|
||||
selectionButtons.getChildren().setAll(btnSelectAll, btnClear);
|
||||
descPane.setLeft(selectionButtons);
|
||||
|
||||
var btnInstall = FXUtils.newRaisedButton(i18n("button.install"));
|
||||
descPane.setRight(btnInstall);
|
||||
btnInstall.setOnAction(e -> install.run());
|
||||
tail.getChildren().add(descPane);
|
||||
}
|
||||
|
||||
// ListView defaults to a large preferred height (~400) regardless of item count, which
|
||||
// leaves empty viewport space when few optional files exist. Size exactly to content —
|
||||
// do not add extra pixels, or a blank strip appears under the last row.
|
||||
body.setFixedCellSize(CELL_HEIGHT);
|
||||
body.prefHeightProperty().bind(Bindings.createDoubleBinding(
|
||||
() -> Math.min(MAX_LIST_HEIGHT, optionalFiles.size() * CELL_HEIGHT + 2),
|
||||
optionalFiles));
|
||||
body.setMaxHeight(Region.USE_PREF_SIZE);
|
||||
body.setCellFactory(it -> new OptionalFileEntry(body));
|
||||
body.setItems(optionalFiles);
|
||||
|
||||
Runnable refreshContent = () -> {
|
||||
if (successful.get()) {
|
||||
componentList.getContent().setAll(body, tail);
|
||||
} else {
|
||||
componentList.getContent().setAll(lblRetry, body, tail);
|
||||
}
|
||||
};
|
||||
refreshContent.run();
|
||||
successful.addListener((obs, oldVal, newVal) -> refreshContent.run());
|
||||
|
||||
borderPane.getChildren().setAll(componentList);
|
||||
setContent(borderPane);
|
||||
loadingProperty().bind(loading);
|
||||
}
|
||||
|
||||
/// Returns the selected property for `key`, creating and wiring it on first use.
|
||||
///
|
||||
/// @param key the file exclusion key
|
||||
/// @return a property that is `true` when the file is selected for install
|
||||
private BooleanProperty selectedProperty(String key) {
|
||||
return selectedByKey.computeIfAbsent(key, k -> {
|
||||
BooleanProperty selected = new SimpleBooleanProperty(!excludedFiles.contains(k));
|
||||
selected.addListener((obs, wasSelected, isSelected) -> {
|
||||
if (updatingFromSet)
|
||||
return;
|
||||
if (isSelected) {
|
||||
excludedFiles.remove(k);
|
||||
} else {
|
||||
excludedFiles.add(k);
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
/// List cell for one optional file with checkbox and detail button.
|
||||
private final class OptionalFileEntry extends MDListCell<ModpackFile> {
|
||||
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton infoButton = new JFXButton();
|
||||
private final HBox container = new HBox(8);
|
||||
|
||||
/// Currently bidirectionally bound selected property, or `null` when unbound.
|
||||
private @Nullable BooleanProperty boundSelected = null;
|
||||
|
||||
/// Creates a cell bound to `listView`.
|
||||
///
|
||||
/// @param listView the owning list view
|
||||
public OptionalFileEntry(JFXListView<ModpackFile> listView) {
|
||||
super(listView);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
content.setMouseTransparent(true);
|
||||
setSelectable();
|
||||
container.getChildren().setAll(checkBox, content);
|
||||
|
||||
infoButton.getStyleClass().add("toggle-icon4");
|
||||
infoButton.setGraphic(SVG.INFO.createIcon());
|
||||
container.getChildren().add(infoButton);
|
||||
getContainer().getChildren().setAll(container);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateControl(ModpackFile item, boolean empty) {
|
||||
if (empty) {
|
||||
if (boundSelected != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(boundSelected);
|
||||
boundSelected = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
String name = item.fileName();
|
||||
if (name != null) {
|
||||
content.setTitle(name);
|
||||
} else {
|
||||
content.setTitle(i18n("modpack.unknown_optional_file"));
|
||||
}
|
||||
RemoteAddon addon = item.remoteAddon();
|
||||
if (addon != null) {
|
||||
content.setSubtitle(addon.title());
|
||||
infoButton.setOnMouseClicked(e -> Controllers.dialog(new ModInfo(addon)));
|
||||
infoButton.setManaged(true);
|
||||
infoButton.setVisible(true);
|
||||
} else {
|
||||
content.setSubtitle("");
|
||||
infoButton.setOnMouseClicked(null);
|
||||
infoButton.setManaged(false);
|
||||
infoButton.setVisible(false);
|
||||
}
|
||||
BooleanProperty selected = OptionalFilesPage.this.selectedProperty(item.key());
|
||||
if (boundSelected != selected) {
|
||||
if (boundSelected != null)
|
||||
checkBox.selectedProperty().unbindBidirectional(boundSelected);
|
||||
checkBox.selectedProperty().bindBidirectional(selected);
|
||||
boundSelected = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog showing remote addon details for an optional file.
|
||||
private static final class ModInfo extends JFXDialogLayout {
|
||||
/// Creates a detail dialog for `addon`.
|
||||
///
|
||||
/// @param addon the remote addon
|
||||
public ModInfo(RemoteAddon addon) {
|
||||
HBox container = new HBox(8);
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setFitHeight(32);
|
||||
imageView.setFitWidth(32);
|
||||
spinnerPane.setContent(imageView);
|
||||
spinnerPane.setPrefSize(32, 32);
|
||||
spinnerPane.setLoading(true);
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return FXUtils.getRemoteImageTask(addon.iconUrl(), 32, 32, true, true).run();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, Schedulers.io()).thenAcceptAsync((image) -> {
|
||||
imageView.setImage(image);
|
||||
spinnerPane.setLoading(false);
|
||||
}, Schedulers.javafx());
|
||||
container.getChildren().add(spinnerPane);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.setTitle(addon.title());
|
||||
title.setSubtitle(addon.author());
|
||||
container.getChildren().add(title);
|
||||
setHeading(container);
|
||||
|
||||
Label description = new Label(addon.description());
|
||||
description.setWrapText(true);
|
||||
description.setPadding(new Insets(8, 0, 0, 0));
|
||||
setBody(description);
|
||||
|
||||
JFXHyperlink pageButton = new JFXHyperlink(i18n("mods.url"));
|
||||
pageButton.setOnAction(e -> FXUtils.openLink(addon.pageUrl()));
|
||||
getActions().add(pageButton);
|
||||
|
||||
JFXButton okButton = new JFXButton();
|
||||
okButton.getStyleClass().add("dialog-accept");
|
||||
okButton.setText(i18n("button.ok"));
|
||||
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
getActions().add(okButton);
|
||||
|
||||
onEscPressed(this, okButton::fire);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return i18n("modpack.optional_files");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(SettingsMap settings) {
|
||||
}
|
||||
}
|
||||
+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;
|
||||
@@ -158,6 +160,10 @@ public class DownloadListPage extends Control implements DecoratorPage {
|
||||
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
|
||||
}
|
||||
|
||||
public DownloadPage.DownloadCallback getCallback() {
|
||||
return callback;
|
||||
}
|
||||
|
||||
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
|
||||
retrySearch = null;
|
||||
setLoading(true);
|
||||
@@ -174,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;
|
||||
@@ -248,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -350,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;
|
||||
@@ -600,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)));
|
||||
|
||||
@@ -207,6 +207,14 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub));
|
||||
}
|
||||
|
||||
private void onBrowseSchematics() {
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
FXUtils.openFolder(gameInstance.getSchematicsDirectory());
|
||||
}
|
||||
|
||||
private void redownloadAssetIndex() {
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
@@ -358,7 +366,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> control.onBrowse("mods"), browsePopup),
|
||||
new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> control.onBrowse("resourcepacks"), browsePopup),
|
||||
new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup),
|
||||
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), () -> control.onBrowse("schematics"), browsePopup),
|
||||
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), control::onBrowseSchematics, browsePopup),
|
||||
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup),
|
||||
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup),
|
||||
new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup),
|
||||
|
||||
+22
-25
@@ -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,31 +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, 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) {
|
||||
@@ -66,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");
|
||||
@@ -82,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)));
|
||||
|
||||
@@ -95,7 +95,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
|
||||
private ModManager modManager;
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
private String gameVersion;
|
||||
|
||||
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
|
||||
|
||||
@@ -137,8 +136,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
return;
|
||||
}
|
||||
|
||||
this.gameVersion = gameInstance.getVersion().toString();
|
||||
|
||||
loadMods(gameInstance.getModManager());
|
||||
}
|
||||
|
||||
@@ -164,7 +161,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
return;
|
||||
}
|
||||
|
||||
updateSupportedLoaders(modManager);
|
||||
supportedLoaders.clear();
|
||||
supportedLoaders.addAll(modManager.getSupportedLoaders());
|
||||
|
||||
if (exception == null) {
|
||||
getItems().setAll(list);
|
||||
@@ -176,51 +174,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
|
||||
private void updateSupportedLoaders(ModManager modManager) {
|
||||
supportedLoaders.clear();
|
||||
|
||||
GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer();
|
||||
if (analyzer == null) {
|
||||
Collections.addAll(supportedLoaders, ModLoaderType.values());
|
||||
return;
|
||||
}
|
||||
|
||||
for (GameComponentType type : GameComponentType.MOD_LOADERS) {
|
||||
if (type.isModLoader() && analyzer.has(type)) {
|
||||
ModLoaderType modLoaderType = type.getModLoaderType();
|
||||
if (modLoaderType != null) {
|
||||
supportedLoaders.add(modLoaderType);
|
||||
|
||||
if (modLoaderType == ModLoaderType.CLEANROOM)
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
}
|
||||
|
||||
if (analyzer.has(GameComponentType.QUILT)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
if (analyzer.has(GameComponentType.LEGACY_FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
supportedLoaders.add(ModLoaderType.NEO_FORGE);
|
||||
}
|
||||
|
||||
// Sinytra Connector
|
||||
if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE))
|
||||
|| "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
}
|
||||
|
||||
public void add() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle(i18n("mods.add.title"));
|
||||
@@ -704,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()));
|
||||
|
||||
@@ -20,32 +20,48 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.PixelWriter;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.scene.text.Text;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalMod;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.schematic.LitematicFile;
|
||||
import org.jackhuang.hmcl.schematic.Schematic;
|
||||
import org.jackhuang.hmcl.schematic.SchematicType;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.FileNameSet;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.SynchronizedExceptionalLazy;
|
||||
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.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
@@ -55,10 +71,12 @@ import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.*;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -67,6 +85,23 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
*/
|
||||
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
private static final SynchronizedExceptionalLazy<RemoteAddon> litematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
|
||||
try {
|
||||
return ModrinthRemoteAddonRepository.getInstance().getAddonById(DownloadProviders.getDownloadProvider(), "litematica");
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to fetch litematica", e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
private static final SynchronizedExceptionalLazy<RemoteAddon> forgematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
|
||||
try {
|
||||
return ModrinthRemoteAddonRepository.getInstance().getAddonById(DownloadProviders.getDownloadProvider(), "forgematica");
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to fetch forgematica", e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
private static String translateAuthorName(String author) {
|
||||
if (I18n.isUseChinese() && "hsds".equals(author)) {
|
||||
return "黑山大叔";
|
||||
@@ -74,9 +109,22 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
return author;
|
||||
}
|
||||
|
||||
private static String translateType(SchematicType type) {
|
||||
return switch (type) {
|
||||
case SCHEM -> i18n("schematics.info.type.schem");
|
||||
case NBT_STRUCTURE -> i18n("schematics.info.type.nbt_structure");
|
||||
case LITEMATIC -> i18n("schematics.info.type.litematic");
|
||||
};
|
||||
}
|
||||
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
private Path schematicsDirectory;
|
||||
private DirItem currentDirectory;
|
||||
|
||||
private final ObjectProperty<DirItem> currentDirectory = new SimpleObjectProperty<>(this, "currentDirectory", null);
|
||||
private final BooleanProperty isRootProperty = new SimpleBooleanProperty(this, "isRoot", true);
|
||||
private final ObjectProperty<RemoteAddon> downloadTarget = new SimpleObjectProperty<>(this, "downloadTarget");
|
||||
|
||||
/// Creates a schematics list that reloads when `instanceContext` changes.
|
||||
///
|
||||
@@ -84,7 +132,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
public SchematicsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this,
|
||||
file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"),
|
||||
file -> currentDirectoryProperty().get() != null && Schematic.isFileSchematic(file),
|
||||
this::addFiles
|
||||
);
|
||||
|
||||
@@ -97,53 +145,120 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new SchematicsPageSkin();
|
||||
return new SchematicsPageSkin(this);
|
||||
}
|
||||
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
HMCLGameInstance gameInstance = instance.instance();
|
||||
this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null;
|
||||
this.gameInstance = instance.instance();
|
||||
|
||||
if (gameInstance == null) {
|
||||
this.schematicsDirectory = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.schematicsDirectory = gameInstance.getSchematicsDirectory();
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
private ObjectProperty<DirItem> currentDirectoryProperty() {
|
||||
return currentDirectory;
|
||||
}
|
||||
|
||||
public BooleanProperty isRootProperty() {
|
||||
return isRootProperty;
|
||||
}
|
||||
|
||||
public void navigateBack() {
|
||||
var d = currentDirectoryProperty().get();
|
||||
if (d == null) return;
|
||||
if (d.parent != null) navigateTo(d.parent);
|
||||
}
|
||||
|
||||
public void downloadLitematica() {
|
||||
if (downloadTarget.get() != null && gameInstance != null) {
|
||||
var modDownloads = HMCLLocalizedDownloadListPage.ofModrinthMod(org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD, false);
|
||||
modDownloads.loadInstance(HMCLGameInstance.Optional.of(gameInstance));
|
||||
Controllers.navigate(new DownloadPage(
|
||||
modDownloads,
|
||||
downloadTarget.get(),
|
||||
modDownloads.getInstanceOptional(),
|
||||
modDownloads.getCallback())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
Path schematicsDirectory = this.schematicsDirectory;
|
||||
if (schematicsDirectory == null) return;
|
||||
HMCLGameInstance currentGameInstance = this.gameInstance;
|
||||
if (currentGameInstance == null) return;
|
||||
|
||||
setLoading(true);
|
||||
Task.supplyAsync(() -> loadAll(schematicsDirectory, null))
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
setLoading(false);
|
||||
if (exception == null) {
|
||||
DirItem target = result;
|
||||
if (currentDirectory != null) {
|
||||
loop:
|
||||
for (int i = 0; i < currentDirectory.relativePath.size(); i++) {
|
||||
String dirName = currentDirectory.relativePath.get(i);
|
||||
|
||||
for (Item child : target.children) {
|
||||
if (child instanceof DirItem && child.getName().equals(dirName)) {
|
||||
target = (DirItem) child;
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
navigateTo(target);
|
||||
} else {
|
||||
LOG.warning("Failed to load schematics", exception);
|
||||
DirItem currentDir = currentDirectoryProperty().get();
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
DirItem target = loadRoot(schematicsDirectory);
|
||||
if (currentDir != null) {
|
||||
loop:
|
||||
for (String dirName : currentDir.relativePath) {
|
||||
try {
|
||||
target.preLoad();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to preload sub-directories in " + target, e);
|
||||
break;
|
||||
}
|
||||
}).start();
|
||||
for (var dirChild : target.dirChildren) {
|
||||
if (dirChild.getName().equals(dirName)) {
|
||||
target = dirChild;
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (!Objects.equals(currentGameInstance, this.gameInstance)) return;
|
||||
if (exception == null) {
|
||||
navigateTo(result);
|
||||
} else {
|
||||
LOG.warning("Failed to load schematics", exception);
|
||||
}
|
||||
setLoading(false);
|
||||
}).start();
|
||||
|
||||
downloadTarget.set(null);
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
var modManager = currentGameInstance.getModManager();
|
||||
modManager.analyze();
|
||||
var analyzer = modManager.getComponentAnalyzer();
|
||||
if (analyzer == null) return null;
|
||||
var modIds = modManager.getLocalFiles().stream()
|
||||
.map(LocalModFile::getMod)
|
||||
.map(LocalMod::getId)
|
||||
.collect(Collectors.toSet());
|
||||
if (modIds.contains("litematica") || modIds.contains("forgematica")) return null;
|
||||
var gameVersionNumber = GameVersionNumber.asGameVersion(modManager.getGameVersion());
|
||||
if (!gameVersionNumber.isAtLeast("1.12", "17w31a"))
|
||||
return null;
|
||||
if ((analyzer.has(GameComponentType.FORGE) || analyzer.has(GameComponentType.NEO_FORGE))
|
||||
&& gameVersionNumber.isAtLeast("1.16.4", "20w45a"))
|
||||
return forgematicaLazy.get();
|
||||
return litematicaLazy.get();
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (!Objects.equals(currentGameInstance, gameInstance)) return;
|
||||
if (exception == null) {
|
||||
downloadTarget.set(result);
|
||||
} else {
|
||||
LOG.warning("Failed to fetch litematica", exception);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void addFiles(List<Path> files) {
|
||||
if (currentDirectory == null)
|
||||
if (currentDirectoryProperty().get() == null)
|
||||
return;
|
||||
|
||||
Path dir = currentDirectory.path;
|
||||
Path dir = currentDirectoryProperty().get().getPath();
|
||||
try {
|
||||
// Can be executed in the background, but be careful that users can call loadVersion during this time
|
||||
Files.createDirectories(dir);
|
||||
@@ -162,7 +277,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle(i18n("schematics.add.title"));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
|
||||
i18n("extension.schematic"), "*.litematic"));
|
||||
i18n("extension.schematic"), "*.litematic", "*.nbt", "*.schematic", "*.schem"));
|
||||
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
|
||||
if (files != null && !files.isEmpty()) {
|
||||
addFiles(files);
|
||||
@@ -170,10 +285,9 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
}
|
||||
|
||||
public void onCreateDirectory() {
|
||||
if (currentDirectory == null)
|
||||
return;
|
||||
if (currentDirectoryProperty().get() == null) return;
|
||||
|
||||
Path parent = currentDirectory.path;
|
||||
Path parent = currentDirectoryProperty().get().getPath();
|
||||
|
||||
FileNameSet existingPaths;
|
||||
try {
|
||||
@@ -208,37 +322,29 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains));
|
||||
}
|
||||
|
||||
private DirItem loadAll(Path dir, @Nullable DirItem parent) {
|
||||
DirItem item = new DirItem(dir, parent);
|
||||
public void onRevealSchematicsFolder() {
|
||||
var d = currentDirectoryProperty().get();
|
||||
var p = d != null ? d.getPath() : schematicsDirectory;
|
||||
if (p != null) FXUtils.openFolder(p);
|
||||
}
|
||||
|
||||
try (Stream<Path> stream = Files.list(dir)) {
|
||||
for (Path path : Lang.toIterable(stream)) {
|
||||
if (Files.isDirectory(path)) {
|
||||
item.children.add(loadAll(path, item));
|
||||
} else if (path.getFileName().toString().endsWith(".litematic") && Files.isRegularFile(path)) {
|
||||
try {
|
||||
item.children.add(new LitematicFileItem(LitematicFile.load(path)));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load litematic file: " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (NoSuchFileException ignored) {
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load schematics in " + dir, e);
|
||||
}
|
||||
|
||||
item.children.sort(Comparator.naturalOrder());
|
||||
private DirItem loadRoot(Path dir) {
|
||||
var item = new DirItem(dir, null);
|
||||
item.load();
|
||||
return item;
|
||||
}
|
||||
|
||||
private void navigateTo(DirItem item) {
|
||||
currentDirectory = item;
|
||||
getItems().clear();
|
||||
if (item.parent != null) {
|
||||
getItems().add(new BackItem(item.parent));
|
||||
}
|
||||
getItems().addAll(item.children);
|
||||
if (currentDirectoryProperty().get() == item) return;
|
||||
currentDirectoryProperty().set(item);
|
||||
isRootProperty().set(item.parent == null);
|
||||
setLoading(true);
|
||||
Task.runAsync(item::load).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (currentDirectoryProperty().get() == item) {
|
||||
getItems().setAll(item.children);
|
||||
setLoading(false);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
abstract sealed class Item implements Comparable<Item> {
|
||||
@@ -280,12 +386,28 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
}
|
||||
}
|
||||
|
||||
private final class BackItem extends Item {
|
||||
private final class DirItem extends Item {
|
||||
final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
private final DirItem parent;
|
||||
final Path path;
|
||||
final @Nullable DirItem parent;
|
||||
final List<Item> children = new ArrayList<>();
|
||||
final List<DirItem> dirChildren = new ArrayList<>();
|
||||
final List<String> relativePath;
|
||||
int size = 0;
|
||||
boolean preLoaded = false;
|
||||
boolean loaded = false;
|
||||
|
||||
BackItem(DirItem parent) {
|
||||
DirItem(Path path, @Nullable DirItem parent) {
|
||||
this.path = path;
|
||||
this.parent = parent;
|
||||
|
||||
if (parent != null) {
|
||||
this.relativePath = new ArrayList<>(parent.relativePath);
|
||||
relativePath.add(FileUtils.getName(path));
|
||||
} else {
|
||||
this.relativePath = Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -293,65 +415,6 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
Path getPath() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
String getName() {
|
||||
return "..";
|
||||
}
|
||||
|
||||
@Override
|
||||
String getDescription() {
|
||||
return i18n("schematics.back_to", parent.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
SVG getIcon() {
|
||||
return SVG.FOLDER;
|
||||
}
|
||||
|
||||
@Override
|
||||
void onClick() {
|
||||
navigateTo(parent);
|
||||
}
|
||||
|
||||
@Override
|
||||
void onReveal() {
|
||||
throw new UnsupportedOperationException("Unreachable");
|
||||
}
|
||||
|
||||
@Override
|
||||
void onDelete() {
|
||||
throw new UnsupportedOperationException("Unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
private final class DirItem extends Item {
|
||||
final Path path;
|
||||
final @Nullable DirItem parent;
|
||||
final List<Item> children = new ArrayList<>();
|
||||
final List<String> relativePath;
|
||||
|
||||
DirItem(Path path, @Nullable DirItem parent) {
|
||||
this.path = path;
|
||||
this.parent = parent;
|
||||
|
||||
if (parent != null) {
|
||||
this.relativePath = new ArrayList<>(parent.relativePath);
|
||||
relativePath.add(path.getFileName().toString());
|
||||
} else {
|
||||
this.relativePath = Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
int order() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
Path getPath() {
|
||||
return path;
|
||||
@@ -359,12 +422,12 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return path.getFileName().toString();
|
||||
return FileUtils.getName(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
String getDescription() {
|
||||
return i18n("schematics.sub_items", children.size());
|
||||
return i18n("schematics.sub_items", size);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -372,6 +435,66 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
return SVG.FOLDER;
|
||||
}
|
||||
|
||||
void preLoad() throws IOException {
|
||||
lock.lock();
|
||||
try {
|
||||
if (this.preLoaded) return;
|
||||
this.size = 0;
|
||||
this.dirChildren.clear();
|
||||
try (Stream<Path> stream = Files.list(path)) {
|
||||
stream.forEach(p -> {
|
||||
boolean b1 = Files.isDirectory(p);
|
||||
boolean b2 = Schematic.isFileSchematic(p);
|
||||
if (b1 || b2) this.size++;
|
||||
if (b1) {
|
||||
var child = new DirItem(p, this);
|
||||
this.dirChildren.add(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.preLoaded = true;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void load() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (this.loaded) return;
|
||||
|
||||
this.children.clear();
|
||||
|
||||
preLoad();
|
||||
for (var dir : dirChildren) {
|
||||
try {
|
||||
dir.preLoad();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to pre-load schematics in " + dir.path, e);
|
||||
}
|
||||
this.children.add(dir);
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(path)) {
|
||||
stream.filter(Schematic::isFileSchematic)
|
||||
.forEach(p -> {
|
||||
try {
|
||||
this.children.add(new SchematicItem(p));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load schematic file: " + p, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.children.sort(Comparator.naturalOrder());
|
||||
this.loaded = true;
|
||||
} catch (NoSuchFileException ignored) {
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load schematics in " + path, e);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
void onClick() {
|
||||
navigateTo(this);
|
||||
@@ -385,8 +508,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
@Override
|
||||
void onDelete() {
|
||||
try {
|
||||
FileUtils.cleanDirectory(path);
|
||||
Files.deleteIfExists(path);
|
||||
FileUtils.deleteDirectory(path);
|
||||
refresh();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete directory: " + path, e);
|
||||
@@ -394,19 +516,27 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
}
|
||||
}
|
||||
|
||||
private final class LitematicFileItem extends Item {
|
||||
final LitematicFile file;
|
||||
private final class SchematicItem extends Item {
|
||||
final Path path;
|
||||
final Schematic file;
|
||||
final String name;
|
||||
final Image image;
|
||||
|
||||
private LitematicFileItem(LitematicFile file) {
|
||||
this.file = file;
|
||||
private SchematicItem(Path path) throws IOException {
|
||||
this.path = path;
|
||||
this.file = Schematic.load(path);
|
||||
|
||||
String name = file.getName();
|
||||
if (name != null && !"Unnamed".equals(name)) {
|
||||
this.name = name;
|
||||
if (file == null) throw new IOException("Unsupported or deleted file: " + path);
|
||||
|
||||
if (this.file instanceof LitematicFile lFile) {
|
||||
String name = lFile.getName();
|
||||
if (StringUtils.isNotBlank(name) && !"Unnamed".equals(name)) {
|
||||
this.name = name;
|
||||
} else {
|
||||
this.name = FileUtils.getNameWithoutExtension(path);
|
||||
}
|
||||
} else {
|
||||
this.name = StringUtils.removeSuffix(file.getFile().getFileName().toString(), ".litematic");
|
||||
this.name = FileUtils.getNameWithoutExtension(path);
|
||||
}
|
||||
|
||||
WritableImage image = null;
|
||||
@@ -430,12 +560,12 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
@Override
|
||||
int order() {
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
Path getPath() {
|
||||
return file.getFile();
|
||||
return path;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -445,7 +575,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
@Override
|
||||
String getDescription() {
|
||||
return file.getFile().getFileName().toString();
|
||||
return path.getFileName().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -469,25 +599,25 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
@Override
|
||||
void onClick() {
|
||||
Controllers.dialog(new LitematicInfoDialog());
|
||||
Controllers.dialog(new SchematicInfoDialog());
|
||||
}
|
||||
|
||||
@Override
|
||||
void onReveal() {
|
||||
FXUtils.showFileInExplorer(file.getFile());
|
||||
FXUtils.showFileInExplorer(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
void onDelete() {
|
||||
try {
|
||||
Files.deleteIfExists(file.getFile());
|
||||
Files.deleteIfExists(path);
|
||||
refresh();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete litematic file: " + file.getFile(), e);
|
||||
LOG.warning("Failed to delete schematic file: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private final class LitematicInfoDialog extends JFXDialogLayout {
|
||||
private final class SchematicInfoDialog extends JFXDialogLayout {
|
||||
private final ComponentList details;
|
||||
|
||||
private void addDetailItem(String key, Object detail) {
|
||||
@@ -497,38 +627,45 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
details.getContent().add(borderPane);
|
||||
}
|
||||
|
||||
private void updateContent(LitematicFile file) {
|
||||
private void updateContent(Schematic file) {
|
||||
details.getContent().clear();
|
||||
addDetailItem(i18n("schematics.info.name"), file.getName());
|
||||
addDetailItem(i18n("schematics.info.type"), translateType(file.getType()));
|
||||
if (StringUtils.isNotBlank(file.getAuthor()))
|
||||
addDetailItem(i18n("schematics.info.schematic_author"), translateAuthorName(file.getAuthor()));
|
||||
if (file.getTimeCreated() != null)
|
||||
addDetailItem(i18n("schematics.info.time_created"), I18n.formatDateTime(file.getTimeCreated()));
|
||||
if (file.getTimeModified() != null && !file.getTimeModified().equals(file.getTimeCreated()))
|
||||
addDetailItem(i18n("schematics.info.time_modified"), I18n.formatDateTime(file.getTimeModified()));
|
||||
if (file.getRegionCount() > 0)
|
||||
addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount()));
|
||||
if (file.getTotalVolume() > 0)
|
||||
addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume());
|
||||
if (file.getTotalBlocks() > 0)
|
||||
addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks());
|
||||
if (file.getRegionCount().isPresent())
|
||||
addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount().getAsInt()));
|
||||
if (file.getTotalVolume().isPresent())
|
||||
addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume().getAsLong());
|
||||
if (file.getTotalBlocks().isPresent())
|
||||
addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks().getAsInt());
|
||||
if (file.getEnclosingSize() != null)
|
||||
addDetailItem(i18n("schematics.info.enclosing_size"),
|
||||
String.format("%d x %d x %d", (int) file.getEnclosingSize().getX(),
|
||||
(int) file.getEnclosingSize().getY(),
|
||||
(int) file.getEnclosingSize().getZ()));
|
||||
|
||||
addDetailItem(i18n("schematics.info.version"), file.getVersion());
|
||||
String.format("%d x %d x %d", file.getEnclosingSize().x(),
|
||||
file.getEnclosingSize().y(),
|
||||
file.getEnclosingSize().z()));
|
||||
if (StringUtils.isNotBlank(file.getMinecraftVersion()))
|
||||
addDetailItem(i18n("schematics.info.mc_data_version"), file.getMinecraftVersion());
|
||||
if (file.getVersion().isPresent())
|
||||
addDetailItem(i18n("schematics.info.version"),
|
||||
file.getSubVersion().isPresent()
|
||||
? "%d.%d".formatted(file.getVersion().getAsInt(), file.getSubVersion().getAsInt())
|
||||
: file.getVersion().getAsInt()
|
||||
);
|
||||
}
|
||||
|
||||
LitematicInfoDialog() {
|
||||
SchematicInfoDialog() {
|
||||
HBox titleBox = new HBox(8);
|
||||
{
|
||||
Node icon = getIcon(40);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.setTitle(getName());
|
||||
title.setSubtitle(file.getFile().getFileName().toString());
|
||||
title.setSubtitle(path.getFileName().toString());
|
||||
|
||||
titleBox.getChildren().setAll(icon, title);
|
||||
setHeading(titleBox);
|
||||
@@ -536,10 +673,15 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
{
|
||||
this.details = new ComponentList();
|
||||
details.setStyle("-fx-effect: null;");
|
||||
StackPane detailsContainer = new StackPane();
|
||||
detailsContainer.setPadding(new Insets(10, 0, 0, 0));
|
||||
detailsContainer.getChildren().add(details);
|
||||
setBody(detailsContainer);
|
||||
ScrollPane scrollPane = new ScrollPane(detailsContainer);
|
||||
scrollPane.setFitToWidth(true);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
StackPane.setMargin(scrollPane, new Insets(10, 0, 0, 0));
|
||||
setBody(scrollPane);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -552,15 +694,16 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
onEscPressed(this, okButton::fire);
|
||||
}
|
||||
|
||||
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.6));
|
||||
this.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
|
||||
|
||||
updateContent(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Cell extends ListCell<Item> {
|
||||
private static final class Cell extends MDListCell<Item> {
|
||||
|
||||
private final RipplerContainer graphics;
|
||||
private final BorderPane root;
|
||||
private final StackPane left;
|
||||
private final TwoLineListItem center;
|
||||
private final HBox right;
|
||||
@@ -568,12 +711,17 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
private final ImageContainer iconImageView;
|
||||
private final SVGContainer iconSVGView;
|
||||
|
||||
private final BooleanProperty isFileProperty = new SimpleBooleanProperty(this, "isFile", false);
|
||||
private final BooleanProperty isDirectoryProperty = new SimpleBooleanProperty(this, "isDirectory", false);
|
||||
|
||||
private final Tooltip tooltip = new Tooltip();
|
||||
|
||||
public Cell() {
|
||||
this.root = new BorderPane();
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
root.setPadding(new Insets(8));
|
||||
public Cell(JFXListView<Item> listView) {
|
||||
super(listView);
|
||||
|
||||
var box = new HBox(8);
|
||||
box.setAlignment(Pos.CENTER_LEFT);
|
||||
box.setPickOnBounds(false);
|
||||
|
||||
{
|
||||
this.left = new StackPane();
|
||||
@@ -581,115 +729,186 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
this.iconImageView = new ImageContainer(32);
|
||||
this.iconSVGView = new SVGContainer(32);
|
||||
|
||||
BorderPane.setAlignment(left, Pos.CENTER);
|
||||
root.setLeft(left);
|
||||
}
|
||||
|
||||
{
|
||||
this.center = new TwoLineListItem();
|
||||
root.setCenter(center);
|
||||
HBox.setHgrow(center, Priority.ALWAYS);
|
||||
}
|
||||
|
||||
{
|
||||
this.right = new HBox(8);
|
||||
right.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
|
||||
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
|
||||
{
|
||||
var fo = SVG.FOLDER_OPEN.createIcon();
|
||||
var f = SVG.FOLDER.createIcon();
|
||||
btnReveal.graphicProperty().bind(Bindings.when(isDirectoryProperty).then(fo).otherwise(f));
|
||||
|
||||
var tooltip = new Tooltip();
|
||||
tooltip.textProperty().bind(Bindings.when(isDirectoryProperty).then(i18n("button.reveal_dir")).otherwise(i18n("reveal.in_file_manager")));
|
||||
FXUtils.installFastTooltip(btnReveal, tooltip);
|
||||
}
|
||||
btnReveal.setOnAction(event -> {
|
||||
Item item = getItem();
|
||||
if (item != null && !(item instanceof BackItem))
|
||||
item.onReveal();
|
||||
if (item != null) item.onReveal();
|
||||
});
|
||||
|
||||
JFXButton btnExplore = FXUtils.newToggleButton4(SVG.EXPLORE); // Change the icon if allows editing
|
||||
btnExplore.setOnAction(event -> {
|
||||
Item item = getItem();
|
||||
if (item instanceof SchematicItem) {
|
||||
try {
|
||||
Controllers.navigate(new NBTEditorPage(item.getPath()));
|
||||
} catch (IOException ignored) { // Should be impossible
|
||||
}
|
||||
}
|
||||
});
|
||||
btnExplore.visibleProperty().bind(isFileProperty);
|
||||
|
||||
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
|
||||
btnDelete.setOnAction(event -> {
|
||||
Item item = getItem();
|
||||
if (item != null && !(item instanceof BackItem)) {
|
||||
if (item != null) {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
|
||||
item::onDelete, null);
|
||||
}
|
||||
});
|
||||
|
||||
right.getChildren().setAll(btnReveal, btnDelete);
|
||||
right.getChildren().setAll(btnExplore, btnReveal, btnDelete);
|
||||
}
|
||||
|
||||
this.graphics = new RipplerContainer(root);
|
||||
FXUtils.onClicked(graphics, () -> {
|
||||
Item item = getItem();
|
||||
if (item != null)
|
||||
item.onClick();
|
||||
box.getChildren().setAll(left, center, right);
|
||||
StackPane.setMargin(box, new Insets(8));
|
||||
getContainer().getChildren().setAll(box);
|
||||
onClicked(() -> {
|
||||
var item = getItem();
|
||||
if (item != null) item.onClick();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Item item, boolean empty) {
|
||||
Item oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
protected void updateControl(Item item, boolean empty) {
|
||||
if (empty || item == null) return;
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
graphics.releaseRippleImmediately();
|
||||
iconImageView.setImage(null);
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
center.setTitle("");
|
||||
center.setSubtitle("");
|
||||
isFileProperty.set(item instanceof SchematicItem);
|
||||
isDirectoryProperty.set(item.isDirectory());
|
||||
|
||||
if (item instanceof SchematicItem fileItem && fileItem.getImage() != null) {
|
||||
iconImageView.setImage(fileItem.getImage());
|
||||
left.getChildren().setAll(iconImageView);
|
||||
} else {
|
||||
if (item instanceof LitematicFileItem fileItem && fileItem.getImage() != null) {
|
||||
iconImageView.setImage(fileItem.getImage());
|
||||
left.getChildren().setAll(iconImageView);
|
||||
} else {
|
||||
iconSVGView.setIcon(item.getIcon());
|
||||
left.getChildren().setAll(iconSVGView);
|
||||
}
|
||||
iconSVGView.setIcon(item.getIcon());
|
||||
left.getChildren().setAll(iconSVGView);
|
||||
}
|
||||
|
||||
center.setTitle(item.getName());
|
||||
center.setSubtitle(item.getDescription());
|
||||
center.setTitle(item.getName());
|
||||
center.setSubtitle(item.getDescription());
|
||||
|
||||
Path path = item.getPath();
|
||||
if (path != null) {
|
||||
tooltip.setText(FileUtils.getAbsolutePath(path));
|
||||
FXUtils.installSlowTooltip(left, tooltip);
|
||||
} else {
|
||||
tooltip.setText("");
|
||||
Tooltip.uninstall(left, tooltip);
|
||||
}
|
||||
|
||||
root.setRight(item instanceof BackItem ? null : right);
|
||||
|
||||
setGraphic(graphics);
|
||||
Path path = item.getPath();
|
||||
if (path != null) {
|
||||
tooltip.setText(FileUtils.getAbsolutePath(path));
|
||||
FXUtils.installSlowTooltip(left, tooltip);
|
||||
} else {
|
||||
Tooltip.uninstall(left, tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class SchematicsPageSkin extends ToolbarListPageSkin<Item, SchematicsPage> {
|
||||
SchematicsPageSkin() {
|
||||
super(SchematicsPage.this);
|
||||
private static final class SchematicsPageSkin extends SkinBase<SchematicsPage> {
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("schematics.empty"));
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
}
|
||||
private final JFXListView<Item> listView;
|
||||
|
||||
@Override
|
||||
protected List<Node> initializeToolbar(SchematicsPage skinnable) {
|
||||
return Arrays.asList(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles),
|
||||
createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory)
|
||||
);
|
||||
}
|
||||
SchematicsPageSkin(SchematicsPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
@Override
|
||||
protected ListCell<Item> createListCell(JFXListView<Item> listView) {
|
||||
return new Cell();
|
||||
StackPane pane = new StackPane();
|
||||
pane.setPadding(new Insets(10));
|
||||
pane.getStyleClass().addAll("notice-pane");
|
||||
|
||||
ComponentList root = new ComponentList();
|
||||
root.getStyleClass().add("no-padding");
|
||||
listView = new JFXListView<>();
|
||||
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
|
||||
{
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("schematics.empty"));
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
}
|
||||
|
||||
{
|
||||
var toolbar = new HBox();
|
||||
JFXButton btnGoBack = createToolbarButton2("", SVG.ARROW_BACK, skinnable::navigateBack);
|
||||
btnGoBack.disableProperty().bind(skinnable.isRootProperty());
|
||||
JFXButton btnDownload = createToolbarButton2(i18n("schematics.install_mod"), SVG.DOWNLOAD, skinnable::downloadLitematica);
|
||||
FXUtils.onChangeAndOperate(skinnable.downloadTarget, (t) -> btnDownload.setDisable(t == null));
|
||||
toolbar.getChildren().setAll(
|
||||
btnGoBack,
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles),
|
||||
createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::onRevealSchematicsFolder),
|
||||
btnDownload
|
||||
);
|
||||
FXUtils.setOverflowHidden(toolbar, 8);
|
||||
root.getContent().add(toolbar);
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(skinnable.loadingProperty());
|
||||
|
||||
listView.setCellFactory(x -> new Cell(listView));
|
||||
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
|
||||
// So we ignore it here
|
||||
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
{
|
||||
var relPath = new HBox();
|
||||
relPath.getStyleClass().add("jfx-tool-bar-tip");
|
||||
relPath.setAlignment(Pos.CENTER_LEFT);
|
||||
FXUtils.onChangeAndOperate(skinnable.currentDirectoryProperty(), currentDir -> {
|
||||
relPath.getChildren().clear();
|
||||
var d = currentDir;
|
||||
while (d != null) {
|
||||
if (d != currentDir) {
|
||||
var box = new HBox(new Text(">"));
|
||||
box.setPadding(new Insets(3));
|
||||
relPath.getChildren().add(0, box);
|
||||
}
|
||||
var txt = new Text(d.getName());
|
||||
var box = new HBox(txt);
|
||||
box.setPadding(new Insets(3));
|
||||
box.setMouseTransparent(true);
|
||||
var rippler = new RipplerContainer(box);
|
||||
var finalD = d;
|
||||
FXUtils.onClicked(rippler, () -> skinnable.navigateTo(finalD));
|
||||
relPath.getChildren().add(0, rippler);
|
||||
d = d.parent;
|
||||
}
|
||||
});
|
||||
var relPathPane = new HBox(relPath);
|
||||
relPathPane.setPadding(new Insets(2));
|
||||
root.getContent().add(relPathPane);
|
||||
}
|
||||
|
||||
pane.getChildren().setAll(root);
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.nio.file.Path;
|
||||
|
||||
/// @author Glavo
|
||||
public enum NBTFileType {
|
||||
COMPRESSED("nbt", "dat", "dat_old") {
|
||||
COMPRESSED("dat", "dat_old", "litematic", "nbt", "schematic", "schem") {
|
||||
@Override
|
||||
public Tag read(Path file) throws IOException {
|
||||
return NBTCodec.of().readTag(file);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.util;
|
||||
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Thread-safe exceptional lazy initialization wrapper.
|
||||
* <p>
|
||||
* On failure, the result is set to null in order to allow retrying.
|
||||
*
|
||||
* @param <T> value type
|
||||
*/
|
||||
public final class SynchronizedExceptionalLazy<T> {
|
||||
private final ExceptionalSupplier<@Nullable T, Exception> supplier;
|
||||
private volatile Result<T> result = null;
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
public SynchronizedExceptionalLazy(ExceptionalSupplier<@Nullable T, Exception> supplier) {
|
||||
this.supplier = Objects.requireNonNull(supplier);
|
||||
}
|
||||
|
||||
/// Gets and returns the result.
|
||||
///
|
||||
/// If the previous attempt succeeded, the cached value is returned directly.
|
||||
///
|
||||
/// Otherwise, the supplier is invoked to compute a new value.If this attempt fails,
|
||||
/// {@code null} is returned; if succeeded, the computed value is cached and returned.
|
||||
///
|
||||
/// @return the result, or null on failure
|
||||
public T get() {
|
||||
if (result != null) return result.value();
|
||||
lock.lock();
|
||||
try {
|
||||
if (result == null) {
|
||||
try {
|
||||
result = new Result<>(supplier.get());
|
||||
} catch (Exception e) {
|
||||
result = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return result.value();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private record Result<T>(T value) {
|
||||
}
|
||||
}
|
||||
@@ -20,19 +20,59 @@ 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;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameInstallTask;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
|
||||
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
|
||||
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
|
||||
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltInstallTask;
|
||||
import org.jackhuang.hmcl.game.HMCLModpackInstallTask;
|
||||
import org.jackhuang.hmcl.java.JavaInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
|
||||
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 {
|
||||
|
||||
@@ -152,6 +192,93 @@ public final class I18n {
|
||||
: StringUtils.capitalizeWords(loader.name());
|
||||
}
|
||||
|
||||
public static @Nullable String translateTaskName(Task<?> task) {
|
||||
if (task instanceof GameAssetDownloadTask) {
|
||||
return i18n("assets.download_all");
|
||||
} else if (task instanceof GameInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.game"));
|
||||
} else if (task instanceof CleanroomInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.cleanroom"));
|
||||
} else if (task instanceof LegacyFabricInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.legacyfabric"));
|
||||
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.forge"));
|
||||
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.neoforge"));
|
||||
} else if (task instanceof LiteLoaderInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.liteloader"));
|
||||
} else if (task instanceof OptiFineInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.optifine"));
|
||||
} else if (task instanceof FabricInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.fabric"));
|
||||
} else if (task instanceof FabricAPIInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.fabric-api"));
|
||||
} else if (task instanceof QuiltInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.quilt"));
|
||||
} else if (task instanceof QuiltAPIInstallTask) {
|
||||
return i18n("install.installer.install", i18n("install.installer.quilt-api"));
|
||||
} else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) {
|
||||
return i18n("modpack.completion");
|
||||
} else if (task instanceof ModpackInstallTask) {
|
||||
return i18n("modpack.installing");
|
||||
} else if (task instanceof ModpackUpdateTask) {
|
||||
return i18n("modpack.update");
|
||||
} else if (task instanceof CurseInstallTask) {
|
||||
return i18n("modpack.installing.given", i18n("modpack.type.curse"));
|
||||
} else if (task instanceof MultiMCModpackInstallTask) {
|
||||
return i18n("modpack.installing.given", i18n("modpack.type.multimc"));
|
||||
} else if (task instanceof ModrinthInstallTask) {
|
||||
return i18n("modpack.installing.given", i18n("modpack.type.modrinth"));
|
||||
} else if (task instanceof ServerModpackLocalInstallTask) {
|
||||
return i18n("install.installing") + ": " + i18n("modpack.type.server");
|
||||
} else if (task instanceof HMCLModpackInstallTask) {
|
||||
return i18n("modpack.installing.given", "HMCL");
|
||||
} else if (task instanceof McbbsModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
|
||||
return i18n("modpack.export");
|
||||
} else if (task instanceof MinecraftInstanceTask) {
|
||||
return i18n("modpack.scan");
|
||||
} else if (task instanceof MojangJavaDownloadTask) {
|
||||
return i18n("download.java");
|
||||
} else if (task instanceof JavaInstallTask) {
|
||||
return i18n("java.installing");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,6 +713,10 @@
|
||||
-fx-text-fill: BLACK;
|
||||
}
|
||||
|
||||
.jfx-tool-bar Text {
|
||||
|
||||
}
|
||||
|
||||
.jfx-tool-bar .jfx-options-burger {
|
||||
-fx-padding: 22px;
|
||||
}
|
||||
@@ -761,6 +765,11 @@
|
||||
-fx-padding: 10.0;
|
||||
}
|
||||
|
||||
.jfx-tool-bar-tip Text {
|
||||
-fx-font-size: 13;
|
||||
-fx-fill: -monet-on-surface;
|
||||
}
|
||||
|
||||
.jfx-decorator-button {
|
||||
-fx-max-width: 40px;
|
||||
-fx-background-radius: 5px;
|
||||
@@ -1093,6 +1102,10 @@
|
||||
-fx-border-width: 0 0 1 0;
|
||||
}
|
||||
|
||||
.md-list-cell:last {
|
||||
-fx-border-width: 0;
|
||||
}
|
||||
|
||||
.md-list-cell:selected {
|
||||
-fx-background-color: -monet-secondary-container;
|
||||
}
|
||||
|
||||
@@ -1023,6 +1023,9 @@ modpack.installing=Installing modpack
|
||||
modpack.installing.given=Installing %s modpack
|
||||
modpack.invalid=Invalid modpack, you can try downloading it again.
|
||||
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type.
|
||||
modpack.optional_files=Optional Files
|
||||
modpack.retry_optional_files=Failed to load optional files. Click here to retry.
|
||||
modpack.unknown_optional_file=Unknown file
|
||||
modpack.name=Modpack Name
|
||||
modpack.origin=Source
|
||||
modpack.origin.url=Official Website
|
||||
@@ -1330,7 +1333,6 @@ schematics=Schematics
|
||||
schematics.add=Add
|
||||
schematics.add.failed=Failed to add schematic files
|
||||
schematics.add.title=Choose schematic file you want to import
|
||||
schematics.back_to=Back to "%s"
|
||||
schematics.create_directory=New Directory
|
||||
schematics.create_directory.prompt=Please enter the new directory name
|
||||
schematics.create_directory.failed=Failed to create directory
|
||||
@@ -1346,7 +1348,13 @@ schematics.info.time_created=Created Time
|
||||
schematics.info.time_modified=Modified Time
|
||||
schematics.info.total_blocks=Total Blocks
|
||||
schematics.info.total_volume=Total Volume
|
||||
schematics.info.type=Schematic Type
|
||||
schematics.info.type.litematic=Litematic (Litematica)
|
||||
schematics.info.type.nbt_structure=NBT Structure (Vanilla, Create)
|
||||
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
|
||||
schematics.info.version=Schematic Version
|
||||
schematics.info.mc_data_version=Minecraft Data Version
|
||||
schematics.install_mod=Install Litematica Mod
|
||||
schematics.manage=Schematics
|
||||
schematics.sub_items=%d sub-item(s)
|
||||
|
||||
|
||||
@@ -1024,6 +1024,9 @@ modpack.installing=جارٍ تثبيت حزمة المودات
|
||||
modpack.installing.given=جارٍ تثبيت حزمة المودات %s
|
||||
modpack.invalid=حزمة مودات غير صالحة، يمكنك محاولة تنزيلها مجدداً.
|
||||
modpack.mismatched_type=نوع حزمة المودات غير متطابق، النسخة الحالية من نوع %1$s، لكن الحزمة المقدمة من نوع %2$s.
|
||||
modpack.optional_files=ملفات اختيارية
|
||||
modpack.retry_optional_files=فشل تحميل الملفات الاختيارية. انقر لإعادة المحاولة.
|
||||
modpack.unknown_optional_file=ملف غير معروف
|
||||
modpack.name=اسم حزمة المودات
|
||||
modpack.origin=المصدر
|
||||
modpack.origin.url=الموقع الرسمي
|
||||
@@ -1330,7 +1333,6 @@ schematics=المخططات
|
||||
schematics.add=إضافة
|
||||
schematics.add.failed=فشل إضافة ملفات المخططات
|
||||
schematics.add.title=اختر ملف المخطط الذي تريد استيراده
|
||||
schematics.back_to=العودة إلى "%s"
|
||||
schematics.create_directory=مجلد جديد
|
||||
schematics.create_directory.prompt=يُرجى إدخال اسم المجلد الجديد
|
||||
schematics.create_directory.failed=فشل إنشاء المجلد
|
||||
@@ -1346,7 +1348,13 @@ schematics.info.time_created=وقت الإنشاء
|
||||
schematics.info.time_modified=وقت التعديل
|
||||
schematics.info.total_blocks=إجمالي البلوكات
|
||||
schematics.info.total_volume=الحجم الكلي
|
||||
# schematics.info.type=
|
||||
# schematics.info.type.litematic=
|
||||
# schematics.info.type.nbt_structure=
|
||||
# schematics.info.type.schem=
|
||||
schematics.info.version=إصدار المخطط
|
||||
# schematics.info.mc_data_version=
|
||||
# schematics.install_mod=
|
||||
schematics.manage=المخططات
|
||||
schematics.sub_items=%d عنصر فرعي
|
||||
|
||||
|
||||
@@ -1023,6 +1023,9 @@ modpack.installing=Modpack wird installiert
|
||||
modpack.installing.given=Installiere %s Modpack
|
||||
modpack.invalid=Ungültiges Modpack, Sie können versuchen, es erneut herunterzuladen.
|
||||
modpack.mismatched_type=Modpack-Typ stimmt nicht überein, die aktuelle Instanz ist ein(e) %1$s Typ, aber der bereitgestellte Typ ist %2$s.
|
||||
modpack.optional_files=Optionale Dateien
|
||||
modpack.retry_optional_files=Optionale Dateien konnten nicht geladen werden. Hier klicken, um es erneut zu versuchen.
|
||||
modpack.unknown_optional_file=Unbekannte Datei
|
||||
modpack.name=Modpack-Name
|
||||
modpack.origin=Quelle
|
||||
modpack.origin.url=Offizielle Website
|
||||
@@ -1329,7 +1332,6 @@ schematics=Schematiken
|
||||
schematics.add=Hinzufügen
|
||||
schematics.add.failed=Fehler beim Hinzufügen der Schematik-Dateien
|
||||
schematics.add.title=Wählen Sie die Schematik-Datei, die Sie importieren möchten
|
||||
schematics.back_to=Zurück zu "%s"
|
||||
schematics.create_directory=Neues Verzeichnis
|
||||
schematics.create_directory.prompt=Bitte geben Sie den neuen Verzeichnisnamen ein
|
||||
schematics.create_directory.failed=Fehler beim Erstellen des Verzeichnisses
|
||||
@@ -1345,7 +1347,13 @@ schematics.info.time_created=Erstellungszeit
|
||||
schematics.info.time_modified=Änderungszeit
|
||||
schematics.info.total_blocks=Gesamte Blöcke
|
||||
schematics.info.total_volume=Gesamtes Volumen
|
||||
# schematics.info.type=
|
||||
# schematics.info.type.litematic=
|
||||
# schematics.info.type.nbt_structure=
|
||||
# schematics.info.type.schem=
|
||||
schematics.info.version=Schematik-Version
|
||||
# schematics.info.mc_data_version=
|
||||
# schematics.install_mod=
|
||||
schematics.manage=Schematiken
|
||||
schematics.sub_items=%d Unterelement(e)
|
||||
|
||||
|
||||
@@ -1016,6 +1016,9 @@ modpack.installing=Instalando modpack
|
||||
modpack.installing.given=Instalando %s modpack
|
||||
modpack.invalid=Modpack inválido, puede intentar volver a descargarlo.
|
||||
modpack.mismatched_type=Tipo de modpack erróneo, la instancia actual es del tipo %s, pero la proporcionada es del tipo %s.
|
||||
modpack.optional_files=Archivos opcionales
|
||||
modpack.retry_optional_files=No se pudieron cargar los archivos opcionales. Haz clic para reintentar.
|
||||
modpack.unknown_optional_file=Archivo desconocido
|
||||
modpack.name=Nombre del modpack
|
||||
modpack.origin=Fuente
|
||||
modpack.origin.url=Sitio web oficial
|
||||
@@ -1322,7 +1325,6 @@ schematics=Esquemas
|
||||
# schematics.add=
|
||||
schematics.add.failed=No se han podido añadir archivos de esquema
|
||||
# schematics.add.title=
|
||||
schematics.back_to=Atrás a «%s»
|
||||
# schematics.create_directory=
|
||||
schematics.create_directory.prompt=Introduzca el nuevo nombre del directorio
|
||||
schematics.create_directory.failed=No se ha podido crear el directorio
|
||||
@@ -1338,7 +1340,13 @@ schematics.info.time_created=Creado
|
||||
schematics.info.time_modified=Modificado
|
||||
schematics.info.total_blocks=Bloques totales
|
||||
schematics.info.total_volume=Volumen total
|
||||
# schematics.info.type=
|
||||
# schematics.info.type.litematic=
|
||||
# schematics.info.type.nbt_structure=
|
||||
# schematics.info.type.schem=
|
||||
schematics.info.version=Versión de esquema
|
||||
# schematics.info.mc_data_version=
|
||||
# schematics.install_mod=
|
||||
schematics.manage=Esquemas
|
||||
schematics.sub_items=%d elemento(s)
|
||||
|
||||
|
||||
@@ -826,6 +826,9 @@ modpack.installing=modpackのインストール
|
||||
# modpack.installing.given=
|
||||
modpack.invalid=無効なmodpackファイル。
|
||||
modpack.mismatched_type=不適切なmodpackタイプ、現在のゲームは %s modpackですが、更新ファイルは %s modpackです。
|
||||
modpack.optional_files=オプションファイル
|
||||
modpack.retry_optional_files=オプションファイルの読み込みに失敗しました。クリックして再試行してください。
|
||||
modpack.unknown_optional_file=不明なファイル
|
||||
modpack.name=Modpack名
|
||||
modpack.origin=Origin
|
||||
modpack.origin.url=公式ウェブサイト
|
||||
@@ -1126,7 +1129,6 @@ reveal.in_file_manager=ファイルマネージャーで表示する
|
||||
# schematics.add=
|
||||
# schematics.add.failed=
|
||||
# schematics.add.title=
|
||||
# schematics.back_to=
|
||||
# schematics.create_directory=
|
||||
# schematics.create_directory.prompt=
|
||||
# schematics.create_directory.failed=
|
||||
@@ -1142,7 +1144,13 @@ reveal.in_file_manager=ファイルマネージャーで表示する
|
||||
# schematics.info.time_modified=
|
||||
# schematics.info.total_blocks=
|
||||
# schematics.info.total_volume=
|
||||
# schematics.info.type=
|
||||
# schematics.info.type.litematic=
|
||||
# schematics.info.type.nbt_structure=
|
||||
# schematics.info.type.schem=
|
||||
# schematics.info.version=
|
||||
# schematics.info.mc_data_version=
|
||||
# schematics.install_mod=
|
||||
# schematics.manage=
|
||||
# schematics.sub_items=
|
||||
|
||||
|
||||
@@ -838,6 +838,9 @@ modpack.installing=裝改囊集
|
||||
modpack.installing.given=裝 %s 改囊集
|
||||
modpack.invalid=無效之改囊集新之案。殆引之誤也。
|
||||
modpack.mismatched_type=不合改囊集之類。夫戲爲「%s」改囊集,顧所供改囊集之案爲「%s」改囊集。\n君可求助於右上之鈕。
|
||||
modpack.optional_files=可選之檔
|
||||
modpack.retry_optional_files=可選檔載入敗,點此再試
|
||||
modpack.unknown_optional_file=未知之檔
|
||||
modpack.name=改囊集名
|
||||
modpack.origin=源
|
||||
modpack.origin.url=官網
|
||||
@@ -1138,7 +1141,6 @@ schematics=蜃圖
|
||||
schematics.add=增蜃圖
|
||||
schematics.add.failed=增蜃圖未成
|
||||
# schematics.add.title=
|
||||
schematics.back_to=退至「%s」
|
||||
schematics.create_directory=增案夾
|
||||
schematics.create_directory.prompt=書新案夾之名
|
||||
schematics.create_directory.failed=增案夾未成
|
||||
@@ -1154,7 +1156,13 @@ schematics.info.time_created=創之時辰
|
||||
schematics.info.time_modified=改之時辰
|
||||
schematics.info.total_blocks=總塊數
|
||||
schematics.info.total_volume=總體積
|
||||
# schematics.info.type=
|
||||
# schematics.info.type.litematic=
|
||||
# schematics.info.type.nbt_structure=
|
||||
# schematics.info.type.schem=
|
||||
schematics.info.version=蜃圖之版
|
||||
# schematics.info.mc_data_version=
|
||||
# schematics.install_mod=
|
||||
schematics.manage=司蜃圖
|
||||
schematics.sub_items=%d 子项
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -978,6 +978,9 @@ modpack.installing=Встановлення модпака
|
||||
modpack.installing.given=Встановлення модпака %s
|
||||
modpack.invalid=Недійсний модпак, ви можете спробувати завантажити його знову.
|
||||
modpack.mismatched_type=Тип модпака не відповідає, поточний екземпляр має тип %s, але наданий має тип %s.
|
||||
modpack.optional_files=Необов’язкові файли
|
||||
modpack.retry_optional_files=Не вдалося завантажити необов’язкові файли. Натисніть, щоб повторити.
|
||||
modpack.unknown_optional_file=Невідомий файл
|
||||
modpack.name=Назва модпака
|
||||
modpack.origin=Джерело
|
||||
modpack.origin.url=Офіційний сайт
|
||||
@@ -1285,7 +1288,6 @@ schematics=Схематики
|
||||
schematics.add=Додати
|
||||
schematics.add.failed=Не вдалося додати файли схематик
|
||||
schematics.add.title=Виберіть файл схеми, який ви хочете імпортувати
|
||||
schematics.back_to=Назад до "%s"
|
||||
schematics.create_directory=Новий каталог
|
||||
schematics.create_directory.prompt=Введіть нову назву каталогу
|
||||
schematics.create_directory.failed=Не вдалося створити каталог
|
||||
@@ -1301,7 +1303,13 @@ schematics.info.time_created=Час створення
|
||||
schematics.info.time_modified=Час зміни
|
||||
schematics.info.total_blocks=Всього блоків
|
||||
schematics.info.total_volume=Загальний об'єм
|
||||
# schematics.info.type=
|
||||
# schematics.info.type.litematic=
|
||||
# schematics.info.type.nbt_structure=
|
||||
# schematics.info.type.schem=
|
||||
schematics.info.version=Версія схематики
|
||||
# schematics.info.mc_data_version=
|
||||
# schematics.install_mod=
|
||||
schematics.manage=Схематики
|
||||
schematics.sub_items=%d підпунктів
|
||||
|
||||
|
||||
@@ -841,6 +841,9 @@ modpack.installing=安装整合包
|
||||
modpack.installing.given=安装 %s 整合包
|
||||
modpack.invalid=无效的整合包升级文件。可能是下载时出现问题。
|
||||
modpack.mismatched_type=整合包类型不匹配。当前游戏为“%s”整合包,但是提供的整合包更新文件为“%s”整合包。\n如遇到问题,你可以点击右上角帮助按钮进行求助。
|
||||
modpack.optional_files=可选文件
|
||||
modpack.retry_optional_files=可选文件加载失败,点击重试
|
||||
modpack.unknown_optional_file=未知文件
|
||||
modpack.name=整合包名称
|
||||
modpack.origin=来源
|
||||
modpack.origin.url=官方网站
|
||||
@@ -1142,7 +1145,6 @@ schematics=原理图
|
||||
schematics.add=添加原理图
|
||||
schematics.add.failed=添加原理图失败
|
||||
schematics.add.title=选择要添加的原理图文件
|
||||
schematics.back_to=返回到“%s”
|
||||
schematics.create_directory=创建文件夹
|
||||
schematics.create_directory.prompt=请输入新文件夹名称
|
||||
schematics.create_directory.failed=创建文件夹失败
|
||||
@@ -1158,7 +1160,13 @@ schematics.info.time_created=创建时间
|
||||
schematics.info.time_modified=修改时间
|
||||
schematics.info.total_blocks=总方块数
|
||||
schematics.info.total_volume=总体积
|
||||
schematics.info.type=原理图类型
|
||||
schematics.info.type.litematic=Litematic (Litematica)
|
||||
schematics.info.type.nbt_structure=NBT 结构 (原版, 机械动力)
|
||||
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
|
||||
schematics.info.version=原理图版本
|
||||
schematics.info.mc_data_version=Minecraft 数据版本
|
||||
schematics.install_mod=安装投影模组
|
||||
schematics.manage=原理图管理
|
||||
schematics.sub_items=%d 个子项
|
||||
|
||||
|
||||
@@ -835,6 +835,9 @@ modpack.installing=安裝模組包
|
||||
modpack.installing.given=安裝 %s 模組包
|
||||
modpack.invalid=無效的模組包升級檔案。可能是下載時出現問題。
|
||||
modpack.mismatched_type=模組包類型不符。目前遊戲是「%s」模組包,但是提供的模組包更新檔案是「%s」模組包。
|
||||
modpack.optional_files=可選檔案
|
||||
modpack.retry_optional_files=可選檔案載入失敗,點擊重試
|
||||
modpack.unknown_optional_file=未知檔案
|
||||
modpack.name=模組包名稱
|
||||
modpack.origin=來源
|
||||
modpack.origin.url=官方網站
|
||||
@@ -1136,7 +1139,6 @@ schematics=原理圖
|
||||
schematics.add=新增原理圖
|
||||
schematics.add.failed=新增原理圖失敗
|
||||
schematics.add.title=選取要新增的原理圖檔案
|
||||
schematics.back_to=返回到「%s」
|
||||
schematics.create_directory=建立目錄
|
||||
schematics.create_directory.prompt=請輸入新目錄名稱
|
||||
schematics.create_directory.failed=建立目錄失敗
|
||||
@@ -1152,7 +1154,13 @@ schematics.info.time_created=建立時間
|
||||
schematics.info.time_modified=修改時間
|
||||
schematics.info.total_blocks=總方塊數
|
||||
schematics.info.total_volume=總體積
|
||||
schematics.info.type=原理圖類型
|
||||
schematics.info.type.litematic=Litematic (Litematica)
|
||||
schematics.info.type.nbt_structure=NBT 結構 (原版, 機械動力)
|
||||
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
|
||||
schematics.info.version=原理圖版本
|
||||
schematics.info.mc_data_version=Minecraft 資料版本
|
||||
schematics.install_mod=安裝投影模組
|
||||
schematics.manage=原理圖管理
|
||||
schematics.sub_items=%d 個子項
|
||||
|
||||
|
||||
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;;
|
||||
@@ -1045,6 +1045,138 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "101c753b129dbdeed565798d259b2b9544ef3878",
|
||||
"size": 126343
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "b558d38d8eaa32831bb66835aae163428fe69646",
|
||||
"size": 235928
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-openal:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "f2083556a7d8a02e4abeb2b933642250ef06c61d",
|
||||
"size": 866036
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "84f229d793388be57ae5555b0cfee09b5990fa20",
|
||||
"size": 80850
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-glfw:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "e2f84903e71f7f2c5fe11a0f6de9576da270c503",
|
||||
"size": 144552
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-stb:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "74f1cd22ccf4fd7432b982372b8a34c93cd9aacf",
|
||||
"size": 258996
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "97296e394d3d7dfebcc68754ab8273d44d8e9602",
|
||||
"size": 45345
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "6babd5ddf9b4850e91a952d494cfd22b895ce200",
|
||||
"size": 1300898
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "f17eb9b12086809a31e61734dabc1dea80f6b7b2",
|
||||
"size": 3540777
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "85d2636eb1368a3864fb10faec02a4269020b31e",
|
||||
"size": 1166740
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-vma:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "0a9e20e7d8ae4d0106cd23ae58a1b72ca151c49f",
|
||||
"size": 60863
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-linux-arm64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-arm64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-arm64.jar",
|
||||
"sha1": "577e41b6d0006f3d3f841c14abe19736e695a760",
|
||||
"size": 1460461
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl.lwjgl:lwjgl-platform:2.9.0:natives": {
|
||||
"name": "org.glavo.hmcl:lwjgl2-natives:2.9.3",
|
||||
"downloads": {
|
||||
@@ -2956,6 +3088,181 @@
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-sdl:3.4.2:natives-linux": null,
|
||||
"org.lwjgl:lwjgl:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar",
|
||||
"sha1": "6105690714874b995aa7812e4f7732a21109276c",
|
||||
"size": 1157957
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
|
||||
"name": "org.glavo.hmcl:lwjgl3-natives:3.4.1-rc1",
|
||||
"downloads": {
|
||||
"classifiers": {
|
||||
"linux-loongarch64": {
|
||||
"path": "org/glavo/hmcl/lwjgl3-natives/3.4.1-rc1/lwjgl3-natives-3.4.1-rc1-linux-loongarch64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/glavo/hmcl/lwjgl3-natives/3.4.1-rc1-linux-loongarch64/lwjgl3-natives-3.4.1-rc1-linux-loongarch64.jar",
|
||||
"sha1": "3fed872fc2f9929d2be25389dd312dfb48e9f89e",
|
||||
"size": 22854580
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract": {
|
||||
"exclude": [
|
||||
"META-INF/"
|
||||
]
|
||||
},
|
||||
"natives": {
|
||||
"linux": "linux-loongarch64"
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-jemalloc:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-jemalloc:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-jemalloc/3.4.1/lwjgl-jemalloc-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.1/lwjgl-jemalloc-3.4.1.jar",
|
||||
"sha1": "16b663092854fc6adfe0a941e61cae2bb2e437b6",
|
||||
"size": 47872
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-openal:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-openal:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-openal/3.4.1/lwjgl-openal-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.1/lwjgl-openal-3.4.1.jar",
|
||||
"sha1": "8508bd60589de0539aa465b78fcf838efd07ae9a",
|
||||
"size": 153919
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-opengl:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-opengl:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-3.4.1.jar",
|
||||
"sha1": "830412cab823c029cda6b9729f9d2ed36cf79959",
|
||||
"size": 937660
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-glfw:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-glfw:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-glfw/3.4.1/lwjgl-glfw-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.1/lwjgl-glfw-3.4.1.jar",
|
||||
"sha1": "a782b1ddd175c9107bf300fd92920579ea65e2a4",
|
||||
"size": 151893
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-stb:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-stb:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-3.4.1.jar",
|
||||
"sha1": "f077c3dafc31924fbe8acb0b913f08977ba27f8e",
|
||||
"size": 142927
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-tinyfd:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-tinyfd:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-3.4.1.jar",
|
||||
"sha1": "562697d40dbd2ed0424bf24dcf65d51170f81adf",
|
||||
"size": 15931
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-freetype:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-freetype:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-freetype/3.4.1/lwjgl-freetype-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.1/lwjgl-freetype-3.4.1.jar",
|
||||
"sha1": "caa75a37e857efc9b0686e434d708d6420c5b6ea",
|
||||
"size": 464342
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-shaderc:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-shaderc:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-shaderc/3.4.1/lwjgl-shaderc-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.1/lwjgl-shaderc-3.4.1.jar",
|
||||
"sha1": "5c02c88f0d879529014c78463b76f43fdecfd272",
|
||||
"size": 153051
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-spvc:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-spvc:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-spvc/3.4.1/lwjgl-spvc-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.1/lwjgl-spvc-3.4.1.jar",
|
||||
"sha1": "188553592e97b5ab2dba54a1b403ee80b48dad4c",
|
||||
"size": 148285
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-vma:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-vma:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-3.4.1.jar",
|
||||
"sha1": "2c54ce10154feb1ede4d23c5947438cdbfdcc003",
|
||||
"size": 109209
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": null,
|
||||
"org.lwjgl:lwjgl-vulkan:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-vulkan:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar",
|
||||
"sha1": "d0601fdc38890755e5638048c14cc0f7b990964b",
|
||||
"size": 8356700
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-sdl:3.4.3": {
|
||||
"name": "org.lwjgl:lwjgl-sdl:3.4.1",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-sdl/3.4.1/lwjgl-sdl-3.4.1.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.1/lwjgl-sdl-3.4.1.jar",
|
||||
"sha1": "ec28db6e949478044c649fea25eae43b1b161971",
|
||||
"size": 983498
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": null,
|
||||
"net.java.dev.jna:jna:5.8.0": {
|
||||
"name": "net.java.dev.jna:jna:5.13.0",
|
||||
"downloads": {
|
||||
@@ -4857,6 +5164,138 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "38374c04f498a9b214f7cb28cfd3fcc9737b8065",
|
||||
"size": 127129
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "79b62b3efdcdba28d2f89e6a7d99d88cf3cda48f",
|
||||
"size": 248363
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-openal:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "f4d3d1050f244b3a7fe9ddc02e94fbe0c507fc4d",
|
||||
"size": 938685
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "56ecca3a33c7966f3d678737757f5feb3efd03c2",
|
||||
"size": 79430
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-glfw:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "7f88d98db5c2fdd33063b5810df8a9809fd0983b",
|
||||
"size": 141071
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-stb:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "8f0aeb06e1d532fd9411d08dcff6c0c3572d71a6",
|
||||
"size": 268102
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "f9b6c895de7f43caf50c12b8d30fa3aef13e82ff",
|
||||
"size": 56940
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "809c6df123ae9ea36aa3117a368e7d300ed712eb",
|
||||
"size": 1409111
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "1cb0fc92286da27d5782140a8ba72af5458eb1cf",
|
||||
"size": 3702544
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "38610faedc2a7c44f02c0bc7246ef1f8357a2846",
|
||||
"size": 1221331
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-vma:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "e4dc73c846d42eb63c39c2b30d7cb5983a59bdbe",
|
||||
"size": 66532
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-linux-riscv64",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-riscv64.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-riscv64.jar",
|
||||
"sha1": "30cd5ef9665905be2c0c7325dc848296d4788b49",
|
||||
"size": 1496337
|
||||
}
|
||||
}
|
||||
},
|
||||
"com.github.oshi:oshi-core:6.6.5": {
|
||||
"name": "com.github.oshi:oshi-core:6.8.0",
|
||||
"downloads": {
|
||||
@@ -7054,6 +7493,138 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "fbab1de5df7db20be4c74bf6cf14750f599e6d79",
|
||||
"size": 110742
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "30a905bf255a0eac294da13cdfddb2963bda4541",
|
||||
"size": 160297
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-openal:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "6f72cdd56726eacaaa1e4f5824f9fc88f0bc2272",
|
||||
"size": 851111
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "97992b72536b99ad922ac1318f397e049de50223",
|
||||
"size": 83069
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-glfw:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "783d56acc7b0bc5690f4d139d843d5cc083a7ef9",
|
||||
"size": 108309
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-stb:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "0422952f19f24b9a10a29fcc44ac77ed8b842b6a",
|
||||
"size": 225716
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-tinyfd:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "5650a5b1cddce100f5c13761f7e4a5a6cf248320",
|
||||
"size": 40582
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "3938689ef674b5894b41af23de67aa53cf5c6a58",
|
||||
"size": 1217752
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "843c2c6b1588175e28eefe260d3833244119607b",
|
||||
"size": 3063282
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "f66777d0f5e784c203fda965e28e6f41f6287eed",
|
||||
"size": 1122077
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-vma:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "b160d451c6224139bcb47d565dac76b1d242cf2b",
|
||||
"size": 63336
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": {
|
||||
"name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-freebsd",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-freebsd.jar",
|
||||
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-freebsd.jar",
|
||||
"sha1": "aad4d48eb5b3b72d550589fa6d07bbc6113b442e",
|
||||
"size": 1242611
|
||||
}
|
||||
}
|
||||
},
|
||||
"net.java.jinput:jinput-platform:2.0.5:natives": null,
|
||||
"com.mojang:text2speech:1.10.3:natives": null,
|
||||
"com.mojang:text2speech:1.11.3:natives": null,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.jackhuang.hmcl.addon.meta.*;
|
||||
import org.jackhuang.hmcl.game.DefaultGameInstance;
|
||||
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.NoSuchGameInstanceException;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
@@ -65,7 +66,9 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
READERS = map;
|
||||
}
|
||||
|
||||
private String gameVersion;
|
||||
private final HashMap<Pair<String, ModLoaderType>, LocalMod> localMods = new HashMap<>();
|
||||
private final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
|
||||
private GameComponentAnalyzer analyzer;
|
||||
|
||||
private boolean loaded = false;
|
||||
@@ -83,7 +86,12 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
}
|
||||
|
||||
public GameComponentAnalyzer getComponentAnalyzer() {
|
||||
return analyzer;
|
||||
lock.lock();
|
||||
try {
|
||||
return analyzer;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public LocalMod getLocalMod(String modId, ModLoaderType modLoaderType) {
|
||||
@@ -105,6 +113,24 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
}
|
||||
}
|
||||
|
||||
public String getGameVersion() {
|
||||
lock.lock();
|
||||
try {
|
||||
return gameVersion;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public EnumSet<ModLoaderType> getSupportedLoaders() {
|
||||
lock.lock();
|
||||
try {
|
||||
return EnumSet.copyOf(supportedLoaders);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void addModInfo(Path file) {
|
||||
String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION);
|
||||
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
|
||||
@@ -176,6 +202,18 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
}
|
||||
}
|
||||
|
||||
public void analyze() throws IOException {
|
||||
lock.lock();
|
||||
try {
|
||||
gameVersion = instance.getVersion().toString();
|
||||
analyzer = instance.getAnalyzer();
|
||||
} catch (NoSuchGameInstanceException e) {
|
||||
throw new IOException(e);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refresh() throws IOException {
|
||||
lock.lock();
|
||||
@@ -183,7 +221,7 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
localFiles.clear();
|
||||
localMods.clear();
|
||||
|
||||
analyzer = instance.getAnalyzer();
|
||||
analyze();
|
||||
|
||||
boolean supportSubfolders = analyzer.has(GameComponentType.FORGE)
|
||||
|| analyzer.has(GameComponentType.QUILT)
|
||||
@@ -205,6 +243,9 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSupportedLoaders();
|
||||
|
||||
loaded = true;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
@@ -254,6 +295,50 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSupportedLoaders() {
|
||||
supportedLoaders.clear();
|
||||
|
||||
if (this.analyzer == null) {
|
||||
Collections.addAll(supportedLoaders, ModLoaderType.values());
|
||||
return;
|
||||
}
|
||||
|
||||
for (GameComponentType type : GameComponentType.values()) {
|
||||
if (type.isModLoader() && this.analyzer.has(type)) {
|
||||
ModLoaderType modLoaderType = type.getModLoaderType();
|
||||
if (modLoaderType != null) {
|
||||
supportedLoaders.add(modLoaderType);
|
||||
|
||||
if (modLoaderType == ModLoaderType.CLEANROOM)
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
}
|
||||
|
||||
if (this.analyzer.has(GameComponentType.QUILT)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
if (this.analyzer.has(GameComponentType.LEGACY_FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
if (this.analyzer.has(GameComponentType.FABRIC) && hasMod("kilt", ModLoaderType.FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
supportedLoaders.add(ModLoaderType.NEO_FORGE);
|
||||
}
|
||||
|
||||
// Sinytra Connector
|
||||
if (this.analyzer.has(GameComponentType.NEO_FORGE) && (hasMod("connector", ModLoaderType.NEO_FORGE) || hasMod("connectormod", ModLoaderType.NEO_FORGE))
|
||||
|| "1.20.1".equals(gameVersion) && this.analyzer.has(GameComponentType.FORGE) && hasMod("connectormod", ModLoaderType.FORGE)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
}
|
||||
|
||||
public void rollback(LocalModFile from, LocalModFile to) throws IOException {
|
||||
lock.lock();
|
||||
try {
|
||||
|
||||
+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) {
|
||||
}
|
||||
|
||||
|
||||
+50
-47
@@ -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) {
|
||||
@@ -213,8 +208,15 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
|
||||
|
||||
@Override
|
||||
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
||||
String sha1 = DigestUtils.digestToString("SHA-1", file);
|
||||
return getRemoteVersionBySHA1(DigestUtils.digestToString("SHA-1", file));
|
||||
}
|
||||
|
||||
/// Looks up a Modrinth version by file SHA-1 hash.
|
||||
///
|
||||
/// @param sha1 the SHA-1 digest of the file
|
||||
/// @return the matching remote version, or empty when not found
|
||||
/// @throws IOException if the Modrinth request fails for a reason other than 404 / missing file
|
||||
public Optional<RemoteAddon.Version> getRemoteVersionBySHA1(String sha1) throws IOException {
|
||||
SEMAPHORE.acquireUninterruptibly();
|
||||
try {
|
||||
ProjectVersion projectVersion = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1,
|
||||
@@ -239,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) {
|
||||
@@ -296,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) {
|
||||
@@ -327,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) {
|
||||
@@ -359,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;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user