Merge pull request 'Remove official groups, multiplayer, downloads, help, and self-update' (#1)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 2m20s
Java CI / build (push) Failing after 4m0s

Merge pull request #1: Remove official groups, multiplayer, downloads, help, and self-update

Server-client HMCL fork cleanup for TDLM.
This commit was merged in pull request #1.
This commit is contained in:
layue13 committed 2026-11-07 18:56:23 +00:00
commit 5e2da98061
116 files changed
+517 -9738

No files matched your search

-26
View File
@@ -1,4 +1,3 @@
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
@@ -248,10 +247,6 @@ tasks.processResources {
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
inputs.property("terracotta_version", libs.versions.terracotta)
doLast {
upgradeTerracottaConfig.get().checkValid()
}
}
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
@@ -405,27 +400,6 @@ tasks.register<JavaExec>("run") {
}
}
// terracotta
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(
listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
)
)
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
templateFile.set(source)
outputFile.set(destination)
}
// Check Translations
@@ -26,8 +26,6 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
@@ -127,7 +125,6 @@ public final class Launcher extends Application {
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
UpdateChecker.init();
primaryStage.show();
});
@@ -265,11 +262,6 @@ public final class Launcher extends Application {
}
public static void main(String[] args) {
if (UpdateHandler.processArguments(args)) {
LOG.shutdown();
return;
}
Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER);
AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false));
@@ -33,18 +33,10 @@ public final class Metadata {
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
public static final int RECOMMENDED_JAVA_VERSION = 21;
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
public static final String REPOSITORY_URL = "https://github.com/HMCL-dev/HMCL";
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
public static final int RECOMMENDED_JAVA_VERSION = 25;
public static final String DOCS_URL = "https://docs.hmcl.net";
public static final String CONTACT_URL = DOCS_URL + "/help.html";
public static final String CHANGELOG_URL = DOCS_URL + "/changelog/";
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
@@ -97,7 +89,7 @@ public final class Metadata {
public static @Nullable String getSuggestedJavaDownloadLink() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html";
return "https://www.loongnix.cn/zh/api/java/downloads-jdk25/index.html";
else {
EnumSet<Architecture> supportedArchitectures;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
@@ -18,32 +18,65 @@ import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/// @author Glavo
@NotNullByDefault
public final class HMCLDownloadProvider extends DownloadProvider {
private volatile DownloadSource versionListSource = DownloadSource.DEFAULT;
private volatile DownloadSource fileSource = DownloadSource.DEFAULT;
private volatile DownloadSource versionListSource = DownloadSource.MIRROR;
private volatile DownloadSource fileSource = DownloadSource.MIRROR;
private static boolean preferMirror(DownloadSource source) {
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
return source != DownloadSource.OFFICIAL;
}
return source == DownloadSource.MIRROR;
}
private static DownloadCandidates getCandidates(
DownloadSource source,
WebURL defaultUrl, WebURL bmclapiUrl
WebURL officialUrl,
List<WebURL> mirrorUrls
) {
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
if (preferMirror(source)) {
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
candidates.addAll(spreadPrimary(mirrorUrls.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
candidates.add(DownloadCandidate.of(officialUrl));
return DownloadCandidates.of(List.copyOf(candidates));
} else {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
candidates.add(DownloadCandidate.of(officialUrl));
for (WebURL mirrorUrl : mirrorUrls) {
candidates.add(DownloadCandidate.of(mirrorUrl));
}
return DownloadCandidates.of(List.copyOf(candidates));
}
}
/// Rotates the first mirror among the top entries using a stable hash of the URL, so concurrent
/// file downloads spread across healthy mirrors instead of stampeding one entry point.
private static List<DownloadCandidate> spreadPrimary(List<DownloadCandidate> mirrors, String key) {
if (mirrors.size() <= 1)
return mirrors;
int top = Math.min(2, mirrors.size());
int primary = Math.floorMod(key.hashCode(), top);
if (primary == 0)
return mirrors;
var rotated = new ArrayList<DownloadCandidate>(mirrors.size());
rotated.add(mirrors.get(primary));
for (int i = 0; i < mirrors.size(); i++) {
if (i != primary)
rotated.add(mirrors.get(i));
}
return rotated;
}
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
if (urls.isEmpty()) {
throw new IllegalArgumentException("urls cannot be empty");
@@ -67,7 +100,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 2);
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 3);
for (String url : urls) {
DownloadCandidate candidate = DownloadCandidate.of(url);
if (candidate.url() == null) {
@@ -93,21 +126,22 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return DownloadCandidates.of(List.copyOf(candidates));
}
private final String bmclRoot;
private final Supplier<@Unmodifiable List<String>> mirrorRoots;
private final List<MirrorRule> rules;
public HMCLDownloadProvider(String bmclRoot) {
this.bmclRoot = bmclRoot;
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
this.rules = List.of(
new SimpleRule("https://bmclapi2.bangbang93.com", bmclRoot),
new SimpleRule("https://launchermeta.mojang.com", bmclRoot),
new SimpleRule("https://piston-meta.mojang.com", bmclRoot),
new SimpleRule("https://piston-data.mojang.com", bmclRoot),
new SimpleRule("https://launcher.mojang.com", bmclRoot),
new SimpleRule("https://libraries.minecraft.net", bmclRoot + "/libraries"),
new SimpleRule("http://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new SimpleRule("https://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new SimpleRule("https://maven.minecraftforge.net", bmclRoot + "/maven"),
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
new MultiRootRule("https://piston-meta.mojang.com", Function.identity()),
new MultiRootRule("https://piston-data.mojang.com", Function.identity()),
new MultiRootRule("https://launcher.mojang.com", Function.identity()),
new MultiRootRule("https://libraries.minecraft.net", root -> root + "/libraries"),
new MultiRootRule("http://files.minecraftforge.net/maven", root -> root + "/maven"),
new MultiRootRule("https://files.minecraftforge.net/maven", root -> root + "/maven"),
new MultiRootRule("https://maven.minecraftforge.net", root -> root + "/maven"),
(source, mirrorFirst) -> {
String neoforgedMaven = "https://maven.neoforged.net/";
String neoforgedMavenRelease = "https://maven.neoforged.net/releases/";
@@ -116,17 +150,19 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return null;
var candidates = new ArrayList<DownloadCandidate>();
List<String> roots = mirrorRoots.get();
if (source.url().href().startsWith(neoforgedMavenRelease)) {
DownloadCandidate bmclapiMirror = DownloadCandidate.of(bmclRoot + "/maven/"
+ source.url().href().substring(neoforgedMavenRelease.length()));
String suffix = source.url().href().substring(neoforgedMavenRelease.length());
List<DownloadCandidate> mirrors = roots.stream()
.map(root -> DownloadCandidate.of(root + "/maven/" + suffix))
.toList();
if (mirrorFirst) {
candidates.add(bmclapiMirror);
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
candidates.add(source);
} else {
candidates.add(source);
candidates.add(bmclapiMirror);
candidates.addAll(mirrors);
}
} else {
candidates.add(source);
@@ -136,11 +172,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
candidates.add(DownloadCandidate.of("https://neoforged.forgecdn.net/" + source.url().href().substring(neoforgedMaven.length())));
return candidates;
},
new SimpleRule("http://dl.liteloader.com/versions", bmclRoot + "/maven"),
new SimpleRule("https://dl.liteloader.com/versions", bmclRoot + "/maven"),
new SimpleRule("https://meta.fabricmc.net", bmclRoot + "/fabric-meta"),
new SimpleRule("https://maven.fabricmc.net", bmclRoot + "/maven"),
new SimpleRule("https://authlib-injector.yushi.moe", bmclRoot + "/mirrors/authlib-injector"),
new MultiRootRule("http://dl.liteloader.com/versions", root -> root + "/maven"),
new MultiRootRule("https://dl.liteloader.com/versions", root -> root + "/maven"),
new MultiRootRule("https://meta.fabricmc.net", root -> root + "/fabric-meta"),
new MultiRootRule("https://maven.fabricmc.net", root -> root + "/maven"),
new MultiRootRule("https://authlib-injector.yushi.moe", root -> root + "/mirrors/authlib-injector"),
new SimpleRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new SimpleRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new SimpleRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
@@ -155,20 +191,32 @@ public final class HMCLDownloadProvider extends DownloadProvider {
);
}
private String primaryMirrorRoot() {
List<String> roots = mirrorRoots.get();
return roots.isEmpty() ? "https://bmclapi2.bangbang93.com" : roots.get(0);
}
private List<WebURL> mirrorUrls(String suffix) {
return mirrorRoots.get().stream()
.map(root -> WebURL.parse(root + suffix))
.toList();
}
@Override
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
assert (gameVersion == null) == (type == GameComponentType.GAME);
String bmclRoot = primaryMirrorRoot();
switch (type) {
case FORGE -> {
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
if (source == DownloadSource.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR) {
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
@@ -178,11 +226,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
if (source == DownloadSource.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR || source == DownloadSource.DEFAULT) {
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
@@ -214,7 +262,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return getCandidates(
versionListSource,
GameRemoteVersion.VERSION_MANIFEST_URL,
WebURL.parse(bmclRoot + "/mc/game/version_manifest.json")
mirrorUrls("/mc/game/version_manifest.json")
);
}
@@ -222,8 +270,8 @@ public final class HMCLDownloadProvider extends DownloadProvider {
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"))
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, mirrorUrls("/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, mirrorUrls("/fabric-meta/v2/versions/game"))
);
}
@@ -246,10 +294,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
String location = assetObject.getLocation();
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + assetObject.getLocation()),
WebURL.parse(bmclRoot + "/assets/" + assetObject.getLocation())
WebURL.parse("https://resources.download.minecraft.net/" + location),
mirrorUrls("/assets/" + location)
);
}
@@ -298,6 +347,38 @@ public final class HMCLDownloadProvider extends DownloadProvider {
@Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst);
}
@NotNullByDefault
private final class MultiRootRule implements MirrorRule {
private final String source;
private final Function<String, String> rootMapper;
private MultiRootRule(String source, Function<String, String> rootMapper) {
this.source = source;
this.rootMapper = rootMapper;
}
@Override
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
if (source.url() == null || !source.url().href().startsWith(this.source))
return null;
String urlWithoutPrefix = source.url().href().substring(this.source.length());
List<DownloadCandidate> mirrors = mirrorRoots.get().stream()
.map(root -> DownloadCandidate.of(rootMapper.apply(root) + urlWithoutPrefix))
.toList();
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 1);
if (mirrorFirst) {
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
candidates.add(source);
} else {
candidates.add(source);
candidates.addAll(mirrors);
}
return candidates;
}
}
@NotNullByDefault
private record SimpleRule(String source, @Nullable String first, @Nullable String fallback) implements MirrorRule {
public SimpleRule(String source, String target) {
@@ -330,4 +411,3 @@ public final class HMCLDownloadProvider extends DownloadProvider {
}
}
}
@@ -53,7 +53,10 @@ public final class Accounts {
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
/// Account factories offered when creating a new account from the UI.
///
/// Offline login remains loadable for existing accounts, but is no longer offered for creation.
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
// ==== login type / account factory mapping ====
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
@@ -26,9 +26,8 @@ import java.util.Objects;
@NotNullByDefault
@JsonSerializable
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
/// The LittleSkin Yggdrasil API endpoint used only when recognizing an existing entry.
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
/// The JSON schema supported by this authlib-injector server list.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
@@ -40,17 +39,10 @@ public final class AuthlibInjectorServerList extends ObservableSetting implement
}
/// Creates the default authlib-injector server list for a newly created file.
///
/// The default list is empty; operators add their own authentication server when needed.
public static AuthlibInjectorServerList createDefault() {
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
result.addLittleSkinIfAbsent();
return result;
}
/// Adds the bundled LittleSkin server when it is not already present.
public void addLittleSkinIfAbsent() {
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
}
return new AuthlibInjectorServerList();
}
/// The schema used by this authlib-injector server list file.
@@ -3,48 +3,27 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.HMCLDownloadProvider;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DownloadProviders {
private DownloadProviders() {
}
private static final String DEFAULT_BMCLAPI_ROOT = "https://bmclapi2.bangbang93.com";
private static final String BMCLAPI_ROOT;
private static final MirrorRoots MIRROR_ROOTS =
new MirrorRoots(System.getProperty("hmcl.bmclapi.override"));
static {
@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);
}
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(MIRROR_ROOTS::getRankedRoots);
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.
/// Initializes download provider settings, mirror probing, and download thread settings.
public static void init() {
InvalidationListener onChangeDownloadThreads = observable -> {
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
@@ -57,13 +36,18 @@ public final class DownloadProviders {
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
MIRROR_ROOTS.maybeProbeAsync();
}
/**
* Get current primary preferred download provider
*/
/// Get current primary preferred download provider
public static DownloadProvider getDownloadProvider() {
MIRROR_ROOTS.maybeProbeAsync();
return PROVIDER;
}
/// Returns the current ranked BMCLAPI-compatible mirror roots (fastest first).
public static @Unmodifiable List<String> getRankedMirrorRoots() {
return MIRROR_ROOTS.getRankedRoots();
}
}
@@ -152,12 +152,6 @@ public final class GameDirectoryManager {
gameDirectories.getGameDirectories().add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
}
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = userGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
}
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
if (needRebuildGameDirectories) {
@@ -166,15 +160,13 @@ public final class GameDirectoryManager {
assert !mergedGameDirectories.isEmpty();
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
// Dedicated server client: always prefer the fixed workspace `.minecraft` directory.
GameDirectory currentGameDirectory = null;
if (selectedId != null) {
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (gameDirectory.getId().equals(selectedId)) {
currentGameDirectory = gameDirectory;
break;
}
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (DEFAULT_GAME_DIRECTORY_ID.equals(gameDirectory.getId())
|| isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
currentGameDirectory = gameDirectory;
break;
}
}
@@ -215,16 +207,13 @@ public final class GameDirectoryManager {
}
}
/// Creates the built-in game directories only when no game directory exists.
/// Creates the fixed workspace game directory when no game directory exists.
private static boolean createDefaultGameDirectoriesIfEmpty() {
if (localGameDirectories().getGameDirectories().isEmpty()
&& userGameDirectories().getGameDirectories().isEmpty()) {
localGameDirectories().getGameDirectories()
.add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
userGameDirectories().getGameDirectories()
.add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
return true;
} else {
return false;
@@ -177,23 +177,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
return language;
}
/// Whether preview builds are accepted by update checks.
@SerializedName("acceptPreviewUpdate")
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
/// Returns the preview update opt-in property.
public BooleanProperty acceptPreviewUpdateProperty() {
return acceptPreviewUpdate;
}
/// Whether automatic update dialogs are disabled.
@SerializedName("disableAutoShowUpdateDialog")
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
/// Returns the automatic update dialog disable property.
public BooleanProperty disableAutoShowUpdateDialogProperty() {
return disableAutoShowUpdateDialog;
}
/// Whether April Fools features are disabled.
@SerializedName("disableAprilFools")
@@ -501,7 +485,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// The selected game version list download source.
@SerializedName("versionListSource")
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.MIRROR);
/// Returns the selected game version list download source property.
public ObjectProperty<DownloadSource> versionListSourceProperty() {
@@ -510,7 +494,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// The selected file download source.
@SerializedName("fileDownloadSource")
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.MIRROR);
/// Returns the selected file download source property.
public ObjectProperty<DownloadSource> fileDownloadSourceProperty() {
@@ -174,25 +174,6 @@ public final class LauncherState extends ObservableSetting implements JsonSchema
this.height.set(height);
}
/// The latest update version that has already prompted the user.
@SerializedName("promptedVersion")
private final StringProperty promptedVersion = new SimpleStringProperty();
/// Returns the latest prompted update version.
public @Nullable String getPromptedVersion() {
return promptedVersion.get();
}
/// Returns the latest prompted update version property.
public StringProperty promptedVersionProperty() {
return promptedVersion;
}
/// Sets the latest prompted update version.
public void setPromptedVersion(@Nullable String promptedVersion) {
this.promptedVersion.set(promptedVersion);
}
/// Tip markers that prevent repeated prompts.
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
@@ -337,7 +337,6 @@ public final class LegacyConfigMigrator {
JsonObject current = new JsonObject();
current.add(JsonSchema.PROPERTY_SCHEMA, JsonUtils.GSON.toJsonTree(UserState.CURRENT_SCHEMA, JsonSchema.class));
copyMember(legacy, current, "agreementVersion");
copyMember(legacy, current, "terracottaAgreementVersion");
copyMember(legacy, current, "platformPromptVersion");
return Objects.requireNonNull(JsonUtils.GSON.fromJson(current, UserState.class));
}
@@ -396,7 +395,6 @@ public final class LegacyConfigMigrator {
moveMember(json, state, "height");
migrateLegacyWindowContentSize(state, "width");
migrateLegacyWindowContentSize(state, "height");
moveMember(json, state, "promptedVersion");
moveMember(json, state, "shownTips");
LauncherState result = JsonUtils.GSON.fromJson(state, LauncherState.class);
@@ -427,18 +425,12 @@ public final class LegacyConfigMigrator {
if (authlibInjectorServers != null) {
servers.add("servers", authlibInjectorServers);
}
JsonElement addedLittleSkin = json.remove("addedLittleSkin");
boolean shouldAddLittleSkin = !(addedLittleSkin instanceof JsonPrimitive primitive
&& primitive.isBoolean()
&& primitive.getAsBoolean());
json.remove("addedLittleSkin");
AuthlibInjectorServerList result = JsonUtils.GSON.fromJson(servers, AuthlibInjectorServerList.class);
if (result == null) {
result = new AuthlibInjectorServerList();
}
if (shouldAddLittleSkin) {
result.addLittleSkinIfAbsent();
}
return result;
}
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Ranks BMCLAPI-compatible mirror roots by a lightweight latency probe.
///
/// Game downloads are many small/medium files. Racing the same file across mirrors wastes
/// volunteer OpenBMCLAPI bandwidth; ranking entry points and keeping file-level concurrency
/// (plus ordered fallback) is the better fit for that workload.
@NotNullByDefault
public final class MirrorRoots {
/// Public BMCLAPI entry points that speak the same path layout.
public static final @Unmodifiable List<String> DEFAULT_ROOTS = List.of(
"https://bmclapi2.bangbang93.com",
"https://bmclapi.bangbang93.com"
);
private static final String PROBE_PATH = "/mc/game/version_manifest.json";
private static final long REPROBE_INTERVAL_NANOS = 30L * 60L * 1_000_000_000L;
private final CopyOnWriteArrayList<String> rankedRoots = new CopyOnWriteArrayList<>(DEFAULT_ROOTS);
private final AtomicBoolean probing = new AtomicBoolean(false);
private volatile long lastProbeNanos;
/// Creates a ranker seeded with the built-in BMCLAPI roots, or a single override root.
public MirrorRoots(@Nullable String overrideRoot) {
if (StringUtils.isNotBlank(overrideRoot)) {
WebURL url;
try {
url = WebURL.parseBrowserInput(overrideRoot);
} catch (Exception e) {
url = null;
LOG.warning("Invalid BMCLAPI override URL: " + overrideRoot, e);
}
if (url != null) {
rankedRoots.clear();
rankedRoots.add(StringUtils.removeSuffix(url.toString(), "/"));
}
}
}
/// Returns the current ranked mirror roots (fastest first). Never empty.
public @Unmodifiable List<String> getRankedRoots() {
List<String> snapshot = List.copyOf(rankedRoots);
return snapshot.isEmpty() ? DEFAULT_ROOTS : snapshot;
}
/// Returns the current primary (fastest) mirror root.
public String getPrimaryRoot() {
return getRankedRoots().get(0);
}
/// Starts a background probe when none is running and the previous result is stale.
public void maybeProbeAsync() {
if (rankedRoots.size() <= 1)
return;
long now = System.nanoTime();
if (now - lastProbeNanos < REPROBE_INTERVAL_NANOS && lastProbeNanos != 0)
return;
if (!probing.compareAndSet(false, true))
return;
Schedulers.io().execute(() -> {
try {
probeAndRank();
} catch (Throwable t) {
LOG.warning("Failed to probe download mirrors", t);
} finally {
lastProbeNanos = System.nanoTime();
probing.set(false);
}
});
}
private void probeAndRank() {
List<String> candidates = List.copyOf(rankedRoots);
if (candidates.size() <= 1)
return;
record Score(String root, long nanos, boolean ok) {
}
List<Score> scores = new ArrayList<>(candidates.size());
for (String root : candidates) {
long started = System.nanoTime();
boolean ok = false;
try {
HttpURLConnection connection = NetworkUtils.createHttpConnection(root + PROBE_PATH);
connection.setInstanceFollowRedirects(true);
connection.setRequestMethod("GET");
connection.setRequestProperty("Range", "bytes=0-0");
connection.connect();
int code = connection.getResponseCode();
// Accept normal OK, partial content, or redirect-resolved success.
ok = code >= 200 && code < 400;
connection.disconnect();
} catch (Exception e) {
LOG.info("Mirror probe failed for " + root + ": " + e);
}
scores.add(new Score(root, System.nanoTime() - started, ok));
}
scores.sort(Comparator
.comparing((Score score) -> !score.ok)
.thenComparingLong(Score::nanos));
List<String> next = scores.stream().map(Score::root).toList();
if (!next.equals(candidates)) {
rankedRoots.clear();
rankedRoots.addAll(next);
LOG.info("Ranked download mirrors: " + next);
}
}
}
@@ -106,15 +106,6 @@ public final class UserState extends ObservableSetting implements JsonSchemaSett
return agreementVersion;
}
/// The accepted Terracotta agreement version.
@SerializedName("terracottaAgreementVersion")
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
/// Returns the accepted Terracotta agreement version property.
public IntegerProperty terracottaAgreementVersionProperty() {
return terracottaAgreementVersion;
}
/// The platform prompt version shown to the user.
@SerializedName("platformPromptVersion")
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
@@ -1,187 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
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;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
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.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaBundle {
private final Path root;
/// Candidate bundle URLs in attempt order.
private final List<WebURL> links;
private final FileDownloadTask.IntegrityCheck hash;
private final 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;
this.files = files;
}
public Task<Path> download(AbstractTerracottaProvider.DownloadContext context) {
return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz"))
.thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
super.withResult(success);
delegate.withResult(success);
}
@Override
public void reset() throws IOException {
delegate.reset();
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
context.checkCancellation();
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess()) {
context.checkCancellation();
}
delegate.close();
}
};
}
};
context.bindProgress(download.progressProperty());
return download.thenSupplyAsync(() -> pkg);
});
}
public Task<?> install(Path pkg) {
return Task.runAsync(() -> {
Files.createDirectories(root);
FileUtils.cleanDirectory(root);
try (TarFileTree tree = TarFileTree.open(pkg)) {
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
String file = entry.getKey();
FileDownloadTask.IntegrityCheck check = entry.getValue();
Path path = root.resolve(file);
TarArchiveEntry archive = tree.getEntry("/" + file);
if (archive == null) {
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
}
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
try (
InputStream is = tree.getInputStream(archive);
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
) {
is.transferTo(os);
}
String hash = HexFormat.of().formatHex(digest.digest());
if (!check.checksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.algorithm(), check.checksum(), hash);
}
switch (OperatingSystem.CURRENT_OS) {
case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode()));
}
}
}
}).whenComplete(exception -> {
if (exception != null) {
FileUtils.deleteDirectory(root);
}
});
}
public Path locate(String file) {
FileDownloadTask.IntegrityCheck check = files.get(file);
if (check == null) {
throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file));
}
return root.resolve(file).toAbsolutePath();
}
public AbstractTerracottaProvider.Status status() throws IOException {
if (Files.exists(root) && isLocalBundleValid()) {
return AbstractTerracottaProvider.Status.READY;
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
LOG.warning("Cannot determine whether legacy versions exist.", e);
}
return AbstractTerracottaProvider.Status.NOT_EXIST;
}
private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer.
long total = 0;
byte[] buffer = new byte[8192];
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
Path path = root.resolve(entry.getKey());
FileDownloadTask.IntegrityCheck check = entry.getValue();
if (!Files.isReadable(path)) {
return false;
}
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
int n;
while ((n = is.read(buffer)) >= 0) {
total += n;
if (total >= 50 * 1024 * 1024) { // >=50MB
return false;
}
}
}
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.checksum())) {
return false;
}
}
return true;
}
}
@@ -1,361 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonObject;
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;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaManager {
private TerracottaManager() {
}
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
static {
Schedulers.io().execute(() -> {
try {
if (TerracottaMetadata.PROVIDER == null)
throw new IOException("Unsupported platform: " + org.jackhuang.hmcl.util.platform.Platform.CURRENT_PLATFORM);
switch (TerracottaMetadata.PROVIDER.status()) {
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
case READY -> launch(setState(new TerracottaState.Launching()), false);
}
} catch (Exception e) {
LOG.warning("Cannot initialize Terracotta.", e);
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
});
}
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
return STATE.getReadOnlyProperty();
}
private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true);
@FXThread // Written in FXThread, read-only on background daemon
private static volatile boolean daemonRunning = false;
private static void runBackground() {
final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500);
final long BACKGROUND = TimeUnit.SECONDS.toMillis(15);
while (true) {
if (daemonRunning) {
LockSupport.parkNanos(ACTIVE);
} else {
long deadline = System.currentTimeMillis() + BACKGROUND;
do {
LockSupport.parkUntil(deadline);
} while (!daemonRunning && System.currentTimeMillis() < deadline - 100);
}
if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) {
continue;
}
int port = state.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port))
.retry(5)
.getJson(TerracottaState.Ready.class);
if (object.index <= index) {
continue;
}
object.port = port;
next = object;
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
compareAndSet(state, next);
}
}
@FXThread
public static void switchDaemon(boolean active) {
FXUtils.checkFxUserThread();
boolean dr = daemonRunning;
if (dr != active) {
daemonRunning = active;
if (active) {
LockSupport.unpark(DAEMON);
}
}
}
private static AbstractTerracottaProvider getProvider() {
AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static boolean isInvalidBundle(Path file) {
return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
@FXThread
public static TerracottaState.Preparing download() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true);
Task.composeAsync(() -> getProvider().download(preparing))
.thenComposeAsync(pkg -> {
if (!preparing.requestInstallFence()) {
return null;
}
return getProvider().install(pkg).thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
});
}).whenComplete(exception -> {
if (exception instanceof CancellationException) {
// no-op
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState.Preparing install(Path bundle) {
FXUtils.checkFxUserThread();
if (isInvalidBundle(bundle)) {
return null;
}
TerracottaState state = STATE_V.get();
TerracottaState.Preparing preparing;
if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) {
preparing = previousPreparing;
} else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) {
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false);
} else {
return null;
}
Task.composeAsync(() -> getProvider().install(bundle))
.thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
})
.whenComplete(exception -> {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}).start();
return state != preparing && compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState recover() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
return null;
}
try {
// FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making
// hash check accept 50MB at most. Calling it on JavaFX should be safe.
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> download();
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching, false);
yield launching;
}
};
} catch (RuntimeException | IOException e) {
LOG.warning("Cannot determine Terracotta state.", e);
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}
private static void launch(TerracottaState.Launching state, boolean removeLegacy) {
Task.supplyAsync(() -> {
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
long exitTime = -1;
while (true) {
if (Files.exists(path)) {
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
return object.get("port").getAsInt();
}
if (!process.isRunning()) {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode()));
}
}
}
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
if (removeLegacy) {
TerracottaMetadata.removeLegacyVersionFiles();
}
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
compareAndSet(state, next);
}).start();
}
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
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);
}
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
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);
}
return null;
}
private static String getPlayerName() {
Account account = Accounts.getSelectedAccount();
return account != null ? account.getProfileName() : i18n("terracotta.player_anonymous");
}
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (WebURL node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
"http://127.0.0.1:%d/state/scanning".formatted(portSpecific.port), query
)).setSignificance(Task.TaskSignificance.MINOR);
}).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestConnecting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
return Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
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))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
});
} else {
return null;
}
}
private static <T extends TerracottaState> T setState(T value) {
if (value == null) {
throw new AssertionError();
}
STATE_V.set(value);
STATE_D.accept(value);
return value;
}
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
if (next == null) {
throw new AssertionError();
}
if (STATE_V.compareAndSet(previous, next)) {
STATE_D.accept(next);
return true;
} else {
return false;
}
}
}
@@ -1,190 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
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;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
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.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OSVersion;
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.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaMetadata {
private TerracottaMetadata() {
}
private record Options(String version, String classifier) {
public String replace(String value) {
return value.replace("${version}", version).replace("${classifier}", classifier);
}
}
@JsonSerializable
public record Link(
@SerializedName("desc") LocalizedText description,
@SerializedName("link") String link
) {
}
@JsonSerializable
private record Package(
@SerializedName("hash") String hash,
@SerializedName("files") Map<String, String> files
) {
}
@JsonSerializable
private record Config(
@SerializedName("version_latest") String latest,
@SerializedName("packages") Map<String, Package> pkgs,
@SerializedName("downloads") List<String> downloads,
@SerializedName("downloads_CN") List<String> downloadsCN,
@SerializedName("links") List<Link> links
) {
private @Nullable TerracottaBundle resolve(Options options) {
Package pkg = pkgs.get(options.classifier);
if (pkg == null) {
return null;
}
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
@Unmodifiable List<WebURL> links = ("CN".equals(RegionUtils.MACHINE_REGION) ? 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(
Map.Entry::getKey,
entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue())
));
return new TerracottaBundle(
Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(),
links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash),
files
);
}
}
public static final AbstractTerracottaProvider PROVIDER;
public static final String PACKAGE_NAME;
public static final List<Link> PACKAGE_LINKS;
public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of(
"v", "v1",
"launcher_version", Metadata.VERSION
));
private static final String LATEST;
static {
Config config;
try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) {
config = JsonUtils.fromNonNullJsonFully(is, Config.class);
} catch (IOException e) {
throw new ExceptionInInitializerError(e);
}
LATEST = config.latest;
Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName());
TerracottaBundle bundle = config.resolve(options);
AbstractTerracottaProvider provider;
if (bundle == null || (provider = locateProvider(bundle, options)) == null) {
PROVIDER = null;
PACKAGE_NAME = null;
PACKAGE_LINKS = null;
} else {
PROVIDER = provider;
PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz");
List<Link> packageLinks = config.links.stream()
.map(link -> new Link(link.description, options.replace(link.link)))
.collect(Collectors.toList());
Collections.shuffle(packageLinks);
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
}
}
@Nullable
private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) {
String prefix = options.replace("terracotta-${version}-${classifier}");
// FIXME: As HMCL is a cross-platform application, developers may mistakenly locate
// non-existent files in non-native platform logic without assertion errors during debugging.
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
yield null;
yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe"));
}
case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix));
case MACOS -> new MacOSProvider(
bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg")
);
default -> null;
};
}
public static void removeLegacyVersionFiles() {
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
if (terracotta == null)
return;
for (Path path : terracotta) {
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
}
public static boolean hasLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
return terracotta != null && terracotta.iterator().hasNext();
}
}
private static @Nullable DirectoryStream<Path> collectLegacyVersionFiles() throws IOException {
Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta");
if (Files.notExists(terracottaDir))
return null;
VersionRange<VersionNumber> range = VersionNumber.atMost(LATEST);
return Files.newDirectoryStream(terracottaDir, path -> {
String name = FileUtils.getName(path);
return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name));
});
}
}
@@ -1,90 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class TerracottaNodeList {
private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes";
@JsonSerializable
private record TerracottaNode(String url, @Nullable String region) implements Validation {
@Override
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
WebURL.parse(url);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
/// Cached node URLs, or `null` before the first fetch completes.
private static volatile @Nullable @Unmodifiable List<WebURL> 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;
synchronized (TerracottaNodeList.class) {
list = TerracottaNodeList.list;
if (list != null)
return list;
try {
List<TerracottaNode> nodes = HttpRequest.GET(NODE_LIST_URL)
.getJson(JsonUtils.listTypeOf(TerracottaNode.class));
if (nodes == null) {
list = List.of();
LOG.info("No available Terracotta nodes found");
} else {
list = nodes.stream()
.filter(node -> {
if (node == null)
return false;
try {
node.validate();
} catch (Exception e) {
LOG.warning("Invalid terracotta node: " + node, e);
return false;
}
return StringUtils.isBlank(node.region) || node.region.equalsIgnoreCase(RegionUtils.MACHINE_REGION);
})
.map(it -> WebURL.parse(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
} catch (Exception e) {
LOG.warning("Failed to fetch terracotta node list", e);
list = List.of();
}
TerracottaNodeList.list = list;
return list;
}
}
private TerracottaNodeList() {
}
}
@@ -1,318 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
protected TerracottaState() {
}
public boolean isUIFakeState() {
return false;
}
public boolean isForkOf(TerracottaState state) {
return false;
}
public static final class Bootstrap extends TerracottaState {
static final Bootstrap INSTANCE = new Bootstrap();
private Bootstrap() {
}
}
public static final class Uninitialized extends TerracottaState {
private final boolean hasLegacy;
Uninitialized(boolean hasLegacy) {
this.hasLegacy = hasLegacy;
}
public boolean hasLegacy() {
return hasLegacy;
}
}
public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean hasInstallFence;
Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) {
this.progress = progress;
this.hasInstallFence = new AtomicBoolean(hasInstallFence);
}
public ReadOnlyDoubleProperty progressProperty() {
return progress.getReadOnlyProperty();
}
public boolean requestInstallFence() {
return hasInstallFence.compareAndSet(true, false);
}
public boolean hasInstallFence() {
return hasInstallFence.get();
}
@Override
public void bindProgress(ObservableDoubleValue progress) {
this.progress.bind(progress);
}
@Override
public void checkCancellation() {
if (!hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
}
}
public static final class Launching extends TerracottaState {
Launching() {
}
}
static abstract sealed class PortSpecific extends TerracottaState {
transient int port;
protected PortSpecific(int port) {
this.port = port;
}
}
@JsonType(
property = "state",
subtypes = {
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
@JsonSubtype(clazz = GuestConnecting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
}
)
static abstract sealed class Ready extends PortSpecific {
@SerializedName("index")
final int index;
@SerializedName("state")
private final String state;
Ready(int port, int index, String state) {
super(port);
this.index = index;
this.state = state;
}
@Override
public boolean isUIFakeState() {
return this.index == -1;
}
}
public static final class Unknown extends PortSpecific {
Unknown(int port) {
super(port);
}
}
public static final class Waiting extends Ready {
Waiting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostScanning extends Ready {
HostScanning(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostStarting extends Ready {
HostStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostOK extends Ready implements Validation {
@SerializedName("room")
private final String code;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.code = code;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (code == null) {
throw new JsonParseException("code is null");
}
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getCode() {
return code;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
}
}
public static final class GuestConnecting extends Ready {
GuestConnecting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestStarting extends Ready {
public enum Difficulty {
UNKNOWN,
EASIEST,
SIMPLE,
MEDIUM,
TOUGH
}
@SerializedName("difficulty")
private final Difficulty difficulty;
GuestStarting(int port, int index, String state, Difficulty difficulty) {
super(port, index, state);
this.difficulty = difficulty;
}
public Difficulty getDifficulty() {
return difficulty;
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.url = url;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getUrl() {
return url;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
}
}
public static final class Exception extends Ready implements Validation {
public enum Type {
PING_HOST_FAIL,
PING_HOST_RST,
GUEST_ET_CRASH,
HOST_ET_CRASH,
PING_SERVER_RST,
SCAFFOLDING_INVALID_RESPONSE
}
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
@SerializedName("type")
private final int type;
Exception(int port, int index, String state, int type) {
super(port, index, state);
this.type = type;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (type < 0 || type >= LOOKUP.length) {
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
}
}
public Type getType() {
return LOOKUP[type];
}
}
public static final class Fatal extends TerracottaState {
public enum Type {
OS,
NETWORK,
INSTALL,
TERRACOTTA,
UNKNOWN;
}
private final Type type;
public Fatal(Type type) {
this.type = type;
}
public Type getType() {
return type;
}
public boolean isRecoverable() {
return this.type != Type.UNKNOWN;
}
}
}
@@ -1,14 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public enum ProfileKind {
@SerializedName("HOST")
HOST,
@SerializedName("LOCAL")
LOCAL,
@SerializedName("GUEST")
GUEST
}
@@ -1,42 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public final class TerracottaProfile {
@SerializedName("machine_id")
private final String machineID;
@SerializedName("name")
private final String name;
@SerializedName("vendor")
private final String vendor;
@SerializedName("kind")
private final ProfileKind type;
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
this.machineID = machineID;
this.name = name;
this.vendor = vendor;
this.type = type;
}
public String getMachineID() {
return machineID;
}
public String getName() {
return name;
}
public String getVendor() {
return vendor;
}
public ProfileKind getType() {
return type;
}
}
@@ -1,48 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta.provider;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.FXThread;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
public abstract class AbstractTerracottaProvider {
public enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
public interface DownloadContext {
@FXThread
void bindProgress(ObservableDoubleValue progress);
void checkCancellation() throws CancellationException;
}
protected final TerracottaBundle bundle;
protected AbstractTerracottaProvider(TerracottaBundle bundle) {
this.bundle = bundle;
}
public Status status() throws IOException {
return bundle.status();
}
public final Task<Path> download(DownloadContext progress) {
return bundle.download(progress);
}
public Task<?> install(Path pkg) throws IOException {
return bundle.install(pkg);
}
public abstract List<String> ofCommandLine(Path portTransfer);
}
@@ -1,22 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import java.nio.file.Path;
import java.util.List;
public final class GeneralProvider extends AbstractTerracottaProvider {
private final Path executable;
public GeneralProvider(TerracottaBundle bundle, Path executable) {
super(bundle);
this.executable = executable;
}
@Override
public List<String> ofCommandLine(Path portTransfer) {
return List.of(executable.toString(), "--hmcl", portTransfer.toString());
}
}
@@ -1,80 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MacOSProvider extends AbstractTerracottaProvider {
private final Path executable, installer;
public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) {
super(bundle);
this.executable = executable;
this.installer = installer;
}
@Override
public Status status() throws IOException {
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
return Status.NOT_EXIST;
}
return bundle.status();
}
@Override
public Task<?> install(Path pkg) throws IOException {
return super.install(pkg).thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_USER_HOME, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer));
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
movedInstaller, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(movedInstaller.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode()
));
}
});
});
}
@Override
public List<String> ofCommandLine(Path path) {
return List.of(executable.toString(), "--hmcl", path.toString());
}
}
@@ -37,15 +37,12 @@ import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
import org.jackhuang.hmcl.ui.instances.GameListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -225,38 +222,7 @@ public final class Controllers {
return accountListPage;
}
/// The cached download page.
private static @Nullable DownloadPage downloadPage;
/// Creates and attaches the download page for a layout pulse unless already cached.
@FXThread
public static void prepareDownloadPage() {
if (downloadPage == null) {
LOG.info("Preparing download page");
downloadPage = getDecorator().prewarmPage(new DownloadPage());
}
}
/// Returns the cached download page, creating it if necessary.
@FXThread
public static DownloadPage getDownloadPage() {
if (downloadPage == null) {
downloadPage = new DownloadPage();
}
return downloadPage;
}
/// The cached Terracotta page.
private static @Nullable TerracottaPage terracottaPage;
/// Returns the cached Terracotta page, creating it if necessary.
@FXThread
public static Node getTerracottaPage() {
if (terracottaPage == null) {
terracottaPage = new TerracottaPage();
}
return terracottaPage;
}
/// Returns the initialized main-window decorator.
///
@@ -311,18 +277,8 @@ public final class Controllers {
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(
decorator.backableProperty().not()
.and(getRootPage().getMainPage().showUpdateProperty())
.and(settings().disableAutoShowUpdateDialogProperty().not())
);
if (settings().commonDirectoryTypeProperty().get() == EnumCommonDirectory.CUSTOM &&
!FileUtils.canCreateDirectory(settings().getResolvedCommonDirectory())) {
settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT);
dialog(i18n("launcher.cache_directory.invalid"));
}
// Dedicated server client: always use the fixed default cache directory.
settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT);
Lang.thread(JavaManager::initialize, "Search Java", true);
@@ -731,10 +687,6 @@ public final class Controllers {
public static void onHyperlinkAction(String href) {
if (href.startsWith("hmcl://")) {
switch (href) {
case "hmcl://settings/feedback":
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
break;
case "hmcl://game/launch":
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep);
@@ -778,10 +730,8 @@ public final class Controllers {
rootPage = null;
gameInstancePage = null;
gameListPage = null;
downloadPage = null;
accountListPage = null;
settingsPage = null;
terracottaPage = null;
decorator = null;
FXUtils.shutdown();
@@ -2,18 +2,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.countly.CrashReport;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.CrashReport;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -26,8 +21,6 @@ public class CrashWindow extends Stage {
Label lblCrash = new Label();
if (report.getThrowable() instanceof InternalError)
lblCrash.setText(i18n("launcher.crash.java_internal_error"));
else if (UpdateChecker.isOutdated())
lblCrash.setText(i18n("launcher.crash.hmcl_out_dated"));
else
lblCrash.setText(i18n("launcher.crash"));
lblCrash.setWrapText(true);
@@ -36,21 +29,12 @@ public class CrashWindow extends Stage {
textArea.setText(report.getDisplayText());
textArea.setEditable(false);
Button btnContact = new Button();
btnContact.setText(i18n("launcher.contact"));
btnContact.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL));
HBox box = new HBox();
box.setStyle("-fx-padding: 8px;");
box.getChildren().add(btnContact);
box.setAlignment(Pos.CENTER_RIGHT);
BorderPane pane = new BorderPane();
StackPane stackPane = new StackPane();
stackPane.setStyle("-fx-padding: 8px;");
stackPane.getChildren().add(lblCrash);
pane.setTop(stackPane);
pane.setCenter(textArea);
pane.setBottom(box);
Scene scene = new Scene(pane, 800, 480);
setScene(scene);
@@ -445,14 +445,10 @@ public class GameCrashWindow extends Stage {
JFXButton logButton = FXUtils.newRaisedButton(i18n("logwindow.title"));
logButton.setOnAction(e -> showLogWindow());
JFXButton helpButton = FXUtils.newRaisedButton(i18n("help"));
helpButton.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL));
FXUtils.installFastTooltip(helpButton, i18n("logwindow.help"));
toolBar.setPadding(new Insets(8));
toolBar.setSpacing(8);
toolBar.getStyleClass().add("jfx-tool-bar");
toolBar.getChildren().setAll(exportButtonPane, logButton, helpButton);
toolBar.getChildren().setAll(exportButtonPane, logButton);
}
getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar);
@@ -1,110 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class UpgradeDialog extends JFXDialogLayout {
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
Label heading = new Label(i18n("update.changelog"));
heading.setPadding(new Insets(0, 0, 8, 0));
setHeading(heading);
setBody(new JFXSpinner());
String url = CHANGELOG_URL + remoteVersion.channel().channelName + ".html";
Task.supplyAsync(Schedulers.io(), () -> {
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.version());
VersionNumber currentVersion = VersionNumber.asVersion(Metadata.VERSION);
if (targetVersion.compareTo(currentVersion) <= 0)
// Downgrade update, no need to display changelog
return null;
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
HTMLRenderer renderer = new HTMLRenderer(uri -> {
LOG.info("Open link: " + uri);
FXUtils.openLink(uri.toString());
});
if (node == null || !"h1".equals(node.nodeName())) {
LOG.warning("Changelog not found");
Element list = new Element("ul");
list.appendElement("li").text(i18n("update.changelog.empty"));
return renderer.appendNode(list).mergeLineBreaks().render();
}
do {
if ("h1".equals(node.nodeName())) {
String changelogVersion = node.attr("data-version");
if (StringUtils.isBlank(changelogVersion) || currentVersion.compareTo(changelogVersion) >= 0) {
break;
}
}
renderer.appendNode(node);
node = node.nextSibling();
} while (node != null);
renderer.mergeLineBreaks();
return renderer.render();
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
if (result != null) {
ScrollPane scrollPane = new ScrollPane(result);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
setBody(scrollPane);
} else {
setBody();
}
} else {
LOG.warning("Failed to load update log, trying to open it in browser");
FXUtils.openLink(url);
setBody();
}
}).start();
JFXHyperlink openInBrowser = new JFXHyperlink(i18n("web.view_in_browser"));
openInBrowser.setExternalLink(url);
JFXButton updateButton = new JFXButton(i18n("update.accept"));
updateButton.getStyleClass().add("dialog-accept");
updateButton.setOnAction(e -> updateRunnable.run());
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(openInBrowser, updateButton, cancelButton);
onEscPressed(this, cancelButton::fire);
}
}
@@ -99,25 +99,6 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
.startCategory(i18n("account.create").toUpperCase(Locale.ROOT))
.add(microsoftItem);
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
offlineItem.setTitle(i18n("account.methods.offline"));
offlineItem.setLeftIcon(SVG.PERSON);
if (restricted) {
offlineItem.setActionDisabled(true);
FXUtils.installFastTooltip(offlineItem, i18n("account.login.restricted"));
} else {
offlineItem.setOnAction(e -> {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE)));
} else {
Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE));
}
});
}
getSidebar().add(offlineItem);
for (AuthlibInjectorServer server : authServers) {
AdvancedListItem item = new AdvancedListItem();
item.getStyleClass().add("navigation-drawer-item");
@@ -89,8 +89,11 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
String preferred = settings().preferredLoginTypeProperty().get();
try {
factory = Accounts.getAccountFactory(preferred);
if (factory == Accounts.FACTORY_OFFLINE) {
factory = Accounts.FACTORY_MICROSOFT;
}
} catch (IllegalArgumentException e) {
factory = Accounts.FACTORY_OFFLINE;
factory = Accounts.FACTORY_MICROSOFT;
}
}
} else {
@@ -28,7 +28,6 @@ import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
@@ -60,7 +59,7 @@ 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.
/// The custom minimize and close buttons shared by both decoration styles.
private final HBox windowButtons;
/// The transition container used when the title-bar state changes.
@@ -171,7 +170,7 @@ final class MainWindowPane extends StackPane {
return backgroundNode;
}
/// Creates the task, help, minimize, and application-close buttons.
/// Creates the task, minimize, and application-close buttons.
///
/// @return a size-limited top-right button row
private HBox createWindowButtons() {
@@ -208,13 +207,6 @@ final class MainWindowPane extends StackPane {
}
});
JFXButton helpButton = new JFXButton();
helpButton.setFocusTraversable(false);
helpButton.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty()));
helpButton.getStyleClass().add("jfx-decorator-button");
helpButton.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL));
decorator.forbidDraggingWindow(helpButton);
JFXButton minimizeButton = new JFXButton();
minimizeButton.setFocusTraversable(false);
minimizeButton.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
@@ -229,7 +221,7 @@ final class MainWindowPane extends StackPane {
closeButton.setOnAction(event -> decorator.closeWindow());
decorator.forbidDraggingWindow(closeButton);
buttons.getChildren().setAll(tasksButton, helpButton, minimizeButton, closeButton);
buttons.getChildren().setAll(tasksButton, minimizeButton, closeButton);
return buttons;
}
@@ -1,57 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.directory;
import javafx.beans.binding.Bindings;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.Controllers;
import org.jetbrains.annotations.NotNullByDefault;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Navigation drawer item for one game directory entry.
@NotNullByDefault
public class GameDirectoryListItem extends RadioButton {
/// Game directory represented by this item.
private final GameDirectory gameDirectory;
/// Creates a list item for the given game directory.
///
/// @param gameDirectory the represented game directory
public GameDirectoryListItem(GameDirectory gameDirectory) {
this.gameDirectory = gameDirectory;
getStyleClass().setAll("game-directory-list-item", "navigation-drawer-item");
setUserData(gameDirectory);
this.selectedProperty().bind(Bindings.equal(gameDirectory, GameDirectoryManager.selectedGameDirectoryProperty()));
}
/// Creates the JavaFX skin for this item.
@Override
protected Skin<?> createDefaultSkin() {
return new GameDirectoryListItemSkin(this);
}
/// Removes the represented game directory after handling read-only storage.
public void remove() {
if (!GameDirectoryManager.canRemoveGameDirectory(gameDirectory)) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
GameDirectoryManager.forceOverwriteGameDirectoryFiles(gameDirectory);
GameDirectoryManager.removeGameDirectory(gameDirectory);
});
return;
}
GameDirectoryManager.removeGameDirectory(gameDirectory);
}
/// Returns the represented game directory.
public GameDirectory getGameDirectory() {
return gameDirectory;
}
}
@@ -1,58 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.directory;
import com.jfoenix.controls.JFXButton;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.scene.control.SkinBase;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jetbrains.annotations.NotNullByDefault;
/// Skin for [GameDirectoryListItem].
@NotNullByDefault
public class GameDirectoryListItemSkin extends SkinBase<GameDirectoryListItem> {
/// Pseudo class applied to the selected item.
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
/// Creates the skin for a game directory list item.
///
/// @param skinnable the list item controlled by this skin
public GameDirectoryListItemSkin(GameDirectoryListItem skinnable) {
super(skinnable);
TwoLineListItem item = new TwoLineListItem();
RipplerContainer container = new RipplerContainer(item);
SVGContainer left = (skinnable.isSelected() ? SVG.FOLDER_FILL : SVG.FOLDER).createIcon(20);
left.setMouseTransparent(true);
TwoLineListItem.setMargin(left, new Insets(0, 6, 0, 6));
item.setLeading(left);
FXUtils.onChangeAndOperate(skinnable.selectedProperty(), active -> {
skinnable.pseudoClassStateChanged(SELECTED, active);
SVG targetIcon = active ? SVG.FOLDER_FILL : SVG.FOLDER;
if (left.getIcon() != targetIcon) {
left.setIcon(targetIcon, Motion.SHORT4);
}
});
FXUtils.onClicked(getSkinnable(), () -> GameDirectoryManager.setSelectedGameDirectory(skinnable.getGameDirectory()));
JFXButton btnRemove = FXUtils.newToggleButton4(SVG.CLOSE, 14);
btnRemove.setOnAction(e -> skinnable.remove());
item.setTrailing(btnRemove);
item.titleProperty().set(GameDirectoryManager.getGameDirectoryDisplayName(skinnable.getGameDirectory()));
item.subtitleProperty().set(skinnable.getGameDirectory().getPath().toString());
getChildren().setAll(container);
}
}
@@ -1,302 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.directory;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.RequiredFieldValidator;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.GameDirectory;
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.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Page used to create or edit a game directory entry.
@NotNullByDefault
public final class GameDirectoryPage extends BorderPane implements DecoratorPage {
/// Decorator title state for this page.
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
/// Editable game directory path shown in the directory chooser.
private final StringProperty location;
/// Game directory being edited, or `null` when creating a new entry.
private final @Nullable GameDirectory gameDirectory;
/// Text field used to edit the game directory display name.
private final JFXTextField txtGameDirectoryName;
/// Directory chooser bound to [#location].
private final LineFileChooserButton gameDir;
/// Switch that controls whether a saved path is converted to a relative path when possible.
private final LineSwitch switchUseRelativePath;
/// Creates a page for adding a new game directory or editing an existing game directory.
///
/// @param gameDirectory the edited game directory, or `null` when creating a new entry
public GameDirectoryPage(@Nullable GameDirectory gameDirectory) {
getStyleClass().add("gray-background");
this.gameDirectory = gameDirectory;
String gameDirectoryDisplayName = Optional.ofNullable(gameDirectory).map(GameDirectoryManager::getGameDirectoryDisplayName).orElse("");
state.set(State.fromTitle(gameDirectory == null ? i18n("game_directory.new") : i18n("game_directory") + " - " + gameDirectoryDisplayName));
location = new SimpleStringProperty(this, "location",
Optional.ofNullable(gameDirectory).map(GameDirectory::getPath).map(PortablePath::toPath).map(FileUtils::getAbsolutePath).orElse(".minecraft"));
ScrollPane scroll = new ScrollPane();
this.setCenter(scroll);
scroll.setFitToHeight(true);
scroll.setFitToWidth(true);
{
VBox rootPane = new VBox();
rootPane.setStyle("-fx-padding: 20;");
{
ComponentList componentList = new ComponentList();
{
BorderPane gameDirectoryNamePane = new BorderPane();
{
Label label = new Label(i18n("game_directory.name"));
gameDirectoryNamePane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
txtGameDirectoryName = new JFXTextField();
gameDirectoryNamePane.setRight(txtGameDirectoryName);
RequiredFieldValidator validator = new RequiredFieldValidator();
validator.setMessage(i18n("input.not_empty"));
txtGameDirectoryName.getValidators().add(validator);
BorderPane.setMargin(txtGameDirectoryName, new Insets(8, 0, 8, 0));
txtGameDirectoryName.setText(gameDirectoryDisplayName);
txtGameDirectoryName.getValidators().add(new ValidatorBase() {
{
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())));
}
});
}
gameDir = new LineFileChooserButton();
gameDir.setTitle(i18n("game_directory.instance_directory"));
gameDir.setFileChooserTitle(i18n("game_directory.instance_directory.choose"));
gameDir.setType(LineFileChooserButton.Type.OPEN_DIRECTORY);
gameDir.locationProperty().bindBidirectional(location);
switchUseRelativePath = new LineSwitch();
switchUseRelativePath.setTitle(i18n("game_directory.use_relative_path"));
gameDir.convertToRelativePathProperty().bind(switchUseRelativePath.selectedProperty());
if (gameDirectory != null) {
switchUseRelativePath.setSelected(!gameDirectory.getPath().isAbsolute());
}
componentList.getContent().setAll(gameDirectoryNamePane, gameDir, switchUseRelativePath);
}
rootPane.getChildren().setAll(componentList);
}
scroll.setContent(rootPane);
}
BorderPane savePane = new BorderPane();
this.setBottom(savePane);
savePane.setPickOnBounds(false);
savePane.setStyle("-fx-padding: 20;");
StackPane.setAlignment(savePane, Pos.BOTTOM_RIGHT);
{
JFXButton saveButton = FXUtils.newRaisedButton(i18n("button.save"));
savePane.setRight(saveButton);
BorderPane.setAlignment(savePane, Pos.BOTTOM_RIGHT);
StackPane.setAlignment(saveButton, Pos.BOTTOM_RIGHT);
saveButton.setPrefSize(100, 40);
saveButton.setOnAction(e -> onSave());
String initialLocation = getLocation();
boolean initialUseRelativePath = switchUseRelativePath.isSelected();
saveButton.disableProperty().bind(Bindings.createBooleanBinding(
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation())
|| (gameDirectory != null
&& Objects.equals(txtGameDirectoryName.getText(), gameDirectoryDisplayName)
&& Objects.equals(getLocation(), initialLocation)
&& switchUseRelativePath.isSelected() == initialUseRelativePath),
txtGameDirectoryName.textProperty(), location, switchUseRelativePath.selectedProperty()));
}
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
private void doSave() {
if (gameDirectory != null) {
LocalizedText name = LocalizedText.plain(txtGameDirectoryName.getText());
PortablePath path = StringUtils.isNotBlank(getLocation()) ? createPortableLocation() : gameDirectory.getPath();
if (!GameDirectoryManager.canUpdateGameDirectory(gameDirectory, path)) {
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
GameDirectoryManager.forceOverwriteGameDirectoryFiles(gameDirectory, path);
GameDirectoryManager.updateGameDirectory(gameDirectory, name, path);
fireEvent(new PageCloseEvent());
});
return;
}
GameDirectoryManager.updateGameDirectory(gameDirectory, name, path);
} else {
if (StringUtils.isBlank(getLocation())) {
gameDir.fire();
}
GameDirectory newGameDirectory = new GameDirectory(
GameDirectoryManager.newGameDirectoryId(),
LocalizedText.plain(txtGameDirectoryName.getText()),
createPortableLocation());
if (newGameDirectory.getPath().isAbsolute()) {
if (SettingsManager.isUserGameDirectoriesReadOnly()) {
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);
}
}
fireEvent(new PageCloseEvent());
}
/// Saves the edited game directory or adds a new entry to the appropriate game directory store.
@FXThread
private void onSave() {
if (Objects.equals(Path.of(getLocation()).getRoot(), Path.of(getLocation()))) {
Controllers.confirm(i18n("game_directory.root"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING, this::doSave, null);
} else doSave();
}
/// Creates the portable path for the current location according to the relative-path switch.
private PortablePath createPortableLocation() {
if (switchUseRelativePath.isSelected()) {
Path path = Path.of(getLocation());
Path absolutePath = FileUtils.toAbsolute(path);
try {
return PortablePath.fromPath(Metadata.CURRENT_DIRECTORY.relativize(absolutePath).normalize());
} catch (IllegalArgumentException ignored) {
// Keep the original path when it cannot be expressed relative to the launcher directory.
}
}
return PortablePath.of(getLocation());
}
/// Returns the decorator title state for this page.
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
/// Returns the current directory path text.
public String getLocation() {
return location.get();
}
/// Returns the editable directory path property.
public StringProperty locationProperty() {
return location;
}
/// Updates the directory path text.
///
/// @param location the new directory path text
public void setLocation(String location) {
this.location.set(location);
}
}
@@ -2,152 +2,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.Node;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLDependencyManager;
import org.jackhuang.hmcl.game.HMCLGameBuilder;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TabController;
import org.jackhuang.hmcl.ui.construct.TabItem;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.DownloadListPage;
import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
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 org.jetbrains.annotations.Unmodifiable;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage {
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_MOD =
(downloadProvider, repository, version, mod, file) -> download(downloadProvider, repository, version, file, "mods");
/// Helpers for downloading a remote addon file into an instance subdirectory.
///
/// The former free-install / CurseForge-Modrinth browse hub was removed for the
/// server-client launcher; only install-into-folder callbacks remain for update flows.
public final class DownloadPage {
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
(downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks");
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER =
(downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks");
private final ReadOnlyObjectWrapper<DecoratorPage.State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1));
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController;
/// The tab for choosing a game version to install.
private final TabItem<VersionsPage> newGameTab;
/// The tab for browsing and importing modpacks.
private final TabItem<DownloadListPage> modpackTab;
/// The tab for browsing downloadable mods.
private final TabItem<DownloadListPage> modTab;
/// The tab for browsing downloadable resource packs.
private final TabItem<DownloadListPage> resourcePackTab;
/// The tab for browsing downloadable shader packs.
private final TabItem<DownloadListPage> shaderTab;
/// The tab for browsing downloadable worlds.
private final TabItem<DownloadListPage> worldTab;
private final TransitionPane transitionPane = new TransitionPane();
private final DownloadNavigator versionPageNavigator = new DownloadNavigator();
private WeakListenerHolder listenerHolder;
/// Creates the download tabs and fills the shared navigation sidebar.
public DownloadPage() {
this(null);
}
/// Creates download navigation, optionally targeting an existing instance for modpack installation.
public DownloadPage(@Nullable GameInstanceID uploadInstance) {
this.newGameTab = new TabItem<>(() -> new VersionsPage(
versionPageNavigator,
i18n("install.installer.choose", i18n("install.installer.game")),
GameVersionNumber.unknown(),
DownloadProviders.getDownloadProvider(),
GameComponentType.GAME, versionPageNavigator::onGameSelected));
this.modpackTab = newDownloadListTab(() -> {
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file);
}, false);
JFXButton installLocalModpackButton = FXUtils.newRaisedButton(i18n("install.modpack"));
installLocalModpackButton.setOnAction(e -> Instances.importModpack());
page.getActions().add(installLocalModpackButton);
return page;
});
this.modTab = newDownloadListTab(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true));
this.resourcePackTab = newDownloadListTab(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true));
this.shaderTab = newDownloadListTab(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true));
this.worldTab = newDownloadListTab(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance()));
this.tabController = new TabController(transitionPane,
newGameTab,
modpackTab,
modTab,
resourcePackTab,
shaderTab,
worldTab
);
GameDirectoryManager.registerVersionsListener(this::loadVersions);
tabController.selectTab(newGameTab);
getSidebar()
.startCategory(i18n("download.game").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, newGameTab, i18n("game"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL)
.addNavigationDrawerTab(tabController, modpackTab, i18n("modpack"), SVG.PACKAGE2, SVG.PACKAGE2_FILL)
.startCategory(i18n("download.content").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, modTab, i18n("mods"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(tabController, resourcePackTab, i18n("resourcepack"), SVG.TEXTURE)
.addNavigationDrawerTab(tabController, shaderTab, i18n("download.shader"), SVG.WB_SUNNY, SVG.WB_SUNNY_FILL)
.addNavigationDrawerTab(tabController, worldTab, i18n("world"), SVG.PUBLIC);
setContent(transitionPane);
}
private static TabItem<DownloadListPage> newDownloadListTab(Supplier<DownloadListPage> pageSupplier) {
return new TabItem<>(() -> {
DownloadListPage page = pageSupplier.get();
page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()));
return page;
});
private DownloadPage() {
}
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
@@ -184,205 +72,5 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
handler.resolve();
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), existingPaths::notContains));
}
private void loadVersions(HMCLGameRepository repository) {
listenerHolder = new WeakListenerHolder();
runInFX(() -> {
if (repository.getGameDirectory() == GameDirectoryManager.getSelectedGameDirectory()) {
listenerHolder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), version -> {
if (modTab.isInitialized()) {
modTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (modpackTab.isInitialized()) {
modpackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (resourcePackTab.isInitialized()) {
resourcePackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (shaderTab.isInitialized()) {
shaderTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
if (worldTab.isInitialized()) {
worldTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
}
}));
}
});
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
public void showGameDownloads() {
tabController.selectTab(newGameTab, false);
}
public void showModpackDownloads() {
tabController.selectTab(modpackTab, false);
}
public DownloadListPage showResourcePackDownloads() {
tabController.selectTab(resourcePackTab, false);
return resourcePackTab.getNode();
}
public DownloadListPage showModDownloads() {
tabController.selectTab(modTab, false);
return modTab.getNode();
}
public void showWorldDownloads() {
tabController.selectTab(worldTab, false);
}
private static final class DownloadNavigator implements Navigation {
private final SettingsMap settings = new SettingsMap();
@Override
public void onStart() {
}
@Override
public void onNext() {
}
@Override
public void onPrev(boolean cleanUp) {
}
@Override
public boolean canPrev() {
return false;
}
@Override
public void onFinish() {
}
@Override
public void onEnd() {
}
@Override
public void onCancel() {
}
@Override
public SettingsMap getSettings() {
return settings;
}
public void onGameSelected() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (repository.isLoaded()) {
Controllers.getDecorator().startWizard(new VanillaInstallWizardProvider(repository, (GameRemoteVersion) settings.get("game")), i18n("install.new_game"));
}
}
}
private static class VanillaInstallWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final HMCLDependencyManager dependencyManager;
private final DownloadProvider downloadProvider;
private final GameRemoteVersion gameVersion;
public VanillaInstallWizardProvider(HMCLGameRepository repository, GameRemoteVersion gameVersion) {
this.repository = repository;
this.gameVersion = gameVersion;
this.downloadProvider = DownloadProviders.getDownloadProvider();
this.dependencyManager = repository.getDependency(downloadProvider);
}
@Override
public void start(SettingsMap settings) {
settings.put(ModpackPage.GAME_DIRECTORY, repository.getGameDirectory());
settings.put(ModpackPage.REPOSITORY, repository);
settings.put(GameComponentType.GAME.getPatchId(), gameVersion);
}
/// Captures the installation selections and defers reserving the instance until execution.
/// Registers cleanup for the eventual builder, including cancellation before its build task
/// starts. Successful completion selects the installed instance in the original repository.
///
/// @param settings the installer selections and target instance id
/// @return a deferred build task retaining the builder task's stage hints
private Task<?> finishVersionDownloadingAsync(SettingsMap settings) {
@Nullable GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
if (instanceId == null) {
throw new IllegalStateException("Instance ID is not set");
}
String selectedGameVersion = ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId()))
.getGameVersion().toString();
@Unmodifiable List<ComponentRemoteVersion> components = settings.asStringMap().entrySet().stream()
.filter(entry -> !GameComponentType.GAME.getPatchId().equals(entry.getKey()))
.map(Map.Entry::getValue)
.filter(ComponentRemoteVersion.class::isInstance)
.map(ComponentRemoteVersion.class::cast)
.toList();
boolean modded = GameComponentType.MOD_LOADERS.stream()
.anyMatch(componentType -> settings.get(componentType.getPatchId()) instanceof ComponentRemoteVersion);
boolean isolated = repository.shouldIsolateNewInstance(modded);
AtomicReference<@Nullable HMCLGameBuilder> builderReference = new AtomicReference<>();
settings.put(TaskCleanup.KEY, () -> {
@Nullable HMCLGameBuilder builder = builderReference.get();
if (builder != null)
builder.abort();
});
return Task.composeAsync(() -> {
try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) {
builderReference.set(builder);
builder.component(GameComponentType.GAME, selectedGameVersion);
components.forEach(builder::component);
if (isolated) {
builder.enableIsolation();
}
Task<?> buildTask = builder.buildAsync();
buildTask.onDone().register(event -> {
if (!event.isFailed()) {
runInFX(() -> repository.setSelectedInstance(repository.getInstance(instanceId)));
}
});
return buildTask;
}
});
}
@Override
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.new_game.installation"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
return finishVersionDownloadingAsync(settings);
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
switch (step) {
case 0:
return new InstallersPage(controller, repository, ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider);
default:
throw new IllegalStateException("error step " + step + ", settings: " + settings + ", pages: " + controller.getPages());
}
}
@Override
public boolean cancel() {
return true;
}
}
}
@@ -13,7 +13,6 @@ 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;
import org.jackhuang.hmcl.task.FileDownloadTask;
@@ -34,7 +33,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.ui.download.LocalModpackPage.MODPACK_FILE;
import static org.jackhuang.hmcl.ui.download.LocalModpackPage.MODPACK_NAME;
import static org.jackhuang.hmcl.ui.download.RemoteModpackPage.MODPACK_SERVER_MANIFEST;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -48,13 +46,12 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
title.setPadding(new Insets(8));
this.getStyleClass().add("jfx-list-view");
this.setMaxSize(400, 150);
this.setMaxSize(400, 120);
this.setSpacing(8);
this.getChildren().setAll(
title,
createButton("local", this::onChooseLocalFile),
createButton("remote", this::onChooseRemoteFile),
createButton("repository", this::onChooseRepository)
createButton("remote", this::onChooseRemoteFile)
);
Path filePath = controller.getSettings().get(MODPACK_FILE);
@@ -148,12 +145,6 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}, "", new URLValidator());
}
public void onChooseRepository() {
String modPackName = controller.getSettings().get(MODPACK_NAME);
DownloadPage downloadPage = new DownloadPage(modPackName != null ? new GameInstanceID(modPackName) : null);
downloadPage.showModpackDownloads();
Controllers.navigate(downloadPage);
}
@Override
public void cleanup(SettingsMap settings) {
@@ -1,231 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.export;
import javafx.scene.Node;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
public final class ExportWizardProvider implements WizardProvider {
private final HMCLGameInstance gameInstance;
public ExportWizardProvider(HMCLGameInstance gameInstance) {
this.gameInstance = gameInstance;
}
@Override
public void start(SettingsMap settings) {
}
@Override
public Object finish(SettingsMap settings) {
List<String> whitelist = settings.get(ModpackFileSelectionPage.MODPACK_FILE_SELECTION);
Path modpackFile = settings.get(ModpackFileSelectionPage.MODPACK_FILE);
ModpackExportInfo exportInfo = Objects.requireNonNull(settings.get(ModpackInfoPage.MODPACK_INFO));
exportInfo.setWhitelist(whitelist);
String modpackType = settings.get(ModpackTypeSelectionPage.MODPACK_TYPE);
return exportWithLauncher(modpackType, exportInfo, modpackFile);
}
private Task<?> exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, Path modpackFile) {
@Nullable Path launcherJar = JarUtils.thisJarPath();
boolean packWithLauncher = exportInfo.isPackWithLauncher() && launcherJar != null;
return new Task<>() {
@Nullable Path tempModpack;
@Nullable Task<?> exportTask;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() throws Exception {
Path dest;
if (packWithLauncher) {
dest = tempModpack = Files.createTempFile("hmcl", ".zip");
} else {
dest = modpackFile;
}
switch (modpackType) {
case ModpackTypeSelectionPage.MODPACK_TYPE_MCBBS:
exportTask = exportAsMcbbs(exportInfo, dest);
break;
case ModpackTypeSelectionPage.MODPACK_TYPE_SERVER:
exportTask = exportAsServer(exportInfo, dest);
break;
case ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH:
exportTask = exportAsModrinth(exportInfo, dest);
break;
default:
throw new IllegalStateException("Unrecognized modpack type " + modpackType);
}
}
@Override
public Collection<Task<?>> getDependents() {
return Collections.singleton(exportTask);
}
@Override
public void execute() throws Exception {
if (!packWithLauncher) return;
try (Zipper zip = new Zipper(modpackFile)) {
LauncherSettings exported = new LauncherSettings();
LauncherSettings current = settings();
exported.versionListSourceProperty().set(current.versionListSourceProperty().get());
exported.fileDownloadSourceProperty().set(current.fileDownloadSourceProperty().get());
exported.preferredLoginTypeProperty().set(current.preferredLoginTypeProperty().get());
zip.putTextFile(exported.toJson(), ".hmcl/config/launcher-settings.json");
AuthlibInjectorServerList exportedServers = new AuthlibInjectorServerList();
exportedServers.getServers().setAll(SettingsManager.getAuthlibInjectorServers());
zip.putTextFile(
JsonUtils.GSON.toJson(exportedServers, AuthlibInjectorServerList.class),
".hmcl/config/authlib-injector-servers.json");
// Bundled package under .hmcl/modpack/ (not the process workdir).
String packageName = ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType)
? "modpack.mrpack"
: "modpack.zip";
zip.putFile(tempModpack, ".hmcl/" + Metadata.BUNDLED_MODPACK_DIRECTORY_NAME + "/" + packageName);
for (String extension : FontManager.FONT_EXTENSIONS) {
String fileName = "font." + extension;
Path font = Metadata.HMCL_LOCAL_HOME.resolve(fileName);
if (!Files.isRegularFile(font))
font = Metadata.CURRENT_DIRECTORY.resolve(fileName);
if (Files.isRegularFile(font))
zip.putFile(font, ".hmcl/" + fileName);
}
zip.putFile(launcherJar, launcherJar.getFileName().toString());
} finally {
if (tempModpack != null) {
try {
Files.deleteIfExists(tempModpack);
} catch (Exception ignored) {
}
}
}
}
};
}
private Task<?> exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
@Nullable Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new McbbsModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
}
@Override
public Collection<Task<?>> getDependencies() {
return Collections.singleton(dependency);
}
};
}
private Task<?> exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
@Nullable Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ServerModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
}
@Override
public Collection<Task<?>> getDependencies() {
return Collections.singleton(dependency);
}
};
}
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
@Nullable Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
resolveCurrentGameInstance(),
exportInfo,
modpackFile
);
}
@Override
public Collection<Task<?>> getDependencies() {
return Collections.singleton(dependency);
}
};
}
/// Returns the current registered snapshot for the instance selected by this wizard.
///
/// @return the current registered instance
private HMCLGameInstance resolveCurrentGameInstance() {
return gameInstance.getRepository().getInstance(gameInstance.getId());
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
return switch (step) {
case 0 -> new ModpackTypeSelectionPage(controller);
case 1 -> new ModpackInfoPage(controller, gameInstance);
case 2 -> new ModpackFileSelectionPage(controller, gameInstance, ModAdviser::suggestMod);
default -> throw new IllegalArgumentException("step");
};
}
@Override
public boolean cancel() {
return true;
}
}
@@ -1,325 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.export;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckTreeCell;
import com.jfoenix.controls.JFXTreeView;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.CheckBoxTreeItem;
import javafx.scene.control.Label;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.export.ModpackInfoPage.MODPACK_INFO;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author huangyuhui
*/
public final class ModpackFileSelectionPage extends BorderPane implements WizardPage {
public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("modpack.file");
private final WizardController controller;
private final HMCLGameInstance gameInstance;
private final ModAdviser adviser;
private @Nullable ModpackFileTreeItem rootNode;
/// Creates a file-selection page and starts loading the instance's exportable files.
public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) {
this.controller = controller;
this.gameInstance = gameInstance;
this.adviser = adviser;
JFXTreeView<String> treeView = new JFXTreeView<>();
treeView.setCellFactory(__ -> new ModpackFileTreeCell());
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
onEscPressed(treeView, () -> controller.onPrev(true));
Label placeholder = new Label(i18n("modpack.files.empty"));
StackPane placeholderPane = new StackPane(placeholder);
placeholderPane.getStyleClass().add("notice-pane");
placeholderPane.setVisible(false);
SpinnerPane spinnerPane = new SpinnerPane();
StackPane center = new StackPane(treeView, placeholderPane);
spinnerPane.setContent(center);
setMargin(spinnerPane, new Insets(10, 10, 5, 10));
this.setCenter(spinnerPane);
HBox nextPane = new HBox();
nextPane.setPadding(new Insets(16, 16, 16, 0));
nextPane.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnNext = FXUtils.newRaisedButton(i18n("wizard.next"));
btnNext.setPrefSize(100, 40);
btnNext.setOnAction(e -> onNext());
nextPane.getChildren().setAll(btnNext);
loadRoot(treeView, placeholderPane, spinnerPane, btnNext);
spinnerPane.setOnFailedAction((__) -> loadRoot(treeView, placeholderPane, spinnerPane, btnNext));
this.setBottom(nextPane);
}
private void loadRoot(JFXTreeView<String> treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) {
spinnerPane.setLoading(true);
btnNext.setDisable(true);
CompletableFuture
.supplyAsync(() -> getTreeItem(gameInstance.getRunDirectory(), "minecraft", 0), Schedulers.io())
.whenCompleteAsync((root, throwable) -> {
if (throwable == null) {
if (root != null) {
treeView.setRoot(rootNode = root);
} else {
placeholderPane.setVisible(true);
}
spinnerPane.setFailedReason(null);
btnNext.setDisable(false);
} else {
LOG.warning("Failed to load modpack file tree", throwable);
spinnerPane.setFailedReason(i18n("modpack.files.load_failed"));
}
spinnerPane.setLoading(false);
}, Schedulers.javafx());
}
private ModpackFileTreeItem getTreeItem(Path file, String basePath, int level) {
if (Files.notExists(file))
return null;
boolean isDirectory = Files.isDirectory(file);
ModAdviser.ModSuggestion state = ModAdviser.ModSuggestion.SUGGESTED;
if (level > 0) {
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (isDirectory ? "/" : ""), isDirectory);
String fileName = FileUtils.getName(file);
if (!isDirectory) {
switch (fileName) {
case ".DS_Store", // macOS system file
"desktop.ini", "Thumbs.db" // Windows system files
-> state = ModAdviser.ModSuggestion.HIDDEN;
}
if (fileName.startsWith("._")) // macOS system file
state = ModAdviser.ModSuggestion.HIDDEN;
if (FileUtils.getNameWithoutExtension(file).equals(gameInstance.getId().toString()))
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (isDirectory) {
if (fileName.equals(gameInstance.getId() + "-natives")) { // Ignore <version>-natives
state = ModAdviser.ModSuggestion.HIDDEN;
}
if (level == 1 && fileName.startsWith("natives-")) { // Ignore natives-os-arch
state = ModAdviser.ModSuggestion.HIDDEN;
}
}
if (state == ModAdviser.ModSuggestion.HIDDEN)
return null;
}
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? gameInstance.getId().toString() : StringUtils.substringAfterLast(basePath, '/'), basePath);
if (state == ModAdviser.ModSuggestion.SUGGESTED)
node.setSelected(true);
if (isDirectory) {
try (var stream = Files.list(file)) {
stream.map(path -> Pair.pair(path, Files.isDirectory(path))).sorted((p1, p2) -> {
if (p1.value().equals(p2.value())) return FileUtils.getName(p1.key()).compareToIgnoreCase(FileUtils.getName(p2.key()));
return p1.value() ? -1 : 1;
}).map(Pair::key).forEach(it -> {
ModpackFileTreeItem subNode = getTreeItem(it, basePath + "/" + FileUtils.getName(it), level + 1);
if (subNode != null) {
node.setSelected(subNode.isSelected() || node.isSelected());
if (!subNode.isSelected()) {
node.setIndeterminate(true);
}
node.getChildren().add(subNode);
}
});
} catch (IOException e) {
LOG.warning("Failed to list contents of " + file, e);
}
if (!node.isSelected()) node.setIndeterminate(false);
// Empty folder need not to be displayed.
if (node.getChildren().isEmpty()) {
return null;
}
}
return node;
}
/// Appends selected paths relative to `minecraft/`, traversing partially selected directories.
/// A `null` node contributes no paths.
private void getFilesNeeded(@Nullable ModpackFileTreeItem node, String basePath, List<String> list) {
if (node == null) return;
if (node.isSelected() || node.isIndeterminate()) {
if (basePath.length() > "minecraft/".length())
list.add(StringUtils.substringAfter(basePath, "minecraft/"));
for (TreeItem<String> child : node.getChildren()) {
if (child instanceof ModpackFileTreeItem mChild) {
getFilesNeeded(mChild, basePath + "/" + mChild.getValue(), list);
}
}
}
}
@Override
public void cleanup(SettingsMap settings) {
controller.getSettings().remove(MODPACK_FILE_SELECTION);
}
private void onNext() {
var info = Objects.requireNonNull(controller.getSettings().get(MODPACK_INFO));
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("modpack.wizard.step.initialization.save"));
if (!info.isPackWithLauncher() && MODPACK_TYPE_MODRINTH.equals(controller.getSettings().get(MODPACK_TYPE))) {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.mrpack"));
fileChooser.setInitialFileName(info.getName() + (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ? "" : ".mrpack"));
} else {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
fileChooser.setInitialFileName(info.getName() + ".zip");
}
Path file = Controllers.showSaveDialog(fileChooser);
if (file == null) {
return;
}
controller.getSettings().put(MODPACK_FILE, file);
ArrayList<String> list = new ArrayList<>();
getFilesNeeded(rootNode, "minecraft", list);
controller.getSettings().put(MODPACK_FILE_SELECTION, list);
controller.onFinish();
}
@Override
public String getTitle() {
return i18n("modpack.wizard.step.2.title");
}
public static final SettingsMap.Key<List<String>> MODPACK_FILE_SELECTION = new SettingsMap.Key<>("modpack.accepted");
private static final Map<String, String> TRANSLATION = mapOf(
pair("minecraft/hmclversion.cfg", i18n("modpack.files.hmclversion_cfg")),
pair("minecraft/servers.dat", i18n("modpack.files.servers_dat")),
pair("minecraft/saves", i18n("modpack.files.saves")),
pair("minecraft/mods", i18n("modpack.files.mods")),
pair("minecraft/config", i18n("modpack.files.config")),
pair("minecraft/liteconfig", i18n("modpack.files.liteconfig")),
pair("minecraft/resourcepacks", i18n("modpack.files.resourcepacks")),
pair("minecraft/resources", i18n("modpack.files.resourcepacks")),
pair("minecraft/options.txt", i18n("modpack.files.options_txt")),
pair("minecraft/optionsshaders.txt", i18n("modpack.files.optionsshaders_txt")),
pair("minecraft/mods/VoxelMods", i18n("modpack.files.mods.voxelmods")),
pair("minecraft/dumps", i18n("modpack.files.dumps")),
pair("minecraft/blueprints", i18n("modpack.files.blueprints")),
pair("minecraft/scripts", i18n("modpack.files.scripts"))
);
/// Stores a file name, optional explanation, and hierarchical selection state.
@NotNullByDefault
private static final class ModpackFileTreeItem extends CheckBoxTreeItem<String> {
/// The localized explanation for a recognized path, or `null` if none exists.
private final @Nullable String comment;
/// Creates an item whose value is the file name; only the `minecraft` root starts expanded.
public ModpackFileTreeItem(String fileName, String basePath) {
super(fileName);
this.comment = TRANSLATION.get(basePath);
this.setExpanded("minecraft".equals(basePath));
}
}
/// Renders file names and localized explanations alongside the bound checkbox.
@NotNullByDefault
private static final class ModpackFileTreeCell extends JFXCheckTreeCell<String> {
/// Contains the inherited checkbox graphic followed by the file labels.
private final HBox content = new HBox();
/// Displays the current file name.
private final Label fileName = new Label();
/// Displays the current path's optional explanation.
private final Label comment = new Label();
/// Creates reusable labels whose mouse events pass through to the cell.
private ModpackFileTreeCell() {
fileName.setMouseTransparent(true);
comment.setStyle("-fx-text-fill: -monet-on-surface-variant;");
comment.setMouseTransparent(true);
content.setAlignment(Pos.CENTER_LEFT);
content.setPickOnBounds(false);
}
/// Refreshes the file labels and removes content when the cell is cleared.
@Override
protected void updateDisplay(@Nullable String item, boolean empty) {
content.getChildren().clear();
super.updateDisplay(item, empty);
fileName.setText(null);
comment.setText(null);
if (empty || item == null) {
return;
}
// Updating the text makes the skin reattach the current graphic to the cell.
// Clear it before moving the checkbox graphic into the content container.
setText(null);
@Nullable Node graphic = getGraphic();
if (graphic != null) {
content.getChildren().add(graphic);
}
fileName.setText(item);
content.getChildren().add(fileName);
if (getTreeItem() instanceof ModpackFileTreeItem treeItem && treeItem.comment != null) {
comment.setText(treeItem.comment);
content.getChildren().add(comment);
}
setGraphic(content);
}
}
}
@@ -1,377 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.export;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXSlider;
import com.jfoenix.controls.JFXTextArea;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.binding.Bindings;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.*;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackInfoPage extends Control implements WizardPage {
private final WizardController controller;
private final HMCLGameInstance gameInstance;
private final ModpackExportInfo.Options options;
private final boolean canIncludeLauncher;
private final ModpackExportInfo exportInfo = new ModpackExportInfo();
private final SimpleStringProperty name = new SimpleStringProperty("");
private final SimpleStringProperty author = new SimpleStringProperty("");
private final SimpleStringProperty version = new SimpleStringProperty("1.0");
private final SimpleStringProperty description = new SimpleStringProperty("");
private final SimpleStringProperty url = new SimpleStringProperty("");
private final SimpleBooleanProperty forceUpdate = new SimpleBooleanProperty();
private final SimpleBooleanProperty packWithLauncher = new SimpleBooleanProperty();
private final SimpleStringProperty fileApi = new SimpleStringProperty("");
private final SimpleIntegerProperty minMemory = new SimpleIntegerProperty(0);
private final SimpleStringProperty authlibInjectorServer = new SimpleStringProperty();
private final SimpleStringProperty launchArguments = new SimpleStringProperty("");
private final SimpleStringProperty javaArguments = new SimpleStringProperty("");
private final SimpleStringProperty mcbbsThreadId = new SimpleStringProperty("");
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
public ModpackInfoPage(WizardController controller, HMCLGameInstance gameInstance) {
this.controller = controller;
this.gameInstance = gameInstance;
this.options = controller.getSettings().get(MODPACK_INFO_OPTION);
if (this.options == null)
throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required");
name.set(gameInstance.getId().toString());
author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse(""));
GameSettings.Effective versionSetting = gameInstance.getEffectiveSettings();
minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0));
launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty));
javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty));
canIncludeLauncher = JarUtils.thisJarPath() != null;
}
private void onNext() {
exportInfo.setName(name.get())
.setFileApi(fileApi.get())
.setVersion(version.get())
.setAuthor(author.get())
.setDescription(description.get())
.setUrl(url.get())
.setForceUpdate(forceUpdate.get())
.setPackWithLauncher(packWithLauncher.get())
.setMinMemory(minMemory.get())
.setLaunchArguments(launchArguments.get())
.setJavaArguments(javaArguments.get())
.setAuthlibInjectorServer(authlibInjectorServer.get())
.setNoCreateRemoteFiles(noCreateRemoteFiles.get())
.setSkipCurseForgeRemoteFiles(skipCurseForgeRemoteFiles.get());
if (StringUtils.isNotBlank(mcbbsThreadId.get())) {
exportInfo.setOrigins(Collections.singletonList(new McbbsModpackManifest.Origin(
"mcbbs", Integer.parseInt(mcbbsThreadId.get())
)));
}
controller.getSettings().put(MODPACK_INFO, exportInfo);
controller.onNext();
}
@Override
public void cleanup(SettingsMap settings) {
controller.getSettings().remove(MODPACK_INFO);
}
@Override
public String getTitle() {
return i18n("modpack.wizard.step.1.title");
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModpackInfoPageSkin(this);
}
public static final SettingsMap.Key<ModpackExportInfo> MODPACK_INFO = new SettingsMap.Key<>("modpack.info");
public static final SettingsMap.Key<ModpackExportInfo.Options> MODPACK_INFO_OPTION = new SettingsMap.Key<>("modpack.info.option");
public static class ModpackInfoPageSkin extends SkinBase<ModpackInfoPage> {
private final List<JFXTextField> validatingFields = new ArrayList<>();
public ModpackInfoPageSkin(ModpackInfoPage skinnable) {
super(skinnable);
Insets componentListMargin = new Insets(16, 0, 16, 0);
ScrollPane scroll = new ScrollPane();
scroll.setFitToWidth(true);
scroll.setFitToHeight(true);
getChildren().setAll(scroll);
{
BorderPane borderPane = new BorderPane();
borderPane.setStyle("-fx-padding: 16;");
scroll.setContent(borderPane);
if (MODPACK_TYPE_SERVER.equals(skinnable.controller.getSettings().get(MODPACK_TYPE))) {
Hyperlink hyperlink = new Hyperlink(i18n("modpack.wizard.step.initialization.server"));
hyperlink.setOnAction(e -> FXUtils.openLink(Metadata.DOCS_URL + "/modpack/serverpack.html"));
borderPane.setTop(hyperlink);
}
if (MODPACK_TYPE_MODRINTH.equals(skinnable.controller.getSettings().get(MODPACK_TYPE))) {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.modrinth.info"));
borderPane.setTop(pane);
} else {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.warning"));
borderPane.setTop(pane);
}
{
ComponentList list = new ComponentList();
BorderPane.setMargin(list, componentListMargin);
borderPane.setCenter(list);
var instanceNamePane = new LineTextPane();
{
instanceNamePane.setTitle(i18n("modpack.wizard.step.initialization.exported_version"));
instanceNamePane.setText(skinnable.gameInstance.getId().toString());
list.getContent().add(instanceNamePane);
}
list.getContent().addAll(
createTextFieldLinePane(i18n("modpack.name"), skinnable.name, new RequiredValidator()),
createTextFieldLinePane(i18n("archive.version"), skinnable.version, new RequiredValidator())
);
if (skinnable.options.isRequireAuthor()) {
list.getContent().add(
createTextFieldLinePane(i18n("archive.author"), skinnable.author, new RequiredValidator())
);
}
if (skinnable.options.isRequireFileApi()) {
list.getContent().add(createTextFieldLinePane(
i18n("modpack.file_api"), skinnable.fileApi,
skinnable.options.isValidateFileApi() ? new RequiredValidator() : null,
new URLValidator(true)
));
}
if (skinnable.options.isRequireLaunchArguments()) {
list.getContent().add(createTextFieldLinePane(
i18n("settings.advanced.minecraft_arguments"), skinnable.launchArguments
));
}
if (skinnable.options.isRequireJavaArguments()) {
list.getContent().add(createTextFieldLinePane(
i18n("settings.advanced.jvm_args"), skinnable.javaArguments
));
}
if (skinnable.options.isRequireUrl()) {
list.getContent().add(createTextFieldLinePane(
i18n("modpack.origin.url"), skinnable.url
));
}
if (skinnable.options.isRequireOrigins()) {
list.getContent().add(createTextFieldLinePane(
i18n("modpack.origin.mcbbs"), skinnable.mcbbsThreadId,
new NumberValidator(i18n("input.number"), true)
));
}
if (skinnable.options.isRequireMinMemory()) {
VBox pane = new VBox();
Label title = new Label(i18n("settings.memory"));
VBox.setMargin(title, new Insets(0, 0, 8, 0));
HBox lowerBoundPane = new HBox(8);
lowerBoundPane.setAlignment(Pos.CENTER);
VBox.setMargin(lowerBoundPane, new Insets(0, 0, 0, 16));
{
Label label = new Label(i18n("settings.memory.lower_bound"));
JFXSlider slider = new JFXSlider(0, 1, 0);
HBox.setMargin(slider, new Insets(0, 0, 0, 8));
HBox.setHgrow(slider, Priority.ALWAYS);
slider.setValueFactory(self -> Bindings.createStringBinding(() -> (int) (self.getValue() * 100) + "%", self.valueProperty()));
AtomicBoolean changedByTextField = new AtomicBoolean(false);
FXUtils.onChangeAndOperate(skinnable.minMemory, minMemory -> {
changedByTextField.set(true);
slider.setValue(minMemory.intValue() * 1.0 / MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()));
changedByTextField.set(false);
});
slider.valueProperty().addListener((value, oldVal, newVal) -> {
if (changedByTextField.get()) return;
skinnable.minMemory.set((int) (value.getValue().doubleValue() * MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())));
});
JFXTextField txtMinMemory = new JFXTextField();
FXUtils.bindInt(txtMinMemory, skinnable.minMemory);
txtMinMemory.getValidators().add(new NumberValidator(i18n("input.number"), false));
FXUtils.setLimitWidth(txtMinMemory, 60);
validatingFields.add(txtMinMemory);
lowerBoundPane.getChildren().setAll(label, slider, txtMinMemory, new Label("MiB"));
}
pane.getChildren().setAll(title, lowerBoundPane);
list.getContent().add(pane);
}
{
VBox pane = new VBox(8);
JFXTextArea area = new JFXTextArea();
area.textProperty().bindBidirectional(skinnable.description);
area.setMinHeight(400);
pane.getChildren().setAll(new Label(i18n("modpack.desc")), area);
list.getContent().add(pane);
}
if (skinnable.options.isRequireAuthlibInjectorServer()) {
var serversSelectButton = new LineSelectButton<AuthlibInjectorServer>();
serversSelectButton.setTitle(i18n("account.injector.server"));
serversSelectButton.setNullSafeConverter(AuthlibInjectorServer::getName);
serversSelectButton.setDescriptionConverter(AuthlibInjectorServer::getUrl);
serversSelectButton.itemsProperty().set(getAuthlibInjectorServers());
skinnable.authlibInjectorServer.bind(Bindings.createStringBinding(() -> {
AuthlibInjectorServer selected = serversSelectButton.getValue();
return selected != null ? selected.getUrl() : null;
}, serversSelectButton.valueProperty()));
list.getContent().add(serversSelectButton);
}
if (skinnable.options.isRequireForceUpdate()) {
var requireForceUpdateButton = new LineSwitch();
requireForceUpdateButton.setTitle(i18n("modpack.wizard.step.initialization.force_update"));
requireForceUpdateButton.selectedProperty().bindBidirectional(skinnable.forceUpdate);
list.getContent().add(requireForceUpdateButton);
}
{
var canIncludeLauncherButton = new LineSwitch();
canIncludeLauncherButton.setTitle(i18n("modpack.wizard.step.initialization.include_launcher"));
canIncludeLauncherButton.setDisable(!skinnable.canIncludeLauncher);
canIncludeLauncherButton.selectedProperty().bindBidirectional(skinnable.packWithLauncher);
list.getContent().add(canIncludeLauncherButton);
}
if (skinnable.options.isRequireNoCreateRemoteFiles()) {
var requireNoCreateRemoteFilesButton = new LineSwitch();
requireNoCreateRemoteFilesButton.setTitle(i18n("modpack.wizard.step.initialization.no_create_remote_files"));
requireNoCreateRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.noCreateRemoteFiles);
list.getContent().add(requireNoCreateRemoteFilesButton);
}
if (skinnable.options.isRequireSkipCurseForgeRemoteFiles()) {
var skipCurseForgeRemoteFilesButton = new LineSwitch();
skipCurseForgeRemoteFilesButton.setTitle(i18n("modpack.wizard.step.initialization.skip_curseforge_remote_files"));
skipCurseForgeRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.skipCurseForgeRemoteFiles);
list.getContent().add(skipCurseForgeRemoteFilesButton);
}
}
{
HBox hbox = new HBox();
hbox.setAlignment(Pos.CENTER_RIGHT);
borderPane.setBottom(hbox);
JFXButton nextButton = FXUtils.newRaisedButton(i18n("wizard.next"));
nextButton.setOnAction(e -> skinnable.onNext());
nextButton.setPrefWidth(100);
nextButton.setPrefHeight(40);
nextButton.disableProperty().bind(
// Disable nextButton if any text of JFXTextFields in validatingFields does not fulfill
// our requirement.
Bindings.createBooleanBinding(() -> validatingFields.stream()
.map(field -> !field.validate())
.reduce(false, (left, right) -> left || right),
validatingFields.stream().map(JFXTextField::textProperty).toArray(StringProperty[]::new)));
hbox.getChildren().add(nextButton);
}
}
FXUtils.smoothScrolling(scroll);
}
private LinePane createTextFieldLinePane(String title, StringProperty property, ValidatorBase... validators) {
LinePane linePane = new LinePane();
JFXTextField textField = new JFXTextField();
textField.setMinWidth(500);
linePane.setTitle(title);
linePane.setRight(textField);
textField.textProperty().bindBidirectional(property);
boolean needValidation = false;
if (validators != null) {
for (ValidatorBase validator : validators) {
if (validator != null) {
needValidation = true;
textField.getValidators().add(validator);
}
}
}
if (needValidation) {
FXUtils.setValidateWhileTextChanged(textField, true);
validatingFields.add(textField);
}
return linePane;
}
}
}
@@ -1,86 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.export;
import com.jfoenix.controls.JFXButton;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.ui.export.ModpackInfoPage.MODPACK_INFO_OPTION;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
private final WizardController controller;
public ModpackTypeSelectionPage(WizardController controller) {
this.controller = controller;
this.setPadding(new Insets(10));
Label title = new Label(i18n("modpack.export.as"));
VBox.setMargin(title, new Insets(8, 0, 8, 12));
this.getStyleClass().add("jfx-list-view");
this.setMaxSize(400, 150);
this.setSpacing(8);
this.getChildren().setAll(
title,
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION),
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION)
);
}
private JFXButton createButton(String type, ModpackExportInfo.Options option) {
JFXButton button = new JFXButton();
button.getStyleClass().add("card");
button.setOnAction(e -> {
controller.getSettings().put(MODPACK_TYPE, type);
controller.getSettings().put(MODPACK_INFO_OPTION, option);
controller.onNext();
});
button.prefWidthProperty().bind(this.widthProperty());
BorderPane graphic = new BorderPane();
graphic.setMouseTransparent(true);
graphic.setLeft(new TwoLineListItem(i18n("modpack.type." + type), i18n("modpack.type." + type + ".export")));
Node arrow = SVG.ARROW_FORWARD.createIcon();
BorderPane.setAlignment(arrow, Pos.CENTER);
graphic.setRight(arrow);
button.setGraphic(graphic);
return button;
}
@Override
public void cleanup(SettingsMap settings) {
}
@Override
public String getTitle() {
return i18n("modpack.wizard.step.3.title");
}
public static final SettingsMap.Key<String> MODPACK_TYPE = new SettingsMap.Key<>("modpack.type");
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
public static final String MODPACK_TYPE_SERVER = "server";
public static final String MODPACK_TYPE_MODRINTH = "modrinth";
}
@@ -393,10 +393,9 @@ public class DownloadPage extends Control implements DecoratorPage {
RemoteAddon.@Nullable Type type = addon.type();
@Nullable DownloadCallback callback = type == null ? null : switch (type) {
case MOD -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD;
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
default -> null; // Dependencies should not be modpacks, worlds or customized stuff
default -> null; // Mods, modpacks, worlds, and custom types are not downloadable from this UI
};
setOnAction((e) -> {
fireEvent(new DialogCloseEvent());
@@ -502,8 +501,6 @@ public class DownloadPage extends Control implements DecoratorPage {
RemoteAddon.Type type = selfPage.type;
String title = switch (type) {
case WORLD -> "world.download.title";
case MODPACK -> "modpack.download.title";
case RESOURCE_PACK -> "resourcepack.download.title";
case SHADER_PACK -> "shaderpack.download.title";
default -> "mods.download.title";
@@ -546,10 +543,10 @@ public class DownloadPage extends Control implements DecoratorPage {
JFXButton downloadButton = null;
if (selfPage.callback != null) {
downloadButton = new JFXButton(type == RemoteAddon.Type.MODPACK ? i18n("install.modpack") : i18n("mods.install"));
downloadButton = new JFXButton(i18n("mods.install"));
downloadButton.getStyleClass().add("dialog-accept");
downloadButton.setOnAction(e -> {
if (type == RemoteAddon.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.download(mod, version);
@@ -47,8 +47,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
private final TabItem<ModListPage> modListTab;
/// The saved worlds tab bound to the current game instance.
private final TabItem<WorldListPage> worldListTab;
/// The schematics tab bound to the current game instance.
private final TabItem<SchematicsPage> schematicsTab;
/// The installed resource packs tab bound to the current game instance.
private final TabItem<ResourcePackListPage> resourcePackTab;
/// The screenshots tab bound to the current game instance.
@@ -85,7 +83,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
this.modListTab = new TabItem<>(() -> new ModListPage(instance));
this.resourcePackTab = new TabItem<>(() -> new ResourcePackListPage(instance));
this.worldListTab = new TabItem<>(() -> new WorldListPage(instance));
this.schematicsTab = new TabItem<>(() -> new SchematicsPage(instance));
this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance));
tabController = new TabController(transitionPane,
@@ -94,7 +91,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
modListTab,
resourcePackTab,
worldListTab,
schematicsTab,
screenshotsTab);
tabController.selectTab(gameSettingsTab);
@@ -126,8 +122,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
.addNavigationDrawerTab(tabController, installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL)
.addNavigationDrawerTab(tabController, modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(tabController, resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE)
.addNavigationDrawerTab(tabController, worldListTab, i18n("world.manage"), SVG.PUBLIC)
.addNavigationDrawerTab(tabController, schematicsTab, i18n("schematics.manage"), SVG.SCHEMA, SVG.SCHEMA_FILL);
.addNavigationDrawerTab(tabController, worldListTab, i18n("world.manage"), SVG.PUBLIC);
PopupMenu browseList = new PopupMenu();
JFXPopup browsePopup = new JFXPopup(browseList);
@@ -138,7 +133,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> onBrowse("mods"), browsePopup),
new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> onBrowse("resourcepacks"), browsePopup),
new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> onBrowse("saves"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), this::onBrowseSchematics, browsePopup),
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> onBrowse("shaderpacks"), browsePopup),
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> onBrowse("screenshots"), browsePopup),
new MenuSeparator(),
@@ -155,7 +149,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), this::rename, managementPopup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), this::duplicate, managementPopup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), this::remove, managementPopup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), this::export, managementPopup),
new MenuSeparator(),
// Due to the sidebar height limit, we temporarily place the entries for these tabs
// in the management menu; they will be moved back to the sidebar after a future UI refactor.
@@ -276,14 +269,6 @@ 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) {
@@ -350,13 +335,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}
}
private void export() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.exportInstance(gameInstance);
}
}
private void rename() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
@@ -184,7 +184,6 @@ public final class GameListCell extends ListCell<GameListItem> {
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), item::remove, popup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), item::export, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
return popup;
@@ -47,10 +47,6 @@ public class GameListItem extends GameItem {
Instances.deleteInstance(gameInstance);
}
public void export() {
Instances.exportInstance(gameInstance);
}
public void browse() {
Instances.openFolder(gameInstance);
}
@@ -4,7 +4,6 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
@@ -34,13 +33,10 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.directory.GameDirectoryListItem;
import org.jackhuang.hmcl.ui.directory.GameDirectoryPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.nio.file.Path;
import java.util.List;
@@ -55,44 +51,11 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("instance.manage")));
/// Navigation drawer items for configured game directories.
@SuppressWarnings("FieldCanBeLocal")
private final ObservableList<GameDirectoryListItem> gameDirectoryListItems;
/// Creates game-directory navigation, its bottom action, and the instance list.
/// Creates the fixed-directory instance list.
public GameListPage() {
gameDirectoryListItems = MappedObservableList.create(GameDirectoryManager.getGameDirectories(), gameDirectory -> {
GameDirectoryListItem item = new GameDirectoryListItem(gameDirectory);
FXUtils.onSecondaryButtonClicked(item, () -> {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().addAll(
new IconedMenuItem(
SVG.EDIT,
i18n("button.edit"),
() -> Controllers.navigate(new GameDirectoryPage(gameDirectory)),
popup),
new IconedMenuItem(
SVG.FOLDER,
i18n("button.reveal_dir"),
() -> FXUtils.openFolder(gameDirectory.getPath().toPath()),
popup));
popup.show(item, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, item.getWidth(), 0);
});
return item;
});
AdvancedListItem addGameDirectoryItem = new AdvancedListItem();
addGameDirectoryItem.getStyleClass().add("navigation-drawer-item");
addGameDirectoryItem.setTitle(i18n("game_directory.new"));
addGameDirectoryItem.setLeftIcon(SVG.ADD_CIRCLE);
addGameDirectoryItem.setOnAction(e -> Controllers.navigate(new GameDirectoryPage(null)));
VBox directories = new VBox();
Bindings.bindContent(directories.getChildren(), gameDirectoryListItems);
getSidebar().add(directories).add(addGameDirectoryItem);
setSidebarBottom(new AdvancedListBox()
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings));
getSidebar()
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings);
setContent(new GameList());
@@ -127,7 +90,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
GameDirectoryManager.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Instances.addNewGame());
setOnFailedAction(e -> Instances.installServerModpack());
}
@FXThread
@@ -232,7 +195,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.new_game"), SVG.DOWNLOAD, Instances::addNewGame), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::importModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::installServerModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
@@ -18,17 +18,8 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
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, 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, true, true);
}
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, true, true);
@@ -5,7 +5,6 @@ package org.jackhuang.hmcl.ui.instances;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLGameInstance;
@@ -13,12 +12,8 @@ import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.download.UpdateInstallerWizardProvider;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -32,10 +27,6 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
/// @param instanceContext the parent page's instance property
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> Set.of("jar", "exe").contains(FileUtils.getExtension(it)), mods -> {
if (!mods.isEmpty())
doInstallOffline(mods.get(0));
});
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
@@ -87,10 +78,6 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
component.versionProperty().set(null);
}
component.setOnInstall(() -> {
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion));
});
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
@@ -129,33 +116,6 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
}
}
public void installOffline() {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.modloader.installer"), "*.jar", "*.exe"));
Path file = Controllers.showOpenDialog(chooser);
if (file != null) doInstallOffline(file);
}
/// Runs a local component installer for the current modifiable instance and displays its result.
private void doInstallOffline(Path file) {
if (gameInstance == null || !gameInstance.getManifest().isModifiable()) {
return;
}
HMCLGameRepository repository = gameInstance.getRepository();
Task<?> task = repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().installComponentLocalAsync(publishedInstance, file));
task.setName(i18n("install.installer.install_offline"));
Controllers.taskDialog(task.whenComplete(Schedulers.javafx(), (@Nullable Exception exception) -> {
if (exception == null) {
reloadCurrentInstance();
Controllers.dialog(i18n("install.success"));
} else {
UpdateInstallerWizardProvider.alertFailureMessage(exception, null);
}
}), i18n("install.installer.install_offline"), TaskCancellationAction.NO_CANCEL);
}
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
@@ -165,9 +125,7 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
@Override
protected List<Node> initializeToolbar(InstallerListPage skinnable) {
return Collections.singletonList(
createToolbarButton2(i18n("install.installer.install_offline"), SVG.ADD, skinnable::installOffline)
);
return Collections.emptyList();
}
}
}
@@ -5,31 +5,25 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.Metadata;
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;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.CreateAccountPane;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
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.ui.construct.Validator;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
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;
@@ -41,7 +35,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
@@ -53,9 +46,24 @@ public final class Instances {
private Instances() {
}
public static void addNewGame() {
Controllers.getDownloadPage().showGameDownloads();
Controllers.navigate(Controllers.getDownloadPage());
/// Installs or updates the server client from the launcher-bundled modpack when present.
///
/// Falls back to the local/remote modpack import wizard when no bundled package is found.
public static void installServerModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
return;
}
Path bundled = Metadata.findBundledModpackFile();
if (bundled != null) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, bundled),
i18n("install.modpack"));
return;
}
importModpack();
}
public static void importModpack() {
@@ -65,54 +73,12 @@ public final class Instances {
}
}
/// Downloads a modpack and opens its installation wizard for the supplied repository.
/// Downloads completed in the background retain an installation action in the task list.
///
/// @param downloadProvider the provider used to download the archive
/// @param repository the installation target, retained if the selected directory changes
/// @param instanceId the instance to update, or `null` to install a new instance
/// @param mod the modpack metadata
/// @param file the archive version to download
public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon mod, RemoteAddon.Version file) {
Path modpack;
DownloadCandidates downloadURLs;
try {
downloadURLs = downloadProvider.getDownloadCandidates(file.file().url());
modpack = Files.createTempFile("modpack", ".zip");
} catch (IOException | IllegalArgumentException e) {
Controllers.dialog(
i18n("install.failed.downloading.detail", file.file().url()) + "\n" + StringUtils.getStackTrace(e),
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
return;
}
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
pane.setTitle(i18n("modpack.download.title", file.name()));
TaskExecutor executor = new FileDownloadTask(downloadURLs, modpack)
.setName(file.name())
.whenComplete(Schedulers.javafx(), (@Nullable Exception e) -> {
if (e == null) {
ModpackInstallWizardProvider installWizardProvider;
if (instanceId != null)
installWizardProvider = new ModpackInstallWizardProvider(repository, modpack, instanceId);
else
installWizardProvider = new ModpackInstallWizardProvider(repository, modpack);
if (StringUtils.isNotBlank(mod.iconUrl()))
installWizardProvider.setIconUrl(mod.iconUrl());
if (!pane.runOrDeferCompletionAction(i18n("button.install"), i18n("modpack.install.ready"),
() -> Controllers.getDecorator().startWizard(installWizardProvider))) {
Controllers.showToast(i18n("modpack.download.completed"));
}
} else if (!(e instanceof CancellationException)) {
Controllers.dialog(
i18n("install.failed.downloading.detail", file.file().url()) + "\n" + StringUtils.getStackTrace(e),
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
}
}).executor();
pane.setExecutor(executor);
Controllers.dialog(pane);
pane.startExecutor();
/// @deprecated use [#installServerModpack()]
public static void addNewGame() {
installServerModpack();
}
public static void deleteInstance(HMCLGameInstance gameInstance) {
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
@@ -161,9 +127,6 @@ public final class Instances {
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString())));
}
public static void exportInstance(HMCLGameInstance gameInstance) {
Controllers.getDecorator().startWizard(new ExportWizardProvider(gameInstance), i18n("modpack.wizard"));
}
public static void openFolder(HMCLGameInstance gameInstance) {
FXUtils.openFolder(gameInstance.getRunDirectory());
@@ -348,13 +311,13 @@ public final class Instances {
@SafeVarargs
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
if (gameInstance == null) {
JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download"));
gotoDownload.getStyleClass().add("dialog-accept");
gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
JFXButton installModpack = new JFXButton(i18n("install.modpack"));
installModpack.getStyleClass().add("dialog-accept");
installModpack.setOnAction(e -> installServerModpack());
Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"),
MessageDialogPane.MessageType.ERROR,
gotoDownload,
installModpack,
null);
return;
}
@@ -309,13 +309,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
}
}
public void download() {
if (gameInstance == null) {
return;
}
Controllers.getDownloadPage().showModDownloads().selectInstance(gameInstance.getId());
Controllers.navigate(Controllers.getDownloadPage());
}
public void rollback(LocalModFile from, LocalModFile to) {
try {
@@ -420,7 +413,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
.toList()
)
),
createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
@@ -187,13 +187,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
}
private void onDownload() {
if (gameInstance == null) {
return;
}
Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(gameInstance.getId());
Controllers.navigate(Controllers.getDownloadPage());
}
private void onOpenFolder() {
if (resourcePackDirectory != null) {
@@ -371,7 +364,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
),
createToolbarButton2(i18n("resourcepack.download"), SVG.DOWNLOAD, control::onDownload),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
@@ -1,899 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
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.*;
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.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;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.util.stream.Stream;
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;
/**
* @author Glavo
*/
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 "黑山大叔";
}
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 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.
///
/// @param instanceContext the parent page's instance property
public SchematicsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this,
file -> currentDirectoryProperty().get() != null && Schematic.isFileSchematic(file),
this::addFiles
);
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new SchematicsPageSkin(this);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
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() {
if (schematicsDirectory == null) return;
HMCLGameInstance currentGameInstance = this.gameInstance;
if (currentGameInstance == null) return;
setLoading(true);
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;
}
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 (currentDirectoryProperty().get() == null)
return;
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);
for (Path file : files) {
Files.copy(file, dir.resolve(file.getFileName()));
}
refresh();
} catch (FileAlreadyExistsException ignored) {
} catch (IOException e) {
Controllers.dialog(i18n("schematics.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
LOG.warning("Failed to add schematics to " + dir, e);
}
}
public void onAddFiles() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("schematics.add.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
i18n("extension.schematic"), "*.litematic", "*.nbt", "*.schematic", "*.schem"));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
public void onCreateDirectory() {
if (currentDirectoryProperty().get() == null) return;
Path parent = currentDirectoryProperty().get().getPath();
FileNameSet existingPaths;
try {
existingPaths = FileNameSet.list(parent, null);
} catch (Exception e) {
LOG.warning("Failed to list folders in " + parent, e);
existingPaths = new FileNameSet(false);
}
Controllers.prompt(
i18n("schematics.create_directory.prompt"),
(result, handler) -> {
Path targetDir = parent.resolve(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("schematics.create_directory.failed.already_exists"));
return;
}
try {
Files.createDirectories(targetDir);
handler.resolve();
refresh();
} catch (IOException e) {
LOG.warning("Failed to create directory: " + targetDir, e);
handler.reject(i18n("schematics.create_directory.failed", targetDir));
}
},
"",
new RequiredValidator(),
new Validator(i18n("schematics.create_directory.failed.invalid_name"), FileUtils::isNameValid),
new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains));
}
public void onRevealSchematicsFolder() {
var d = currentDirectoryProperty().get();
var p = d != null ? d.getPath() : schematicsDirectory;
if (p != null) FXUtils.openFolder(p);
}
private DirItem loadRoot(Path dir) {
var item = new DirItem(dir, null);
item.load();
return item;
}
private void navigateTo(DirItem item) {
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> {
boolean isDirectory() {
return this instanceof DirItem;
}
abstract Path getPath();
abstract String getName();
abstract String getDescription();
abstract SVG getIcon();
Node getIcon(int size) {
StackPane icon = new StackPane();
icon.setPrefSize(size, size);
icon.setMaxSize(size, size);
icon.getChildren().add(getIcon().createIcon(size));
return icon;
}
abstract int order();
abstract void onClick();
abstract void onReveal();
abstract void onDelete();
@Override
public int compareTo(@NotNull SchematicsPage.Item o) {
if (this.order() != o.order())
return Integer.compare(this.order(), o.order());
return this.getName().compareTo(o.getName());
}
}
private final class DirItem extends Item {
final ReentrantLock lock = new ReentrantLock();
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;
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
int order() {
return 0;
}
@Override
Path getPath() {
return path;
}
@Override
public String getName() {
return FileUtils.getName(path);
}
@Override
String getDescription() {
return i18n("schematics.sub_items", size);
}
@Override
SVG getIcon() {
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);
}
@Override
void onReveal() {
FXUtils.openFolder(path);
}
@Override
void onDelete() {
try {
FileUtils.deleteDirectory(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete directory: " + path, e);
}
}
}
private final class SchematicItem extends Item {
final Path path;
final Schematic file;
final String name;
final Image image;
private SchematicItem(Path path) throws IOException {
this.path = path;
this.file = Schematic.load(path);
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 = FileUtils.getNameWithoutExtension(path);
}
WritableImage image = null;
int[] previewImageData = file.getPreviewImageData();
if (previewImageData != null && previewImageData.length > 0) {
int size = (int) Math.sqrt(previewImageData.length);
if ((size * size) == previewImageData.length) {
image = new WritableImage(size, size);
PixelWriter pixelWriter = image.getPixelWriter();
for (int y = 0, i = 0; y < size; ++y) {
for (int x = 0; x < size; ++x) {
pixelWriter.setArgb(x, y, previewImageData[i++]);
}
}
}
}
this.image = image;
}
@Override
int order() {
return 1;
}
@Override
Path getPath() {
return path;
}
@Override
public String getName() {
return name;
}
@Override
String getDescription() {
return path.getFileName().toString();
}
@Override
SVG getIcon() {
return SVG.SCHEMA;
}
public @Nullable Image getImage() {
return image;
}
Node getIcon(int size) {
if (image == null) {
return super.getIcon(size);
} else {
var imageView = new ImageContainer(size);
imageView.setImage(image);
return imageView;
}
}
@Override
void onClick() {
Controllers.dialog(new SchematicInfoDialog());
}
@Override
void onReveal() {
FXUtils.showFileInExplorer(path);
}
@Override
void onDelete() {
try {
Files.deleteIfExists(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete schematic file: " + path, e);
}
}
private final class SchematicInfoDialog extends JFXDialogLayout {
private final ComponentList details;
private void addDetailItem(String key, Object detail) {
BorderPane borderPane = new BorderPane();
borderPane.setLeft(new Label(key));
borderPane.setRight(new Label(detail.toString()));
details.getContent().add(borderPane);
}
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().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", 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()
);
}
SchematicInfoDialog() {
HBox titleBox = new HBox(8);
{
Node icon = getIcon(40);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(getName());
title.setSubtitle(path.getFileName().toString());
titleBox.getChildren().setAll(icon, title);
setHeading(titleBox);
}
{
this.details = new ComponentList();
details.setStyle("-fx-effect: null;");
StackPane detailsContainer = new StackPane();
detailsContainer.getChildren().add(details);
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);
}
{
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);
}
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 MDListCell<Item> {
private final StackPane left;
private final TwoLineListItem center;
private final HBox right;
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(JFXListView<Item> listView) {
super(listView);
var box = new HBox(8);
box.setAlignment(Pos.CENTER_LEFT);
box.setPickOnBounds(false);
{
this.left = new StackPane();
left.setPadding(new Insets(0, 8, 0, 0));
this.iconImageView = new ImageContainer(32);
this.iconSVGView = new SVGContainer(32);
}
{
this.center = new TwoLineListItem();
HBox.setHgrow(center, Priority.ALWAYS);
}
{
this.right = new HBox(8);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
{
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.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) {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
item::onDelete, null);
}
});
right.getChildren().setAll(btnExplore, btnReveal, btnDelete);
}
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 updateControl(Item item, boolean empty) {
if (empty || item == null) return;
iconImageView.setImage(null);
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 {
iconSVGView.setIcon(item.getIcon());
left.getChildren().setAll(iconSVGView);
}
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.uninstall(left, tooltip);
}
}
}
private static final class SchematicsPageSkin extends SkinBase<SchematicsPage> {
private final JFXListView<Item> listView;
SchematicsPageSkin(SchematicsPage skinnable) {
super(skinnable);
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);
}
}
}
@@ -149,10 +149,6 @@ public final class WorldListPage extends ListPageBase<World> {
installWorld(res.get(0));
}
public void download() {
Controllers.getDownloadPage().showWorldDownloads();
Controllers.navigate(Controllers.getDownloadPage());
}
private void installWorld(Path zipFile) {
// Only accept one world file because user is required to confirm the new world name
@@ -242,8 +238,7 @@ public final class WorldListPage extends ListPageBase<World> {
chkShowAll,
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openSavesFolder),
createToolbarButton2(i18n("world.download"), SVG.DOWNLOAD, skinnable::download)
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openSavesFolder)
);
}
@@ -52,10 +52,10 @@ public final class AboutPage extends SpinnerPane {
ComponentList about = new ComponentList();
{
var launcher = LineButton.createExternalLinkButton(Metadata.REPOSITORY_URL);
var launcher = new LinePane();
launcher.setLargeTitle(true);
launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
launcher.setTitle("Hello Minecraft! Launcher");
launcher.setTitle(Metadata.FULL_NAME);
launcher.setSubtitle(Metadata.VERSION);
about.getContent().setAll(launcher);
@@ -229,17 +229,7 @@ public final class AboutPage extends SpinnerPane {
copyright.setTitle(i18n("about.copyright"));
copyright.setSubtitle(i18n("about.copyright.statement"));
var claim = LineButton.createExternalLinkButton(Metadata.EULA_URL);
claim.setLargeTitle(true);
claim.setTitle(i18n("about.claim"));
claim.setSubtitle(i18n("about.claim.statement"));
var openSource = LineButton.createExternalLinkButton("https://github.com/HMCL-dev/HMCL");
openSource.setLargeTitle(true);
openSource.setTitle(i18n("about.open_source"));
openSource.setSubtitle(i18n("about.open_source.statement"));
legal.getContent().setAll(copyright, claim, openSource);
legal.getContent().setAll(copyright);
}
content.getChildren().setAll(
@@ -12,26 +12,15 @@ import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.setting.ProxyType;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -49,76 +38,9 @@ public class DownloadSettingsPage extends StackPane {
scrollPane.setFitToWidth(true);
getChildren().setAll(scrollPane);
{
var gameContent = new ComponentList();
gameContent.getStyleClass().add("card-non-transparent");
{
Function<DownloadSource, String> converter = source -> switch (source) {
case DEFAULT -> i18n("settings.launcher.download_source.auto");
case OFFICIAL -> i18n("download.provider.official");
case MIRROR -> i18n("download.provider.mirror");
};
Function<DownloadSource, String> descriptionConverter = source -> {
String bundleKey = switch (source) {
case DEFAULT -> "download.provider.balanced.desc";
case OFFICIAL -> "download.provider.official.desc";
case MIRROR -> "download.provider.mirror.desc";
};
return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null;
};
var versionListSourcePane = new LineSelectButton<DownloadSource>();
versionListSourcePane.setTitle(i18n("settings.launcher.version_list_source"));
versionListSourcePane.setNullSafeConverter(converter);
versionListSourcePane.setDescriptionConverter(descriptionConverter);
versionListSourcePane.setItems(DownloadSource.values());
versionListSourcePane.valueProperty().bindBidirectional(settings().versionListSourceProperty());
var downloadSourcePane = new LineSelectButton<DownloadSource>();
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
downloadSourcePane.setNullSafeConverter(converter);
downloadSourcePane.setDescriptionConverter(descriptionConverter);
downloadSourcePane.setItems(DownloadSource.values());
downloadSourcePane.valueProperty().bindBidirectional(settings().fileDownloadSourceProperty());
var defaultAddonSourcePane = new LineSelectButton<String>();
defaultAddonSourcePane.setTitle(i18n("settings.launcher.default_addon_source"));
defaultAddonSourcePane.setNullSafeConverter(key -> I18n.i18n("addon." + key));
defaultAddonSourcePane.setItems("modrinth", "curseforge");
defaultAddonSourcePane.valueProperty().bindBidirectional(settings().defaultAddonSourceProperty());
gameContent.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.game_content")), gameContent);
}
{
var downloadList = new ComponentList();
ComponentSublist fileCommonLocationSublist = new ComponentSublist(() -> {
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
fileCommonLocation.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
.setChooserTitle(i18n("launcher.cache_directory.choose"))
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY)
.bindBidirectional(settings().commonDirectoryProperty())
));
fileCommonLocation.selectedDataProperty().bindBidirectional(settings().commonDirectoryTypeProperty());
return List.of(fileCommonLocation);
});
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
fileCommonLocationSublist.setHasSubtitle(true);
fileCommonLocationSublist.descriptionProperty().bind(
Bindings.createObjectBinding(() -> Optional.ofNullable(settings().getResolvedCommonDirectory())
.orElse(i18n("launcher.cache_directory.disabled")),
settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory(cleanButton));
fileCommonLocationSublist.setHeaderRight(cleanButton);
ComponentSublist downloadThreadsSublist = new ComponentSublist(() -> {
var downloadThreadsList = new RadioChoiceList<Boolean>();
downloadThreadsList.setChoices(
@@ -129,7 +51,6 @@ public class DownloadSettingsPage extends StackPane {
HBox hbox = new HBox(8);
hbox.setViewOrder(-1);
hbox.setAlignment(Pos.CENTER);
// hbox.setPadding(new Insets(0, 0, 0, 30));
hbox.disableProperty().bind(settings().autoDownloadThreadsProperty());
JFXSlider slider = new JFXSlider(1, 256, 64);
@@ -172,7 +93,7 @@ public class DownloadSettingsPage extends StackPane {
}
}, settings().autoDownloadThreadsProperty(), settings().downloadThreadsProperty()));
downloadList.getContent().addAll(fileCommonLocationSublist, downloadThreadsSublist);
downloadList.getContent().addAll(downloadThreadsSublist);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadList);
}
@@ -335,28 +256,4 @@ public class DownloadSettingsPage extends StackPane {
}
}
@FXThread
private boolean cleaningCache = false;
private void clearCacheDirectory(JFXButton cleanButton) {
if (cleaningCache) return;
String commonDirectory = settings().getResolvedCommonDirectory();
if (commonDirectory != null) {
cleaningCache = true;
cleanButton.setMinWidth(cleanButton.getWidth());
var txt = cleanButton.getText();
cleanButton.setText("");
var spinner = new JFXSpinner();
spinner.setRadius(8);
cleanButton.setGraphic(spinner);
Task.runAsync("Clear Cache Directory", Schedulers.io(), () -> FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache")))
.whenComplete(Schedulers.javafx(), ignored -> {
cleanButton.setGraphic(null);
cleanButton.setText(txt);
cleaningCache = false;
}).setSignificance(Task.TaskSignificance.MINOR)
.start();
}
}
}
@@ -1,70 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import org.jackhuang.hmcl.Metadata;
public class FeedbackPage extends SpinnerPane {
private final WeakListenerHolder holder = new WeakListenerHolder();
public FeedbackPage() {
VBox content = new VBox();
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
ComponentList groups = new ComponentList();
{
var users = LineButton.createExternalLinkButton(Metadata.GROUPS_URL);
users.setLargeTitle(true);
users.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
users.setTitle(i18n("contact.chat.qq_group"));
users.setSubtitle(i18n("contact.chat.qq_group.statement"));
var discord = LineButton.createExternalLinkButton("https://discord.gg/jVvC7HfM6U");
discord.setLargeTitle(true);
discord.setLeading(FXUtils.newBuiltinImage("/assets/img/discord.png"));
discord.setTitle(i18n("contact.chat.discord"));
discord.setSubtitle(i18n("contact.chat.discord.statement"));
groups.getContent().setAll(users, discord);
}
ComponentList feedback = new ComponentList();
{
var github = LineButton.createExternalLinkButton("https://github.com/HMCL-dev/HMCL/issues/new/choose");
github.setLargeTitle(true);
github.setTitle(i18n("contact.feedback.github"));
github.setSubtitle(i18n("contact.feedback.github.statement"));
holder.add(FXUtils.onWeakChangeAndOperate(Themes.darkModeProperty(), darkMode -> {
github.setLeading(darkMode
? FXUtils.newBuiltinImage("/assets/img/github-white.png")
: FXUtils.newBuiltinImage("/assets/img/github.png"));
}));
feedback.getContent().setAll(github);
}
content.getChildren().addAll(
ComponentList.createComponentListTitle(i18n("contact.chat")),
groups,
ComponentList.createComponentListTitle(i18n("contact.feedback")),
feedback
);
}
}
@@ -1,108 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import com.google.gson.annotations.SerializedName;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.io.HttpRequest;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class HelpPage extends SpinnerPane {
private final VBox content;
public HelpPage() {
content = new VBox();
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
var docPane = LineButton.createExternalLinkButton(Metadata.DOCS_URL);
docPane.setLargeTitle(true);
docPane.setTitle(i18n("help.doc"));
docPane.setSubtitle(i18n("help.detail"));
ComponentList doc = new ComponentList();
doc.getContent().setAll(docPane);
content.getChildren().add(doc);
loadHelp();
}
private void loadHelp() {
showSpinner();
Task.supplyAsync(() -> HttpRequest.GET(Metadata.DOCS_URL + "/index.json").getJson(listTypeOf(HelpCategory.class)))
.whenComplete(Schedulers.javafx(), (helpCategories, exception) -> {
if (exception != null) {
LOG.warning("Failed to load help page: ", exception);
showLoadFailure();
return;
}
try {
List<Node> nodes = new ArrayList<>();
for (HelpCategory category : helpCategories) {
ComponentList categoryPane = new ComponentList();
for (Help help : category.items()) {
var item = LineButton.createExternalLinkButton(help.url());
item.setLargeTitle(true);
item.setTitle(help.title());
item.setSubtitle(help.subtitle());
categoryPane.getContent().add(item);
}
nodes.add(ComponentList.createComponentListTitle(category.title()));
nodes.add(categoryPane);
}
content.getChildren().addAll(nodes);
hideSpinner();
} catch (Exception e) {
LOG.warning("Failed to display help page: ", e);
showLoadFailure();
}
}).start();
}
private void showLoadFailure() {
setFailedReason(i18n("download.failed.refresh"));
setOnFailedAction(e -> loadHelp());
setLoading(false);
}
@JsonSerializable
private record HelpCategory(
@SerializedName("title") String title,
@SerializedName("items") List<Help> items) {
}
@JsonSerializable
private record Help(
@SerializedName("title") String title,
@SerializedName("subtitle") String subtitle,
@SerializedName("url") String url) {
}
}
@@ -17,6 +17,7 @@ import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.java.JavaDistribution;
import org.jackhuang.hmcl.download.java.JavaPackageType;
@@ -128,12 +129,16 @@ public final class JavaDownloadDialog extends StackPane {
button.setDisable(true);
button.setSelected(true);
} else {
if (version.majorVersion() == Metadata.RECOMMENDED_JAVA_VERSION) {
button.setSelected(true);
}
button.selectedProperty().addListener(updateValidStatus);
}
vbox.getChildren().add(button);
checkboxes.add(button);
}
updateValidStatus.invalidated(null);
setBody(vbox);
@@ -33,12 +33,8 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
private final TabItem<PersonalizationPage> personalizationTab;
/// The tab for download settings.
private final TabItem<DownloadSettingsPage> downloadTab;
/// The tab displaying launcher help.
private final TabItem<HelpPage> helpTab;
/// The tab displaying launcher information.
private final TabItem<AboutPage> aboutTab;
/// The tab displaying feedback and contact information.
private final TabItem<FeedbackPage> feedbackTab;
private final TransitionPane transitionPane = new TransitionPane();
/// Creates the settings tabs and fills the shared navigation sidebar.
@@ -48,8 +44,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
settingsTab = new TabItem<>(SettingsPage::new);
personalizationTab = new TabItem<>(PersonalizationPage::new);
downloadTab = new TabItem<>(DownloadSettingsPage::new);
helpTab = new TabItem<>(HelpPage::new);
feedbackTab = new TabItem<>(FeedbackPage::new);
aboutTab = new TabItem<>(AboutPage::new);
tabController = new TabController(transitionPane,
gameTab,
@@ -57,8 +51,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
settingsTab,
personalizationTab,
downloadTab,
helpTab,
feedbackTab,
aboutTab);
tabController.selectTab(gameTab);
@@ -71,9 +63,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
.addNavigationDrawerTab(tabController, personalizationTab, i18n("settings.launcher.appearance"), SVG.STYLE, SVG.STYLE_FILL)
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, helpTab, i18n("help"), SVG.HELP, SVG.HELP_FILL)
.addNavigationDrawerTab(tabController, feedbackTab, i18n("contact"), SVG.FEEDBACK, SVG.FEEDBACK_FILL)
.startCategory(i18n("about").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
setContent(transitionPane);
@@ -94,10 +84,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
tabController.selectTab(gameTab, false);
}
public void showFeedback() {
tabController.selectTab(feedbackTab, false);
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
@@ -4,10 +4,7 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.RotateTransition;
import javafx.animation.Timeline;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
@@ -15,71 +12,41 @@ import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.Lang;
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.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.download.ComponentRemoteVersion.Type.RELEASE;
import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.ui.FXUtils.SINE;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays the launcher home controls for the currently selected game repository.
public final class MainPage extends StackPane implements DecoratorPage {
private static final String ANNOUNCEMENT = "announcement";
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final ObjectProperty<@Nullable HMCLGameInstance> currentGame = new SimpleObjectProperty<>(this, "currentGame");
private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
private final BooleanProperty showUpdateDialog = new SimpleBooleanProperty(this, "showUpdateDialog");
private final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>(this, "latestVersion");
/// Mutable storage for visible instances from the selected repository's current snapshot.
private final ObservableList<HMCLGameInstance> mutableInstances = FXCollections.observableArrayList();
@@ -92,12 +59,8 @@ public final class MainPage extends StackPane implements DecoratorPage {
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
.flatMap(HMCLGameRepository::snapshotProperty);
private TransitionPane announcementPane;
private final StackPane updatePane;
private final JFXButton menuButton;
private RemoteVersion lastShownVersion;
{
HBox titleNode = new HBox(8);
titleNode.setPadding(new Insets(0, 0, 0, 2));
@@ -118,88 +81,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
setPadding(new Insets(20));
if (Metadata.isNightly() || (Metadata.isDev() && !Objects.equals(Metadata.VERSION, state().getShownTips().get(ANNOUNCEMENT)))) {
String title;
String content;
if (Metadata.isNightly()) {
title = i18n("update.channel.nightly.title");
content = i18n("update.channel.nightly.hint");
} else {
title = i18n("update.channel.dev.title");
content = i18n("update.channel.dev.hint");
}
VBox announcementCard = new VBox();
BorderPane titleBar = new BorderPane();
titleBar.getStyleClass().add("title");
titleBar.setLeft(new Label(title));
JFXButton btnHide = new JFXButton();
btnHide.setOnAction(e -> {
announcementPane.setContent(new StackPane(), ContainerAnimations.FADE);
if (Metadata.isDev()) {
state().getShownTips().put(ANNOUNCEMENT, Metadata.VERSION);
}
});
btnHide.getStyleClass().add("announcement-close-button");
btnHide.setGraphic(SVG.CLOSE.createIcon(20));
titleBar.setRight(btnHide);
TextFlow body = FXUtils.segmentToTextFlow(content, Controllers::onHyperlinkAction);
body.setLineSpacing(4);
announcementCard.getChildren().setAll(titleBar, body);
announcementCard.setSpacing(16);
announcementCard.getStyleClass().addAll("card", "announcement");
VBox announcementBox = new VBox(16);
announcementBox.setPadding(new Insets(15));
announcementBox.getChildren().add(announcementCard);
announcementPane = new TransitionPane();
announcementPane.setContent(announcementBox, ContainerAnimations.NONE);
StackPane.setMargin(announcementPane, new Insets(-15));
getChildren().add(announcementPane);
}
updatePane = new StackPane();
updatePane.setVisible(false);
updatePane.getStyleClass().add("bubble");
FXUtils.setLimitWidth(updatePane, 230);
FXUtils.setLimitHeight(updatePane, 55);
StackPane.setAlignment(updatePane, Pos.TOP_RIGHT);
FXUtils.onClicked(updatePane, this::onUpgrade);
updatePane.setCursor(Cursor.HAND);
FXUtils.onChange(showUpdateProperty(), this::doAnimation);
FXUtils.onChange(showUpdateDialogProperty(), this::showUpdateDialog);
{
HBox hBox = new HBox();
hBox.setSpacing(12);
hBox.setAlignment(Pos.CENTER_LEFT);
StackPane.setAlignment(hBox, Pos.CENTER_LEFT);
StackPane.setMargin(hBox, new Insets(9, 12, 9, 16));
{
TwoLineListItem prompt = new TwoLineListItem();
prompt.setSubtitle(i18n("update.bubble.subtitle"));
prompt.setPickOnBounds(false);
prompt.titleProperty().bind(BindingMapping.of(latestVersionProperty()).map(latestVersion ->
latestVersion == null ? "" : i18n("update.bubble.title", latestVersion.version())));
hBox.getChildren().setAll(SVG.UPDATE.createIcon(20), prompt);
}
JFXButton closeUpdateButton = new JFXButton();
closeUpdateButton.setGraphic(SVG.CLOSE.createIcon(10));
StackPane.setAlignment(closeUpdateButton, Pos.TOP_RIGHT);
closeUpdateButton.getStyleClass().add("toggle-icon-tiny");
StackPane.setMargin(closeUpdateButton, new Insets(5));
closeUpdateButton.setOnAction(e -> closeUpdateBubble());
updatePane.getChildren().setAll(new RipplerContainer(hBox), closeUpdateButton);
}
HBox launchPane = new HBox();
launchPane.getStyleClass().add("launch-pane");
@@ -300,101 +182,24 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchPane.getChildren().setAll(launchButton, menuButton);
}
getChildren().addAll(updatePane, launchPane);
getChildren().addAll(launchPane);
}
private void showUpdateDialog(boolean show) {
if (show && getLatestVersion() != null && !Objects.equals(getLatestVersion(), lastShownVersion)
&& !Objects.equals(state().getPromptedVersion(), getLatestVersion().version())
) {
lastShownVersion = getLatestVersion();
Controllers.dialogLater(new MessageDialogPane.Builder("", i18n("update.bubble.title", getLatestVersion().version()), MessageDialogPane.MessageType.INFO)
.addAction(i18n("button.view"), () -> {
state().setPromptedVersion(getLatestVersion().version());
onUpgrade();
})
.addCancel(null)
.build());
}
}
private void doAnimation(boolean show) {
if (AnimationUtils.isAnimationEnabled()) {
Duration duration = Duration.millis(320);
Timeline nowAnimation = new Timeline();
nowAnimation.getKeyFrames().addAll(
new KeyFrame(Duration.ZERO,
new KeyValue(updatePane.translateXProperty(), show ? 260 : 0, SINE)),
new KeyFrame(duration,
new KeyValue(updatePane.translateXProperty(), show ? 0 : 260, SINE)));
if (show) nowAnimation.getKeyFrames().add(
new KeyFrame(Duration.ZERO, e -> updatePane.setVisible(true)));
else nowAnimation.getKeyFrames().add(
new KeyFrame(duration, e -> updatePane.setVisible(false)));
nowAnimation.play();
} else {
updatePane.setVisible(show);
}
}
private void launch() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance());
}
/// Installs the latest supported release in the currently selected repository, then launches
/// that instance even if the selected repository or instance changes during installation.
/// Opens the server modpack install/update flow when no instance is selected.
private void launchNoGame() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
Task<?> task = downloadProvider.getVersionsAsync(GameComponentType.GAME, null, false)
.thenApplyAsync(versions -> versions.stream()
.filter(it -> it.getVersionType() == RELEASE)
.filter(it -> NativePatcher.checkSupportedStatus(it.getGameVersion(), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
.sorted()
.findFirst()
.orElseThrow(() -> new IOException("No versions found")))
.thenComposeAsync(version -> {
HMCLDependencyManager dependency = repository.getDependency();
String gameVersion = version.getGameVersion().toString();
GameInstanceID instanceId = new GameInstanceID(gameVersion);
try (HMCLGameBuilder builder = dependency.newGameBuilder(instanceId)) {
return builder
.component(GameComponentType.GAME, gameVersion)
.buildAsync()
.thenApplyAsync(ignored -> repository.getInstance(instanceId));
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
repository.setSelectedInstance(result);
Instances.launch(result);
} else if (!(exception instanceof CancellationException)) {
LOG.warning("Failed to install game", exception);
Controllers.dialog(StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.WARNING);
}
});
Controllers.taskDialog(task, i18n("instance.launch.empty.installing"), TaskCancellationAction.NORMAL);
Instances.installServerModpack();
}
private void onUpgrade() {
RemoteVersion target = UpdateChecker.getLatestVersion();
if (target == null) {
return;
}
UpdateHandler.updateFrom(target);
}
private void closeUpdateBubble() {
showUpdate.unbind();
showUpdate.set(false);
}
@Override
public ReadOnlyObjectWrapper<State> stateProperty() {
@@ -432,40 +237,5 @@ public final class MainPage extends StackPane implements DecoratorPage {
return instances;
}
public boolean isShowUpdate() {
return showUpdate.get();
}
public BooleanProperty showUpdateProperty() {
return showUpdate;
}
public void setShowUpdate(boolean showUpdate) {
this.showUpdate.set(showUpdate);
}
public boolean isShowUpdateDialog() {
return showUpdateDialog.get();
}
public BooleanProperty showUpdateDialogProperty() {
return showUpdateDialog;
}
public void setShowUpdateDialog(boolean showUpdateDialog) {
this.showUpdateDialog.set(showUpdateDialog);
}
public RemoteVersion getLatestVersion() {
return latestVersion.get();
}
public ObjectProperty<RemoteVersion> latestVersionProperty() {
return latestVersion;
}
public void setLatestVersion(RemoteVersion latestVersion) {
this.latestVersion.set(latestVersion);
}
}
@@ -235,154 +235,6 @@ public class PersonalizationPage extends StackPane {
refresh.invalidated(null);
}
/// Returns the display name for the currently selected launcher theme.
private static String getSelectedThemeTitle() {
ThemeReference reference = settings().getSelectedThemeOrDefault();
try {
@Nullable ThemePackManager.InstalledThemePack themePack = ThemePackManager.findInstalled(reference);
if (themePack != null) {
@Nullable Theme theme = themePack.manifest().findTheme(reference.themeId());
if (theme != null) {
return getThemeTitle(themePack.manifest(), theme);
}
}
} catch (IOException | RuntimeException e) {
LOG.warning("Failed to load selected theme", e);
}
return reference.themeId() == null
? reference.packId()
: reference.packId() + " - " + reference.themeId();
}
/// Returns the display title for one theme-pack theme.
private static String getThemeTitle(ThemePackManifest manifest, Theme theme) {
if (manifest.themes().size() == 1 && theme.id() == null) {
return manifest.displayName();
}
String themeName = Objects.requireNonNullElse(
theme.displayName(),
Objects.requireNonNullElse(theme.id(), manifest.displayName()));
return manifest.displayName() + " - " + themeName;
}
/// Sanitizes a package name for use as a file name without changing the package metadata.
private static String sanitizeThemePackFileName(String value) {
StringBuilder builder = new StringBuilder(value.length());
value.trim().codePoints().forEach(codePoint -> {
if (isUnsafeThemePackFileNameCodePoint(codePoint)) {
builder.append('_');
} else {
builder.appendCodePoint(codePoint);
}
});
String sanitized = builder.toString().replaceAll("[.\\s]+$", "");
return sanitized.isBlank() ? "theme-pack" : sanitized;
}
/// Returns `true` when the code point is unsafe in cross-platform file names.
private static boolean isUnsafeThemePackFileNameCodePoint(int codePoint) {
return !Character.isValidCodePoint(codePoint)
|| Character.isISOControl(codePoint)
|| codePoint == '/'
|| codePoint == '\\'
|| codePoint == ':'
|| codePoint == '<'
|| codePoint == '>'
|| codePoint == '"'
|| codePoint == '|'
|| codePoint == '?'
|| codePoint == '*'
|| codePoint == 0xfffd
|| codePoint == 0xfffe
|| codePoint == 0xffff;
}
/// Shows a theme-pack operation error dialog.
private static void showThemePackError(String title, Exception exception) {
Controllers.dialog(
title + "\n\n" + StringUtils.getStackTrace(exception),
i18n("message.error"),
MessageType.ERROR);
}
/// Asks for theme-pack metadata and then saves the current launcher appearance as a theme-pack file.
private void exportCurrentThemePack() {
Instant exportTimestamp = Instant.now();
String defaultPackId = "com.example.hmcl.theme-pack." + UUIDs.toCompactString(UUIDs.generateV7(exportTimestamp));
String defaultPackName = LocalDateTime.ofInstant(exportTimestamp, ZoneId.systemDefault()).format(EXPORTED_THEME_NAME_FORMATTER);
String accountName = null;
{
var currentAccount = Accounts.getSelectedAccount();
if (currentAccount != null) accountName = currentAccount.getProfileName();
}
if (StringUtils.isBlank(accountName)) accountName = System.getProperty("user.name").trim();
String defaultAuthorName = StringUtils.isBlank(accountName) ? "Unknown" : accountName;
PromptDialogPane.Builder.StringQuestion packNameQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.name"),
defaultPackName)
.setPromptText(defaultPackName);
PromptDialogPane.Builder.StringQuestion versionQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.version"),
ThemePackManager.CURRENT_THEME_PACK_VERSION)
.setPromptText(ThemePackManager.CURRENT_THEME_PACK_VERSION);
PromptDialogPane.Builder.StringQuestion authorNameQuestion = new PromptDialogPane.Builder.StringQuestion(
i18n("theme_pack.export.author"),
defaultAuthorName)
.setPromptText(defaultAuthorName);
Controllers.prompt(new PromptDialogPane.Builder(i18n("theme_pack.export.title"), (questions, handler) -> handler.resolve())
.addQuestion(packNameQuestion)
.addQuestion(versionQuestion)
.addQuestion(authorNameQuestion)).thenAccept(questions -> exportCurrentThemePack(
defaultPackId,
StringUtils.isBlank(versionQuestion.getValue())
? ThemePackManager.CURRENT_THEME_PACK_VERSION
: versionQuestion.getValue().trim(),
StringUtils.isBlank(packNameQuestion.getValue()) ? defaultPackName : packNameQuestion.getValue().trim(),
StringUtils.isBlank(authorNameQuestion.getValue()) ? defaultAuthorName : authorNameQuestion.getValue().trim()));
}
/// Saves current launcher appearance as a theme-pack file with the given package metadata.
private void exportCurrentThemePack(String packId, String version, String packName, String authorName) {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("theme_pack.export.title"));
String initialFileName = sanitizeThemePackFileName(packName);
chooser.setInitialFileName(
(FileUtils.isNameValid(initialFileName) ? initialFileName : "theme-pack")
+ (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ? "" : ThemePackExporter.FILE_EXTENSION)
);
chooser.getExtensionFilters().setAll(
new FileChooser.ExtensionFilter(i18n("theme_pack.file"), "*" + ThemePackExporter.FILE_EXTENSION));
@Nullable Path output = Controllers.showSaveDialog(chooser);
if (output == null) {
return;
}
String fileName = output.getFileName().toString();
if (!fileName.toLowerCase(Locale.ROOT).endsWith(ThemePackExporter.FILE_EXTENSION)) {
output = output.resolveSibling(fileName + ThemePackExporter.FILE_EXTENSION);
}
try {
ThemePackManager.exportCurrent(
output,
packId,
version,
packName,
authorName);
Controllers.dialog(
i18n("theme_pack.export.success", output),
i18n("message.success"),
MessageType.SUCCESS);
} catch (IOException | RuntimeException e) {
showThemePackError(i18n("theme_pack.export.failed"), e);
}
}
/// Creates the launcher appearance settings page.
public PersonalizationPage() {
VBox content = new VBox(10);
@@ -393,26 +245,6 @@ public class PersonalizationPage extends StackPane {
scrollPane.setFitToWidth(true);
getChildren().setAll(scrollPane);
ComponentList themeList = new ComponentList();
{
LineButton currentThemeButton = LineButton.createNavigationButton();
currentThemeButton.setTitle(i18n("theme_pack.theme"));
Runnable updateCurrentThemeButton = () -> currentThemeButton.setSubtitle(getSelectedThemeTitle());
FXUtils.onChange(settings().selectedThemeProperty(), ignored -> updateCurrentThemeButton.run());
updateCurrentThemeButton.run();
currentThemeButton.setOnAction(event ->
Controllers.navigateForward(new ThemePackManagementPage(updateCurrentThemeButton)));
themeList.getContent().add(currentThemeButton);
LineButton exportThemeButton = new LineButton();
exportThemeButton.setTitle(i18n("theme_pack.export"));
exportThemeButton.setSubtitle(i18n("theme_pack.export.subtitle"));
exportThemeButton.setTrailingIcon(SVG.ARCHIVE);
exportThemeButton.setOnAction(event -> exportCurrentThemePack());
themeList.getContent().add(exportThemeButton);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.theme")), themeList);
ComponentList themeAppearanceList = new ComponentList();
ComponentList backgroundLoadingList = new ComponentList();
@@ -10,7 +10,6 @@ import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -18,27 +17,20 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem;
import org.jackhuang.hmcl.ui.account.AccountListPopupMenu;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.ui.nbt.NBTFileType;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays the home content and launcher navigation sidebar.
public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
@@ -88,17 +80,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
gameListItem.setPrepareAction(Controllers::prepareGameListPage);
}
// forth item in left sidebar
AdvancedListItem downloadItem = new AdvancedListItem();
downloadItem.setLeftIcon(SVG.DOWNLOAD);
downloadItem.setTitle(i18n("download"));
downloadItem.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
if (AnimationUtils.isAnimationEnabled()) {
downloadItem.setPrepareAction(Controllers::prepareDownloadPage);
}
// fifth item in left sidebar
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
@@ -111,29 +92,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
}
// sixth item in left sidebar
AdvancedListItem terracottaItem = new AdvancedListItem();
terracottaItem.setLeftIcon(SVG.GRAPH2);
terracottaItem.setTitle(i18n("terracotta"));
terracottaItem.setOnAction(e -> {
if (TerracottaMetadata.PROVIDER != null) {
Controllers.navigate(Controllers.getTerracottaPage());
} else {
String message;
if (Architecture.SYSTEM_ARCH.getBits() == Bits.BIT_32)
message = i18n("terracotta.unsupported.arch.32bit");
else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
message = i18n("terracotta.unsupported.os.windows.old");
else if (Platform.SYSTEM_PLATFORM.equals(OperatingSystem.LINUX, Architecture.LOONGARCH64_OW))
message = i18n("terracotta.unsupported.arch.loongarch64_ow");
else
message = i18n("terracotta.unsupported");
Controllers.dialog(message, null, MessageDialogPane.MessageType.WARNING);
}
});
// the left sidebar
getSidebar()
.startCategory(i18n("account").toUpperCase(Locale.ROOT))
@@ -141,14 +99,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
.startCategory(i18n("instance").toUpperCase(Locale.ROOT))
.add(gameInstanceItem)
.add(gameListItem)
.add(downloadItem)
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
.add(launcherSettingsItem)
.add(terracottaItem)
.addNavigationDrawerItem(i18n("contact.chat"), SVG.CHAT, () -> {
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
});
.add(launcherSettingsItem);
// the root page, with the sidebar in left, navigator in center.
setContent(getMainPage());
@@ -164,28 +116,19 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
if (mainPage == null) {
MainPage mainPage = new MainPage();
FXUtils.applyDragListener(mainPage,
file -> ModpackHelper.isFileModpackByExtension(file) || NBTFileType.isNBTFileByExtension(file) || "json".equalsIgnoreCase(FileUtils.getExtension(file)),
file -> ModpackHelper.isFileModpackByExtension(file) || "json".equalsIgnoreCase(FileUtils.getExtension(file)),
modpacks -> {
Path file = modpacks.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file),
i18n("install.modpack"));
} else if (NBTFileType.isNBTFileByExtension(file)) {
try {
Controllers.navigate(new NBTEditorPage(file));
} catch (Throwable e) {
LOG.warning("Fail to open nbt file", e);
Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
});
FXUtils.onChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), mainPage::setCurrentGame);
mainPage.latestVersionProperty().bind(UpdateChecker.latestVersionProperty());
this.mainPage = mainPage;
}
return mainPage;
@@ -3,14 +3,8 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
@@ -23,14 +17,12 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.upgrade.*;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.tukaani.xz.XZInputStream;
import java.io.IOException;
@@ -54,9 +46,6 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class SettingsPage extends ScrollPane {
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener updateListener;
public SettingsPage() {
this.setFitToWidth(true);
@@ -65,87 +54,6 @@ public final class SettingsPage extends ScrollPane {
this.setContent(rootPane);
FXUtils.smoothScrolling(this);
{
ComponentList updatePaneList = new ComponentList();
{
ObjectProperty<UpdateChannel> updateChannel;
{
JFXButton updateButton = FXUtils.newToggleButton4(SVG.UPDATE, 20);
updateButton.setOnAction(e -> onUpdate());
updateButton.setPadding(Insets.EMPTY);
FXUtils.installFastTooltip(updateButton, i18n("update.tooltip"));
var updatePane = new UpdateChannelButton(updateButton);
updateChannel = updatePane.valueProperty();
updatePane.setTitle(i18n("update"));
updatePane.setValue(UpdateChannel.getChannel());
updatePane.setNullSafeConverter(channel -> i18n("update.channel." + channel.channelName));
updatePane.setItems(List.of(UpdateChannel.STABLE, UpdateChannel.DEVELOPMENT));
updatePane.setDescriptionConverter(channel -> i18n("update.note." + channel.channelName));
final StringProperty lblUpdateSubProperty = updatePane.subtitleProperty();
{
updateListener = any -> {
boolean outdated = UpdateChecker.isOutdated();
updateButton.setVisible(outdated);
updateButton.setManaged(outdated);
updatePane.pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), outdated);
if (UpdateChecker.isCheckingUpdate()) {
lblUpdateSubProperty.set(i18n("update.checking"));
} else if (outdated) {
lblUpdateSubProperty.set(i18n("update.newest_version", UpdateChecker.getLatestVersion().version()));
} else if (UpdateChecker.errorProperty().get() != null) {
Throwable t = UpdateChecker.errorProperty().get();
if (t instanceof SelfVerificationException) {
lblUpdateSubProperty.set(i18n("update.unverified"));
} else {
String msg = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
lblUpdateSubProperty.set(i18n("update.check_failed", msg));
}
} else {
lblUpdateSubProperty.set(i18n("update.latest"));
}
};
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.errorProperty().addListener(new WeakInvalidationListener(updateListener));
updateListener.invalidated(null);
}
updatePaneList.getContent().add(updatePane);
}
{
LineSwitch previewPane = new LineSwitch();
previewPane.setTitle(i18n("update.preview"));
previewPane.setSubtitle(i18n("update.preview.subtitle"));
previewPane.selectedProperty().bindBidirectional(settings().acceptPreviewUpdateProperty());
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
updateChannel.addListener(checkUpdateListener);
previewPane.selectedProperty().addListener(checkUpdateListener);
updatePaneList.getContent().add(previewPane);
}
{
LineSwitch disableAutoShowUpdateDialogPane = new LineSwitch();
disableAutoShowUpdateDialogPane.setTitle(i18n("update.disable_auto_show_update_dialog"));
disableAutoShowUpdateDialogPane.setSubtitle(i18n("update.disable_auto_show_update_dialog.subtitle"));
disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(settings().disableAutoShowUpdateDialogProperty());
updatePaneList.getContent().add(disableAutoShowUpdateDialogPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("update")), updatePaneList);
}
{
ComponentList languagePaneList = new ComponentList();
@@ -231,20 +139,12 @@ public final class SettingsPage extends ScrollPane {
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
}
}
}
private void openLogFolder() {
FXUtils.openFolder(LOG.getLogFile().getParent());
}
private void onUpdate() {
RemoteVersion target = UpdateChecker.getLatestVersion();
if (target == null) {
return;
}
UpdateHandler.updateFrom(target);
}
private static String getEntryName(Set<String> entryNames, String name) {
if (entryNames.add(name)) {
@@ -370,19 +270,4 @@ public final class SettingsPage extends ScrollPane {
}), Schedulers.io());
}
/// Combines an independent update action with the channel selector's existing trailing content.
@NotNullByDefault
private static final class UpdateChannelButton extends LineSelectButton<UpdateChannel> {
/// Creates a channel selector with the supplied action before its value and dropdown icon.
private UpdateChannelButton(Node updateButton) {
getStyleClass().add("update-pane");
Node selection = Objects.requireNonNull(container.getTrailing());
setTrailing(null);
setMargin(selection, null);
HBox trailing = new HBox(SPACING, updateButton, selection);
trailing.setAlignment(Pos.CENTER_LEFT);
trailing.setPickOnBounds(false);
setTrailing(trailing);
}
}
}
@@ -1,729 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;
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;
/// Shows installed theme packs and common theme-pack actions.
@NotNullByDefault
public final class ThemePackManagementPage extends ListPageBase<ThemePackManager.InstalledThemePack> implements DecoratorPage {
/// The icon container size used by theme-pack icons.
private static final double ICON_SIZE = 40;
/// The requested decoded icon image size used for HiDPI displays.
private static final int ICON_IMAGE_SIZE = (int) (ICON_SIZE * 2);
/// The decorator title state for this page.
private final ObjectProperty<State> state = new SimpleObjectProperty<>(State.fromTitle(i18n("theme_pack.manage")));
/// Source list containing all installed theme packs.
private final ObservableList<ThemePackManager.InstalledThemePack> sourceList =
FXCollections.observableArrayList();
/// Filtered list shown by the current search query.
private final FilteredList<ThemePackManager.InstalledThemePack> filteredList = new FilteredList<>(sourceList);
/// Page-scoped icon image cache cleared when installed theme packs are reloaded.
private final IconCache iconCache = new IconCache();
/// Callback invoked after the installed theme-pack set changes.
private final Runnable onThemePacksChanged;
/// Creates the theme-pack management page and loads installed packages.
///
/// @param onThemePacksChanged callback invoked after importing or deleting a theme pack
public ThemePackManagementPage(Runnable onThemePacksChanged) {
getStyleClass().add("gray-background");
this.onThemePacksChanged = Objects.requireNonNull(onThemePacksChanged);
setItems(filteredList);
setOnFailedAction(event -> refreshThemePacks());
refreshThemePacks();
FXUtils.applyDragListener(this, it -> "hmcl-theme".equals(FileUtils.getExtension(it)), paths -> paths.forEach(this::importThemePackImpl));
}
/// Creates the default list skin for the management page.
@Override
protected Skin<?> createDefaultSkin() {
return new ThemePackManagementPageSkin(this);
}
/// Returns the decorator title state for this page.
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
/// Reloads installed theme packs from disk.
private void refreshThemePacks() {
iconCache.clear();
try {
List<ThemePackManager.InstalledThemePack> themePacks = ThemePackManager.listInstalled();
sourceList.setAll(themePacks);
setFailedReason(themePacks.isEmpty() ? i18n("theme_pack.empty") : null);
} catch (IOException | RuntimeException e) {
LOG.warning("Failed to load installed theme packs", e);
sourceList.clear();
setFailedReason(i18n("theme_pack.load.failed"));
}
}
/// Creates a predicate used by the theme-pack search field.
private Predicate<ThemePackManager.InstalledThemePack> createPredicate(String searchText) {
if (StringUtils.isBlank(searchText)) {
return themePack -> true;
}
String query = searchText.toLowerCase(Locale.ROOT);
return themePack -> {
ThemePackManifest manifest = themePack.manifest();
return containsIgnoreCase(manifest.displayName(), query)
|| containsIgnoreCase(manifest.id(), query)
|| containsIgnoreCase(manifest.version(), query)
|| containsIgnoreCase(manifest.displayDescription(), query)
|| manifest.authors().stream().anyMatch(author -> containsIgnoreCase(author.displayName(), query))
|| manifest.themes().stream()
.flatMap(theme -> theme.authors().stream())
.anyMatch(author -> containsIgnoreCase(author.displayName(), query));
};
}
/// Opens a theme-pack file and installs it.
private void importThemePack() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("theme_pack.import.title"));
chooser.getExtensionFilters().setAll(
new FileChooser.ExtensionFilter(i18n("theme_pack.file"), "*" + ThemePackExporter.FILE_EXTENSION));
@Nullable Path file = Controllers.showOpenDialog(chooser);
if (file == null) {
return;
}
importThemePackImpl(file);
}
private void importThemePackImpl(Path file) {
ThemePackManager.InstalledThemePack themePack;
try {
themePack = ThemePackManager.install(file);
refreshThemePacks();
} catch (IOException | RuntimeException e) {
String title = i18n("theme_pack.import.failed");
Controllers.dialog(
title + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"),
MessageType.ERROR);
return;
}
onThemePacksChanged.run();
Controllers.showToast(i18n("theme_pack.import.success", themePack.manifest().displayName()));
}
/// Shows the installed package file in the platform file manager.
private static void revealThemePack(ThemePackManager.InstalledThemePack themePack) {
if (themePack.builtin()) {
return;
}
@Nullable Path file = themePack.file();
if (file != null) {
FXUtils.showFileInExplorer(file);
}
}
/// Asks for confirmation and deletes an installed theme pack.
private void deleteThemePack(ThemePackManager.InstalledThemePack themePack) {
if (themePack.builtin()) {
return;
}
Controllers.confirm(
i18n("theme_pack.delete.confirm", themePack.manifest().displayName()),
i18n("theme_pack.delete"),
MessageType.WARNING,
() -> deleteThemePackConfirmed(themePack),
null);
}
/// Deletes an installed theme pack after confirmation.
private void deleteThemePackConfirmed(ThemePackManager.InstalledThemePack themePack) {
try {
ThemePackManager.uninstall(themePack);
refreshThemePacks();
onThemePacksChanged.run();
Controllers.showToast(i18n("theme_pack.delete.success", themePack.manifest().displayName()));
} catch (IOException | RuntimeException e) {
Controllers.dialog(
i18n("theme_pack.delete.failed") + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"),
MessageType.ERROR);
}
}
/// Handles a theme-pack row click.
private void selectThemePack(ThemePackManager.InstalledThemePack themePack) {
ThemePackManifest manifest = themePack.manifest();
if (manifest.themes().size() == 1 && manifest.themes().get(0).id() == null) {
confirmApplyTheme(themePack, manifest.themes().get(0));
} else {
Controllers.dialog(new ThemePackInfoDialog(this, themePack));
}
}
/// Asks for confirmation before applying one theme.
private void confirmApplyTheme(ThemePackManager.InstalledThemePack themePack, Theme theme) {
Controllers.confirm(
i18n("theme_pack.apply.confirm", getThemeTitle(themePack.manifest(), theme)),
i18n("theme_pack.apply"),
MessageType.QUESTION,
() -> applyTheme(themePack, theme),
null);
}
/// Applies one theme after confirmation.
private void applyTheme(ThemePackManager.InstalledThemePack themePack, Theme theme) {
try {
if (Themes.shouldWaitForThemeBackground()) {
Controllers.taskDialog(
Themes.applyTheme(themePack, theme),
i18n("launcher.background.loading"),
TaskCancellationAction.NO_CANCEL);
} else {
ThemePackManager.apply(themePack, theme);
}
onThemePacksChanged.run();
} catch (IOException | RuntimeException e) {
Controllers.dialog(
i18n("theme_pack.apply.failed") + "\n\n" + StringUtils.getStackTrace(e),
i18n("message.error"),
MessageType.ERROR);
}
}
/// Returns comma-separated author display names.
private static String getAuthorDisplayNames(List<ThemePackAuthor> authors) {
return authors.stream()
.map(ThemePackAuthor::displayName)
.filter(author -> !StringUtils.isBlank(author))
.collect(Collectors.joining(", "));
}
/// Returns the display title for one theme-pack theme.
private static String getThemeTitle(ThemePackManifest manifest, Theme theme) {
if (manifest.themes().size() == 1 && theme.id() == null) {
return manifest.displayName();
}
return manifest.displayName() + " - " + getThemeDisplayName(manifest, theme);
}
/// Returns the display name for one theme inside a theme list.
private static String getThemeDisplayName(ThemePackManifest manifest, Theme theme) {
return Objects.requireNonNullElse(
theme.displayName(),
Objects.requireNonNullElse(theme.id(), manifest.displayName()));
}
/// Returns whether a nullable value contains the lower-cased query.
private static boolean containsIgnoreCase(@Nullable String value, String query) {
return value != null && value.toLowerCase(Locale.ROOT).contains(query);
}
/// Creates an icon node for a theme-pack asset path.
private Node createIconNode(
ThemePackManager.InstalledThemePack themePack,
@Nullable String icon,
SVG fallback) {
ImageContainer imageContainer = new ImageContainer(ICON_SIZE);
SVGContainer fallbackIcon = createFallbackIcon(fallback);
updateIcon(imageContainer, fallbackIcon, themePack, icon);
StackPane container = new StackPane(imageContainer, fallbackIcon);
container.setMouseTransparent(true);
return container;
}
/// Creates a vector fallback icon.
private static SVGContainer createFallbackIcon(SVG fallback) {
SVGContainer fallbackIcon = new SVGContainer(fallback, ICON_SIZE);
fallbackIcon.setMouseTransparent(true);
return fallbackIcon;
}
/// Updates reusable icon nodes for the supplied theme pack.
private void updateIcon(
ImageContainer imageContainer,
SVGContainer fallbackIcon,
ThemePackManager.InstalledThemePack themePack,
@Nullable String icon) {
@Nullable Image image = getIconImage(themePack, icon);
imageContainer.setImage(image);
fallbackIcon.setVisible(image == null);
}
/// Returns the icon image for a fixed-size icon container.
private @Nullable Image getIconImage(
ThemePackManager.InstalledThemePack themePack,
@Nullable String icon) {
@Nullable Image image = null;
if (!StringUtils.isBlank(icon)) {
image = iconCache.getThemePackIcon(themePack.location(), icon);
}
return image != null || !themePack.builtin() ? image : iconCache.getDefaultBuiltinIcon();
}
/// Cache key for one decoded icon image.
///
/// @param location the source theme-pack location
/// @param icon the theme-pack asset path
@NotNullByDefault
private record IconCacheKey(
ThemePackManager.ThemePackLocation location,
String icon) {
/// Creates an icon cache key.
///
/// @param location the source theme-pack location
/// @param icon the theme-pack asset path
private IconCacheKey {
Objects.requireNonNull(location);
Objects.requireNonNull(icon);
}
}
/// Page-scoped cache for decoded icon images.
@NotNullByDefault
private static final class IconCache {
/// Decoded theme-pack icons keyed by location and asset path.
private final Map<IconCacheKey, Optional<Image>> icons = new HashMap<>();
/// Decoded built-in fallback icon.
private @Nullable Image defaultBuiltinIcon;
/// Clears all decoded images held by this cache.
private void clear() {
icons.clear();
defaultBuiltinIcon = null;
}
/// Returns a theme-pack icon image, or `null` when it cannot be loaded.
private @Nullable Image getThemePackIcon(
ThemePackManager.ThemePackLocation location,
String icon) {
IconCacheKey key = new IconCacheKey(location, icon);
return icons.computeIfAbsent(key, this::loadThemePackIcon).orElse(null);
}
/// Returns the built-in default icon for built-in theme packs.
private Image getDefaultBuiltinIcon() {
if (defaultBuiltinIcon == null) {
defaultBuiltinIcon = FXUtils.newBuiltinImage(
"/assets/img/icon@8x.png",
ICON_IMAGE_SIZE,
ICON_IMAGE_SIZE,
true,
true);
}
return defaultBuiltinIcon;
}
/// Loads and decodes one theme-pack icon image.
private Optional<Image> loadThemePackIcon(IconCacheKey key) {
try {
ThemePackResource resource = ThemePackManager.resolveInstalledAsset(key.location(), key.icon());
return Optional.of(FXUtils.loadImage(
resource.openStream(),
resource.name(),
ICON_IMAGE_SIZE,
ICON_IMAGE_SIZE,
true,
true));
} catch (Exception e) {
LOG.warning("Failed to load theme-pack icon: " + key.icon(), e);
return Optional.empty();
}
}
}
/// Dialog showing all themes declared by one installed theme pack.
@NotNullByDefault
private static final class ThemePackInfoDialog extends JFXDialogLayout {
/// Creates the theme-pack information dialog.
///
/// @param themePack the installed theme pack to display
private ThemePackInfoDialog(ThemePackManagementPage page, ThemePackManager.InstalledThemePack themePack) {
ThemePackManifest manifest = themePack.manifest();
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
Node icon = page.createIconNode(themePack, manifest.icon(), SVG.PACKAGE2);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(manifest.displayName());
title.setSubtitle(manifest.id());
title.addTag(i18n("theme_pack.version", manifest.version()));
title.addTag(i18n("theme_pack.themes", manifest.themes().size()));
String authors = getAuthorDisplayNames(manifest.authors());
if (!StringUtils.isBlank(authors)) {
title.addTag(i18n("archive.author") + ": " + authors);
}
TwoLineListItem.setMargin(icon, new Insets(0, 8, 0, 0));
title.setLeading(icon);
setHeading(title);
ComponentList themes = new ComponentList();
themes.getStyleClass().add("no-padding");
for (Theme theme : manifest.themes()) {
TwoLineListItem item = new TwoLineListItem();
item.setTitle(getThemeDisplayName(manifest, theme));
@Nullable String description = theme.displayDescription();
item.setSubtitle(StringUtils.isBlank(description) ? null : description);
if (theme.id() != null) {
item.addTag(i18n("theme_pack.theme.id", theme.id()));
}
List<ThemePackAuthor> themeAuthors = theme.authors().isEmpty() ? manifest.authors() : theme.authors();
String authorNames = getAuthorDisplayNames(themeAuthors);
if (!StringUtils.isBlank(authorNames)) {
item.addTag(i18n("archive.author") + ": " + authorNames);
}
HBox row = new HBox(8);
row.setAlignment(Pos.CENTER_LEFT);
row.setPadding(new Insets(10, 16, 10, 16));
Node themeIcon = page.createIconNode(themePack, theme.icon(), SVG.STYLE);
HBox.setHgrow(item, Priority.ALWAYS);
item.setMouseTransparent(true);
row.getChildren().setAll(themeIcon, item);
var rowRippler = new RipplerContainer(row);
rowRippler.setCursor(Cursor.HAND);
ComponentList.setNoPadding(rowRippler);
FXUtils.onClicked(rowRippler, () -> {
fireEvent(new DialogCloseEvent());
page.confirmApplyTheme(themePack, theme);
});
themes.getContent().add(rowRippler);
}
ScrollPane scrollPane = new ScrollPane(themes);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setPrefViewportWidth(520);
scrollPane.setPrefViewportHeight(Math.min(360, manifest.themes().size() * 86));
scrollPane.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.55));
StackPane body = new StackPane(scrollPane);
body.setPadding(new Insets(10, 0, 0, 0));
setBody(body);
JFXButton okButton = new JFXButton(i18n("button.ok"));
okButton.getStyleClass().add("dialog-accept");
okButton.setOnAction(event -> fireEvent(new DialogCloseEvent()));
setActions(okButton);
onEscPressed(this, okButton::fire);
}
}
/// Skin for the theme-pack management list page.
@NotNullByDefault
private static final class ThemePackManagementPageSkin extends SkinBase<ThemePackManagementPage> {
/// Toolbar container used to switch between normal and search actions.
private final TransitionPane toolbarPane = new TransitionPane();
/// Search toolbar.
private final HBox searchBar = new HBox();
/// Toolbar shown during normal browsing.
private final HBox toolbarNormal = new HBox();
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
/// Search input.
private final JFXTextField searchField = new JFXTextField();
/// List of installed theme packs.
private final JFXListView<ThemePackManager.InstalledThemePack> listView = new JFXListView<>();
/// Creates the management page skin.
///
/// @param skinnable the page controlled by this skin
private ThemePackManagementPageSkin(ThemePackManagementPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
initializeToolbar(skinnable, root);
initializeList(skinnable, root);
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
/// Initializes toolbar actions and search.
private void initializeToolbar(ThemePackManagementPage skinnable, ComponentList root) {
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(event ->
skinnable.filteredList.setPredicate(skinnable.createPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
pause.setRate(1);
pause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> {
changeToolbar(toolbarNormal);
searchField.clear();
pause.stop();
skinnable.filteredList.setPredicate(null);
isSearching.set(false);
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.setAlignment(Pos.CENTER_LEFT);
toolbarNormal.setPickOnBounds(false);
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshThemePacks),
createToolbarButton2(i18n("theme_pack.import"), SVG.FILE_OPEN, skinnable::importThemePack),
createToolbarButton2(i18n("theme_pack.directory"), SVG.FOLDER_OPEN,
() -> FXUtils.openFolder(ThemePackManager.THEME_PACKS_DIRECTORY)),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
/// Initializes the installed theme-pack list.
private void initializeList(ThemePackManagementPage skinnable, ComponentList root) {
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
center.failedReasonProperty().bind(skinnable.failedReasonProperty());
center.onFailedActionProperty().bind(skinnable.onFailedActionProperty());
listView.setPadding(Insets.EMPTY);
listView.setCellFactory(x -> new ThemePackItemCell(skinnable));
listView.setItems(skinnable.getItems());
listView.getStyleClass().add("no-horizontal-scrollbar");
ignoreEvent(listView, KeyEvent.KEY_PRESSED, event -> event.getCode() == KeyCode.ESCAPE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label();
placeholderLabel.textProperty().bind(
Bindings.when(isSearching).then(i18n("search.no_results_found")).otherwise("")
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
center.setContent(listView);
root.getContent().add(center);
}
/// Switches the visible toolbar.
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
runInFX(searchField::requestFocus);
isSearching.set(true);
}
}
}
}
/// List cell that renders one installed theme pack.
@NotNullByDefault
private static final class ThemePackItemCell extends ListCell<ThemePackManager.@Nullable InstalledThemePack> {
/// The owning management page.
private final ThemePackManagementPage page;
/// Root graphic reused by this cell.
private final RipplerContainer graphic;
/// The text content shown for the current theme pack.
private final TwoLineListItem content = new TwoLineListItem();
/// Left-side package icon container.
private final StackPane icon = new StackPane();
/// Reused icon image node.
private final ImageContainer iconImage = new ImageContainer(ICON_SIZE);
/// Reused fallback icon.
private final SVGContainer iconFallback = createFallbackIcon(SVG.PACKAGE2);
/// Right-side action container.
private final HBox right = new HBox();
/// Shows the package file.
private final JFXButton revealButton;
/// Deletes the package.
private final JFXButton deleteButton;
/// Creates a reusable theme-pack list cell.
///
/// @param page the owning management page
private ThemePackItemCell(ThemePackManagementPage page) {
this.page = page;
content.getStyleClass().add("md-list-cell");
content.setPadding(new Insets(8));
content.setCursor(Cursor.HAND);
var ripplerContainer = new RipplerContainer(content);
this.graphic = ripplerContainer;
icon.setMouseTransparent(true);
icon.getChildren().setAll(iconImage, iconFallback);
TwoLineListItem.setMargin(icon, new Insets(0, 8, 0, 0));
content.setLeading(icon);
FXUtils.onClicked(ripplerContainer, () -> {
ThemePackManager.@Nullable InstalledThemePack themePack = getItem();
if (themePack != null) {
page.selectThemePack(themePack);
}
});
right.setAlignment(Pos.CENTER_RIGHT);
right.setPickOnBounds(false);
content.setTrailing(right);
revealButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
revealButton.setOnAction(event -> {
ThemePackManager.@Nullable InstalledThemePack themePack = getItem();
if (themePack != null) {
revealThemePack(themePack);
}
});
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
deleteButton = FXUtils.newToggleButton4(SVG.DELETE);
deleteButton.setOnAction(event -> {
ThemePackManager.@Nullable InstalledThemePack themePack = getItem();
if (themePack != null) {
page.deleteThemePack(themePack);
}
});
FXUtils.installFastTooltip(deleteButton, i18n("theme_pack.delete"));
}
/// Updates this cell for one installed theme pack.
@Override
protected void updateItem(ThemePackManager.@Nullable InstalledThemePack themePack, boolean empty) {
ThemePackManager.@Nullable InstalledThemePack currentItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(themePack, empty);
if (Objects.equals(getItem(), currentItem) && oldEmpty == empty) return;
this.graphic.resetFeedback();
iconImage.setImage(null);
iconFallback.setVisible(false);
if (empty || themePack == null) {
content.getTags().clear();
setGraphic(null);
return;
}
setGraphic(graphic);
ThemePackManifest manifest = themePack.manifest();
page.updateIcon(iconImage, iconFallback, themePack, manifest.icon());
content.setTitle(manifest.displayName());
@Nullable String description = manifest.displayDescription();
content.setSubtitle(StringUtils.isBlank(description) ? manifest.id() : description);
var tags = new ArrayList<Tag>(3);
if (themePack.builtin()) {
tags.add(new Tag(i18n("theme_pack.builtin")));
}
tags.add(new Tag(i18n("theme_pack.version", manifest.version())));
tags.add(new Tag(i18n("theme_pack.themes", manifest.themes().size())));
content.getTags().setAll(tags);
if (themePack.builtin()) {
right.getChildren().clear();
} else {
right.getChildren().setAll(revealButton, deleteButton);
}
}
}
}
@@ -1,123 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Skin;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.control.skin.TreeViewSkin;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Glavo
*/
public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state;
private final Path file;
private final NBTFileType type;
private final BorderPane root = new BorderPane();
public NBTEditorPage(Path file) throws IOException {
getStyleClass().add("gray-background");
this.state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("nbt.title", file.toString())));
this.file = file;
//noinspection DataFlowIssue
this.type = NBTFileType.ofFile(file);
if (type == null) {
throw new IOException("Unknown type of file " + file);
}
setContent(root);
setLoading(true);
HBox actions = new HBox(8);
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXButton saveButton = FXUtils.newRaisedButton(i18n("button.save"));
saveButton.setOnAction(e -> {
try {
save();
} catch (IOException ex) {
LOG.warning("Failed to save NBT file", ex);
Controllers.dialog(i18n("nbt.save.failed") + "\n\n" + StringUtils.getStackTrace(ex));
}
});
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
onEscPressed(this, cancelButton::fire);
actions.getChildren().setAll(saveButton, cancelButton);
Task.supplyAsync(() -> type.read(file))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
setLoading(false);
NBTTreeItem root = new NBTTreeItem(result, FileUtils.getName(file));
var view = new TreeView<>(root) {
@Override
protected Skin<?> createDefaultSkin() {
return new TreeViewSkin<>(this) {
{
FXUtils.smoothScrolling(getVirtualFlow());
}
};
}
};
view.setCellFactory(ignored -> new NBTTreeCell());
view.addEventHandler(TreeItem.<NBTElement>branchExpandedEvent(), event -> {
TreeItem<NBTElement> item = event.getTreeItem();
if (item.getValue() instanceof Tag && item.getChildren().size() == 1)
item.getChildren().get(0).setExpanded(true);
});
root.setExpanded(true);
BorderPane.setMargin(view, new Insets(10));
onEscPressed(view, cancelButton::fire);
this.root.setCenter(view);
} else {
LOG.warning("Fail to open nbt file", exception);
Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(exception), null, MessageDialogPane.MessageType.WARNING, cancelButton::fire);
}
}).start();
}
public void save() throws IOException {
// TODO
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
}
@@ -1,62 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.chunk.ChunkRegion;
import org.glavo.nbt.io.NBTCodec;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
/// @author Glavo
public enum NBTFileType {
COMPRESSED("dat", "dat_old", "litematic", "nbt", "schematic", "schem") {
@Override
public Tag read(Path file) throws IOException {
return NBTCodec.of().readTag(file);
}
},
ANVIL("mca") {
@Override
public NBTElement read(Path file) throws IOException {
return NBTCodec.of().readRegion(file);
}
},
REGION("mcr") {
@Override
public ChunkRegion read(Path file) throws IOException {
return NBTCodec.of().readRegion(file);
}
};
static final NBTFileType[] types = values();
public static boolean isNBTFileByExtension(Path file) {
return NBTFileType.ofFile(file) != null;
}
public static @Nullable NBTFileType ofFile(Path file) {
String ext = FileUtils.getExtension(file);
for (NBTFileType type : types) {
for (String extension : type.extensions) {
if (extension.equals(ext))
return type;
}
}
return null;
}
private final String[] extensions;
NBTFileType(String... extensions) {
this.extensions = extensions;
}
public abstract NBTElement read(Path file) throws IOException;
}
@@ -1,114 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import javafx.scene.control.TreeCell;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.chunk.Chunk;
import org.glavo.nbt.chunk.ChunkRegion;
import org.glavo.nbt.tag.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class NBTTreeCell extends TreeCell<@Nullable NBTElement> {
private static @Nullable Image getIcon(NBTElement element) {
if (element instanceof Tag tag) {
TagType<?> type = tag.getType();
if (type == TagType.BYTE) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Byte.png");
} else if (type == TagType.SHORT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Short.png");
} else if (type == TagType.INT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Int.png");
} else if (type == TagType.LONG) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Long.png");
} else if (type == TagType.FLOAT) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Float.png");
} else if (type == TagType.DOUBLE) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Double.png");
} else if (type == TagType.STRING) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_String.png");
} else if (type == TagType.BYTE_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Byte_Array.png");
} else if (type == TagType.INT_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Int_Array.png");
} else if (type == TagType.LONG_ARRAY) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Long_Array.png");
} else if (type == TagType.LIST) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_List.png");
} else if (type == TagType.COMPOUND) {
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Compound.png");
} else {
return null;
}
} else if (element instanceof ChunkRegion)
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_List.png");
else if (element instanceof Chunk)
return FXUtils.newBuiltinImage("/assets/img/nbt/TAG_Compound.png");
else
return null;
}
private final ImageView imageView;
public NBTTreeCell() {
imageView = new ImageView();
imageView.setFitHeight(16);
imageView.setFitWidth(16);
setGraphic(imageView);
}
private NBTTreeItem getNBTTreeItem() {
return (NBTTreeItem) getTreeItem();
}
@Override
public void updateItem(@Nullable NBTElement item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
imageView.setImage(null);
setText(null);
return;
}
imageView.setImage(getIcon(item));
String name = getNBTTreeItem().getOverrideName();
if (name == null) {
NBTElement value = getNBTTreeItem().getValue();
if (value instanceof Tag tag) {
if (tag.getParent() instanceof ListTag<?>) {
name = Integer.toString(tag.getIndex());
} else {
name = tag.getName();
}
} else if (value instanceof Chunk chunk) {
name = "Chunk (" + chunk.getLocalX() + ", " + chunk.getLocalZ() + ")";
} else {
name = "";
}
}
String text;
if (item instanceof ParentTag<?> parentTag) {
text = i18n("nbt.entries", parentTag.size());
} else if (item instanceof ValueTag<?> valueTag) {
text = valueTag.getAsString();
} else {
text = null;
}
if (text == null) {
setText(name);
} else {
setText(name + ": " + text);
}
}
}
@@ -1,53 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.nbt;
import javafx.collections.ObservableList;
import javafx.scene.control.TreeItem;
import org.glavo.nbt.NBTElement;
import org.glavo.nbt.NBTParent;
import org.glavo.nbt.chunk.Chunk;
import org.glavo.nbt.tag.*;
import org.jetbrains.annotations.Nullable;
/// @author Glavo
public final class NBTTreeItem extends TreeItem<NBTElement> {
private final @Nullable String overrideName;
public NBTTreeItem(NBTElement value) {
this(value, null);
}
public NBTTreeItem(NBTElement value, @Nullable String overrideName) {
super(value);
this.overrideName = overrideName;
}
@Override
public boolean isLeaf() {
return !(getValue() instanceof NBTParent<?> parent) || parent.isEmpty();
}
private boolean needInitChildren = true;
@Override
public ObservableList<TreeItem<NBTElement>> getChildren() {
ObservableList<TreeItem<NBTElement>> children = super.getChildren();
if (needInitChildren) {
needInitChildren = false;
if (getValue() instanceof Chunk chunk) {
if (chunk.getRootTag() != null) {
children.setAll(chunk.getRootTag().stream().map(NBTTreeItem::new).toList());
}
} else if (getValue() instanceof NBTParent<?> parent) {
children.setAll(parent.stream().map(NBTTreeItem::new).toList());
}
}
return children;
}
public @Nullable String getOverrideName() {
return overrideName;
}
}
@@ -1,7 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
@NotNullByDefault
package org.jackhuang.hmcl.ui.nbt;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,610 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.terracotta;
import com.jfoenix.controls.JFXProgressBar;
import javafx.beans.binding.Binding;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaManager;
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
import org.jackhuang.hmcl.terracotta.TerracottaState;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ThreadLocalRandom;
import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class TerracottaControllerPage extends StackPane {
private static final String FEEDBACK_TIP = "terracotta-feedback";
private static final ObjectProperty<TerracottaState> UI_STATE = new SimpleObjectProperty<>();
static {
FXUtils.onChangeAndOperate(TerracottaManager.stateProperty(), state -> {
if (state != null) {
UI_STATE.set(state);
}
});
}
@SuppressWarnings("FieldCanBeLocal")
private final Binding<Boolean> focusedBinding;
@SuppressWarnings("FieldCanBeLocal")
private final WeakListenerHolder holder = new WeakListenerHolder();
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
Consider moving UI into multiple files for each state respectively. */
public TerracottaControllerPage() {
focusedBinding = BindingMapping.of(this.sceneProperty())
.flatMap(it -> it != null ? it.windowProperty() : new SimpleObjectProperty<>())
.flatMap(it -> it != null ? it.focusedProperty() : null, () -> false);
FXUtils.onChange(focusedBinding, focused -> {
// Run daemon process only if HMCL is focused and is displaying current node.
TerracottaManager.switchDaemon(getScene() != null && focused);
});
TransitionPane transition = new TransitionPane();
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
DoubleProperty progressProperty = new SimpleDoubleProperty();
ObservableList<Node> nodesProperty = FXCollections.observableList(new ArrayList<>());
FXUtils.applyDragListener(this, path -> {
TerracottaState state = UI_STATE.get();
if (state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal fatal && fatal.isRecoverable()
) {
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
} else {
return false;
}
}, files -> {
Path path = files.get(0);
if (TerracottaManager.isInvalidBundle(path)) {
Controllers.dialog(
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR
);
return;
}
TerracottaState.Preparing next = TerracottaManager.install(path);
if (next != null) {
UI_STATE.set(next);
}
});
ChangeListener<TerracottaState> listener = (_uiState, legacyState, state) -> {
if (legacyState != null && legacyState.isUIFakeState() && !state.isUIFakeState() && legacyState.getClass() == state.getClass()) {
return;
}
progressProperty.unbind();
if (state instanceof TerracottaState.Bootstrap) {
statusProperty.set(i18n("terracotta.status.bootstrap"));
progressProperty.set(-1);
nodesProperty.setAll();
} else if (state instanceof TerracottaState.Uninitialized uninitialized) {
String fork = uninitialized.hasLegacy() ? "update" : "not_exist";
statusProperty.set(i18n("terracotta.status.uninitialized." + fork));
progressProperty.set(0);
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
body.getStyleClass().add("terracotta-hint");
body.setLineSpacing(4);
var download = createLargeTitleLineButton();
download.setLeading(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
download.setTitle(i18n(String.format("terracotta.status.uninitialized.%s.title", fork)));
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
download.setTrailingIcon(SVG.ARROW_FORWARD, ICON_SIZE);
download.setOnAction(event -> {
TerracottaState.Preparing s = TerracottaManager.download();
if (s != null) {
UI_STATE.set(s);
}
if (uninitialized.hasLegacy() && I18n.isUseChinese()) {
Object feedback = state().getShownTips().get(FEEDBACK_TIP);
if (!(feedback instanceof Number number) || number.intValue() < 1) {
Controllers.confirm(i18n("terracotta.feedback.desc.update"), i18n("terracotta.feedback.title"), () -> {
FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK);
state().getShownTips().put(FEEDBACK_TIP, 1);
}, () -> {
});
}
}
});
nodesProperty.setAll(body, download, getThirdPartyDownloadNodes());
} else if (state instanceof TerracottaState.Preparing) {
statusProperty.set(i18n("terracotta.status.preparing"));
progressProperty.bind(((TerracottaState.Preparing) state).progressProperty());
nodesProperty.setAll(getThirdPartyDownloadNodes());
} else if (state instanceof TerracottaState.Launching) {
statusProperty.set(i18n("terracotta.status.launching"));
progressProperty.set(-1);
nodesProperty.setAll();
} else if (state instanceof TerracottaState.Unknown) {
statusProperty.set(i18n("terracotta.status.unknown"));
progressProperty.set(-1);
nodesProperty.setAll();
} else if (state instanceof TerracottaState.Waiting) {
statusProperty.set(i18n("terracotta.status.waiting"));
progressProperty.set(1);
TextFlow flow = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
flow.getStyleClass().add("terracotta-hint");
flow.setLineSpacing(4);
var host = createLargeTitleLineButton();
host.setLeading(SVG.HOST, ICON_SIZE);
host.setTitle(i18n("terracotta.status.waiting.host.title"));
host.setSubtitle(i18n("terracotta.status.waiting.host.desc"));
host.setTrailingIcon(SVG.ARROW_FORWARD, ICON_SIZE);
host.setOnAction(event -> {
if (LauncherHelper.countMangedProcesses() >= 1) {
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
if (s1 != null) {
UI_STATE.set(s1);
}
} else {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("terracotta.status.waiting.host.launch.desc"),
i18n("terracotta.status.waiting.host.launch.title"),
MessageDialogPane.MessageType.QUESTION
).addAction(i18n("instance.launch"), () -> {
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance(), launcherHelper -> {
launcherHelper.setKeep();
launcherHelper.setDisableOfflineSkin();
});
}).addCancel(i18n("terracotta.status.waiting.host.launch.skip"), () -> {
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
if (s1 != null) {
UI_STATE.set(s1);
}
}).addCancel(() -> {
}).build());
}
});
var guest = createLargeTitleLineButton();
guest.setLeading(SVG.ADD_CIRCLE, ICON_SIZE);
guest.setTitle(i18n("terracotta.status.waiting.guest.title"));
guest.setSubtitle(i18n("terracotta.status.waiting.guest.desc"));
guest.setTrailingIcon(SVG.ARROW_FORWARD, ICON_SIZE);
guest.setOnAction(event -> {
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, handler) -> {
Task<TerracottaState.GuestConnecting> task = TerracottaManager.setGuesting(code);
if (task != null) {
task.whenComplete(Schedulers.javafx(), (s, e) -> {
if (e != null) {
handler.reject(i18n("terracotta.status.waiting.guest.prompt.invalid"));
} else {
handler.resolve();
UI_STATE.set(s);
}
}).setSignificance(Task.TaskSignificance.MINOR).start();
} else {
handler.resolve();
}
}, "", new RequiredValidator(i18n("input.not_empty")));
});
if (ThreadLocalRandom.current().nextDouble() < 0.02D) {
var feedback = createLargeTitleLineButton();
feedback.setLeading(SVG.FEEDBACK, ICON_SIZE);
feedback.setTitle(i18n("terracotta.feedback.title"));
feedback.setSubtitle(i18n("terracotta.feedback.desc"));
feedback.setTrailingIcon(SVG.OPEN_IN_NEW, ICON_SIZE);
FXUtils.onClicked(feedback, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
nodesProperty.setAll(flow, host, guest, feedback);
} else {
nodesProperty.setAll(flow, host, guest);
}
} else if (state instanceof TerracottaState.HostScanning) {
statusProperty.set(i18n("terracotta.status.scanning"));
progressProperty.set(-1);
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.status.scanning.desc"), Controllers::onHyperlinkAction);
body.getStyleClass().add("terracotta-hint");
body.setLineSpacing(4);
var room = createLargeTitleLineButton();
room.setLeading(SVG.ARROW_BACK, ICON_SIZE);
room.setTitle(i18n("terracotta.back"));
room.setSubtitle(i18n("terracotta.status.scanning.back"));
room.setOnAction(event -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.setAll(body, room);
} else if (state instanceof TerracottaState.HostStarting) {
statusProperty.set(i18n("terracotta.status.host_starting"));
progressProperty.set(-1);
var room = createLargeTitleLineButton();
room.setLeading(SVG.ARROW_BACK, ICON_SIZE);
room.setTitle(i18n("terracotta.back"));
room.setSubtitle(i18n("terracotta.status.host_starting.back"));
room.setOnAction(event -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.setAll(room);
} else if (state instanceof TerracottaState.HostOK hostOK) {
if (hostOK.isForkOf(legacyState)) {
if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) {
profileUI.updateProfiles(hostOK.getProfiles());
} else { // Should NOT happen
nodesProperty.add(new PlayerProfileUI(hostOK.getProfiles()));
}
return;
} else {
String cs = hostOK.getCode();
copyCode(cs);
statusProperty.set(i18n("terracotta.status.host_ok"));
progressProperty.set(1);
VBox code = new VBox(4);
code.setAlignment(Pos.CENTER);
{
Label desc = new Label(i18n("terracotta.status.host_ok.code"));
desc.setMouseTransparent(true);
Label label = new Label(cs);
label.setMouseTransparent(true);
label.setStyle("-fx-font-size: 24");
label.setAlignment(Pos.CENTER);
VBox.setMargin(label, new Insets(10, 0, 10, 0));
code.getChildren().setAll(desc, label);
}
code.setCursor(Cursor.HAND);
FXUtils.onClicked(code, () -> copyCode(cs));
var copy = createLargeTitleLineButton();
copy.setLeading(SVG.CONTENT_COPY, ICON_SIZE);
copy.setTitle(i18n("terracotta.status.host_ok.code.copy"));
copy.setSubtitle(i18n("terracotta.status.host_ok.code.desc"));
FXUtils.onClicked(copy, () -> copyCode(cs));
var back = createLargeTitleLineButton();
back.setLeading(SVG.ARROW_BACK, ICON_SIZE);
back.setTitle(i18n("terracotta.back"));
back.setSubtitle(i18n("terracotta.status.host_ok.back"));
back.setOnAction(event -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
if (hostOK.getProfiles().isEmpty()) {
nodesProperty.setAll(code, copy, back);
} else {
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
}
}
} else if (state instanceof TerracottaState.GuestConnecting || state instanceof TerracottaState.GuestStarting) {
statusProperty.set(i18n("terracotta.status.guest_starting"));
progressProperty.set(-1);
var room = createLargeTitleLineButton();
room.setLeading(SVG.ARROW_BACK, ICON_SIZE);
room.setTitle(i18n("terracotta.back"));
room.setSubtitle(i18n("terracotta.status.guest_starting.back"));
room.setOnAction(event -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.clear();
if (state instanceof TerracottaState.GuestStarting) {
TerracottaState.GuestStarting.Difficulty difficulty = ((TerracottaState.GuestStarting) state).getDifficulty();
if (difficulty != null && difficulty != TerracottaState.GuestStarting.Difficulty.UNKNOWN) {
var info = createLargeTitleLineButton();
info.setLeading(switch (difficulty) {
case UNKNOWN -> throw new AssertionError();
case EASIEST, SIMPLE -> SVG.INFO;
case MEDIUM, TOUGH -> SVG.WARNING;
}, ICON_SIZE);
String difficultyID = difficulty.name().toLowerCase(Locale.ROOT);
info.setTitle(i18n(String.format("terracotta.difficulty.%s", difficultyID)));
info.setSubtitle(i18n("terracotta.difficulty.estimate_only"));
nodesProperty.add(info);
}
}
nodesProperty.add(room);
} else if (state instanceof TerracottaState.GuestOK guestOK) {
if (guestOK.isForkOf(legacyState)) {
if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) {
profileUI.updateProfiles(guestOK.getProfiles());
} else { // Should NOT happen
nodesProperty.add(new PlayerProfileUI(guestOK.getProfiles()));
}
return;
} else {
statusProperty.set(i18n("terracotta.status.guest_ok"));
progressProperty.set(1);
var tutorial = createLargeTitleLineButton();
tutorial.setTitle(i18n("terracotta.status.guest_ok.title"));
tutorial.setSubtitle(i18n("terracotta.status.guest_ok.desc", guestOK.getUrl()));
var back = createLargeTitleLineButton();
back.setLeading(SVG.ARROW_BACK, ICON_SIZE);
back.setTitle(i18n("terracotta.back"));
back.setSubtitle(i18n("terracotta.status.guest_ok.back"));
back.setOnAction(event -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
if (guestOK.getProfiles().isEmpty()) {
nodesProperty.setAll(tutorial, back);
} else {
nodesProperty.setAll(tutorial, back, new PlayerProfileUI(guestOK.getProfiles()));
}
}
} else if (state instanceof TerracottaState.Exception exception) {
statusProperty.set(i18n("terracotta.status.exception.desc." + exception.getType().name().toLowerCase(Locale.ROOT)));
progressProperty.set(1);
nodesProperty.setAll();
var back = createLargeTitleLineButton();
back.setLeading(SVG.ARROW_BACK, ICON_SIZE);
back.setTitle(i18n("terracotta.back"));
back.setSubtitle(i18n("terracotta.status.exception.back"));
back.setOnAction(event -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
SpinnerPane exportLog = new SpinnerPane();
var exportLogInner = createLargeTitleLineButton();
exportLogInner.setLeading(SVG.OUTPUT, ICON_SIZE);
exportLogInner.setTitle(i18n("terracotta.export_log"));
exportLogInner.setSubtitle(i18n("terracotta.export_log.desc"));
exportLog.setContent(exportLogInner);
ComponentList.setNoPadding(exportLog);
// FIXME: SpinnerPane loses its content width in loading state.
exportLog.minHeightProperty().bind(back.heightProperty());
exportLogInner.setOnAction(event -> {
exportLog.setLoading(true);
TerracottaManager.exportLogs().thenAcceptAsync(Schedulers.io(), data -> {
if (data == null || data.isEmpty()) {
return;
}
Path path = Path.of("terracotta-log-" + LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")
) + ".zip").toAbsolutePath();
try (Zipper zipper = new Zipper(path)) {
zipper.putTextFile(data, StandardCharsets.UTF_8, "terracotta.log");
try (OutputStream os = zipper.putStream("hmcl-latest.log")) {
LOG.exportLogs(os);
}
}
FXUtils.showFileInExplorer(path);
}).thenRunAsync(
() -> Thread.sleep(3000)
).whenComplete(
Schedulers.javafx(),
e -> exportLog.setLoading(false)
).start();
});
nodesProperty.setAll(back, exportLog);
} else if (state instanceof TerracottaState.Fatal fatal) {
String message = i18n("terracotta.status.fatal." + fatal.getType().name().toLowerCase(Locale.ROOT));
statusProperty.set(message);
progressProperty.set(1);
if (fatal.isRecoverable()) {
var retry = createLargeTitleLineButton();
retry.setLeading(SVG.RESTORE, ICON_SIZE);
retry.setTitle(i18n("terracotta.status.fatal.retry"));
retry.setSubtitle(message);
retry.setOnAction(event -> {
TerracottaState s = TerracottaManager.recover();
if (s != null) {
UI_STATE.set(s);
}
});
if (fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
nodesProperty.setAll(retry, getThirdPartyDownloadNodes());
} else {
nodesProperty.setAll(retry);
}
} else {
nodesProperty.setAll();
}
} else {
throw new AssertionError(state.getClass().getName());
}
ComponentList components = new ComponentList();
{
VBox statusPane = new VBox(8);
VBox.setMargin(statusPane, new Insets(0, 0, 0, 4));
{
Label status = new Label();
status.textProperty().bind(statusProperty);
JFXProgressBar progress = new JFXProgressBar();
progress.progressProperty().bind(progressProperty);
progress.setMaxWidth(Double.MAX_VALUE);
statusPane.getChildren().setAll(status, progress);
}
ObservableList<Node> children = components.getContent();
children.add(statusPane);
children.addAll(nodesProperty);
}
// Prevent the shadow of components from being clipped
StackPane.setMargin(components, new Insets(0, 0, 5, 0));
transition.setContent(components, TabController.TabAnimation.TAB_UP);
};
listener.changed(UI_STATE, null, UI_STATE.get());
holder.add(listener);
UI_STATE.addListener(new WeakChangeListener<>(listener));
VBox content = new VBox(10);
content.getChildren().add(ComponentList.createComponentListTitle(i18n("terracotta.status")));
if (!"CN".equals(RegionUtils.MACHINE_REGION)) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setText(i18n("terracotta.unsupported.region"));
content.getChildren().add(hintPane);
}
content.getChildren().add(transition);
content.setPadding(new Insets(10));
content.setFillWidth(true);
ScrollPane scrollPane = new ScrollPane(content);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setFitToWidth(true);
getChildren().setAll(scrollPane);
}
/// Creates an expandable group of third-party download links with a mouse-transparent decorative icon.
private ComponentSublist getThirdPartyDownloadNodes() {
ComponentSublist locals = new ComponentSublist();
locals.setLargeTitle(true);
ImageView icon = new ImageView(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
icon.setMouseTransparent(true);
locals.setLeading(icon);
locals.setTitle(i18n("terracotta.from_local.title"));
locals.setSubtitle(i18n("terracotta.from_local.desc"));
for (TerracottaMetadata.Link link : TerracottaMetadata.PACKAGE_LINKS) {
LineButton item = new LineButton();
item.setTrailingIcon(SVG.OPEN_IN_NEW);
item.setTitle(link.description().getText(I18n.getLocale().getCandidateLocales()));
item.setOnAction(event -> Controllers.dialog(
i18n("terracotta.from_local.guide", TerracottaMetadata.PACKAGE_NAME),
i18n("message.info"), MessageDialogPane.MessageType.INFO,
() -> FXUtils.openLink(link.link())
));
locals.getContent().add(item);
}
return locals;
}
private void copyCode(String code) {
FXUtils.copyText(code, i18n("terracotta.status.host_ok.code.copy.toast"));
}
private static final double ICON_SIZE = 28;
private static LineButton createLargeTitleLineButton() {
var lineButton = new LineButton();
lineButton.setLargeTitle(true);
return lineButton;
}
private static final class PlayerProfileUI extends VBox {
private final TransitionPane transition;
public PlayerProfileUI(List<TerracottaProfile> profiles) {
super(8);
VBox.setMargin(this, new Insets(0, 0, 0, 4));
{
Label status = new Label();
status.setText(i18n("terracotta.player_list"));
transition = new TransitionPane();
getChildren().setAll(status, transition);
updateProfiles(profiles);
}
}
private void updateProfiles(List<TerracottaProfile> profiles) {
VBox pane = new VBox(8);
for (TerracottaProfile profile : profiles) {
TwoLineListItem item = new TwoLineListItem();
item.setTitle(profile.getName());
item.setSubtitle(profile.getVendor());
item.addTag(i18n("terracotta.player_kind." + profile.getType().name().toLowerCase(Locale.ROOT)));
pane.getChildren().add(item);
}
this.transition.setContent(pane, TabController.TabAnimation.TAB_UP);
}
}
}
@@ -1,112 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.terracotta;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.value.ChangeListener;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem;
import org.jackhuang.hmcl.ui.account.AccountListPopupMenu;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.main.MainPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.Lang;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.setting.SettingsManager.userState;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
private static final int TERRACOTTA_AGREEMENT_VERSION = 2;
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta")));
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController;
/// The tab displaying the Terracotta controller page.
private final TabItem<TerracottaControllerPage> statusPage;
private final TransitionPane transitionPane = new TransitionPane();
@SuppressWarnings("unused")
private @Nullable ChangeListener<@Nullable HMCLGameInstance> instanceChangeListenerHolder;
/// Creates the connection tab and the fixed account and launch actions.
public TerracottaPage() {
statusPage = new TabItem<>(TerracottaControllerPage::new);
tabController = new TabController(transitionPane, statusPage);
tabController.selectTab(statusPage);
getSidebar()
.addNavigationDrawerTab(tabController, statusPage, i18n("terracotta.status"), SVG.TUNE);
AccountAdvancedListItem accountListItem = new AccountAdvancedListItem();
accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage()));
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
FXUtils.onSecondaryButtonClicked(accountListItem, () -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0));
AdvancedListBox toolbar = new AdvancedListBox()
.add(accountListItem)
.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> {
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance(), launcherHelper -> {
launcherHelper.setKeep();
launcherHelper.setDisableOfflineSkin();
});
}, item -> {
instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(),
instance -> item.setSubtitle(instance != null ? instance.getId().toString() : i18n("instance.empty"))
);
MainPage mainPage = Controllers.getRootPage().getMainPage();
FXUtils.onScroll(item, mainPage.getInstances(), list -> {
@Nullable HMCLGameInstance currentGame = mainPage.getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.LEFT,
item.getWidth(),
0,
mainPage.getInstances()));
})
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
setSidebarBottom(toolbar);
setContent(transitionPane);
}
@Override
public void onPageShown() {
tabController.onPageShown();
if (SettingsManager.userState().terracottaAgreementVersionProperty().get() < TERRACOTTA_AGREEMENT_VERSION) {
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5, MessageDialogPane.MessageType.INFO, () -> {
UserState userState = userState();
userState.terracottaAgreementVersionProperty().set(TERRACOTTA_AGREEMENT_VERSION);
}, () -> fireEvent(new PageCloseEvent()));
}
}
@Override
public void onPageHidden() {
tabController.onPageHidden();
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
}
@@ -1,109 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.upgrade;
import java.io.IOException;
import java.io.InputStream;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileChannel.MapMode;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.jackhuang.hmcl.util.io.IOUtils;
import static java.nio.file.StandardOpenOption.*;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
/**
* Helper class for adding/removing executable header from HMCL file.
*
* @author yushijinhun
*/
final class ExecutableHeaderHelper {
private ExecutableHeaderHelper() {}
private static Map<String, String> suffix2header = mapOf(
pair("exe", "assets/HMCLauncher.exe"),
pair("sh", "assets/HMCLauncher.sh")
);
private static Optional<String> getSuffix(Path file) {
String filename = file.getFileName().toString();
int idxDot = filename.lastIndexOf('.');
if (idxDot < 0) {
return Optional.empty();
} else {
return Optional.of(filename.substring(idxDot + 1));
}
}
private static Optional<byte[]> readHeader(ZipFile zip, String suffix) throws IOException {
String location = suffix2header.get(suffix);
if (location != null) {
ZipEntry entry = zip.getEntry(location);
if (entry != null && !entry.isDirectory()) {
try (InputStream in = zip.getInputStream(entry)) {
return Optional.of(IOUtils.readFully(in));
}
}
}
return Optional.empty();
}
private static int detectHeaderLength(ZipFile zip, FileChannel channel) throws IOException {
ByteBuffer buf = channel.map(MapMode.READ_ONLY, 0, channel.size());
suffixLoop: for (String suffix : suffix2header.keySet()) {
Optional<byte[]> header = readHeader(zip, suffix);
if (header.isPresent()) {
((Buffer) buf).rewind();
for (byte b : header.get()) {
if (!buf.hasRemaining() || b != buf.get()) {
continue suffixLoop;
}
}
return header.get().length;
}
}
return 0;
}
/**
* Copies the executable and removes its header.
*/
public static void copyWithoutHeader(Path from, Path to) throws IOException {
try (
FileChannel in = FileChannel.open(from, READ);
FileChannel out = FileChannel.open(to, CREATE, WRITE, TRUNCATE_EXISTING);
ZipFile zip = new ZipFile(from.toFile())
) {
in.transferTo(detectHeaderLength(zip, in), Long.MAX_VALUE, out);
}
}
/**
* Copies the executable and appends the header according to the suffix.
*/
public static void copyWithHeader(Path from, Path to) throws IOException {
try (
FileChannel in = FileChannel.open(from, READ);
FileChannel out = FileChannel.open(to, CREATE, WRITE, TRUNCATE_EXISTING);
ZipFile zip = new ZipFile(from.toFile())
) {
Optional<String> suffix = getSuffix(to);
if (suffix.isPresent()) {
Optional<byte[]> header = readHeader(zip, suffix.get());
if (header.isPresent()) {
out.write(ByteBuffer.wrap(header.get()));
}
}
in.transferTo(detectHeaderLength(zip, in), Long.MAX_VALUE, out);
}
}
}
@@ -1,41 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.upgrade;
import org.jackhuang.hmcl.task.FileDownloadTask;
import java.nio.file.Files;
import java.nio.file.Path;
final class HMCLDownloadTask extends FileDownloadTask {
private final RemoteVersion.Type archiveFormat;
public HMCLDownloadTask(RemoteVersion version, Path target) {
super(version.url(), target, version.integrityCheck());
archiveFormat = version.type();
}
@Override
public void execute() throws Exception {
super.execute();
try {
Path target = getPath();
switch (archiveFormat) {
case JAR:
break;
default:
throw new IllegalArgumentException("Unknown format: " + archiveFormat);
}
} catch (Throwable e) {
try {
Files.deleteIfExists(getPath());
} catch (Throwable e2) {
e.addSuppressed(e2);
}
throw e;
}
}
}
@@ -1,44 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.upgrade;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.Optional;
public record RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck,
boolean preview, boolean force) {
public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String url) throws IOException {
try {
JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(url), JsonObject.class);
String version = Optional.ofNullable(response.get("version")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("version is missing"));
String jarUrl = Optional.ofNullable(response.get("jar")).map(JsonElement::getAsString).orElse(null);
String jarHash = Optional.ofNullable(response.get("jarsha1")).map(JsonElement::getAsString).orElse(null);
boolean force = Optional.ofNullable(response.get("force")).map(JsonElement::getAsBoolean).orElse(false);
if (jarUrl != null && jarHash != null) {
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), preview, force);
} else {
throw new IOException("No download url is available");
}
} catch (JsonParseException e) {
throw new IOException("Malformed response", e);
}
}
@Override
public @NotNull String toString() {
return "[" + version + " from " + url + "]";
}
public enum Type {
JAR
}
}
@@ -1,13 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.upgrade;
import java.io.IOException;
public class SelfVerificationException extends IOException {
public SelfVerificationException() {
super("Self verification failed");
}
}
@@ -1,27 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.upgrade;
import org.jackhuang.hmcl.Metadata;
public enum UpdateChannel {
STABLE("stable"),
DEVELOPMENT("dev"),
NIGHTLY("nightly");
public final String channelName;
UpdateChannel(String channelName) {
this.channelName = channelName;
}
public static UpdateChannel getChannel() {
if (Metadata.isDev()) {
return DEVELOPMENT;
} else if (Metadata.isNightly()) {
return NIGHTLY;
} else {
return STABLE;
}
}
}
@@ -1,127 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.upgrade;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.*;
import javafx.beans.value.ObservableBooleanValue;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.LinkedHashMap;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.Lang.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class UpdateChecker {
private UpdateChecker() {
}
private static final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>();
private static final BooleanBinding outdated = Bindings.createBooleanBinding(
() -> {
RemoteVersion latest = latestVersion.get();
if (latest == null || isDevelopmentVersion(Metadata.VERSION)) {
return false;
} else if (latest.force()
|| Metadata.isNightly()
|| latest.channel() == UpdateChannel.NIGHTLY
|| latest.channel() != UpdateChannel.getChannel()) {
return !latest.version().equals(Metadata.VERSION);
} else {
return VersionNumber.compare(Metadata.VERSION, latest.version()) < 0;
}
},
latestVersion);
private static final ReadOnlyBooleanWrapper checkingUpdate = new ReadOnlyBooleanWrapper(false);
private static final ReadOnlyObjectWrapper<@Nullable Throwable> error = new ReadOnlyObjectWrapper<>();
public static void init() {
requestCheckUpdate(UpdateChannel.getChannel(), settings().acceptPreviewUpdateProperty().get());
}
public static RemoteVersion getLatestVersion() {
return latestVersion.get();
}
public static ReadOnlyObjectProperty<RemoteVersion> latestVersionProperty() {
return latestVersion;
}
public static boolean isOutdated() {
return outdated.get();
}
public static ObservableBooleanValue outdatedProperty() {
return outdated;
}
public static boolean isCheckingUpdate() {
return checkingUpdate.get();
}
public static ReadOnlyBooleanProperty checkingUpdateProperty() {
return checkingUpdate.getReadOnlyProperty();
}
public static ReadOnlyObjectProperty<@Nullable Throwable> errorProperty() {
return error.getReadOnlyProperty();
}
private static RemoteVersion checkUpdate(UpdateChannel channel, boolean preview) throws IOException {
if (!IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK && !IntegrityChecker.isSelfVerified()) {
throw new SelfVerificationException();
}
var query = new LinkedHashMap<String, String>();
query.put("version", Metadata.VERSION);
query.put("channel", preview ? channel.channelName + "-preview" : channel.channelName);
String url = NetworkUtils.withQuery(Metadata.HMCL_UPDATE_URL, query);
return RemoteVersion.fetch(channel, preview, url);
}
private static boolean isDevelopmentVersion(String version) {
return version.contains("@") || // eg. @develop@
version.contains("SNAPSHOT"); // eg. 3.5.SNAPSHOT
}
public static void requestCheckUpdate(UpdateChannel channel, boolean preview) {
Platform.runLater(() -> {
if (isCheckingUpdate())
return;
checkingUpdate.set(true);
thread(() -> {
RemoteVersion result = null;
Throwable t = null;
try {
result = checkUpdate(channel, preview);
LOG.info("Latest version (" + channel + ", preview=" + preview + ") is " + result);
} catch (Throwable e) {
t = e;
LOG.warning("Failed to check for update", e);
}
Throwable throwable = t;
RemoteVersion finalResult = result;
Platform.runLater(() -> {
if (throwable != null) {
latestVersion.set(null);
error.set(throwable);
} else {
error.set(null);
latestVersion.set(finalResult);
}
checkingUpdate.set(false);
});
}, "Update Checker", true);
});
}
}
@@ -1,310 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.upgrade;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
import javafx.application.Platform;
import org.jackhuang.hmcl.EntryPoint;
import org.jackhuang.hmcl.Main;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.UpgradeDialog;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.checkFxUserThread;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class UpdateHandler {
private UpdateHandler() {
}
/**
* @return whether to exit
*/
public static boolean processArguments(String[] args) {
breakForceUpdateFeature();
if (isNestedApplication()) {
// updated from old versions
try {
performMigration();
} catch (IOException e) {
LOG.warning("Failed to perform migration", e);
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.MANUAL_UPDATE_URL) + "\n" + StringUtils.getStackTrace(e));
}
return true;
}
if (args.length == 2 && args[0].equals("--apply-to")) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && !OperatingSystem.isWindows7OrLater()) {
SwingUtils.showErrorDialog(i18n("fatal.apply_update_need_win7", Metadata.PUBLISH_URL));
return true;
}
try {
applyUpdate(Paths.get(args[1]));
} catch (IOException e) {
LOG.warning("Failed to apply update", e);
SwingUtils.showErrorDialog(i18n("fatal.apply_update_failure", Metadata.MANUAL_UPDATE_URL) + "\n" + StringUtils.getStackTrace(e));
}
return true;
}
if (isFirstLaunchAfterUpgrade()) {
SwingUtils.showInfoDialog(i18n("fatal.migration_requires_manual_reboot"));
return true;
}
return false;
}
/// Prompts for an update, then downloads and hands it off while holding one business-task slot.
/// Must be invoked on the JavaFX thread; a successful handoff exits this launcher process.
public static void updateFrom(RemoteVersion version) {
checkFxUserThread();
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && !OperatingSystem.isWindows7OrLater()) {
Controllers.dialog(i18n("fatal.apply_update_need_win7", Metadata.PUBLISH_URL), i18n("message.error"), MessageType.ERROR);
return;
}
Controllers.dialog(new UpgradeDialog(version, () -> {
Path downloaded;
try {
downloaded = Files.createTempFile("hmcl-update-", ".jar");
} catch (IOException e) {
LOG.warning("Failed to create temp file", e);
return;
}
Task<?> task = new HMCLDownloadTask(version, downloaded)
.thenRunAsync(Schedulers.io(), () -> {
if (!IntegrityChecker.isSelfVerified() && !IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK) {
throw new IOException("Current JAR is not verified");
}
var latch = new CountDownLatch(1);
Platform.runLater(() -> {
try {
SettingsManager.savePendingChanges();
} finally {
latch.countDown();
}
});
try {
latch.await();
} catch (InterruptedException ignored) {
// Ignore
}
try {
FileSaver.waitForAllSaves();
} catch (InterruptedException ignored) {
// Ignore
}
requestUpdate(downloaded, getCurrentLocation());
EntryPoint.exit(0);
})
.whenComplete(Schedulers.javafx(), (@Nullable Exception exception) -> {
if (exception != null && !(exception instanceof CancellationException)) {
LOG.warning("Failed to update to " + version, exception);
Controllers.dialog(StringUtils.getStackTrace(exception), i18n("update.failed"), MessageType.ERROR);
}
});
Controllers.taskDialog(task, i18n("message.downloading"), TaskCancellationAction.NORMAL);
}));
}
private static void applyUpdate(Path target) throws IOException {
LOG.info("Applying update to " + target);
Path self = getCurrentLocation();
if (!IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK && !IntegrityChecker.isSelfVerified()) {
throw new SelfVerificationException();
}
ExecutableHeaderHelper.copyWithHeader(self, target);
Optional<Path> newFilename = tryRename(target, Metadata.VERSION);
if (newFilename.isPresent()) {
LOG.info("Move " + target + " to " + newFilename.get());
try {
Files.move(target, newFilename.get());
target = newFilename.get();
} catch (IOException e) {
LOG.warning("Failed to move target", e);
}
}
startJava(target);
}
private static void requestUpdate(Path updateTo, Path self) throws IOException {
if (!IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK) {
IntegrityChecker.verifyJar(updateTo);
}
startJava(updateTo, "--apply-to", self.toString());
}
public static void startJava(Path jar, String... appArgs) throws IOException {
List<String> commandline = new ArrayList<>();
commandline.add(JavaRuntime.getDefault().getBinary().toString());
try {
for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) {
if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) {
commandline.add(inputArgument);
}
}
} catch (Throwable ignored) {
// ManagementFactory not available
for (Map.Entry<Object, Object> entry : System.getProperties().entrySet()) {
if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) {
commandline.add("-D" + key + "=" + entry.getValue());
}
}
}
commandline.add("-jar");
commandline.add(jar.toAbsolutePath().toString());
commandline.addAll(Arrays.asList(appArgs));
LOG.info("Starting process: " + maskCommandline(commandline));
new ProcessBuilder(commandline)
.directory(Paths.get("").toAbsolutePath().toFile())
.inheritIO()
.start();
}
private static String maskCommandline(List<String> commandline) {
return commandline.stream().map(str -> {
if (str.startsWith("-D")) {
int eqIdx = str.indexOf('=');
if (eqIdx != -1) {
String key = str.substring(2, eqIdx);
String value = str.substring(eqIdx + 1);
if (key.contains("http.proxy") ||
key.startsWith("https.proxy") ||
key.startsWith("socksProxy") ||
key.equals("hmcl.microsoft.auth.id") ||
key.equals("hmcl.curseforge.apikey")
) {
return "-D" + key + "=" + (value.isEmpty() ? "" : value.charAt(0) + "*".repeat(value.length() - 1));
}
}
}
return str;
}).collect(Collectors.joining(" "));
}
private static Optional<Path> tryRename(Path path, String newVersion) {
String filename = path.getFileName().toString();
Matcher matcher = Pattern.compile("^(?<prefix>[hH][mM][cC][lL][.-])(?<version>\\d+(?:\\.\\d+)*)(?<suffix>\\.[^.]+)$").matcher(filename);
if (matcher.find()) {
String newFilename = matcher.group("prefix") + newVersion + matcher.group("suffix");
if (!newFilename.equals(filename)) {
return Optional.of(path.resolveSibling(newFilename));
}
}
return Optional.empty();
}
private static Path getCurrentLocation() throws IOException {
Path path = JarUtils.thisJarPath();
if (path == null) {
throw new IOException("Failed to find current HMCL location");
}
return path;
}
// ==== support for old versions ===
private static void performMigration() throws IOException {
LOG.info("Migrating from old versions");
Path location = getParentApplicationLocation()
.orElseThrow(() -> new IOException("Failed to get parent application location"));
requestUpdate(getCurrentLocation(), location);
}
/**
* This method must be called from the main thread.
*/
private static boolean isNestedApplication() {
StackTraceElement[] stacktrace = Thread.currentThread().getStackTrace();
for (int i = 0; i < stacktrace.length; i++) {
StackTraceElement element = stacktrace[i];
if (Main.class.getName().equals(element.getClassName()) && "main".equals(element.getMethodName())) {
// we've reached the main method
return i + 1 != stacktrace.length;
}
}
return false;
}
private static Optional<Path> getParentApplicationLocation() {
String command = System.getProperty("sun.java.command");
if (command != null) {
Path path = Paths.get(command);
if (Files.isRegularFile(path)) {
return Optional.of(path.toAbsolutePath());
}
}
return Optional.empty();
}
private static boolean isFirstLaunchAfterUpgrade() {
Path currentPath = JarUtils.thisJarPath();
if (currentPath != null) {
Path updated = Metadata.HMCL_USER_HOME.resolve("HMCL-" + Metadata.VERSION + ".jar");
if (currentPath.equals(updated.toAbsolutePath())) {
return true;
}
}
return false;
}
private static void breakForceUpdateFeature() {
Path hmclVersionJson = Metadata.HMCL_USER_HOME.resolve("hmclver.json");
if (Files.isRegularFile(hmclVersionJson)) {
try {
Map<?, ?> content = new Gson().fromJson(Files.readString(hmclVersionJson), Map.class);
Object ver = content.get("ver");
if (ver instanceof String && ((String) ver).startsWith("3.")) {
Files.delete(hmclVersionJson);
LOG.info("Successfully broke the force update feature");
}
} catch (IOException e) {
LOG.warning("Failed to break the force update feature", e);
} catch (JsonParseException e) {
hmclVersionJson.toFile().delete();
}
}
}
}
@@ -1,6 +1,6 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.countly;
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.StringUtils;
@@ -5,7 +5,6 @@ package org.jackhuang.hmcl.util;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import org.jackhuang.hmcl.countly.CrashReport;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.CrashWindow;
@@ -2,14 +2,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.io.JarUtils;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Restarts the current launcher process.
///
/// @author Glavo
public final class Restarter {
@@ -22,7 +30,62 @@ public final class Restarter {
throw new IOException("Failed to find current HMCL location");
}
UpdateHandler.startJava(thisJar);
startJava(thisJar);
}
/// Starts a new Java process that runs `jar`.
///
/// @param jar the launcher jar to execute
/// @param appArgs additional application arguments
public static void startJava(Path jar, String... appArgs) throws IOException {
List<String> commandline = new ArrayList<>();
commandline.add(JavaRuntime.getDefault().getBinary().toString());
try {
for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) {
if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) {
commandline.add(inputArgument);
}
}
} catch (Throwable ignored) {
// ManagementFactory not available
for (Map.Entry<Object, Object> entry : System.getProperties().entrySet()) {
if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) {
commandline.add("-D" + key + "=" + entry.getValue());
}
}
}
commandline.add("-jar");
commandline.add(jar.toAbsolutePath().toString());
commandline.addAll(List.of(appArgs));
LOG.info("Starting process: " + maskCommandline(commandline));
new ProcessBuilder(commandline)
.directory(Paths.get("").toAbsolutePath().toFile())
.inheritIO()
.start();
}
private static String maskCommandline(List<String> commandline) {
return commandline.stream().map(str -> {
if (str.startsWith("-D")) {
int eqIdx = str.indexOf('=');
if (eqIdx != -1) {
String key = str.substring(2, eqIdx);
String value = str.substring(eqIdx + 1);
if (key.contains("http.proxy") ||
key.startsWith("https.proxy") ||
key.startsWith("socksProxy") ||
key.equals("hmcl.microsoft.auth.id") ||
key.equals("hmcl.curseforge.apikey")
) {
return "-D" + key + "=" + (value.isEmpty() ? "" : value.charAt(0) + "*".repeat(value.length() - 1));
}
}
}
return str;
}).collect(Collectors.joining(" "));
}
private Restarter() {
@@ -347,10 +347,6 @@
-fx-font-size: 15px;
}
.terracotta-hint Text {
-fx-fill: -monet-on-surface;
}
.game-crash-window {
-fx-background-color: -monet-surface-container;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

@@ -229,11 +229,6 @@ button.view=View
button.yes=Yes
contact=Feedback
contact.chat=Join Group Chat
contact.chat.discord=Discord
contact.chat.discord.statement=Welcome to join our Discord server.
contact.chat.qq_group=HMCL User QQ Group
contact.chat.qq_group.statement=Welcome to join our user QQ group.
contact.feedback=Feedback Channel
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=Submit an issue on GitHub.
@@ -392,9 +387,9 @@ download.provider.mojang.desc=Note that OptiFine is provided by BMCLAPI.
download.provider.official=From Official Sources
download.provider.official.desc=Always obtain from official sources.
download.provider.balanced=From Fastest Available
download.provider.balanced.desc=Best choice for ALL users.
download.provider.balanced.desc=Auto-selects mirrors for Chinese Mainland; otherwise prefers official sources.
download.provider.mirror=From Mirror
download.provider.mirror.desc=Recommended option for users in Chinese Mainland.
download.provider.mirror.desc=Uses multiple China BMCLAPI mirrors with auto latency ranking and file-level concurrency.
download.java=Downloading Java
download.java.process=Java Download Process
download.javafx=Downloading dependencies for the launcher...
@@ -449,14 +444,6 @@ fatal.config_unsupported_version=The current configuration file was created by a
fatal.mac_app_translocation=HMCL is isolated to a temporary directory by the OS due to macOS security mechanisms.\n\
Please move HMCL to a different directory before attempting to open. Otherwise, your settings and game data may be lost after restarting.\n\
Do you still want to continue?
fatal.migration_requires_manual_reboot=HMCL has been upgraded. Please restart the launcher.
fatal.apply_update_failure=We are sorry, but HMCL is unable to update automatically.\n\
\n\
You can update manually by downloading a newer launcher version from %s.\n\
If the problem persists, please consider reporting this to us.
fatal.apply_update_need_win7=HMCL cannot automatically update on Windows XP/Vista.\n\
\n\
You can update manually by downloading a newer launcher version from %s.
fatal.deprecated_java_version=HMCL will require Java 17 or later to run in the future, but will still support launching games with Java 6~16.\n\
\n\
It is recommended to install the latest version of Java to ensure that HMCL works properly.\n\
@@ -469,7 +456,7 @@ fatal.unsupported_platform=Minecraft is not fully supported on your platform yet
\n\
If you cannot launch Minecraft 1.17 and later, you can try switching the "Renderer" to "Mesa LLVMpipe" or "Mesa Lavapipe" in "Global/Instance-specific Settings → Graphics Settings" to use CPU rendering for better compatibility.
fatal.unsupported_platform.loongarch=HMCL has provided support for the Loongson platform.\n\
If you encounter problems when playing a game, you can visit https://docs.hmcl.net/groups.html for help.
If you encounter problems when playing a game, you can visit https://docs.hmcl.net/help.html for help.
fatal.unsupported_platform.windows_arm64=HMCL has provided native support for the Windows on Arm platform. If you encounter problems when playing a game, please try launching the game with Java based on x86 architecture.\n\
\n\
If you are using the <b>Qualcomm</b> platform, you may need to install the <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL Compatibility Pack</a> before playing games.\n\
@@ -790,10 +777,10 @@ install.success=Successfully installed.
instance=Games
instance.name=Instance Name
instance.empty=No Instances
instance.empty.add=Add new instance
instance.empty.launch=No available instances.\nYou can go to the "Download" page to get the game, or switch the game directory in the "All Instances" page.
instance.empty.launch.goto_download=Go to Download Page
instance.empty.hint=There are no Minecraft instances here.\nYou can try switching to another game directory or clicking here to download one.
instance.empty.add=Install the server modpack
instance.empty.launch=No available instances.\nInstall the bundled server modpack, or switch the game directory in the "All Instances" page.
instance.empty.launch.goto_download=Install Modpack
instance.empty.hint=There are no Minecraft instances here.\nYou can try switching to another game directory or clicking here to install the server modpack.
instance.game.all=All
instance.game.april_fools=April Fools
instance.game.old=Historical
@@ -808,7 +795,7 @@ instance.launch=Launch Game
instance.launch_and_enter_world=Play World
instance.launch.empty=Start Game
instance.launch.empty.installing=Installing Game
instance.launch.empty.tooltip=Install and launch the latest official release
instance.launch.empty.tooltip=Install and launch the bundled server modpack
instance.launch.test=Test Launch
instance.switch=Switch Instance
instance.launch_script=Export Launch Script
@@ -950,10 +937,8 @@ launcher.cache_directory.choose=Choose cache directory
launcher.cache_directory.default=Default ("%APPDATA%/.minecraft" or "~/.minecraft")
launcher.cache_directory.disabled=Disabled
launcher.cache_directory.invalid=Failed to create a cache directory, falling back to default.
launcher.contact=Contact Us
launcher.crash=HMCL has encountered a fatal error! Please copy the following log and ask for help on our Discord, QQ group, GitHub, or other Minecraft forum.
launcher.crash=HMCL has encountered a fatal error! Please copy the following log and ask for help on GitHub or other Minecraft forums.
launcher.crash.java_internal_error=HMCL has encountered a fatal error because your Java is corrupted. Please uninstall your Java and download a suitable Java <a href="https://bell-sw.com/pages/downloads/#downloads">here</a>.
launcher.crash.hmcl_out_dated=HMCL has encountered a fatal error! Your launcher is outdated. Please update your launcher!
launcher.title=Hello Minecraft! Launcher v%s
launcher.title.modified=Hello Minecraft! Launcher v%s (Modified)
launcher.title.unofficial=Hello Minecraft! Launcher v%s (Unofficial Build)
@@ -967,12 +952,11 @@ logwindow.always_on_top=Always On Top
logwindow.show_lines=Show Row Number
logwindow.terminate_game=Kill Game Process
logwindow.title=Log
logwindow.help=You can navigate to the HMCL community and find others for help.
logwindow.autoscroll=Auto-scroll
logwindow.wrap_text=Wrap Text
logwindow.export_game_crash_logs=Export Crash Logs
logwindow.export_dump=Export Game Stack Dump
logwindow.export_dump.no_dependency=Your Java does not contain the dependencies to create the stack dump. Please join our Discord or QQ group for help.
logwindow.export_dump.no_dependency=Your Java does not contain the dependencies to create the stack dump. Please ask for help on GitHub or other Minecraft forums.
message.cancelled=Operation Canceled
@@ -995,15 +979,10 @@ modpack.choose.local=Import from Local File
modpack.choose.local.detail=You can drag the modpack file here to install it.
modpack.choose.remote=Download from URL
modpack.choose.remote.detail=A direct download link to the remote modpack file is required.
modpack.choose.repository=Download Modpack from CurseForge or Modrinth
modpack.choose.repository.detail=You can choose the desired modpack on the next page.
modpack.choose.remote.tooltip=Please enter your modpack URL
modpack.completion=Downloading dependencies
modpack.desc=Describe your modpack, including an introduction and probably some changelog. Markdown and images from URL are currently supported.
modpack.description=Modpack Description
modpack.download=Download Modpacks
modpack.download.completed=Modpack download complete
modpack.download.title=Download Modpack - %1s
modpack.export=Export as Modpack
modpack.export.as=Export Modpack As...
modpack.file_api=Modpack URL Prefix
@@ -1174,8 +1153,6 @@ mods.add.success=%s was successfully added.
mods.add.title=Choose mod file you want to add
mods.delete.failed=Failed to delete mod(s). The mod file(s) may be in use by another program.
mods.disable=Disable
mods.download=Download
mods.download.title=Download Mod - %1s
mods.empty=No Mods
mods.enable=Enable
mods.game.version=Game Version
@@ -1248,8 +1225,6 @@ world.datapack=Data Packs
world.datetime=Last played on %s
world.delete=Delete the World
world.delete.failed=Failed to delete world.\n%s
world.download=Download
world.download.title=Download World - %1s
world.empty=No Worlds
world.export=Export the World
world.export.title=Choose the directory for this exported world
@@ -1362,7 +1337,6 @@ 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)
@@ -1674,87 +1648,6 @@ task.list.empty=No tasks
task.queued=Waiting for other tasks to finish
task.show=Show Progress
terracotta=Multiplayer
terracotta.terracotta=Terracotta | Multiplayer
terracotta.status=Lobby
terracotta.back=Exit
terracotta.feedback.title=Fill Out Feedback Form
terracotta.feedback.desc=We hope you can take 10 seconds to fill out the feedback form.
terracotta.feedback.desc.update=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
terracotta.difficulty.easiest=Excellent network: almost connected!
terracotta.difficulty.simple=Good network: connection may take some time
terracotta.difficulty.medium=Average network: enabling fallback routes, though connection may fail
terracotta.difficulty.tough=Poor network: enabling fallback routes, though connection may fail
terracotta.difficulty.estimate_only=Success rate is an estimate based on host and client NAT types, for reference only!
terracotta.from_local.title=Third-party download channels for Multiplayer Core
terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable.
terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it.
terracotta.from_local.file_name_mismatch=You should download the Multiplayer Core package named %1$s instead of %2$s
terracotta.export_log=Exports the Multiplayer Core log
terracotta.export_log.desc=Gathering more information for analysis
terracotta.status.bootstrap=Gathering information
terracotta.status.uninitialized.not_exist=Multiplayer Core: Not Downloaded
terracotta.status.uninitialized.not_exist.title=Download Multiplayer Core (~ 8MiB)
terracotta.status.uninitialized.update=Multiplayer Core: Update Available
terracotta.status.uninitialized.update.title=Update Multiplayer Core (~ 8MiB)
terracotta.status.uninitialized.desc=You legally promise to strictly abide by all laws and regulations of your country or region during the multiplayer process.
terracotta.confirm.title=User Notice
terracotta.confirm.desc=Terracotta is a third-party open source free software. Please provide feedback on any issues you encounter through the relevant channels during use.\n\
Terracotta uses P2P technology. After a successful connection, users in the room will connect directly to each other, and no third-party server will relay your traffic. The final multiplayer experience largely depends on the network conditions of the participants.\n\
During the entire multiplayer process, you must strictly comply with all laws and regulations of your country and region.
terracotta.status.preparing=Multiplayer Core: Downloading (DO NOT exit HMCL)
terracotta.status.launching=Multiplayer Core: Initializing
terracotta.status.unknown=Multiplayer Core: Initializing
terracotta.status.waiting=Multiplayer Core: Ready
terracotta.status.waiting.host.title=I want to host a session
terracotta.status.waiting.host.desc=Create a room and generate an invite code to play with friends
terracotta.status.waiting.host.launch.title=You seem to have forgotten to launch the game
terracotta.status.waiting.host.launch.desc=No running game found
terracotta.status.waiting.host.launch.skip=Game has launched
terracotta.status.waiting.guest.title=I want to join a session
terracotta.status.waiting.guest.desc=Enter the invite code from the host player to join the game world
terracotta.status.waiting.guest.prompt.title=Please enter the invite code from the host
terracotta.status.waiting.guest.prompt.invalid=Invalid invite code
terracotta.status.scanning=Scanning LAN worlds
terracotta.status.scanning.desc=Please <a href="hmcl://game/launch">start the game</a>, open a world, press ESC, select "Open to LAN", then select "Start LAN World".
terracotta.status.scanning.back=This will also stop scanning LAN worlds.
terracotta.status.host_starting=Room Creating
terracotta.status.host_starting.back=This will stop creating the room.
terracotta.status.host_ok=Room created
terracotta.status.host_ok.code=Invitation code (Copied)
terracotta.status.host_ok.code.copy=Copy invitation code
terracotta.status.host_ok.code.copy.toast=Invitation code has been copied to clipboard
terracotta.status.host_ok.code.desc=Please remind your friends to select Guest mode in HMCL - Multiplayer or PCL CE and enter this invitation code.
terracotta.status.host_ok.back=This will also close the room, other guests will leave and cannot rejoin.
terracotta.status.guest_starting=Joining room
terracotta.status.guest_starting.back=This will not stop other guests from joining the room.
terracotta.status.guest_ok=Room Joined
terracotta.status.guest_ok.back=This will not stop other guests from joining the room.
terracotta.status.guest_ok.title=Please launch the game, select Multiplayer, and double-click Terracotta Lobby.
terracotta.status.guest_ok.desc=Backup address: %s
terracotta.status.exception.back=Please try again
terracotta.status.exception.desc.ping_host_fail=Failed to join room: Room is closed or network unstable
terracotta.status.exception.desc.ping_host_rst=Room connection lost: Room is closed or network unstable
terracotta.status.exception.desc.guest_et_crash=Failed to join room: EasyTier crashed, please report this issue to developers
terracotta.status.exception.desc.host_et_crash=Failed to create room: EasyTier crashed, please report this issue to developers
terracotta.status.exception.desc.ping_server_rst=Room closed: You exited the game world, room closed automatically
terracotta.status.exception.desc.scaffolding_invalid_response=Invalid Protocol:Host has sent invalid response, please report this issue to developers
terracotta.status.fatal.retry=Retry
terracotta.status.fatal.network=Failed to download Multiplayer Core. Please check your network connection and try again.
terracotta.status.fatal.install=Fatal Error: Unable to install Multiplayer Core.
terracotta.status.fatal.terracotta=Fatal Error: Unable to connect to Multiplayer Core.
terracotta.status.fatal.unknown=Fatal Error: Unknown.
terracotta.player_list=Player List
terracotta.player_anonymous=Anonymous Player
terracotta.player_kind.host=Host
terracotta.player_kind.local=Yourself
terracotta.player_kind.guest=Guest
terracotta.unsupported=Multiplayer is not yet supported on the current platform.
terracotta.unsupported.os.windows.old=Multiplayer requires Windows 10 or later. Please update your system.
terracotta.unsupported.arch.32bit=Multiplayer is not supported on 32-bit systems. Please upgrade to a 64-bit system.
terracotta.unsupported.arch.loongarch64_ow=Multiplayer is not supported on Linux LoongArch64 Old World distributions. Please update to a New World distribution (such as AOSC OS).
terracotta.unsupported.region=The multiplayer feature is currently only available to users in Chinese Mainland and may not be available in your region.
theme_pack.appearance.follow_theme.tooltip=Currently follows the selected theme. Click to use a custom launcher setting.
theme_pack.appearance.custom.tooltip=Currently uses a custom launcher setting. Click to follow the selected theme.
@@ -1792,41 +1685,6 @@ theme_pack.version=Version: %s
unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security.
update=Update
update.accept=Update
update.changelog=Changelog
update.changelog.empty=Changelog has not been uploaded yet
update.channel.dev=Beta
update.channel.dev.hint=You are currently using a Beta channel build of the launcher. While it may include some extra features, it is also sometimes less stable than the Stable channel builds.\n\
\n\
If you encounter any bugs or problems, please submit feedback via the channels provided on the <a href="hmcl://settings/feedback">Feedback</a> page.\n\
\n\
Follow <a href="https://space.bilibili.com/1445341">@huanghongxun</a> on Bilibili to stay up to date on important HMCL news, or <a href="https://space.bilibili.com/20314891">@Glavo</a> to learn about HMCL development progress.
update.channel.dev.title=Beta Channel Notice
update.channel.nightly=Nightly
update.channel.nightly.hint=You are currently using a Nightly channel build of the launcher. While it may include some extra features, it is also always less stable than the other channel builds.\n\
\n\
If you encounter any bugs or problems, please submit feedback via the channels provided on the <a href="hmcl://settings/feedback">Feedback</a> page.\n\
\n\
Follow <a href="https://space.bilibili.com/1445341">@huanghongxun</a> on Bilibili to stay up to date on important HMCL news, or <a href="https://space.bilibili.com/20314891">@Glavo</a> to learn about HMCL development progress.
update.channel.nightly.title=Nightly Channel Notice
update.channel.stable=Stable
update.checking=Checking for updates
update.check_failed=Failed to check for update. %s
update.disable_auto_show_update_dialog=Do not show update dialog automatically
update.disable_auto_show_update_dialog.subtitle=Enable this option to prevent HMCL from automatically showing the update dialog.
update.failed=Failed to update
update.found=Update Available!
update.newest_version=Latest version: %s
update.bubble.title=Update Available: %s
update.bubble.subtitle=Click here to update
update.note.stable=Suitable for users who prioritize software stability.\nNew features are added to the stable version only after thorough testing.
update.note.dev=Suitable for users who want to experience new features first.\nThe development version includes the latest features and bug fixes,\nbut may also have more issues due to insufficient testing.
update.latest=This is the latest version
update.tooltip=Update
update.preview=Preview HMCL releases early
update.preview.subtitle=Enable this option to receive new versions of HMCL early for testing before their official release.
update.unverified=Update unavailable due to this launcher instance not verified
warning.java_interpreted_mode=HMCL is running in an interpreted Java environment, which will greatly affect performance.\n\
\n\
@@ -230,11 +230,6 @@ button.view=عرض
button.yes=نعم
contact=تواصل معنا
contact.chat=انضم إلى مجموعة الدردشة
contact.chat.discord=Discord
contact.chat.discord.statement=مرحباً بك في خادم Discord الخاص بنا.
contact.chat.qq_group=مجموعة QQ لمستخدمي HMCL
contact.chat.qq_group.statement=مرحباً بك في مجموعة المستخدمين على QQ.
contact.feedback=قناة الإبلاغ عن المشكلات
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=أرسل تقريراً عن مشكلة على GitHub.
@@ -451,14 +446,6 @@ fatal.config_unsupported_version=ملف الإعدادات الحالي أُنش
fatal.mac_app_translocation=تم عزل HMCL في مجلد مؤقت بواسطة نظام التشغيل بسبب آليات أمان macOS.\n\
يُرجى نقل HMCL إلى مجلد آخر قبل محاولة الفتح. وإلا، قد تُفقد إعداداتك وبيانات اللعبة بعد إعادة التشغيل.\n\
هل تريد المتابعة؟
fatal.migration_requires_manual_reboot=تم ترقية HMCL. يُرجى إعادة تشغيل المشغّل.
fatal.apply_update_failure=نأسف، لكن HMCL غير قادر على التحديث التلقائي.\n\
\n\
يمكنك التحديث يدوياً بتنزيل إصدار أحدث من المشغّل من %s.\n\
إذا استمرت المشكلة، يُرجى إرسال تقرير عنها إلينا.
fatal.apply_update_need_win7=لا يستطيع HMCL التحديث التلقائي على Windows XP/Vista.\n\
\n\
يمكنك التحديث يدوياً بتنزيل إصدار أحدث من المشغّل من %s.
fatal.deprecated_java_version=سيتطلب HMCL Java 17 أو أحدث للتشغيل في المستقبل، لكنه سيستمر في دعم تشغيل الألعاب باستخدام Java 6~16.\n\
\n\
يُنصح بتثبيت أحدث إصدار من Java لضمان عمل HMCL بشكل صحيح.\n\
@@ -471,7 +458,7 @@ fatal.unsupported_platform=لا يدعم Minecraft منصتك الحالية ب
\n\
إذا لم تتمكن من تشغيل Minecraft 1.17 أو أحدث، يمكنك محاولة تغيير "المُصيِّر" إلى "Mesa LLVMpipe" في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة" لاستخدام معالجة المعالج المركزي لتوافق أفضل.
fatal.unsupported_platform.loongarch=وفّر HMCL دعماً لمنصة Loongson.\n\
إذا واجهت مشاكل أثناء اللعب، يمكنك زيارة https://docs.hmcl.net/groups.html للحصول على مساعدة.
إذا واجهت مشاكل أثناء اللعب، يمكنك زيارة https://docs.hmcl.net/help.html للحصول على مساعدة.
fatal.unsupported_platform.windows_arm64=وفّر HMCL دعماً أصلياً لمنصة Windows on Arm. إذا واجهت مشاكل أثناء اللعب، يُرجى محاولة تشغيل اللعبة بـ Java المبني على معمارية x86.\n\
\n\
إذا كنت تستخدم منصة <b>Qualcomm</b>، قد تحتاج إلى تثبيت <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">حزمة توافق OpenGL</a> قبل اللعب.\n\
@@ -791,10 +778,10 @@ install.success=تم التثبيت بنجاح.
instance=الألعاب
instance.name=اسم النسخة
instance.empty=لا توجد نسخ
instance.empty.add=إضافة نسخة جديدة
instance.empty.launch=لا توجد نسخ متاحة.\nيمكنك الانتقال إلى صفحة "التنزيل" للحصول على اللعبة، أو تغيير مجلد اللعبة في صفحة "جميع النسخ".
instance.empty.launch.goto_download=الانتقال إلى صفحة التنزيل
instance.empty.hint=لا توجد نسخ Minecraft هنا.\nيمكنك محاولة التبديل إلى مجلد لعبة آخر أو النقر هنا لتنزيل واحدة.
instance.empty.add=تثبيت حزمة خادم التعديلات
instance.empty.launch=لا توجد نسخ متاحة.\nثبّت حزمة خادم التعديلات المدمجة، أو غيّر مجلد اللعبة في صفحة "جميع النسخ".
instance.empty.launch.goto_download=تثبيت الحزمة
instance.empty.hint=لا توجد نسخ Minecraft هنا.\nيمكنك محاولة التبديل إلى مجلد لعبة آخر أو النقر هنا لتثبيت حزمة خادم التعديلات.
instance.game.all=الكل
instance.game.april_fools=كذبة أبريل
instance.game.old=تاريخي
@@ -809,7 +796,7 @@ instance.launch=تشغيل اللعبة
instance.launch_and_enter_world=لعب في العالم
instance.launch.empty=تشغيل اللعبة
instance.launch.empty.installing=جارٍ تثبيت اللعبة
instance.launch.empty.tooltip=تثبيت وتشغيل أحدث إصدار رسمي
instance.launch.empty.tooltip=تثبيت وتشغيل حزمة خادم التعديلات المدمجة
instance.launch.test=تشغيل تجريبي
instance.switch=تبديل النسخة
instance.launch_script=تصدير سكريبت التشغيل
@@ -951,10 +938,8 @@ launcher.cache_directory.choose=اختر مجلد الذاكرة المؤقتة
launcher.cache_directory.default=افتراضي ("%APPDATA%/.minecraft" أو "~/.minecraft")
launcher.cache_directory.disabled=معطّل
launcher.cache_directory.invalid=فشل إنشاء مجلد الذاكرة المؤقتة، جارٍ الرجوع إلى الافتراضي.
launcher.contact=تواصل معنا
launcher.crash=واجه HMCL خطأً فادحاً! يُرجى نسخ السجل التالي وطلب المساعدة على Discord أو مجموعة QQ أو GitHub أو أي منتدى Minecraft آخر.
launcher.crash=واجه HMCL خطأً فادحاً! يُرجى نسخ السجل التالي وطلب المساعدة على GitHub أو أي منتدى Minecraft آخر.
launcher.crash.java_internal_error=واجه HMCL خطأً فادحاً بسبب تلف Java. يُرجى إلغاء تثبيت Java وتنزيل إصدار مناسب من <a href="https://bell-sw.com/pages/downloads/#downloads">هنا</a>.
launcher.crash.hmcl_out_dated=واجه HMCL خطأً فادحاً! المشغّل قديم. يُرجى تحديثه!
# launcher.title=
# launcher.title.modified=
# launcher.title.unofficial=
@@ -968,12 +953,11 @@ login.enter_password=يُرجى إدخال كلمة المرور.
logwindow.show_lines=إظهار أرقام الأسطر
logwindow.terminate_game=إنهاء عملية اللعبة
logwindow.title=السجل
logwindow.help=يمكنك الانتقال إلى مجتمع HMCL وإيجاد مساعدة من الآخرين.
logwindow.autoscroll=التمرير التلقائي
# logwindow.wrap_text=
logwindow.export_game_crash_logs=تصدير سجلات التعطل
logwindow.export_dump=تصدير تفريغ مكدس اللعبة
logwindow.export_dump.no_dependency=لا يحتوي Java الخاص بك على المكتبات اللازمة لإنشاء تفريغ المكدس. يُرجى الانضمام إلى Discord أو مجموعة QQ للحصول على مساعدة.
logwindow.export_dump.no_dependency=لا يحتوي Java الخاص بك على المكتبات اللازمة لإنشاء تفريغ المكدس. يُرجى طلب المساعدة على GitHub أو منتديات Minecraft الأخرى.
message.cancelled=تم إلغاء العملية
@@ -996,15 +980,11 @@ modpack.choose.local=استيراد من ملف محلي
modpack.choose.local.detail=يمكنك سحب ملف حزمة المودات وإفلاته هنا.
modpack.choose.remote=تنزيل من رابط
modpack.choose.remote.detail=مطلوب رابط تنزيل مباشر لملف حزمة المودات البعيد.
modpack.choose.repository=تنزيل حزمة مودات من CurseForge أو Modrinth
modpack.choose.repository.detail=يمكنك اختيار حزمة المودات المطلوبة في الصفحة التالية.
modpack.choose.remote.tooltip=يُرجى إدخال رابط حزمة المودات
modpack.completion=جارٍ تنزيل التبعيات
modpack.desc=صف حزمة مودتك، بما في ذلك مقدمة وربما سجل تغييرات. يدعم حالياً Markdown والصور من الروابط.
modpack.description=وصف حزمة المودات
modpack.download=تنزيل حزم المودات
# modpack.download.completed=
modpack.download.title=تنزيل حزمة المودات - %1s
modpack.export=تصدير كحزمة مودات
modpack.export.as=تصدير حزمة المودات باسم...
modpack.file_api=بادئة رابط حزمة المودات
@@ -1175,7 +1155,6 @@ mods.add.title=اختر ملف المود الذي تريد إضافته
# mods.delete.failed=
mods.disable=تعطيل
#mods.download=تنزيل مود
mods.download.title=تنزيل مود - %1s
# mods.empty=
mods.enable=تفعيل
mods.game.version=إصدار اللعبة
@@ -1248,8 +1227,6 @@ world.datapack=حزم البيانات
world.datetime=آخر لعب في %s
world.delete=حذف العالم
world.delete.failed=فشل حذف العالم.\n%s
world.download=تنزيل
world.download.title=تنزيل العالم - %1s
# world.empty=
world.export=تصدير العالم
world.export.title=اختر المجلد لهذا العالم المُصدَّر
@@ -1671,87 +1648,7 @@ system.operating_system=نظام التشغيل
# task.queued=
# task.show=
terracotta=متعدد اللاعبين
terracotta.terracotta=Terracotta | متعدد اللاعبين
terracotta.status=الردهة
terracotta.back=خروج
terracotta.feedback.title=ملء استمارة التقييم
# terracotta.feedback.desc=
terracotta.feedback.desc.update=مع تحديث HMCL لنواة متعدد اللاعبين، نأمل أن تخصص 10 ثوانٍ لملء استمارة التقييم.
terracotta.sudo_installing=يجب على HMCL التحقق من كلمة مرورك قبل تثبيت نواة متعدد اللاعبين
terracotta.difficulty.easiest=شبكة ممتازة: الاتصال شبه مضمون!
terracotta.difficulty.simple=شبكة جيدة: قد يستغرق الاتصال بعض الوقت
terracotta.difficulty.medium=شبكة متوسطة: جارٍ تفعيل مسارات بديلة، وقد يفشل الاتصال
terracotta.difficulty.tough=شبكة ضعيفة: جارٍ تفعيل مسارات بديلة، وقد يفشل الاتصال
terracotta.difficulty.estimate_only=نسبة النجاح تقديرية بناءً على نوعي NAT للمضيف والعميل، للاستئناس فقط!
terracotta.from_local.title=قنوات تنزيل بديلة لنواة متعدد اللاعبين
terracotta.from_local.desc=في بعض المناطق، قد تكون قناة التنزيل الافتراضية المدمجة غير مستقرة.
terracotta.from_local.guide=يُرجى تنزيل حزمة نواة متعدد اللاعبين باسم %s. بعد التنزيل، اسحب الملف إلى الصفحة الحالية لتثبيته.
terracotta.from_local.file_name_mismatch=يجب تنزيل حزمة نواة متعدد اللاعبين باسم %1$s وليس %2$s
terracotta.export_log=تصدير سجل نواة متعدد اللاعبين
terracotta.export_log.desc=جمع مزيد من المعلومات للتحليل
terracotta.status.bootstrap=جارٍ جمع المعلومات
terracotta.status.uninitialized.not_exist=نواة متعدد اللاعبين: غير منزَّلة
terracotta.status.uninitialized.not_exist.title=تنزيل نواة متعدد اللاعبين (~ 8 ميجابايت)
terracotta.status.uninitialized.update=نواة متعدد اللاعبين: تحديث متاح
terracotta.status.uninitialized.update.title=تحديث نواة متعدد اللاعبين (~ 8 ميجابايت)
terracotta.status.uninitialized.desc=أنت تتعهد قانونياً بالالتزام الصارم بجميع قوانين وأنظمة بلدك أو منطقتك أثناء اللعب الجماعي.
terracotta.confirm.title=إشعار للمستخدم
terracotta.confirm.desc=Terracotta هو برنامج مفتوح المصدر ومجاني من طرف ثالث. يُرجى إرسال تقارير عن أي مشاكل تواجهها عبر القنوات المخصصة أثناء الاستخدام.\n\
يستخدم Terracotta تقنية P2P. بعد الاتصال الناجح، يتصل المستخدمون في الغرفة مباشرةً ببعضهم دون أي خادم وسيط لنقل البيانات. تعتمد تجربة اللعب الجماعي بشكل كبير على جودة الشبكة لدى المشاركين.\n\
طوال عملية اللعب الجماعي، يجب الالتزام الصارم بجميع قوانين وأنظمة بلدك ومنطقتك.
terracotta.status.preparing=نواة متعدد اللاعبين: جارٍ التنزيل (لا تغلق HMCL)
terracotta.status.launching=نواة متعدد اللاعبين: جارٍ التهيئة
terracotta.status.unknown=نواة متعدد اللاعبين: جارٍ التهيئة
terracotta.status.waiting=نواة متعدد اللاعبين: جاهزة
terracotta.status.waiting.host.title=أريد استضافة جلسة
terracotta.status.waiting.host.desc=أنشئ غرفة وأنشئ رمز دعوة للعب مع الأصدقاء
terracotta.status.waiting.host.launch.title=يبدو أنك نسيت تشغيل اللعبة
terracotta.status.waiting.host.launch.desc=لم يتم العثور على لعبة تعمل
terracotta.status.waiting.host.launch.skip=اللعبة قيد التشغيل
terracotta.status.waiting.guest.title=أريد الانضمام إلى جلسة
terracotta.status.waiting.guest.desc=أدخل رمز الدعوة من اللاعب المضيف للانضمام إلى عالم اللعبة
terracotta.status.waiting.guest.prompt.title=يُرجى إدخال رمز الدعوة من المضيف
terracotta.status.waiting.guest.prompt.invalid=رمز دعوة غير صالح
terracotta.status.scanning=جارٍ البحث عن عوالم الشبكة المحلية
terracotta.status.scanning.desc=يُرجى <a href="hmcl://game/launch">تشغيل اللعبة</a>، وفتح عالم، والضغط على ESC، واختيار "فتح على الشبكة المحلية"، ثم اختيار "بدء عالم الشبكة المحلية".
terracotta.status.scanning.back=سيؤدي هذا أيضاً إلى إيقاف البحث عن عوالم الشبكة المحلية.
terracotta.status.host_starting=جارٍ إنشاء الغرفة
terracotta.status.host_starting.back=سيؤدي هذا إلى إيقاف إنشاء الغرفة.
terracotta.status.host_ok=تم إنشاء الغرفة
terracotta.status.host_ok.code=رمز الدعوة (تم النسخ)
terracotta.status.host_ok.code.copy=نسخ رمز الدعوة
terracotta.status.host_ok.code.copy.toast=تم نسخ رمز الدعوة إلى الحافظة
terracotta.status.host_ok.code.desc=يُرجى تذكير أصدقائك باختيار وضع الضيف في HMCL - متعدد اللاعبين أو PCL CE وإدخال رمز الدعوة هذا.
terracotta.status.host_ok.back=سيؤدي هذا أيضاً إلى إغلاق الغرفة، وسيغادر الضيوف الآخرون ولن يتمكنوا من إعادة الانضمام.
terracotta.status.guest_starting=جارٍ الانضمام إلى الغرفة
terracotta.status.guest_starting.back=لن يؤدي هذا إلى منع الضيوف الآخرين من الانضمام إلى الغرفة.
terracotta.status.guest_ok=تم الانضمام إلى الغرفة
terracotta.status.guest_ok.back=لن يؤدي هذا إلى منع الضيوف الآخرين من الانضمام إلى الغرفة.
terracotta.status.guest_ok.title=يُرجى تشغيل اللعبة، واختيار متعدد اللاعبين، والنقر المزدوج على ردهة Terracotta.
terracotta.status.guest_ok.desc=العنوان الاحتياطي: %s
terracotta.status.exception.back=يُرجى إعادة المحاولة
terracotta.status.exception.desc.ping_host_fail=فشل الانضمام إلى الغرفة: الغرفة مغلقة أو الشبكة غير مستقرة
terracotta.status.exception.desc.ping_host_rst=انقطع الاتصال بالغرفة: الغرفة مغلقة أو الشبكة غير مستقرة
terracotta.status.exception.desc.guest_et_crash=فشل الانضمام إلى الغرفة: تعطّل EasyTier، يُرجى إبلاغ المطوّرين بهذه المشكلة
terracotta.status.exception.desc.host_et_crash=فشل إنشاء الغرفة: تعطّل EasyTier، يُرجى إبلاغ المطوّرين بهذه المشكلة
terracotta.status.exception.desc.ping_server_rst=أُغلقت الغرفة: لقد خرجت من عالم اللعبة، أُغلقت الغرفة تلقائياً
terracotta.status.exception.desc.scaffolding_invalid_response=بروتوكول غير صالح: أرسل المضيف استجابة غير صالحة، يُرجى إبلاغ المطوّرين بهذه المشكلة
terracotta.status.fatal.retry=إعادة المحاولة
terracotta.status.fatal.network=فشل تنزيل نواة متعدد اللاعبين. يُرجى التحقق من اتصالك بالإنترنت والمحاولة مجدداً.
terracotta.status.fatal.install=خطأ فادح: تعذّر تثبيت نواة متعدد اللاعبين.
terracotta.status.fatal.terracotta=خطأ فادح: تعذّر الاتصال بنواة متعدد اللاعبين.
terracotta.status.fatal.unknown=خطأ فادح: غير معروف.
terracotta.player_list=قائمة اللاعبين
terracotta.player_anonymous=لاعب مجهول
terracotta.player_kind.host=مضيف
terracotta.player_kind.local=أنت
terracotta.player_kind.guest=ضيف
terracotta.unsupported=متعدد اللاعبين غير مدعوم على المنصة الحالية بعد.
terracotta.unsupported.os.windows.old=يتطلب متعدد اللاعبين Windows 10 أو أحدث. يُرجى تحديث نظامك.
terracotta.unsupported.arch.32bit=متعدد اللاعبين غير مدعوم على الأنظمة 32-bit. يُرجى الترقية إلى نظام 64-bit.
terracotta.unsupported.arch.loongarch64_ow=متعدد اللاعبين غير مدعوم على توزيعات Linux LoongArch64 القديمة. يُرجى التحديث إلى توزيعة العالم الجديد (مثل AOSC OS).
terracotta.unsupported.region=ميزة متعدد اللاعبين متاحة حالياً فقط لمستخدمي البر الرئيسي الصيني وقد لا تكون متاحة في منطقتك.
# theme_pack.appearance.follow_theme.tooltip=
# theme_pack.appearance.custom.tooltip=
@@ -1789,40 +1686,12 @@ terracotta.unsupported.region=ميزة متعدد اللاعبين متاحة ح
unofficial.hint=أنت تستخدم إصداراً غير رسمي من HMCL. لا نستطيع ضمان أمانه.
update=تحديث
update.accept=تحديث
update.changelog=سجل التغييرات
# update.changelog.empty=
update.channel.dev=تجريبي
update.channel.dev.hint=أنت تستخدم حالياً إصدار القناة التجريبية من المشغّل. على الرغم من أنه قد يتضمن ميزات إضافية، إلا أنه أقل استقراراً أحياناً من إصدارات قناة الإصدار الرسمي.\n\
\n\
إذا واجهت أي أخطاء أو مشاكل، يُرجى إرسال تقرير عبر القنوات المتاحة في صفحة <a href="hmcl://settings/feedback">الإبلاغ عن المشكلات</a>.\n\
\n\
تابع <a href="https://space.bilibili.com/1445341">@huanghongxun</a> على Bilibili للاطلاع على أهم أخبار HMCL، أو <a href="https://space.bilibili.com/20314891">@Glavo</a> لمتابعة تطور HMCL.
update.channel.dev.title=إشعار القناة التجريبية
update.channel.nightly=ليلي
update.channel.nightly.hint=أنت تستخدم حالياً إصدار القناة الليلية من المشغّل. على الرغم من أنه قد يتضمن ميزات إضافية، إلا أنه دائماً أقل استقراراً من إصدارات القنوات الأخرى.\n\
\n\
إذا واجهت أي أخطاء أو مشاكل، يُرجى إرسال تقرير عبر القنوات المتاحة في صفحة <a href="hmcl://settings/feedback">الإبلاغ عن المشكلات</a>.\n\
\n\
تابع <a href="https://space.bilibili.com/1445341">@huanghongxun</a> على Bilibili للاطلاع على أهم أخبار HMCL، أو <a href="https://space.bilibili.com/20314891">@Glavo</a> لمتابعة تطور HMCL.
update.channel.nightly.title=إشعار القناة الليلية
update.channel.stable=رسمي
update.checking=جارٍ التحقق من التحديثات
# update.check_failed=
# update.disable_auto_show_update_dialog=
# update.disable_auto_show_update_dialog.subtitle=
update.failed=فشل التحديث
update.found=تحديث متاح!
update.newest_version=أحدث إصدار: %s
update.bubble.title=تحديث متاح: %s
update.bubble.subtitle=انقر هنا للتحديث
# update.note.stable=
# update.note.dev=
update.latest=هذا هو أحدث إصدار
update.tooltip=تحديث
update.preview=الاطلاع المبكر على إصدارات HMCL
update.preview.subtitle=فعّل هذا الخيار لاستقبال إصدارات HMCL الجديدة مبكراً للاختبار قبل إصدارها الرسمي.
# update.unverified=
warning.java_interpreted_mode=يعمل HMCL في بيئة Java مُترجمة تفسيرياً، مما سيؤثر بشكل كبير على الأداء.\n\
@@ -228,11 +228,6 @@ button.view=Anzeigen
button.yes=Ja
contact=Feedback
contact.chat=Gruppenchat beitreten
contact.chat.discord=Discord
contact.chat.discord.statement=Willkommen auf unserem Discord-Server.
contact.chat.qq_group=HMCL-Benutzer-QQ-Gruppe
contact.chat.qq_group.statement=Willkommen in unserer Benutzer-QQ-Gruppe.
contact.feedback=Feedback-Kanal
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=Reichen Sie ein Issue auf GitHub ein.
@@ -448,14 +443,6 @@ fatal.config_unsupported_version=Die aktuelle Konfigurationsdatei wurde von eine
fatal.mac_app_translocation=HMCL wurde aufgrund von macOS-Sicherheitsmechanismen vom Betriebssystem in ein temporäres Verzeichnis isoliert.\n\
Bitte verschieben Sie HMCL in ein anderes Verzeichnis, bevor Sie versuchen, es zu öffnen. Andernfalls könnten Ihre Einstellungen und Spieldaten nach einem Neustart verloren gehen.\n\
Möchten Sie trotzdem fortfahren?
fatal.migration_requires_manual_reboot=HMCL wurde aktualisiert. Bitte starten Sie den Launcher neu.
fatal.apply_update_failure=Es tut uns leid, aber der HMCL kann nicht automatisch aktualisiert werden.\n\
\n\
Sie können manuell aktualisieren, indem Sie eine neuere Launcher-Version von %s herunterladen.\n\
Wenn das Problem weiterhin besteht, melden Sie es uns bitte.
fatal.apply_update_need_win7=Der HMCL kann unter Windows XP/Vista nicht automatisch aktualisiert werden.\n\
\n\
Sie können manuell aktualisieren, indem Sie eine neuere Launcher-Version von %s herunterladen.
fatal.deprecated_java_version=HMCL wird in Zukunft Java 17 oder neuer benötigen, um zu laufen, wird aber weiterhin das Starten von Spielen mit Java 6~16 unterstützen.\n\
\n\
Es wird empfohlen, die neueste Java-Version zu installieren, um sicherzustellen, dass HMCL ordnungsgemäß funktioniert.\n\
@@ -468,7 +455,7 @@ fatal.unsupported_platform=Minecraft wird auf Ihrer Plattform noch nicht vollst
\n\
Wenn Sie Minecraft 1.17 und neuer nicht starten können, können Sie versuchen, den "Renderer" unter "Allgemeine/Instanzspezifische Einstellungen → Grafikeinstellungen" auf "Mesa LLVMpipe" oder "Mesa Lavapipe" umzustellen, um CPU-Rendering für bessere Kompatibilität zu verwenden.
fatal.unsupported_platform.loongarch=Der HMCL bietet nun Unterstützung für die Loongson-Plattform.\n\
Wenn beim Spielen Probleme auftreten, können Sie https://docs.hmcl.net/groups.html für Hilfe besuchen.
Wenn beim Spielen Probleme auftreten, können Sie https://docs.hmcl.net/help.html für Hilfe besuchen.
fatal.unsupported_platform.windows_arm64=Der HMCL bietet native Unterstützung für die Windows-on-Arm-Plattform. Wenn beim Spielen Probleme auftreten, versuchen Sie bitte, das Spiel mit Java basierend auf der x86-Architektur zu starten.\n\
\n\
Wenn Sie die <b>Qualcomm</b>-Plattform verwenden, müssen Sie möglicherweise das <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL-Kompatibilitätspaket</a> installieren, bevor Sie Spiele spielen.\n\
@@ -789,10 +776,10 @@ install.success=Erfolgreich installiert.
instance=Spiele
instance.name=Instanzname
instance.empty=Keine Instanzen
instance.empty.add=Neue Instanz hinzufügen
instance.empty.launch=Keine verfügbaren Instanzen.\nSie können zur Seite "Herunterladen" gehen, um das Spiel zu erhalten, oder das Spielverzeichnis auf der Seite "Alle Instanzen" wechseln.
instance.empty.launch.goto_download=Zur Download-Seite gehen
instance.empty.hint=Hier gibt es keine Minecraft-Instanzen.\nSie können versuchen, in ein anderes Spielverzeichnis zu wechseln, oder hier klicken, um eine herunterzuladen.
instance.empty.add=Server-Modpack installieren
instance.empty.launch=Keine verfügbaren Instanzen.\nInstallieren Sie das mitgelieferte Server-Modpack, oder wechseln Sie das Spielverzeichnis auf der Seite "Alle Instanzen".
instance.empty.launch.goto_download=Modpack installieren
instance.empty.hint=Hier gibt es keine Minecraft-Instanzen.\nSie können versuchen, in ein anderes Spielverzeichnis zu wechseln, oder hier klicken, um das Server-Modpack zu installieren.
instance.game.all=Alle
instance.game.april_fools=Aprilscherz
instance.game.old=Historisch
@@ -807,7 +794,7 @@ instance.launch=Spiel starten
instance.launch_and_enter_world=Welt spielen
instance.launch.empty=Spiel starten
instance.launch.empty.installing=Installiere Spiel
instance.launch.empty.tooltip=Die neueste offizielle Release installieren und starten
instance.launch.empty.tooltip=Das mitgelieferte Server-Modpack installieren und starten
instance.launch.test=Teststart
instance.switch=Instanz wechseln
instance.launch_script=Startskript exportieren
@@ -950,10 +937,8 @@ launcher.cache_directory.choose=Cache-Verzeichnis auswählen
launcher.cache_directory.default=Standard ("%APPDATA%/.minecraft" oder "~/.minecraft")
launcher.cache_directory.disabled=Deaktiviert
launcher.cache_directory.invalid=Fehler beim Erstellen des Cache-Verzeichnisses, Rückgriff auf Standard.
launcher.contact=Kontaktiere uns
launcher.crash=HMCL ist auf einen fatalen Fehler gestoßen! Bitte kopieren Sie das folgende Protokoll und bitten Sie in unserem Discord, der QQ-Gruppe, auf GitHub oder in einem anderen Minecraft-Forum um Hilfe.
launcher.crash=HMCL ist auf einen fatalen Fehler gestoßen! Bitte kopieren Sie das folgende Protokoll und bitten Sie auf GitHub oder in einem anderen Minecraft-Forum um Hilfe.
launcher.crash.java_internal_error=HMCL ist auf einen fatalen Fehler gestoßen, da Ihre Java-Installation beschädigt ist. Bitte deinstallieren Sie Java und laden Sie eine geeignete Java-Version <a href="https://bell-sw.com/pages/downloads/#downloads">hier</a> herunter.
launcher.crash.hmcl_out_dated=HMCL ist auf einen fatalen Fehler gestoßen! Ihr Launcher ist veraltet. Bitte aktualisieren Sie Ihren Launcher!
# launcher.title=
# launcher.title.modified=
# launcher.title.unofficial=
@@ -967,12 +952,11 @@ logwindow.always_on_top=Immer im Vordergrund
logwindow.show_lines=Zeilennummer anzeigen
logwindow.terminate_game=Spielprozess beenden
logwindow.title=Protokoll
logwindow.help=Sie können die HMCL-Community besuchen und andere um Hilfe bitten.
logwindow.autoscroll=Automatisches Scrollen
logwindow.wrap_text=Zeilenumbruch
logwindow.export_game_crash_logs=Absturzprotokolle exportieren
logwindow.export_dump=Spiel-Stackdump exportieren
logwindow.export_dump.no_dependency=Ihr Java enthält nicht die Abhängigkeiten, um den Stackdump zu erstellen. Bitte treten Sie unserem Discord oder der QQ-Gruppe für Hilfe bei.
logwindow.export_dump.no_dependency=Ihr Java enthält nicht die Abhängigkeiten, um den Stackdump zu erstellen. Bitte bitten Sie auf GitHub oder in einem anderen Minecraft-Forum um Hilfe.
message.cancelled=Vorgang abgebrochen
@@ -995,15 +979,11 @@ modpack.choose.local=Aus lokaler Datei importieren
modpack.choose.local.detail=Sie können die Modpack-Datei hierher ziehen.
modpack.choose.remote=Von URL herunterladen
modpack.choose.remote.detail=Ein direkter Download-Link zur entfernten Modpack-Datei ist erforderlich.
modpack.choose.repository=Modpack von CurseForge oder Modrinth herunterladen
modpack.choose.repository.detail=Sie können das gewünschte Modpack auf der nächsten Seite auswählen.
modpack.choose.remote.tooltip=Bitte geben Sie Ihre Modpack-URL ein
modpack.completion=Abhängigkeiten werden heruntergeladen
modpack.desc=Beschreiben Sie Ihr Modpack, einschließlich einer Einführung und eventuell eines Änderungsprotokolls. Markdown und Bilder aus URLs werden derzeit unterstützt.
modpack.description=Modpack-Beschreibung
modpack.download=Modpacks herunterladen
# modpack.download.completed=
modpack.download.title=Modpack herunterladen - %1s
modpack.export=Als Modpack exportieren
modpack.export.as=Modpack exportieren als...
modpack.file_api=Modpack-URL-Präfix
@@ -1174,7 +1154,6 @@ mods.add.title=Wählen Sie die Mod-Datei, die Sie hinzufügen möchten
# mods.delete.failed=
mods.disable=Deaktivieren
#mods.download=Mod herunterladen
mods.download.title=Mod herunterladen - %1s
mods.empty=Keine Mods
mods.enable=Aktivieren
mods.game.version=Spielversion
@@ -1247,8 +1226,6 @@ world.datapack=Datenpakete
world.datetime=Zuletzt gespielt am %s
world.delete=Welt löschen
world.delete.failed=Fehler beim Löschen der Welt.\n%s
world.download=Herunterladen
world.download.title=Welt herunterladen - %1s
world.empty=Keine Welten
world.export=Welt exportieren
world.export.title=Wählen Sie das Verzeichnis für diese exportierte Welt aus
@@ -1673,87 +1650,6 @@ system.operating_system=Betriebssystem
# task.queued=
# task.show=
terracotta=Mehrspieler
terracotta.terracotta=Terracotta | Mehrspieler
terracotta.status=Lobby
terracotta.back=Verlassen
terracotta.feedback.title=Feedback-Formular ausfüllen
terracotta.feedback.desc=Wir hoffen, dass Sie sich 10 Sekunden Zeit nehmen können, um das Feedback-Formular auszufüllen.
terracotta.feedback.desc.update=Während HMCL Multiplayer Core aktualisiert, hoffen wir, dass Sie sich 10 Sekunden Zeit nehmen können, um das Feedback-Formular auszufüllen.
terracotta.sudo_installing=HMCL muss Ihr Passwort überprüfen, bevor Multiplayer Core installiert wird
terracotta.difficulty.easiest=Ausgezeichnetes Netzwerk: fast verbunden!
terracotta.difficulty.simple=Gutes Netzwerk: Die Verbindung kann einige Zeit dauern
terracotta.difficulty.medium=Durchschnittliches Netzwerk: Fallback-Routen werden aktiviert, aber die Verbindung kann fehlschlagen
terracotta.difficulty.tough=Schlechtes Netzwerk: Fallback-Routen werden aktiviert, aber die Verbindung kann fehlschlagen
terracotta.difficulty.estimate_only=Die Erfolgsrate ist eine Schätzung basierend auf Host- und Client-NAT-Typen, nur als Referenz!
terracotta.from_local.title=Download-Kanäle von Drittanbietern für Multiplayer Core
terracotta.from_local.desc=In einigen Gebieten kann der integrierte Standard-Download-Kanal instabil sein.
terracotta.from_local.guide=Bitte laden Sie das Multiplayer-Core-Paket namens %s herunter. Nach dem Herunterladen ziehen Sie die Datei auf die aktuelle Seite, um sie zu installieren.
terracotta.from_local.file_name_mismatch=Sie sollten das Multiplayer-Core-Paket namens %1$s anstelle von %2$s herunterladen
terracotta.export_log=Exportiert das Multiplayer-Core-Protokoll
terracotta.export_log.desc=Sammeln weiterer Informationen zur Analyse
terracotta.status.bootstrap=Informationen werden gesammelt
terracotta.status.uninitialized.not_exist=Multiplayer Core: Nicht heruntergeladen
terracotta.status.uninitialized.not_exist.title=Multiplayer Core herunterladen (~ 8MiB)
terracotta.status.uninitialized.update=Multiplayer Core: Update verfügbar
terracotta.status.uninitialized.update.title=Multiplayer Core aktualisieren (~ 8MiB)
terracotta.status.uninitialized.desc=Sie verpflichten sich rechtlich, alle Gesetze und Vorschriften Ihres Landes oder Ihrer Region während des Mehrspieler-Vorgangs strikt einzuhalten.
terracotta.confirm.title=Benutzerhinweis
terracotta.confirm.desc=Terracotta ist eine kostenlose Open-Source-Software von Drittanbietern. Bitte geben Sie während der Nutzung über die entsprechenden Kanäle Feedback zu aufgetretenen Problemen.\n\
Terracotta verwendet P2P-Technologie. Nach einer erfolgreichen Verbindung verbinden sich die Benutzer im Raum direkt miteinander, und kein Server von Drittanbietern leitet Ihren Datenverkehr weiter. Das endgültige Mehrspieler-Erlebnis hängt stark von den Netzwerkbedingungen der Teilnehmer ab.\n\
Während des gesamten Mehrspieler-Vorgangs müssen Sie alle Gesetze und Vorschriften Ihres Landes und Ihrer Region strikt einhalten.
terracotta.status.preparing=Multiplayer Core: Wird heruntergeladen (HMCL NICHT beenden)
terracotta.status.launching=Multiplayer Core: Wird initialisiert
terracotta.status.unknown=Multiplayer Core: Wird initialisiert
terracotta.status.waiting=Multiplayer Core: Bereit
terracotta.status.waiting.host.title=Ich möchte eine Sitzung hosten
terracotta.status.waiting.host.desc=Erstellen Sie einen Raum und generieren Sie einen Einladungscode, um mit Freunden zu spielen
terracotta.status.waiting.host.launch.title=Sie scheinen vergessen zu haben, das Spiel zu starten
terracotta.status.waiting.host.launch.desc=Kein laufendes Spiel gefunden
terracotta.status.waiting.host.launch.skip=Das Spiel wurde gestartet
terracotta.status.waiting.guest.title=Ich möchte einer Sitzung beitreten
terracotta.status.waiting.guest.desc=Geben Sie den Einladungscode des Host-Spielers ein, um der Spielwelt beizutreten
terracotta.status.waiting.guest.prompt.title=Bitte geben Sie den Einladungscode des Hosts ein
terracotta.status.waiting.guest.prompt.invalid=Ungültiger Einladungscode
terracotta.status.scanning=Suche nach LAN-Welten
terracotta.status.scanning.desc=Bitte <a href="hmcl://game/launch">starten Sie das Spiel</a>, öffnen Sie eine Welt, drücken Sie ESC, wählen Sie "Im LAN öffnen", und wählen Sie dann "LAN-Welt starten".
terracotta.status.scanning.back=Dadurch wird auch die Suche nach LAN-Welten beendet.
terracotta.status.host_starting=Raum wird erstellt
terracotta.status.host_starting.back=Dadurch wird die Erstellung des Raums beendet.
terracotta.status.host_ok=Raum erstellt
terracotta.status.host_ok.code=Einladungscode (Kopiert)
terracotta.status.host_ok.code.copy=Einladungscode kopieren
terracotta.status.host_ok.code.copy.toast=Einladungscode wurde in die Zwischenablage kopiert
terracotta.status.host_ok.code.desc=Bitte erinnern Sie Ihre Freunde daran, den Gastmodus in HMCL - Mehrspieler oder PCL CE auszuwählen und diesen Einladungscode einzugeben.
terracotta.status.host_ok.back=Dadurch wird auch der Raum geschlossen, andere Gäste verlassen ihn und können nicht wieder beitreten.
terracotta.status.guest_starting=Raum wird betreten
terracotta.status.guest_starting.back=Dies hindert andere Gäste nicht daran, dem Raum beizutreten.
terracotta.status.guest_ok=Raum beigetreten
terracotta.status.guest_ok.back=Dies hindert andere Gäste nicht daran, dem Raum beizutreten.
terracotta.status.guest_ok.title=Bitte starten Sie das Spiel, wählen Sie Mehrspieler und doppelklicken Sie auf Terracotta Lobby.
terracotta.status.guest_ok.desc=Sicherungsadresse: %s
terracotta.status.exception.back=Bitte versuchen Sie es erneut
terracotta.status.exception.desc.ping_host_fail=Fehler beim Beitreten des Raums: Raum ist geschlossen oder Netzwerk instabil
terracotta.status.exception.desc.ping_host_rst=Raumverbindung unterbrochen: Raum ist geschlossen oder Netzwerk instabil
terracotta.status.exception.desc.guest_et_crash=Fehler beim Beitreten des Raums: EasyTier ist abgestürzt, bitte melden Sie dieses Problem den Entwicklern
terracotta.status.exception.desc.host_et_crash=Fehler beim Erstellen des Raums: EasyTier ist abgestürzt, bitte melden Sie dieses Problem den Entwicklern
terracotta.status.exception.desc.ping_server_rst=Raum geschlossen: Sie haben die Spielwelt verlassen, der Raum wurde automatisch geschlossen
terracotta.status.exception.desc.scaffolding_invalid_response=Ungültiges Protokoll:Der Host hat eine ungültige Antwort gesendet, bitte melden Sie dieses Problem den Entwicklern
terracotta.status.fatal.retry=Erneut versuchen
terracotta.status.fatal.network=Fehler beim Herunterladen von Multiplayer Core. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.
terracotta.status.fatal.install=Schwerwiegender Fehler: Multiplayer Core kann nicht installiert werden.
terracotta.status.fatal.terracotta=Schwerwiegender Fehler: Verbindung zu Multiplayer Core kann nicht hergestellt werden.
terracotta.status.fatal.unknown=Schwerwiegender Fehler: Unbekannt.
terracotta.player_list=Spielerliste
terracotta.player_anonymous=Anonymer Spieler
terracotta.player_kind.host=Host
terracotta.player_kind.local=Sie selbst
terracotta.player_kind.guest=Gast
terracotta.unsupported=Mehrspieler wird auf der aktuellen Plattform noch nicht unterstützt.
terracotta.unsupported.os.windows.old=Mehrspieler erfordert Windows 10 oder höher. Bitte aktualisieren Sie Ihr System.
terracotta.unsupported.arch.32bit=Mehrspieler wird auf 32-Bit-Systemen nicht unterstützt. Bitte rüsten Sie auf ein 64-Bit-System auf.
terracotta.unsupported.arch.loongarch64_ow=Mehrspieler wird auf Linux LoongArch64 Old World Distributionen nicht unterstützt. Bitte aktualisieren Sie auf eine New World Distribution (wie AOSC OS).
terracotta.unsupported.region=Die Mehrspieler-Funktion ist derzeit nur für Benutzer auf dem chinesischen Festland verfügbar und in Ihrer Region möglicherweise nicht verfügbar.
theme_pack.appearance.follow_theme.tooltip=Folgt derzeit dem ausgewählten Design. Klicken Sie hier, um eine benutzerdefinierte Launcher-Einstellung zu verwenden.
theme_pack.appearance.custom.tooltip=Verwendet derzeit eine benutzerdefinierte Launcher-Einstellung. Klicken Sie hier, um dem ausgewählten Design zu folgen.
@@ -1791,40 +1687,8 @@ theme_pack.version=Version: %s
unofficial.hint=Sie verwenden einen inoffiziellen Build von HMCL. Wir können die Sicherheit nicht garantieren.
update=Aktualisieren
update.accept=Aktualisieren
update.changelog=Änderungsprotokoll
# update.changelog.empty=
update.channel.dev=Beta
update.channel.dev.hint=Sie verwenden derzeit einen Beta-Kanal-Build des Launchers. Obwohl er möglicherweise einige zusätzliche Funktionen enthält, ist er manchmal auch weniger stabil als die Builds des stabilen Kanals.\n\
\n\
Wenn Sie auf Fehler oder Probleme stoßen, geben Sie bitte Feedback über die auf der Seite <a href="hmcl://settings/feedback">Feedback</a> bereitgestellten Kanäle ab.\n\
\n\
Folgen Sie <a href="https://space.bilibili.com/1445341">@huanghongxun</a> auf Bilibili, um über wichtige HMCL-Neuigkeiten auf dem Laufenden zu bleiben, oder <a href="https://space.bilibili.com/20314891">@Glavo</a>, um mehr über den Entwicklungsfortschritt von HMCL zu erfahren.
update.channel.dev.title=Hinweis zum Beta-Kanal
update.channel.nightly=Nightly
update.channel.nightly.hint=Sie verwenden derzeit einen Nightly-Kanal-Build des Launchers. Obwohl er möglicherweise einige zusätzliche Funktionen enthält, ist er auch immer weniger stabil als die anderen Kanal-Builds.\n\
\n\
Wenn Sie auf Fehler oder Probleme stoßen, geben Sie bitte Feedback über die auf der Seite <a href="hmcl://settings/feedback">Feedback</a> bereitgestellten Kanäle ab.\n\
\n\
Folgen Sie <a href="https://space.bilibili.com/1445341">@huanghongxun</a> auf Bilibili, um über wichtige HMCL-Neuigkeiten auf dem Laufenden zu bleiben, oder <a href="https://space.bilibili.com/20314891">@Glavo</a>, um mehr über den Entwicklungsfortschritt von HMCL zu erfahren.
update.channel.nightly.title=Hinweis zum Nightly-Kanal
update.channel.stable=Stabil
update.checking=Suche nach Updates
# update.check_failed=
update.disable_auto_show_update_dialog=Update-Dialog nicht automatisch anzeigen
update.disable_auto_show_update_dialog.subtitle=Aktivieren Sie diese Option, um zu verhindern, dass HMCL den Update-Dialog automatisch anzeigt.
update.failed=Fehler beim Aktualisieren
update.found=Update verfügbar!
update.newest_version=Neueste Version: %s
update.bubble.title=Update verfügbar: %s
update.bubble.subtitle=Klicken Sie hier zum Aktualisieren
update.note.stable=Geeignet für Benutzer, die Wert auf Softwarestabilität legen.\nNeue Funktionen werden erst nach gründlichen Tests zur stabilen Version hinzugefügt.
update.note.dev=Geeignet für Benutzer, die neue Funktionen als Erste ausprobieren möchten.\nDie Entwicklungsversion enthält die neuesten Funktionen und Fehlerbehebungen,\nkann aber aufgrund unzureichender Tests auch mehr Probleme aufweisen.
update.latest=Dies ist die neueste Version
update.tooltip=Aktualisieren
update.preview=Vorschau auf HMCL-Veröffentlichungen frühzeitig erhalten
update.preview.subtitle=Aktivieren Sie diese Option, um neue Versionen von HMCL frühzeitig zum Testen vor ihrer offiziellen Veröffentlichung zu erhalten.
# update.unverified=
warning.java_interpreted_mode=HMCL wird in einer interpretierten Java-Umgebung ausgeführt, was die Leistung erheblich beeinträchtigt.\n\
@@ -228,11 +228,6 @@ button.view=Vista
button.yes=Sí
contact=Comentarios
contact.chat=Chat de grupo
contact.chat.discord=Discord
contact.chat.discord.statement=¡Únase a nuestro servidor Discord!
contact.chat.qq_group=Grupo QQ de usuarios de HMCL
contact.chat.qq_group.statement=¡Únase a nuestro grupo QQ de usuarios!
contact.feedback=Canal de comentarios
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=Envíe una propuesta en GitHub.
@@ -450,14 +445,6 @@ fatal.config_unsupported_version=El archivo de configuración actual fue creado
fatal.mac_app_translocation=El sistema operativo aísla HMCL en un directorio temporal debido a los mecanismos de seguridad de macOS.\n\
Por favor, mueve HMCL a un directorio diferente antes de intentar abrirlo. De lo contrario, tus ajustes y datos de juego podrían perderse tras reiniciar.\n\
¿Todavía quieres continuar?
fatal.migration_requires_manual_reboot=HMCL ha sido actualizado. Por favor, reinicie el launcher.
fatal.apply_update_failure=Lo sentimos, pero HMCL no puede actualizarse.\n\
\n\
Puedes actualizar manualmente descargando una versión más reciente del launcher desde %s.\n\
Si el problema persiste, por favor considere reportarlo a nosotros.
fatal.apply_update_need_win7=HMCL no puede actualizarse automáticamente en Windows XP/Vista.\n\
\n\
Puedes actualizar manualmente descargando una versión más reciente del launcher desde %s.
fatal.deprecated_java_version=HMCL requerirá Java 17 o posterior para funcionar en el futuro, pero seguirá siendo compatible con el lanzamiento de juegos con Java 6~16.\n\
\n\
Se recomienda instalar la última versión de Java para garantizar el correcto funcionamiento de HMCL.\n\
@@ -471,7 +458,7 @@ fatal.unsupported_platform=Minecraft aún no es totalmente compatible con tu pla
\n\
Si no puedes iniciar Minecraft 1.17 y versiones posteriores, puedes intentar cambiar el «Renderizador» a «Mesa LLVMpipe» en «Configuración global/específica de la instancia → Configuración avanzada» para utilizar el renderizado por CPU y mejorar la compatibilidad.
fatal.unsupported_platform.loongarch=HMCL ha prestado apoyo a la plataforma Loongson.\n\
Si tienes problemas al jugar, puedes visitar https://docs.hmcl.net/groups.html para obtener ayuda.
Si tienes problemas al jugar, puedes visitar https://docs.hmcl.net/help.html para obtener ayuda.
fatal.unsupported_platform.windows_arm64=HMCL ha proporcionado soporte nativo para la plataforma Windows en Arm. Si tiene problemas al jugar a un juego, intente iniciarlo con Java de arquitectura x86.\n\
\n\
Si utilizas la plataforma <b>Qualcomm</b>, es posible que tengas que instalar el <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">paquete de compatibilidad OpenGL</a> antes de jugar.\n\
@@ -783,10 +770,10 @@ install.success=Instalado con éxito.
instance=Juegos
instance.name=Nombre de instancia
instance.empty=No hay instancias
instance.empty.add=Añadir una instancia
instance.empty.add=Instalar el modpack del servidor
# instance.empty.launch=
# instance.empty.launch.goto_download=
instance.empty.hint=No hay instancias de Minecraft aquí.\nPuedes intentar cambiar a otro directorio del juego o hacer clic aquí para descargar una.
instance.empty.hint=No hay instancias de Minecraft aquí.\nPuedes intentar cambiar a otro directorio del juego o hacer clic aquí para instalar el modpack del servidor.
instance.game.all=Todos
instance.game.april_fools=Día de los Inocentes
instance.game.old=Histórico
@@ -801,7 +788,7 @@ instance.launch=Iniciar Minecraft
# instance.launch_and_enter_world=
instance.launch.empty=Iniciar Minecraft
instance.launch.empty.installing=Instalando el juego
instance.launch.empty.tooltip=Instala y ejecuta la última versión oficial del juego.
instance.launch.empty.tooltip=Instala y ejecuta el modpack del servidor incluido.
instance.launch.test=Probar instalación
instance.switch=Cambiar Instancia
instance.launch_script=Exportar script de ejecución
@@ -943,10 +930,8 @@ launcher.cache_directory.choose=Elegir el directorio de la caché
launcher.cache_directory.default=Por defecto («%APPDATA%/.minecraft» o «~/.minecraft»)
launcher.cache_directory.disabled=Desactivado
launcher.cache_directory.invalid=No se ha podido crear el directorio de la caché, volviendo a los valores por defecto.
launcher.contact=Contacta con nosotros
launcher.crash=HMCL ha encontrado un error fatal. Por favor, copie el siguiente registro y pida ayuda en nuestra comunidad en Discord, GitHub o Minecraft Forums.
launcher.crash=HMCL ha encontrado un error fatal. Por favor, copie el siguiente registro y pida ayuda en GitHub o en Minecraft Forums.
launcher.crash.java_internal_error=HMCL ha encontrado un error fatal porque su Java está dañado. Por favor, desinstala tu Java y descarga un Java adecuado <a href="https://bell-sw.com/pages/downloads/#downloads">aquí</a>.
launcher.crash.hmcl_out_dated=HMCL ha encontrado un error fatal. Su launcher está desactualizado. Por favor, ¡actualícelo!
# launcher.title=
# launcher.title.modified=
# launcher.title.unofficial=
@@ -960,12 +945,11 @@ login.enter_password=Por favor, introduzca su contraseña.
logwindow.show_lines=Mostrar número de líneas
logwindow.terminate_game=Terminar proceso
logwindow.title=Registro
logwindow.help=Puede ir a la comunidad HMCL y encontrar a otros para ayudar
logwindow.autoscroll=Desplazamiento automático
# logwindow.wrap_text=
logwindow.export_game_crash_logs=Exportar registros de colgado
logwindow.export_dump=Exportar volcado de pila de juegos
logwindow.export_dump.no_dependency=Su Java no contiene las dependencias para crear el volcado de pila. Dirígete a nuestro Discord o grupo QQ para obtener ayuda.
logwindow.export_dump.no_dependency=Su Java no contiene las dependencias para crear el volcado de pila. Dirígete a GitHub u otros foros de Minecraft para obtener ayuda.
message.cancelled=La operación se ha cancelado
@@ -988,15 +972,11 @@ modpack.choose.local=Importar desde un archivo local
modpack.choose.local.detail=Puede arrastrar y soltar el archivo modpack aquí
modpack.choose.remote=Descargar desde Internet
modpack.choose.remote.detail=Se requiere un enlace de descarga directa al archivo modpack remoto
modpack.choose.repository=Descargar Modpack de CurseForge o Modrinth
modpack.choose.repository.detail=Puedes elegir el modpack que desees en la página siguiente.
modpack.choose.remote.tooltip=Por favor, introduzca la URL de su modpack
modpack.completion=Descargando dependencias
modpack.desc=Describa su modpack, incluyendo una introducción y probablemente algún registro de cambios. Actualmente se admiten Markdown e imágenes desde URL.
modpack.description=Descripción
modpack.download=Descargar Modpacks
# modpack.download.completed=
modpack.download.title=Descargar Modpack - %1s
modpack.export=Exportar como modpack
modpack.export.as=Exportar Modpack como...
modpack.file_api=Prefijo de la URL del modpack
@@ -1167,7 +1147,6 @@ mods.add.success=%s se ha añadido correctamente.
# mods.delete.failed=
mods.disable=Desactivar
#mods.download=Descargar mod
mods.download.title=Descargar mod - %1s
# mods.empty=
mods.enable=Activar
mods.game.version=Versión del juego
@@ -1241,7 +1220,6 @@ world.datetime=Jugado por última vez en %s
world.delete=Eliminar este mundo
world.delete.failed=No se pudo eliminar el mundo.\n%s
# world.download=
world.download.title=Descargar mundo - %1s
# world.empty=
world.export=Exportar el mundo
world.export.title=Elija el directorio para este mundo exportado
@@ -1779,36 +1757,12 @@ system.operating_system=Sistema operativo
unofficial.hint=Está utilizando una versión no oficial de HMCL. No podemos garantizar su seguridad.
update=Actualizar
update.accept=Actualizar
update.changelog=Registro de cambios
# update.changelog.empty=
update.channel.dev=Beta
update.channel.dev.hint=Actualmente está utilizando una versión beta del launcher, que puede incluir algunas características adicionales, pero también es más inestable que las versiones de lanzamiento.\n\
\n\
Si encuentra algún error o problema, puede ir a la <a href="hmcl://settings/feedback">página de comentarios</a> para informar de ello.
update.channel.dev.title=Aviso de versión beta
update.channel.nightly=Nocturna
update.channel.nightly.hint=Estás utilizando una versión nocturna del launcher, que puede incluir algunas características adicionales, pero también es siempre más inestable que las otras versiones.\n\
\n\
Si encuentra algún error o problema, puede ir a la <a href="hmcl://settings/feedback">página de comentarios</a> para informar de ello.
update.channel.nightly.title=Aviso de versión nocturna
update.channel.stable=Estable
update.checking=Buscando actualizaciones
# update.check_failed=
# update.disable_auto_show_update_dialog=
# update.disable_auto_show_update_dialog.subtitle=
update.failed=No se puede actualizar
update.found=¡Actualización disponible!
update.newest_version=Última versión: %s
update.bubble.title=Actualización disponible: %s
update.bubble.subtitle=Haga clic aquí para actualizar
# update.note.stable=
# update.note.dev=
update.latest=Esta es la última versión.
update.tooltip=Actualización
update.preview=Vista previa de actualizaciones anticipadas
update.preview.subtitle=Activa esta opción para recibir nuevas versiones del lanzador antes de su lanzamiento oficial para probarlas
# update.unverified=
warning.java_interpreted_mode=El lanzador se ejecuta en un entorno Java interpretado, lo que afectará considerablemente al rendimiento.\n\
@@ -217,11 +217,6 @@ button.view=読む
button.yes=はい
contact=フィードバック
contact.chat=グループチャット
contact.chat.discord=Discord
contact.chat.discord.statement=Discordサーバーに参加してください!
contact.chat.qq_group=HMCLユーザーQQグループ
contact.chat.qq_group.statement=ユーザーQQグループに参加してください!
contact.feedback=フィードバックチャンネル
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=GitHubで問題を送信します。
@@ -422,8 +417,6 @@ fatal.config_loading_failure=構成にアクセスできません。\nHelloMinec
# fatal.config_loading_failure.unix=
# fatal.config_unsupported_version=
# fatal.mac_app_translocation=
fatal.migration_requires_manual_reboot=更新が完了しました。Hello Minecraftを再開してください!ランチャー。
fatal.apply_update_failure=ごめんなさい、HMCL 何か問題が発生したため、ランチャーは更新を完了できませんでした。\nただし、Hello Minecraftをダウンロードすることで、手動で更新を終了できます。%s からのランチャー。\nこの問題を報告することを検討してください。
# fatal.apply_update_need_win7=
# fatal.deprecated_java_version=
# fatal.deprecated_java_version.download_link=
@@ -593,10 +586,10 @@ install.success=正常にインストールされました
instance=ゲーム
# instance.name=
instance.empty=ゲームなし
instance.empty.add=起動構成を作成
instance.empty.add=サーバー用統合パックをインストール
# instance.empty.launch=
# instance.empty.launch.goto_download=
instance.empty.hint=インストールされているゲームバージョンはありません。新しいゲームをダウンロードするには、ここをクリックしてください。
instance.empty.hint=インストールされているゲームバージョンはありません。サーバー用統合パックをインストールするには、ここをクリックしてください。
# instance.game.all=
# instance.game.april_fools=
instance.game.old=Old
@@ -753,10 +746,8 @@ launcher.cache_directory.choose=キャッシュするディレクトリを選択
launcher.cache_directory.default=標準(%APPDATA%/.minecraft または ~/.minecraft)
launcher.cache_directory.disabled=無効(常にゲームパスを使用する)
launcher.cache_directory.invalid=無効なディレクトリ。デフォルト設定の復元。
launcher.contact=お問い合わせ
launcher.crash=Hello Minecraft!ランチャーがクラッシュしました!次のコンテンツをコピーして、MCBBS、Baidu Tieba、GitHub、またはMinecraftForumを介してフィードバックを送信してください。
# launcher.crash.java_internal_error=
launcher.crash.hmcl_out_dated=Hello Minecraft!ランチャーがクラッシュしました!ランチャーが古くなっています。ランチャーを更新してください!
# launcher.title=
# launcher.title.modified=
# launcher.title.unofficial=
@@ -770,7 +761,6 @@ login.enter_password=パスワードを入力してください。
logwindow.show_lines=行を表示
logwindow.terminate_game=ゲームを終了する
logwindow.title=ログ
logwindow.help=HMCL コミュニティにアクセスして、他のユーザーからのヘルプを見つけることができます
logwindow.autoscroll=Autoscroll
# logwindow.wrap_text=
logwindow.export_game_crash_logs=ゲームのクラッシュ情報をエクスポートする
@@ -804,7 +794,6 @@ modpack.choose.remote.tooltip=リモートmodpackファイルへの直接ダウ
# modpack.completion=
modpack.desc=注意事項と変更ログを含めてmodpackを説明してください。マークダウンとオンライン写真がサポートされています。
modpack.description=説明
modpack.download=Modpackをダウンロード
# modpack.download.completed=
# modpack.download.title=
modpack.export=Modpackをエクスポート
@@ -975,7 +964,6 @@ mods.add.success=mods %s が正常に追加されました。
# mods.delete.failed=
mods.disable=無効にする
#mods.download=Modのダウンロード
mods.download.title=Modダウンロード- %1s
# mods.empty=
mods.enable=有効にする
# mods.game.version=
@@ -1583,34 +1571,12 @@ system.operating_system=OS
# unofficial.hint=
update=更新
update.accept=更新
update.changelog=変更
# update.changelog.empty=
update.channel.dev=ベータ
update.channel.dev.hint=ベータ版を使用しています。ベータ版には、リリースバージョンと比較していくつかの追加機能が含まれている可能性があり、テストにのみ使用されます。ベータ版は不安定なはずです!\n\
\n\
問題が発生した場合は、<a href="hmcl://settings/feedback">フィードバックページ</a>にアクセスして報告してください。
update.channel.dev.title=ベータ版のヒント
update.channel.nightly=アルファ
update.channel.nightly.hint=アルファ版を使用しています。これには、ベータ版およびリリース版と比較していくつかの追加機能が含まれている可能性があり、テストにのみ使用されます。アルファ版は不安定です!\n\
\n\
問題が発生した場合は、<a href="hmcl://settings/feedback">フィードバックページ</a>にアクセスして報告してください。
update.channel.nightly.title=アルファ版のヒント
update.channel.stable=リリース
update.checking=更新の確認
# update.check_failed=
# update.disable_auto_show_update_dialog=
# update.disable_auto_show_update_dialog.subtitle=
update.failed=更新の実行に失敗しました
update.found=アップデートが利用可能です!
update.newest_version=最新バージョン:%s
update.bubble.title=利用可能な更新:%s
update.bubble.subtitle=更新するにはここをクリックしてください
# update.note.stable=
# update.note.dev=
update.latest=これは最新バージョンです。
update.tooltip=更新
# update.preview=
# update.preview.subtitle=
# update.unverified=
@@ -228,11 +228,6 @@ button.view=覽
button.yes=然
contact=建言
contact.chat=會集
contact.chat.discord=齟齬
contact.chat.discord.statement=恭迎至齟齬伺服器,且循論議之規
contact.chat.qq_group=HMCL 群組
contact.chat.qq_group.statement=恭迎至 HMCL 群組,且循論議之規
contact.feedback=建言之徑
contact.feedback.github=Github 議題
contact.feedback.github.statement=舉一 Github 議題
@@ -434,9 +429,6 @@ fatal.config_loading_failure=HMCL 不能閲置設之案。\n宜驗 HMCL 訪案
fatal.config_loading_failure.unix=HMCL 不能閲置設之案,以之見立於戶簿「%1$s」。\n君可啟 HMCL 以根戶簿 (弗宜),抑行令以遷案於君之戶簿:\n%2$s\n君可求助于 https://docs.hmcl.net/help.html。
# fatal.config_unsupported_version=
fatal.mac_app_translocation=林檎爲求安,乃遷 HMCL 与暫夾中。\n宜遷 HMCL 於他夾,然後啟之。否,則置設與戲事或佚。\n君可以求助於 https://docs.hmcl.net/help.html。\n猶啟諸?
fatal.migration_requires_manual_reboot=HMCL 將晉畢,宜復啟之。\n君可求助於 https://docs.hmcl.net/help.html。
fatal.apply_update_failure=嗚呼!HMCL 不能自晉,以有謬焉。\n君可自取之於 %s ,以手晉之。\n君可求助於 https://docs.hmcl.net/help.html。
fatal.apply_update_need_win7=HMCL 不能自晉於視窗 XP/Vista 版。宜引諸 %s 以晉之。
fatal.deprecated_java_version=HMCL 將須爪哇十七並後者,然猶可啟戲以爪哇六至十六也。宜置至新之爪哇,以保 HMCL 之行。\n爪哇之舊者猶可留。HMCL 識理諸爪哇,以行適者以戲事之版也。\n君可求助於 https://docs.hmcl.net/help.html。
fatal.deprecated_java_version.download_link=引爪哇 %d
fatal.samba=誠行於案夾之同享以 Samba 者,啟者或不能行。宜新爪哇,抑遷於自案。\n君可求助於 https://docs.hmcl.net/help.html。
@@ -605,10 +597,10 @@ install.success=裝成
instance=戲
instance.name=戲例之名
instance.empty=無游戲例
instance.empty.add=入引頁以裝戲
instance.empty.add=裝伺服器整合包
# instance.empty.launch=
# instance.empty.launch.goto_download=
instance.empty.hint=無畢裝者。\n君且更他戲夾,抑擊斯以往戲之引頁。
instance.empty.hint=無畢裝者。\n君且更他戲夾,抑擊斯以裝伺服器整合包。
instance.game.all=全覽
instance.game.april_fools=愚人節
instance.game.old=古典版
@@ -623,7 +615,7 @@ instance.launch=戲
# instance.launch_and_enter_world=
instance.launch.empty=啟戲
instance.launch.empty.installing=裝戲
instance.launch.empty.tooltip=裝啟至新之當版
instance.launch.empty.tooltip=裝啟伺服器整合包
instance.launch.test=試其戲
instance.switch=迭例
instance.launch_script=成啟戲角本
@@ -765,10 +757,8 @@ launcher.cache_directory.choose=擇取案之快取夾
launcher.cache_directory.default=本 (「%APPDATA%/.minecraft」或「~/.minecraft」)
launcher.cache_directory.disabled=禁 (恒用戲案夾之徑)
launcher.cache_directory.invalid=自訂之快取夾不能立。既復初。
launcher.contact=伏惟候告
launcher.crash=HMCL 有謬而弗能正。宜鈔下文而報謬于右下之鈕。
launcher.crash.java_internal_error=HMCL 不能行,以爪哇壞也。宜去是爪哇,而擊<a href="https://bell-sw.com/pages/downloads/#downloads">此</a>以置爪哇之適者。
launcher.crash.hmcl_out_dated=HMCL 有謬而弗能正之。余識君之啟者舊矣,宜新之。
launcher.title=Hello Minecraft! Launcher v%s
launcher.title.modified=Hello Minecraft! Launcher %s (已改)
launcher.title.unofficial=Hello Minecraft! Launcher %s (非官版)
@@ -782,12 +772,11 @@ login.enter_password=告汝符節
logwindow.show_lines=示行次
logwindow.terminate_game=罷戲之進程
logwindow.title=誌
logwindow.help=君自可往 HMCL 之會,求於他人也。
logwindow.autoscroll=自滾
# logwindow.wrap_text=
logwindow.export_game_crash_logs=導崩記
logwindow.export_dump=導行棧
logwindow.export_dump.no_dependency=君之爪哇殆闕創行棧之所憑。蓋赴 HMCL 之群組抑齟齬,庶得援手。
logwindow.export_dump.no_dependency=君之爪哇殆闕創行棧之所憑。蓋赴 GitHub 或其他 Minecraft 論壇,庶得援手。
message.cancelled=所行即止
@@ -810,15 +799,11 @@ modpack.choose.local=錄改囊集於私案
modpack.choose.local.detail=君可徑曳改囊集於此而裝
modpack.choose.remote=引改囊集於網絡
modpack.choose.remote.detail=需改囊集引之鏈
modpack.choose.repository=引改囊集於 CurseForge/Modrinth
modpack.choose.repository.detail=徑至所躍頁即裝改囊集
modpack.choose.remote.tooltip=改囊集引之鏈
modpack.completion=引改囊集之案
modpack.desc=述欲製改囊集,蓋諸項與誌等,可行以 Markdown。(宜鏈圖以網)。
modpack.description=改囊集述
modpack.download=引改囊集
# modpack.download.completed=
modpack.download.title=引改囊集 - %1s
modpack.export=錄出改囊集
modpack.export.as=擇改囊集之類
modpack.file_api=改囊集所引鏈前綴
@@ -986,8 +971,6 @@ mods.add.success=增改囊 %s 畢。
mods.add.title=擇改囊
# mods.delete.failed=
mods.disable=禁此改囊
mods.download=引改囊
mods.download.title=引改囊 - %1s
# mods.empty=
mods.enable=啟之
mods.game.version=戲版
@@ -1060,8 +1043,6 @@ world.datapack=司錄囊
world.datetime=前戲之時辰: %s
world.delete=刪斯生界
world.delete.failed=刪斯生界未成\n%s
world.download=引生界
world.download.title=引生界 - %1s
# world.empty=
world.export=錄出生界
world.export.title=擇是生界之存處
@@ -1479,7 +1460,6 @@ system.operating_system=械網
# task.queued=
# task.show=
terracotta=眾戲
# terracotta.terracotta=
# terracotta.status=
# terracotta.back=
@@ -1595,36 +1575,10 @@ terracotta=眾戲
unofficial.hint=子方用非官築之 HMCL。余無能證其安,請君自辨。
update=啟者迭更
update.accept=迭更
update.changelog=迭更誌
# update.changelog.empty=
update.channel.dev=開發版
update.channel.dev.hint=君所用之 HMCL 乃開發版也。開發版納試功之未行於定版者,蓋所以驗新者也。預版曾不以全驗,安能穩也!<a href="https://hmcl.huangyuhui.net/download">引定版</a>\n\
\n\
詎有疑焉,置設之<a href="hmcl://settings/feedback">言事葉</a>徑達天聽。垂矚 B 站賬號 <a href="https://space.bilibili.com/1445341">@huanghongxun</a> 以伺要機,抑矚 <a href="https://space.bilibili.com/20314891">@Glavo</a> 以察 HMCL 之功。
update.channel.dev.title=開發版告
update.channel.nightly=預版
update.channel.nightly.hint=君所用之 HMCL 乃預版也。預版之更迭常,納試功之未行於定版與開發版者,蓋所以驗新者也。預版曾不以全驗,安能穩也!<a href="https://hmcl.huangyuhui.net/download">引定版</a>\n\
\n\
詎有疑焉,置設之<a href="hmcl://settings/feedback">言事葉</a>徑達天聽。垂矚 B 站賬號 <a href="https://space.bilibili.com/1445341">@huanghongxun</a> 以伺要機,抑矚 <a href="https://space.bilibili.com/20314891">@Glavo</a> 以察 HMCL 之功。
update.channel.nightly.title=預版告
update.channel.stable=定版
update.checking=甫察其新
# update.check_failed=
update.disable_auto_show_update_dialog=弗自顯迭更窗
update.disable_auto_show_update_dialog.subtitle=啟此,HMCL 將弗自顯迭更窗。
update.failed=迭更未成
update.found=尋得新者
update.newest_version=至新:%s
update.bubble.title=尋得更新:%s
update.bubble.subtitle=是擊以晉
# update.note.stable=
# update.note.dev=
update.latest=當版至新
update.tooltip=迭更
update.preview=先覽 HMCL 之版
update.preview.subtitle=啟此,君可先得 HMCL 之新,試之於未行。
# update.unverified=
# warning.java_interpreted_mode=
@@ -224,11 +224,6 @@ button.view=Просмотреть
button.yes=Да
contact=Обратная связь
contact.chat=Групповой чат
contact.chat.discord=Discord
contact.chat.discord.statement=Добро пожаловать в наш Discord.
contact.chat.qq_group=Группа QQ пользователей HMCL
contact.chat.qq_group.statement=Добро пожаловать в нашу группу QQ.
contact.feedback=Канал обратной связи
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=Сообщить о проблеме на GitHub.
@@ -434,10 +429,6 @@ fatal.config_loading_failure=Не удалось прочитать файлы
fatal.config_loading_failure.unix=HMCL не смог прочитать файл конфигурации, так как он был создан пользователем «%1$s».\nОткройте HMCL от имени пользователя root (не рекомендуется) или выполните следующую команду в терминале, чтобы изменить право собственности на конфигурационный файл для текущего пользователя:\n%2$s
fatal.config_unsupported_version=Текущий файл конфигурации создан более новой версией HMCL, и эта версия не может корректно его загрузить.\nОбновите HMCL и перезапустите его.\nДо обновления лаунчера изменённые настройки не будут сохраняться.
fatal.mac_app_translocation=HMCL изолирован ОС во временной папке из-за механизмов безопасности macOS.\nПереместите HMCL в другую папку, прежде чем пытаться открыть. В противном случае ваши настройки и игровые данные могут быть потеряны после перезапуска.\nВы все еще хотите продолжить?
fatal.migration_requires_manual_reboot=Обновление завершено. Перезапустите лаунчер.
fatal.apply_update_failure=К сожалению, HMCL не удалось обновиться автоматически.\n\nВы можете обновить лаунчер вручную, скачав новую версию с %s.\nЕсли проблема повторяется, сообщите нам об этом.
fatal.apply_update_need_win7=Лаунчер не может автоматически обновляться на Windows XP/Vista.\n\
Вы можете обновить программу вручную, скачав более новую версию с %s.
fatal.deprecated_java_version=В будущем для работы HMCL потребуется Java 17 или более поздняя версия, однако запуск игр будет поддерживаться и на Java 6~16.\n\
\n\
Рекомендуется установить последнюю версию Java, чтобы обеспечить правильную работу HMCL.\n\
@@ -447,7 +438,7 @@ fatal.deprecated_java_version.download_link=Скачать Java %d
fatal.samba=Если вы пытаетесь открыть лаунчер в общей папке Samba, он может не работать, попробуйте обновить Java или запустить лаунчер из локальной папки.
fatal.illegal_char=Путь к рабочей папке содержит недопустимый символ «=». Вы не сможете использовать authlib-injector или менять скин оффлайн-аккаунта.
fatal.unsupported_platform=Minecraft еще не полностью поддерживается на вашей платформе, поэтому вы можете столкнуться с отсутствием функций или даже не сможете запустить игру.\n\nЕсли вы не можете запустить Minecraft версии 1.17 и новее, попробуйте переключить «Рендерер» на «Mesa LLVMpipe» или «Mesa Lavapipe» в разделе «Глобальные настройки / Раздельные настройки для сборки → Настройки графики», чтобы использовать рендеринг через CPU для лучшей совместимости.
fatal.unsupported_platform.loongarch=HMCL поддерживает платформу Loongson.\nЕсли у вас возникнут проблемы во время игры, обратитесь за помощью на https://docs.hmcl.net/groups.html.
fatal.unsupported_platform.loongarch=HMCL поддерживает платформу Loongson.\nЕсли у вас возникнут проблемы во время игры, обратитесь за помощью на https://docs.hmcl.net/help.html.
fatal.unsupported_platform.windows_arm64=HMCL нативно поддерживает Windows на Arm. Если у вас возникли проблемы во время игры, попробуйте запустить игру с Java для архитектуры x86.\n\nЕсли вы используете платформу <b>Qualcomm</b>, вам может потребоваться установить <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">пакет совместимости OpenGL</a> перед началом игры.\nЩелкните ссылку, чтобы перейти в Microsoft Store и установить пакет совместимости.
fatal.wine_warning=HMCL запущен в Wine или одном из его дистрибутивов.\nHMCL — кроссплатформенное приложение, и лучше всего он работает нативно в вашей ОС.\nНажмите «ОК», чтобы продолжить, но учтите, что возможны проблемы.
@@ -720,10 +711,10 @@ install.success=Успешно установлено.
instance=Игры
instance.name=Название сборки
instance.empty=Нет сборок
instance.empty.add=Добавить новую сборку
instance.empty.launch=Нет доступных сборок.\nСкачайте игру на странице «Скачать» или смените папку с игрой на странице «Все сборки».
instance.empty.launch.goto_download=Перейти к скачиванию
instance.empty.hint=Нет сборок.\nВы можете попробовать перейти в другую папку с игрой или нажать здесь, чтобы скачать игру.
instance.empty.add=Установить серверный модпак
instance.empty.launch=Нет доступных сборок.\nУстановите встроенный серверный модпак или смените папку с игрой на странице «Все сборки».
instance.empty.launch.goto_download=Установить модпак
instance.empty.hint=Нет сборок.\nВы можете попробовать перейти в другую папку с игрой или нажать здесь, чтобы установить серверный модпак.
instance.game.all=Все
instance.game.april_fools=День смеха
instance.game.old=Исторические
@@ -738,7 +729,7 @@ instance.launch=Играть
instance.launch_and_enter_world=Играть в этом мире
instance.launch.empty=Играть
instance.launch.empty.installing=Установка игры
instance.launch.empty.tooltip=Установить и запустить последнюю официальную версию игры.
instance.launch.empty.tooltip=Установить и запустить встроенный серверный модпак.
instance.launch.test=Тестовый запуск
instance.switch=Сменить сборку
instance.launch_script=Создать сценарий запуска
@@ -882,10 +873,8 @@ launcher.cache_directory.choose=Выберите папку для кэша
launcher.cache_directory.default=По умолчанию («%APPDATA%/.minecraft» или «~/.minecraft»)
launcher.cache_directory.disabled=Отключено
launcher.cache_directory.invalid=Не удалось создать папку для кэша, будет использована папка по умолчанию.
launcher.contact=Связаться с нами
launcher.crash=Лаунчер столкнулся с фатальной ошибкой! Скопируйте следующий лог и попросите помощи в нашем Discord, группе QQ, GitHub или на другом форуме Minecraft.
launcher.crash=Лаунчер столкнулся с фатальной ошибкой! Скопируйте следующий лог и попросите помощи на GitHub или на другом форуме Minecraft.
launcher.crash.java_internal_error=HMCL столкнулся с критической ошибкой, потому что ваша Java повреждена. Удалите Java и скачайте подходящую <a href="https://bell-sw.com/pages/downloads/#downloads">здесь</a>.
launcher.crash.hmcl_out_dated=Лаунчер столкнулся с фатальной ошибкой! Ваш лаунчер устарел. Обновите его!
# launcher.title=
# launcher.title.modified=
# launcher.title.unofficial=
@@ -899,12 +888,11 @@ logwindow.always_on_top=Поверх всех окон
logwindow.show_lines=Показывать номера строк
logwindow.terminate_game=Завершить игру
logwindow.title=Лог
logwindow.help=Вы можете обратиться к сообществу HMCL, чтобы получить помощь от других
logwindow.autoscroll=Автопрокрутка
logwindow.wrap_text=Перенос строк
logwindow.export_game_crash_logs=Экспорт логов вылета
logwindow.export_dump=Экспорт дампа стека игры
logwindow.export_dump.no_dependency=Ваша Java не содержит зависимостей для создания дампа стека. За помощью обратитесь в наш Discord или группу QQ.
logwindow.export_dump.no_dependency=Ваша Java не содержит зависимостей для создания дампа стека. Обратитесь за помощью на GitHub или на другом форуме Minecraft.
message.cancelled=Операция была отменена
@@ -927,15 +915,11 @@ modpack.choose.local=Импорт из файла
modpack.choose.local.detail=Можно перетащить файл модпака сюда
modpack.choose.remote=Скачать по ссылке
modpack.choose.remote.detail=Необходима прямая ссылка на скачивание модпака
modpack.choose.repository=Скачать модпак с CurseForge или Modrinth
modpack.choose.repository.detail=Вы сможете выбрать нужный вам модпак на следующей странице.
modpack.choose.remote.tooltip=Введите URL-адрес модпака
modpack.completion=Скачивание зависимостей
modpack.desc=Опишите модпак, включая введение и список изменений. Поддерживаются Markdown и изображения по URL-адресу.
modpack.description=Описание
modpack.download=Скачать модпак
# modpack.download.completed=
modpack.download.title=Скачать модпак - %1s
modpack.export=Экспортировать модпак
modpack.export.as=Экспортировать модпак как...
modpack.file_api=Префикс URL модпака
@@ -1107,7 +1091,6 @@ mods.add.title=Выберите файл мода, который хотите
# mods.delete.failed=
mods.disable=Отключить
#mods.download=Скачать мод
mods.download.title=Скачать мод - %1s
mods.empty=Нет модов
mods.enable=Включить
mods.game.version=Версия игры
@@ -1180,8 +1163,6 @@ world.datapack=Наборы данных
world.datetime=Последний запуск игры %s
world.delete=Удалить этот мир
world.delete.failed=Не удалось удалить мир.\n%s
world.download=Скачать
world.download.title=Скачать мир - %1s
world.empty=Нет миров
world.export=Экспорт мира
world.export.title=Выберите папку для экспорта мира
@@ -1603,85 +1584,6 @@ system.operating_system=Операционная система
# task.queued=
# task.show=
terracotta=Сетевая игра
terracotta.terracotta=Terracotta | Сетевая игра
terracotta.status=Лобби
terracotta.back=Выйти
terracotta.feedback.title=Заполнить форму обратной связи
terracotta.feedback.desc=Надеемся, вы потратите 10 секунд на заполнение формы обратной связи.
terracotta.feedback.desc.update=HMCL обновил ядро сетевой игры. Надеемся, вы потратите 10 секунд на заполнение формы обратной связи.
terracotta.sudo_installing=Перед установкой ядра сетевой игры HMCL должен проверить ваш пароль
terracotta.difficulty.easiest=Отличная сеть: подключение почти гарантировано!
terracotta.difficulty.simple=Хорошая сеть: подключение может занять некоторое время
terracotta.difficulty.medium=Средняя сеть: включаются резервные маршруты, но подключиться может не получиться
terracotta.difficulty.tough=Плохая сеть: включаются резервные маршруты, но подключиться может не получиться
terracotta.difficulty.estimate_only=Вероятность успеха — лишь оценка по типам NAT хоста и гостя, только для справки!
terracotta.from_local.title=Сторонние каналы скачивания ядра сетевой игры
terracotta.from_local.desc=В некоторых регионах встроенный канал скачивания по умолчанию может работать нестабильно.
terracotta.from_local.guide=Скачайте пакет ядра сетевой игры с именем %s. После скачивания перетащите файл на эту страницу, чтобы установить его.
terracotta.from_local.file_name_mismatch=Нужно скачать пакет ядра сетевой игры с именем %1$s, а не %2$s
terracotta.export_log=Экспорт лога ядра сетевой игры
terracotta.export_log.desc=Сбор дополнительной информации для анализа
terracotta.status.bootstrap=Сбор информации
terracotta.status.uninitialized.not_exist=Ядро сетевой игры: не скачано
terracotta.status.uninitialized.not_exist.title=Скачать ядро сетевой игры (~ 8 МиБ)
terracotta.status.uninitialized.update=Ядро сетевой игры: доступно обновление
terracotta.status.uninitialized.update.title=Обновить ядро сетевой игры (~ 8 МиБ)
terracotta.status.uninitialized.desc=Вы обязуетесь строго соблюдать все законы и правила своей страны или региона во время сетевой игры.
terracotta.confirm.title=Уведомление для пользователей
terracotta.confirm.desc=Terracotta — стороннее бесплатное ПО с открытым исходным кодом. О любых проблемах при использовании сообщайте через соответствующие каналы.\nTerracotta использует технологию P2P. После подключения пользователи в комнате соединяются напрямую друг с другом, и сторонний сервер не передаёт ваш трафик. Качество сетевой игры во многом зависит от сети участников.\nВо время сетевой игры вы обязаны строго соблюдать все законы и правила своей страны и региона.
terracotta.status.preparing=Ядро сетевой игры: скачивание (НЕ закрывайте HMCL)
terracotta.status.launching=Ядро сетевой игры: инициализация
terracotta.status.unknown=Ядро сетевой игры: инициализация
terracotta.status.waiting=Ядро сетевой игры: готово
terracotta.status.waiting.host.title=Я хочу быть хостом
terracotta.status.waiting.host.desc=Создайте комнату и получите код приглашения, чтобы играть с друзьями
terracotta.status.waiting.host.launch.title=Похоже, вы забыли запустить игру
terracotta.status.waiting.host.launch.desc=Запущенная игра не найдена
terracotta.status.waiting.host.launch.skip=Игра запущена
terracotta.status.waiting.guest.title=Я хочу присоединиться
terracotta.status.waiting.guest.desc=Введите код приглашения от хоста, чтобы войти в его мир
terracotta.status.waiting.guest.prompt.title=Введите код приглашения от хоста
terracotta.status.waiting.guest.prompt.invalid=Недопустимый код приглашения
terracotta.status.scanning=Поиск миров в локальной сети
terracotta.status.scanning.desc=<a href="hmcl://game/launch">Запустите игру</a>, откройте мир, нажмите ESC, выберите «Открыть для сети», а затем «Открыть мир для сети».
terracotta.status.scanning.back=Поиск миров в локальной сети тоже будет остановлен.
terracotta.status.host_starting=Создание комнаты
terracotta.status.host_starting.back=Создание комнаты будет остановлено.
terracotta.status.host_ok=Комната создана
terracotta.status.host_ok.code=Код приглашения (скопирован)
terracotta.status.host_ok.code.copy=Скопировать код приглашения
terracotta.status.host_ok.code.copy.toast=Код приглашения скопирован в буфер обмена
terracotta.status.host_ok.code.desc=Попросите друзей выбрать режим гостя в HMCL (раздел «Сетевая игра») или в PCL CE и ввести этот код приглашения.
terracotta.status.host_ok.back=Комната тоже будет закрыта: остальные гости выйдут и не смогут подключиться снова.
terracotta.status.guest_starting=Подключение к комнате
terracotta.status.guest_starting.back=Другим гостям это не помешает подключиться к комнате.
terracotta.status.guest_ok=Вы в комнате
terracotta.status.guest_ok.back=Другим гостям это не помешает подключиться к комнате.
terracotta.status.guest_ok.title=Запустите игру, выберите «Сетевая игра» и дважды щёлкните по Terracotta Lobby.
terracotta.status.guest_ok.desc=Резервный адрес: %s
terracotta.status.exception.back=Попробуйте ещё раз
terracotta.status.exception.desc.ping_host_fail=Не удалось подключиться к комнате: комната закрыта или сеть нестабильна
terracotta.status.exception.desc.ping_host_rst=Соединение с комнатой потеряно: комната закрыта или сеть нестабильна
terracotta.status.exception.desc.guest_et_crash=Не удалось подключиться к комнате: EasyTier вылетел, сообщите об этом разработчикам
terracotta.status.exception.desc.host_et_crash=Не удалось создать комнату: EasyTier вылетел, сообщите об этом разработчикам
terracotta.status.exception.desc.ping_server_rst=Комната закрыта: вы вышли из игрового мира, и комната закрылась автоматически
terracotta.status.exception.desc.scaffolding_invalid_response=Недопустимый протокол: хост отправил недопустимый ответ, сообщите об этом разработчикам
terracotta.status.fatal.retry=Повторить
terracotta.status.fatal.network=Не удалось скачать ядро сетевой игры. Проверьте подключение к сети и попробуйте снова.
terracotta.status.fatal.install=Критическая ошибка: не удалось установить ядро сетевой игры.
terracotta.status.fatal.terracotta=Критическая ошибка: не удалось подключиться к ядру сетевой игры.
terracotta.status.fatal.unknown=Критическая ошибка: неизвестная ошибка.
terracotta.player_list=Список игроков
terracotta.player_anonymous=Анонимный игрок
terracotta.player_kind.host=Хост
terracotta.player_kind.local=Вы
terracotta.player_kind.guest=Гость
terracotta.unsupported=Сетевая игра пока не поддерживается на этой платформе.
terracotta.unsupported.os.windows.old=Для сетевой игры нужна Windows 10 или новее. Обновите систему.
terracotta.unsupported.arch.32bit=Сетевая игра не поддерживается на 32-разрядных системах. Перейдите на 64-разрядную систему.
terracotta.unsupported.arch.loongarch64_ow=Сетевая игра не поддерживается в дистрибутивах Linux LoongArch64 Old World. Перейдите на дистрибутив New World (например, AOSC OS).
terracotta.unsupported.region=Функция сетевой игры сейчас доступна только пользователям из материкового Китая и может быть недоступна в вашем регионе.
theme_pack.appearance.follow_theme.tooltip=Сейчас используется значение из выбранной темы. Нажмите, чтобы задать своё значение в настройках лаунчера.
theme_pack.appearance.custom.tooltip=Сейчас используется своё значение из настроек лаунчера. Нажмите, чтобы использовать значение из выбранной темы.
@@ -1719,33 +1621,7 @@ theme_pack.version=Версия: %s
unofficial.hint=Вы используете неофициальную версию HMCL. Мы не можем гарантировать безопасность.
update=Обновление
update.accept=Обновить
update.changelog=Изменения
# update.changelog.empty=
update.channel.dev=Бета
update.channel.dev.hint=Вы используете бета-версию лаунчера. В ней могут быть дополнительные функции, но иногда она работает менее стабильно, чем стабильная версия.\n\nЕсли вы столкнулись с ошибками или проблемами, сообщите о них через каналы, указанные на странице <a href="hmcl://settings/feedback">«Обратная связь»</a>.\n\nПодпишитесь на <a href="https://space.bilibili.com/1445341">@huanghongxun</a> в Bilibili, чтобы следить за важными новостями HMCL, или на <a href="https://space.bilibili.com/20314891">@Glavo</a>, чтобы следить за ходом разработки HMCL.
update.channel.dev.title=Уведомление о бета-версии
update.channel.nightly=Ночная
update.channel.nightly.hint=Вы используете ночную версию лаунчера. В ней могут быть дополнительные функции, но она всегда менее стабильна, чем версии других каналов.\n\nЕсли вы столкнулись с ошибками или проблемами, сообщите о них через каналы, указанные на странице <a href="hmcl://settings/feedback">«Обратная связь»</a>.\n\nПодпишитесь на <a href="https://space.bilibili.com/1445341">@huanghongxun</a> в Bilibili, чтобы следить за важными новостями HMCL, или на <a href="https://space.bilibili.com/20314891">@Glavo</a>, чтобы следить за ходом разработки HMCL.
update.channel.nightly.title=Уведомление о ночной версии
update.channel.stable=Стабильная
update.checking=Проверка наличия обновлений
update.check_failed=Не удалось проверить наличие обновлений. %s
update.disable_auto_show_update_dialog=Не показывать окно обновления автоматически
update.disable_auto_show_update_dialog.subtitle=Включите, чтобы HMCL не показывал окно обновления автоматически.
update.failed=Не удалось выполнить обновление
update.found=Доступно обновление!
update.newest_version=Последняя версия: %s
update.bubble.title=Доступно обновление: %s
update.bubble.subtitle=Кликните здесь для обновления
update.note.stable=Подходит тем, для кого важнее всего стабильность.\nНовые функции попадают в стабильную версию только после тщательного тестирования.
update.note.dev=Подходит тем, кто хочет первым опробовать новые функции.\nБета-версия содержит новейшие функции и исправления ошибок,\nно из-за недостаточного тестирования в ней может быть больше проблем.
update.latest=Это последняя версия.
update.tooltip=Обновить
update.preview=Получать предварительные версии HMCL
update.preview.subtitle=Включите, чтобы получать новые версии HMCL для тестирования раньше официального выпуска.
update.unverified=Обновление недоступно: эта копия лаунчера не прошла проверку
warning.java_interpreted_mode=HMCL работает в интерпретируемой среде Java, что сильно снижает производительность.\n\nДля лучшей работы рекомендуем запускать HMCL на Java с поддержкой JIT.
warning.software_rendering=HMCL сейчас использует программный рендеринг, что сильно снижает производительность.
@@ -230,11 +230,6 @@ button.view=Переглянути
button.yes=Так
contact=Зворотний зв'язок
contact.chat=Приєднатися до групового чату
contact.chat.discord=Discord
contact.chat.discord.statement=Ласкаво просимо приєднатися до нашого сервера Discord.
contact.chat.qq_group=Група користувачів QQ HMCL
contact.chat.qq_group.statement=Ласкаво просимо приєднатися до нашої групи користувачів QQ.
contact.feedback=Канал зворотного зв'язку
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=Надіслати проблему на GitHub.
@@ -444,12 +439,6 @@ fatal.config_unsupported_version=Поточний файл конфігурац
fatal.mac_app_translocation=Hello Minecraft! Лаунчер ізольовано до тимчасового каталогу ОС через механізми безпеки macOS.\n\
Перемістіть HMCL в інший каталог перед спробою відкриття. Інакше ваші налаштування та ігрові дані можуть бути втрачені після перезавантаження.\n\
Все одно продовжити?
fatal.migration_requires_manual_reboot=Hello Minecraft! Лаунчер було оновлено. Перезапустіть лаунчер.
fatal.apply_update_failure=Вибачте, але Hello Minecraft! Лаунчер не може оновитися автоматично.\n\
Ви можете оновити вручну, завантаживши новішу версію лаунчера з %s.\n\
Якщо проблема не зникає, подумайте про те, щоб повідомити нас про це.
fatal.apply_update_need_win7=Hello Minecraft! Лаунчер не може автоматично оновитися на Windows XP/Vista. \n\
Ви можете оновити вручну, завантаживши новішу версію лаунчера з %s.
fatal.deprecated_java_version=У майбутньому HMCL потребуватиме Java 17 або новішої версії для роботи, але все ще підтримуватиме запуск ігор на Java 6~16.\n\nРекомендується встановити останню версію Java, щоб гарантувати належну роботу HMCL.\n\nВи можете продовжувати зберігати стару версію Java. HMCL може розпізнавати та керувати декількома версіями Java і автоматично вибере відповідну Java для вас на основі версії гри.
fatal.deprecated_java_version.download_link=Завантажити Java %d
fatal.samba=Якщо ви відкрили Hello Minecraft! Лаунчер з мережевого диска Samba, деякі функції можуть не працювати. Спробуйте оновити вашу Java або перемістити лаунчер в інший каталог.
@@ -458,7 +447,7 @@ fatal.unsupported_platform=Minecraft ще не повністю підтриму
\n\
Якщо ви не можете запустити Minecraft версії 1.17 і новіші, спробуйте змінити «Рендерер» на «Mesa LLVMpipe» у розділі «Глобальні/налаштування екземпляра → Розширені налаштування», щоб використовувати рендеринг через CPU для кращої сумісності.
fatal.unsupported_platform.loongarch=Hello Minecraft! Лаунчер надав підтримку платформи Loongson.\n\
Якщо у вас виникли проблеми під час гри, ви можете відвідати https://docs.hmcl.net/groups.html для отримання допомоги.
Якщо у вас виникли проблеми під час гри, ви можете відвідати https://docs.hmcl.net/help.html для отримання допомоги.
fatal.unsupported_platform.windows_arm64=Hello Minecraft! Лаунчер надав нативну підтримку платформи Windows on Arm. Якщо у вас виникли проблеми під час гри, спробуйте запустити гру з Java на основі архітектури x86.\n\
Якщо ви використовуєте платформу <b>Qualcomm</b>, вам може знадобитися встановити <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">Пакет сумісності OpenGL</a> перед грою.\n\
Натисніть посилання, щоб перейти до Microsoft Store та встановити пакет сумісності.
@@ -744,10 +733,10 @@ install.success=Успішно встановлено.
instance=Ігри
instance.name=Назва екземпляра
instance.empty=Немає екземплярів
instance.empty.add=Додати новий екземпляр
instance.empty.launch=Немає доступних екземплярів.\nВи можете перейти на сторінку "Завантажити", щоб отримати гру, або переключити ігровий каталог на сторінці "Усі екземпляри".
instance.empty.launch.goto_download=Перейти до сторінки завантаження
instance.empty.hint=Тут немає екземплярів Minecraft. Ви можете спробувати переключитися на інший ігровий каталог або натиснути тут, щоб завантажити один.
instance.empty.add=Встановити серверний модпак
instance.empty.launch=Немає доступних екземплярів.\nВстановіть вбудований серверний модпак або переключіть ігровий каталог на сторінці "Усі екземпляри".
instance.empty.launch.goto_download=Встановити модпак
instance.empty.hint=Тут немає екземплярів Minecraft.\nВи можете спробувати переключитися на інший ігровий каталог або натиснути тут, щоб встановити серверний модпак.
instance.game.all=Усі
instance.game.april_fools=Перший квітня
instance.game.old=Історичні
@@ -762,7 +751,7 @@ instance.launch=Запустити гру
instance.launch_and_enter_world=Грати у світ
instance.launch.empty=Почати гру
instance.launch.empty.installing=Встановлення гри
instance.launch.empty.tooltip=Встановити та запустити останній офіційний реліз
instance.launch.empty.tooltip=Встановити та запустити вбудований серверний модпак
instance.launch.test=Тестовий запуск
instance.switch=Переключити екземпляр
instance.launch_script=Експортувати скрипт запуску
@@ -905,10 +894,8 @@ launcher.cache_directory.choose=Вибрати каталог кешу
launcher.cache_directory.default=Типовий ("%APPDATA%/.minecraft" або "~/.minecraft")
launcher.cache_directory.disabled=Вимкнено
launcher.cache_directory.invalid=Не вдалося створити каталог кешу, повернення до типового.
launcher.contact=Зв'яжіться з нами
launcher.crash=Hello Minecraft! Лаунчер зіткнувся з фатальною помилкою! Скопіюйте наступний журнал та попросіть допомоги на нашому Discord, групі QQ, GitHub або іншому форумі Minecraft.
launcher.crash=Hello Minecraft! Лаунчер зіткнувся з фатальною помилкою! Скопіюйте наступний журнал та попросіть допомоги на GitHub або іншому форумі Minecraft.
launcher.crash.java_internal_error=Hello Minecraft! Лаунчер зіткнувся з фатальною помилкою, оскільки ваша Java пошкоджена. Видаліть вашу Java та завантажте відповідну Java <a href="https://bell-sw.com/pages/downloads/#downloads">тут</a>.
launcher.crash.hmcl_out_dated=Hello Minecraft! Лаунчер зіткнувся з фатальною помилкою! Ваш лаунчер застарів. Оновіть ваш лаунчер!
# launcher.title=
# launcher.title.modified=
# launcher.title.unofficial=
@@ -922,12 +909,11 @@ logwindow.always_on_top=Завжди поверх усіх вікон
logwindow.show_lines=Показати номер рядка
logwindow.terminate_game=Вбити процес гри
logwindow.title=Журнал
logwindow.help=Ви можете перейти до спільноти HMCL та знайти інших для допомоги.
logwindow.autoscroll=Автопрокрутка
logwindow.wrap_text=Перенесення тексту
logwindow.export_game_crash_logs=Експортувати журнали збоїв
logwindow.export_dump=Експортувати дамп стека гри
logwindow.export_dump.no_dependency=Ваша Java не містить залежностей для створення дампу стека. Приєднуйтесь до нашого Discord або групи QQ для допомоги.
logwindow.export_dump.no_dependency=Ваша Java не містить залежностей для створення дампу стека. Зверніться по допомогу на GitHub або інших форумах Minecraft.
message.cancelled=Операцію скасовано
@@ -950,15 +936,11 @@ modpack.choose.local=Імпортувати з локального файлу
modpack.choose.local.detail=Ви можете перетягнути файл модпака сюди.
modpack.choose.remote=Завантажити з URL
modpack.choose.remote.detail=Потрібне пряме посилання для завантаження на віддалений файл модпака.
modpack.choose.repository=Завантажити модпак з CurseForge або Modrinth
modpack.choose.repository.detail=Ви можете вибрати бажаний модпак на наступній сторінці.
modpack.choose.remote.tooltip=Введіть URL вашого модпака
modpack.completion=Завантаження залежностей
modpack.desc=Опишіть свій модпак, включаючи вступ та, ймовірно, деякий журнал змін. Наразі підтримуються Markdown та зображення з URL.
modpack.description=Опис модпака
modpack.download=Завантажити модпаки
# modpack.download.completed=
modpack.download.title=Завантажити модпак - %1s
modpack.export=Експортувати як модпак
modpack.export.as=Експортувати модпак як...
modpack.file_api=Префікс URL модпака
@@ -1130,7 +1112,6 @@ mods.add.title=Виберіть файл мода, який ви хочете д
# mods.delete.failed=
mods.disable=Вимкнути
#mods.download=Завантажити мод
mods.download.title=Завантажити мод - %1s
mods.empty=Немає модів
mods.enable=Увімкнути
mods.game.version=Версія гри
@@ -1203,8 +1184,6 @@ world.datapack=Datapacks
world.datetime=Останній раз грали %s
world.delete=Видалити цей світ
world.delete.failed=Не вдалося видалити світ.\n%s
world.download=Завантажити
world.download.title=Завантажити світ - %1s
world.empty=Немає світів
world.export=Експортувати світ
world.export.title=Виберіть каталог для цього експортованого світу
@@ -1626,85 +1605,6 @@ system.operating_system=Операційна система
# task.queued=
# task.show=
terracotta=Мережева гра
terracotta.terracotta=Terracotta | Мережева гра
terracotta.status=Лобі
terracotta.back=Вихід
terracotta.feedback.title=Заповнити форму зворотного зв'язку
terracotta.feedback.desc=Сподіваємось, ви зможете приділити 10 секунд на заповнення форми зворотного зв'язку.
terracotta.feedback.desc.update=Оскільки HMCL оновлює Multiplayer Core, ми сподіваємось, що ви зможете приділити 10 секунд на заповнення форми зворотного зв'язку.
terracotta.sudo_installing=HMCL повинен перевірити ваш пароль перед встановленням Multiplayer Core
terracotta.difficulty.easiest=Чудова мережа: майже підключено!
terracotta.difficulty.simple=Хороша мережа: підключення може зайняти деякий час
terracotta.difficulty.medium=Середня мережа: вмикання резервних маршрутів, хоча підключення може не вдатися
terracotta.difficulty.tough=Погана мережа: вмикання резервних маршрутів, хоча підключення може не вдатися
terracotta.difficulty.estimate_only=Відсоток успішності - це оцінка, заснована на типах NAT хоста та клієнта, лише для довідки!
terracotta.from_local.title=Сторонні канали завантаження для Multiplayer Core
terracotta.from_local.desc=У деяких регіонах вбудований типовий канал завантаження може бути нестабільним.
terracotta.from_local.guide=Будь ласка, завантажте пакет Multiplayer Core з назвою %s. Після завантаження перетягніть файл на поточну сторінку, щоб встановити його.
terracotta.from_local.file_name_mismatch=Вам слід завантажити пакет Multiplayer Core з назвою %1$s замість %2$s
terracotta.export_log=Експортувати журнал Multiplayer Core
terracotta.export_log.desc=Збір додаткової інформації для аналізу
terracotta.status.bootstrap=Збір інформації
terracotta.status.uninitialized.not_exist=Multiplayer Core: Не завантажено
terracotta.status.uninitialized.not_exist.title=Завантажити Multiplayer Core (~ 8MiB)
terracotta.status.uninitialized.update=Multiplayer Core: Доступне оновлення
terracotta.status.uninitialized.update.title=Оновити Multiplayer Core (~ 8MiB)
terracotta.status.uninitialized.desc=Ви юридично зобов'язуєтесь суворо дотримуватися всіх законів і правил вашої країни або регіону під час мережевої гри.
terracotta.confirm.title=Повідомлення для користувача
terracotta.confirm.desc=Terracotta - це стороннє безкоштовне програмне забезпечення з відкритим вихідним кодом. Будь ласка, залишайте відгуки про будь-які проблеми, з якими ви стикаєтеся під час використання, через відповідні канали.\n\ Terracotta використовує технологію P2P. Після успішного підключення користувачі в кімнаті підключатимуться безпосередньо один до одного, і жоден сторонній сервер не буде ретранслювати ваш трафік. Кінцевий досвід мережевої гри значною мірою залежить від стану мережі учасників.\n\ Протягом усього процесу мережевої гри ви повинні суворо дотримуватися всіх законів і правил вашої країни та регіону.
terracotta.status.preparing=Multiplayer Core: Завантаження (НЕ ВИХОДЬТЕ з HMCL)
terracotta.status.launching=Multiplayer Core: Ініціалізація
terracotta.status.unknown=Multiplayer Core: Ініціалізація
terracotta.status.waiting=Multiplayer Core: Готово
terracotta.status.waiting.host.title=Я хочу створити сесію
terracotta.status.waiting.host.desc=Створіть кімнату та згенеруйте код запрошення, щоб грати з друзями
terracotta.status.waiting.host.launch.title=Здається, ви забули запустити гру
terracotta.status.waiting.host.launch.desc=Не знайдено запущеної гри
terracotta.status.waiting.host.launch.skip=Гру запущено
terracotta.status.waiting.guest.title=Я хочу приєднатися до сесії
terracotta.status.waiting.guest.desc=Введіть код запрошення від хоста, щоб приєднатися до ігрового світу
terracotta.status.waiting.guest.prompt.title=Будь ласка, введіть код запрошення від хоста
terracotta.status.waiting.guest.prompt.invalid=Недійсний код запрошення
terracotta.status.scanning=Сканування світів у локальній мережі
terracotta.status.scanning.desc=Будь ласка, <a href="hmcl://game/launch">запустіть гру</a>, відкрийте світ, натисніть ESC, виберіть "Відкрити для мережі", потім виберіть "Запустити світ у локальній мережі".
terracotta.status.scanning.back=Це також зупинить сканування світів у локальній мережі.
terracotta.status.host_starting=Створення кімнати
terracotta.status.host_starting.back=Це зупинить створення кімнати.
terracotta.status.host_ok=Кімнату створено
terracotta.status.host_ok.code=Код запрошення (Скопійовано)
terracotta.status.host_ok.code.copy=Скопіювати код запрошення
terracotta.status.host_ok.code.copy.toast=Код запрошення скопійовано в буфер обміну
terracotta.status.host_ok.code.desc=Будь ласка, нагадайте своїм друзям вибрати режим Гостя в HMCL - Мережева гра або PCL CE та ввести цей код запрошення.
terracotta.status.host_ok.back=Це також закриє кімнату, інші гості вийдуть і не зможуть приєднатися знову.
terracotta.status.guest_starting=Приєднання до кімнати
terracotta.status.guest_starting.back=Це не зупинить інших гостей від приєднання до кімнати.
terracotta.status.guest_ok=До кімнати приєднано
terracotta.status.guest_ok.back=Це не зупинить інших гостей від приєднання до кімнати.
terracotta.status.guest_ok.title=Будь ласка, запустіть гру, виберіть "Мережева гра" та двічі клацніть "Terracotta Lobby".
terracotta.status.guest_ok.desc=Резервна адреса: %s
terracotta.status.exception.back=Будь ласка, спробуйте ще раз
terracotta.status.exception.desc.ping_host_fail=Не вдалося приєднатися до кімнати: Кімната закрита або мережа нестабільна
terracotta.status.exception.desc.ping_host_rst=Зв'язок із кімнатою втрачено: Кімната закрита або мережа нестабільна
terracotta.status.exception.desc.guest_et_crash=Не вдалося приєднатися до кімнати: Збій EasyTier, будь ласка, повідомте про цю проблему розробникам
terracotta.status.exception.desc.host_et_crash=Не вдалося створити кімнату: Збій EasyTier, будь ласка, повідомте про цю проблему розробникам
terracotta.status.exception.desc.ping_server_rst=Кімнату закрито: Ви вийшли з ігрового світу, кімнату закрито автоматично
terracotta.status.exception.desc.scaffolding_invalid_response=Недійсний протокол: Хост надіслав недійсну відповідь, будь ласка, повідомте про цю проблему розробникам
terracotta.status.fatal.retry=Повторити
terracotta.status.fatal.network=Не вдалося завантажити Multiplayer Core. Будь ласка, перевірте підключення до мережі та спробуйте ще раз.
terracotta.status.fatal.install=Фатальна помилка: Не вдалося встановити Multiplayer Core.
terracotta.status.fatal.terracotta=Фатальна помилка: Не вдалося підключитися до Multiplayer Core.
terracotta.status.fatal.unknown=Фатальна помилка: Невідомо.
terracotta.player_list=Список гравців
terracotta.player_anonymous=Анонімний гравець
terracotta.player_kind.host=Хост
terracotta.player_kind.local=Ви
terracotta.player_kind.guest=Гість
terracotta.unsupported=Мережева гра ще не підтримується на поточній платформі.
terracotta.unsupported.os.windows.old=Для мережевої гри потрібна Windows 10 або новіша версія. Будь ласка, оновіть вашу систему.
terracotta.unsupported.arch.32bit=Мережева гра не підтримується на 32-розрядних системах. Будь ласка, оновіть систему до 64-розрядної.
terracotta.unsupported.arch.loongarch64_ow=Мережева гра не підтримується на дистрибутивах Linux LoongArch64 Old World. Будь ласка, оновіть систему до дистрибутива New World (наприклад, AOSC OS).
terracotta.unsupported.region=Функція мережевої гри наразі доступна лише для користувачів на материковому Китаї та може бути недоступна у вашому регіоні.
theme_pack.appearance.follow_theme.tooltip=Наразі слідує за вибраною темою. Натисніть, щоб використати власне налаштування лаунчера.
theme_pack.appearance.custom.tooltip=Наразі використовує власне налаштування лаунчера. Натисніть, щоб слідувати за вибраною темою.
@@ -1742,40 +1642,8 @@ theme_pack.version=Версія: %s
unofficial.hint=Ви використовуєте неофіційну збірку HMCL. Ми не можемо гарантувати її безпеку.
update=Оновлення
update.accept=Оновити
update.changelog=Журнал змін
# update.changelog.empty=
update.channel.dev=Бета
update.channel.dev.hint=Ви зараз використовуєте збірку лаунчера каналу Бета. Хоча вона може включати деякі додаткові функції, іноді вона менш стабільна, ніж збірки каналу Реліз. \n\
\n\
Якщо ви зіткнетеся з будь-якими помилками або проблемами, надішліть відгук через канали, надані на сторінці <a href="hmcl://settings/feedback">Зворотний зв'язок</a>. \n\
\n\
Підписуйтесь на <a href="https://space.bilibili.com/1445341">@huanghongxun</a> на Bilibili, щоб бути в курсі важливих новин HMCL, або на <a href="https://space.bilibili.com/20314891">@Glavo</a>, щоб дізнатися про прогрес розробки HMCL.
update.channel.dev.title=Повідомлення каналу Бета
update.channel.nightly=Нічний
update.channel.nightly.hint=Ви зараз використовуєте збірку лаунчера каналу Нічний. Хоча вона може включати деякі додаткові функції, вона завжди менш стабільна, ніж збірки інших каналів. \n\
\n\
Якщо ви зіткнетеся з будь-якими помилками або проблемами, надішліть відгук через канали, надані на сторінці <a href="hmcl://settings/feedback">Зворотний зв'язок</a>. \n\
\n\
Підписуйтесь на <a href="https://space.bilibili.com/1445341">@huanghongxun</a> на Bilibili, щоб бути в курсі важливих новин HMCL, або на <a href="https://space.bilibili.com/20314891">@Glavo</a>, щоб дізнатися про прогрес розробки HMCL.
update.channel.nightly.title=Повідомлення каналу Нічний
update.channel.stable=Реліз
update.checking=Перевірка оновлень
# update.check_failed=
update.disable_auto_show_update_dialog=Не показувати діалогове вікно оновлення автоматично
update.disable_auto_show_update_dialog.subtitle=Увімкніть цю опцію, щоб HMCL автоматично не показував діалогове вікно оновлення.
update.failed=Не вдалося оновити
update.found=Доступне оновлення!
update.newest_version=Остання версія: %s
update.bubble.title=Доступне оновлення: %s
update.bubble.subtitle=Натисніть тут, щоб оновити
update.note.stable=Підходить для користувачів, які цінують стабільність програмного забезпечення.\nНові функції додаються до стабільної версії лише після ретельного тестування.
update.note.dev=Підходить для користувачів, які хочуть спробувати нові функції першими.\nВерсія для розробників включає найновіші функції та виправлення помилок,\nале також може мати більше проблем через недостатнє тестування.
update.latest=Це остання версія
update.tooltip=Оновити
update.preview=Ранній попередній перегляд випусків HMCL
update.preview.subtitle=Увімкніть цю опцію, щоб отримувати нові версії HMCL раніше для тестування перед їх офіційним випуском.
# update.unverified=
warning.java_interpreted_mode=HMCL працює в інтерпретованому середовищі Java, що значно вплине на продуктивність.\n\ \n\ Для найкращого досвіду ми рекомендуємо використовувати Java з підтримкою JIT для запуску HMCL.
@@ -229,11 +229,6 @@ button.view=查看
button.yes=是
contact=反馈
contact.chat=官方群组
contact.chat.discord=Discord
contact.chat.discord.statement=欢迎加入 Discord 服务器,加入后请遵守讨论区规定
contact.chat.qq_group=用户 QQ 群
contact.chat.qq_group.statement=欢迎加入 HMCL 官方 QQ 群,加入后请遵守群规
contact.feedback=提交反馈
contact.feedback.github=GitHub Issue
contact.feedback.github.statement=提交一个 GitHub Issue
@@ -392,9 +387,9 @@ download.provider.mojang.desc=注:OptiFine 自动安装使用 BMCLAPI 下载
download.provider.official=尽量使用官方源
download.provider.official.desc=优先从官方源获取信息
download.provider.balanced=选择加载速度快的下载源
download.provider.balanced.desc=根据你的网络连接情况选择最佳源
download.provider.balanced.desc=国内优先镜像;海外优先官方源
download.provider.mirror=尽量使用镜像源
download.provider.mirror.desc=推荐中国大陆用户使用
download.provider.mirror.desc=多国内镜像入口自动测速排序,文件并发下载并按源回退
download.java=下载 Java
download.java.process=下载 Java
download.javafx=正在下载必要的运行时组件……
@@ -435,9 +430,6 @@ fatal.config_loading_failure=HMCL 无法加载配置文件。\n请确保 HMCL
fatal.config_loading_failure.unix=HMCL 无法加载配置文件,因为配置文件是由用户“%1$s”创建的。\n请使用 root 账户启动 HMCL (不推荐),或在终端中执行以下命令将配置文件的所有权变更为当前用户:\n%2$s\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。
fatal.config_unsupported_version=当前配置文件是由更高版本的 HMCL 创建的,当前版本的 HMCL 无法正常加载。请更新并重启 HMCL。\n在更新启动器之前,你修改的所有设置都不会被保存。\n如果遇到问题,你可以点击右上角帮助按钮进行求助。
fatal.mac_app_translocation=由于 macOS 的安全机制,HMCL 被系统隔离至临时文件夹中。\n请将 HMCL 移动到其他文件夹后再尝试启动,否则你的设置和游戏数据可能会在重启后丢失。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。\n是否继续启动?
fatal.migration_requires_manual_reboot=HMCL 即将完成升级,请重新打开 HMCL。\n如遇到问题,你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。
fatal.apply_update_failure=我们很抱歉 HMCL 无法自动完成升级,因为出现了一些问题。\n但你依可以从 %s 手动下载 HMCL 来完成升级。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。
fatal.apply_update_need_win7=HMCL 无法在 Windows XP/Vista 上进行自动更新。请从 %s 手动下载 HMCL 来完成升级。
fatal.deprecated_java_version=HMCL 未来需要 Java 17 或更高版本才能运行,但依然支持使用 Java 6~16 启动游戏。建议安装最新版本的 Java 以确保 HMCL 能正常工作。\n你可以继续保留旧版本 Java。HMCL 能够识别与管理多个 Java,并会自动根据游戏版本为你选择合适的 Java。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。
fatal.deprecated_java_version.download_link=下载 Java %d
fatal.samba=如果你正在通过 Samba 共享的文件夹中运行 HMCL,启动器可能无法正常工作。请尝试更新你的 Java 或在本地文件夹内运行 HMCL。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。
@@ -608,10 +600,10 @@ install.success=安装成功
instance=游戏
instance.name=游戏实例名称
instance.empty=没有游戏实例
instance.empty.add=进入下载页安装游戏
instance.empty.launch=没有可启动的游戏。\n你可以前往下载页面下载新游戏,或在“实例列表”中切换游戏文件夹。
instance.empty.launch.goto_download=前往下载页面
instance.empty.hint=没有已安装的游戏。\n你可以切换其他游戏文件夹,或者点击此处进入游戏下载页面。
instance.empty.add=安装服务器整合包
instance.empty.launch=没有可启动的游戏。\n请安装自带的服务器整合包,或在“实例列表”中切换游戏文件夹。
instance.empty.launch.goto_download=安装整合包
instance.empty.hint=没有已安装的游戏。\n你可以切换其他游戏文件夹,或者点击此处安装服务器整合包。
instance.game.all=全部
instance.game.april_fools=愚人节
instance.game.old=远古版
@@ -626,7 +618,7 @@ instance.launch=启动游戏
instance.launch_and_enter_world=进入世界
instance.launch.empty=开始游戏
instance.launch.empty.installing=安装游戏
instance.launch.empty.tooltip=安装并启动最新正式版游戏
instance.launch.empty.tooltip=安装并启动服务器整合包
instance.launch.test=测试游戏
instance.switch=切换实例
instance.launch_script=生成启动脚本
@@ -768,10 +760,8 @@ launcher.cache_directory.choose=选择文件下载缓存文件夹
launcher.cache_directory.default=默认 ("%APPDATA%/.minecraft" 或 "~/.minecraft")
launcher.cache_directory.disabled=禁用 (总是使用游戏文件夹路径)
launcher.cache_directory.invalid=无法创建自定义的缓存文件夹。已经恢复到默认设置。
launcher.contact=联系我们
launcher.crash=HMCL 遇到了无法处理的错误。请复制下列内容并点击右下角的按钮反馈问题。
launcher.crash.java_internal_error=HMCL 由于当前 Java 损坏而无法继续运行。请卸载当前 Java,点击 <a href="https://bell-sw.com/pages/downloads/#downloads">此处</a> 安装合适的 Java 版本。
launcher.crash.hmcl_out_dated=HMCL 遇到了无法处理的错误。已检测到你的启动器不是最新版本,请更新后再试。
launcher.title=Hello Minecraft! Launcher %s
launcher.title.modified=Hello Minecraft! Launcher %s (已修改)
launcher.title.unofficial=Hello Minecraft! Launcher %s (非官方构建)
@@ -785,12 +775,11 @@ logwindow.always_on_top=置顶窗口
logwindow.show_lines=显示行数
logwindow.terminate_game=结束游戏进程
logwindow.title=日志
logwindow.help=你可以前往 HMCL 社区,寻找他人帮助
logwindow.autoscroll=自动滚动
logwindow.wrap_text=自动换行
logwindow.export_game_crash_logs=导出游戏崩溃信息
logwindow.export_dump=导出游戏运行栈
logwindow.export_dump.no_dependency=你的 Java 不包含用于创建游戏运行栈的依赖。请前往 HMCL QQ 群或 Discord 寻求帮助。
logwindow.export_dump.no_dependency=你的 Java 不包含用于创建游戏运行栈的依赖。请前往 GitHub 或其他 Minecraft 论坛寻求帮助。
message.cancelled=操作被取消
@@ -813,15 +802,10 @@ modpack.choose.local=导入本地整合包文件
modpack.choose.local.detail=你可以将整合包文件拖到此处以安装
modpack.choose.remote=从互联网下载整合包
modpack.choose.remote.detail=需要提供整合包的下载链接
modpack.choose.repository=从 CurseForge/Modrinth 下载整合包
modpack.choose.repository.detail=可直接在跳转到的页面安装整合包
modpack.choose.remote.tooltip=要下载的整合包的链接
modpack.completion=下载整合包相关文件
modpack.desc=描述你要制作的整合包,比如整合包注意事项和更新记录。支持 Markdown (图片请用网络链接)。
modpack.description=整合包描述
modpack.download=下载整合包
modpack.download.completed=整合包下载完成
modpack.download.title=整合包下载 - %1s
modpack.export=导出整合包
modpack.export.as=请选择整合包类型
modpack.file_api=整合包下载链接前缀
@@ -990,8 +974,6 @@ mods.add.success=成功添加模组 %s。
mods.add.title=选择要添加的模组文件
mods.delete.failed=删除模组失败,模组文件可能已被其他程序占用。
mods.disable=禁用
mods.download=下载模组
mods.download.title=模组下载 - %1s
mods.empty=没有模组
mods.enable=启用
mods.game.version=游戏版本
@@ -1064,8 +1046,6 @@ world.datapack=数据包管理
world.datetime=上一次游戏时间: %s
world.delete=删除此世界
world.delete.failed=删除世界失败。\n%s
world.download=下载世界
world.download.title=世界下载 - %1s
world.empty=没有世界
world.export=导出此世界
world.export.title=选择该世界的存储位置
@@ -1174,7 +1154,6 @@ 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 个子项
@@ -1486,87 +1465,6 @@ task.list.empty=当前没有任务。
task.queued=等待其他任务完成
task.show=显示进度
terracotta=多人联机
terracotta.terracotta=Terracotta | 陶瓦联机
terracotta.status=联机大厅
terracotta.back=退出
terracotta.feedback.title=填写反馈表
terracotta.feedback.desc=我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.feedback.desc.update=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
terracotta.difficulty.easiest=当前网络状态极好:稍等一下就成功!
terracotta.difficulty.simple=当前网络状态较好:建立连接需要一段时间……
terracotta.difficulty.medium=当前网络状态中等:已启用抗干扰备用线路,连接可能失败
terracotta.difficulty.tough=当前网络状态极差:已启用抗干扰备用线路,连接可能失败
terracotta.difficulty.estimate_only=连接成功率由房主和房客的 NAT 类型推算得到,仅供参考。
terracotta.from_local.title=联机核心第三方下载渠道
terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢
terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。
terracotta.from_local.file_name_mismatch=您应当下载名为 %1$s 的联机核心包,而非 %2$s
terracotta.export_log=导出联机核心日志
terracotta.export_log.desc=为分析错误提供更多信息
terracotta.status.bootstrap=正在收集信息
terracotta.status.uninitialized.not_exist=未下载联机核心
terracotta.status.uninitialized.not_exist.title=下载联机核心 (约 8MiB)
terracotta.status.uninitialized.update=需更新联机核心
terracotta.status.uninitialized.update.title=更新联机核心 (约 8MiB)
terracotta.status.uninitialized.desc=您承诺,在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规
terracotta.confirm.title=用户须知
terracotta.confirm.desc=陶瓦联机是第三方开源自由软件,您在使用过程中所遇到的问题请通过相关渠道进行反馈。\n\
陶瓦联机使用 P2P 技术,联机成功后房间内用户之间将直接连接,不会使用第三方服务器对您的流量进行转发。最终联机体验和参与联机者的网络情况有较大关系。\n\
在多人联机全过程中,您必须严格遵守您所在国家与地区的全部法律法规。
terracotta.status.preparing=正在下载联机核心 (请勿退出启动器)
terracotta.status.launching=正在初始化联机核心
terracotta.status.unknown=正在初始化联机核心
terracotta.status.waiting=联机核心已就绪
terracotta.status.waiting.host.title=我想当房主
terracotta.status.waiting.host.desc=创建房间并生成邀请码,与好友一起畅玩
terracotta.status.waiting.host.launch.title=您似乎忘记启动游戏了
terracotta.status.waiting.host.launch.desc=未能找到正在运行的游戏
terracotta.status.waiting.host.launch.skip=游戏已启动
terracotta.status.waiting.guest.title=我想当房客
terracotta.status.waiting.guest.desc=输入房主提供的邀请码加入游戏世界
terracotta.status.waiting.guest.prompt.title=请输入房主提供的邀请码
terracotta.status.waiting.guest.prompt.invalid=邀请码错误
terracotta.status.scanning=正在扫描局域网世界
terracotta.status.scanning.desc=请<a href="hmcl://game/launch">启动游戏</a>,进入单人世界,按下 ESC 键,选择对局域网开放,点击创建局域网世界。
terracotta.status.scanning.back=这将同时停止扫描局域网世界。
terracotta.status.host_starting=正在启动房间
terracotta.status.host_starting.back=这将会取消创建房间。
terracotta.status.host_ok=已启动房间
terracotta.status.host_ok.code=邀请码 (已自动复制到剪贴板)
terracotta.status.host_ok.code.copy=复制邀请码
terracotta.status.host_ok.code.copy.toast=已将邀请码复制到剪贴板
terracotta.status.host_ok.code.desc=请提醒您的朋友在 HMCL 或 PCL CE 多人联机功能中选择房客模式,并输入该邀请码。
terracotta.status.host_ok.back=这将同时彻底关闭房间,其他房客将退出并不再能重新加入该房间。
terracotta.status.guest_starting=正在加入房间
terracotta.status.guest_starting.back=这不会影响其他房客加入当前房间。
terracotta.status.guest_ok=已加入房间
terracotta.status.guest_ok.back=这不会影响其他房客加入当前房间。
terracotta.status.guest_ok.title=请启动游戏,选择多人游戏,双击进入陶瓦联机大厅。
terracotta.status.guest_ok.desc=备用联机地址:%s
terracotta.status.exception.back=可再试一次
terracotta.status.exception.desc.ping_host_fail=加入房间失败:房间已关闭或网络不稳定
terracotta.status.exception.desc.ping_host_rst=房间连接断开:房间已关闭或网络不稳定
terracotta.status.exception.desc.guest_et_crash=加入房间失败:EasyTier 已崩溃,请向开发者反馈该问题
terracotta.status.exception.desc.host_et_crash=创建房间失败:EasyTier 已崩溃,请向开发者反馈该问题
terracotta.status.exception.desc.ping_server_rst=房间已关闭:您已退出游戏世界,房间已自动关闭
terracotta.status.exception.desc.scaffolding_invalid_response=协议错误:房主发送了错误的响应数据,请向开发者反馈该问题
terracotta.status.fatal.retry=重试
terracotta.status.fatal.network=未能下载联机核心。请检查网络连接,然后再试一次
terracotta.status.fatal.install=严重错误:无法安装联机核心
terracotta.status.fatal.terracotta=严重错误:无法与联机核心通讯
terracotta.status.fatal.unknown=严重错误:原因未知
terracotta.player_list=玩家列表
terracotta.player_anonymous=匿名玩家
terracotta.player_kind.host=房主
terracotta.player_kind.local=你
terracotta.player_kind.guest=房客
terracotta.unsupported=多人联机功能尚不支持当前平台。
terracotta.unsupported.os.windows.old=多人联机功能需要 Windows 10 或更高版本。请更新系统。
terracotta.unsupported.arch.32bit=多人联机功能不支持 32 位系统。请更新至 64 位系统。
terracotta.unsupported.arch.loongarch64_ow=多人联机功能不支持 Linux LoongArch64 旧世界发行版,请更新至新世界发行版 (如 AOSC OC)。
terracotta.unsupported.region=多人联机功能目前仅为中国内地用户提供服务,在您所在地区可能不可用。
theme_pack.appearance.follow_theme.tooltip=当前跟随所选主题。点击后使用自定义启动器设置。
theme_pack.appearance.custom.tooltip=当前使用自定义启动器设置。点击后跟随所选主题。
@@ -1604,37 +1502,6 @@ theme_pack.version=版本:%s
unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。
update=启动器更新
update.accept=更新
update.changelog=更新日志
update.changelog.empty=暂未上传更新日志
update.channel.dev=开发版
update.channel.dev.hint=你正在使用 HMCL 开发版。开发版包含一些未在稳定版中包含的测试性功能,仅用于体验新功能。开发版功能未受充分验证,使用起来可能不稳定!<a href="https://hmcl.huangyuhui.net/download">下载稳定版</a>\n\
\n\
如果你使用时遇到了问题,可以通过设置中<a href="hmcl://settings/feedback">反馈页面</a>提供的渠道进行反馈。欢迎关注 B 站账号 <a href="https://space.bilibili.com/1445341">@huanghongxun</a> 以关注 HMCL 的重要动态,或关注 <a href="https://space.bilibili.com/20314891">@Glavo</a> 以了解 HMCL 的开发进展。
update.channel.dev.title=开发版提示
update.channel.nightly=预览版
update.channel.nightly.hint=你正在使用 HMCL 预览版。预览版更新较为频繁,包含一些未在稳定版和开发版中包含的测试性功能,仅用于体验新功能。预览版功能未受充分验证,使用起来可能不稳定!<a href="https://hmcl.huangyuhui.net/download">下载稳定版</a>\n\
\n\
如果你使用时遇到了问题,可以通过设置中<a href="hmcl://settings/feedback">反馈页面</a>提供的渠道进行反馈。欢迎关注 B 站账号 <a href="https://space.bilibili.com/1445341">@huanghongxun</a> 以关注 HMCL 的重要动态,或关注 <a href="https://space.bilibili.com/20314891">@Glavo</a> 以了解 HMCL 的开发进展。
update.channel.nightly.title=预览版提示
update.channel.stable=稳定版
update.checking=正在检查更新
update.check_failed=检查更新失败 %s
update.disable_auto_show_update_dialog=不自动显示更新弹窗
update.disable_auto_show_update_dialog.subtitle=启用此选项,HMCL 将不会自动弹出更新弹窗。
update.failed=更新失败
update.found=发现更新
update.newest_version=最新版本为:%s
update.bubble.title=发现更新:%s
update.bubble.subtitle=点击此处进行升级
update.note.stable=适合优先追求软件稳定性的用户使用。\n新功能在经过充分测试后才会被添加到稳定版中。
update.note.dev=适合希望优先体验新功能的用户使用。\n包含最新功能和错误修复,但未经充分测试,可能会存在更多问题。
update.latest=当前版本为最新版本
update.tooltip=更新
update.preview=提前预览 HMCL 版本
update.preview.subtitle=启用此选项,你将可以提前获取 HMCL 的新版本,以便在正式发布前进行测试。
update.unverified=更新不可用,因为无法验证此启动器实例完整性
warning.java_interpreted_mode=HMCL 正在运行在解释器模式的 Java 环境中,性能将会受到很大影响。\n我们建议你使用支持 JIT 的 Java 启动 HMCL 以获取最佳体验。
warning.software_rendering=HMCL 正在使用软件渲染,性能将会受到很大影响。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。
@@ -227,11 +227,6 @@ button.view=查看
button.yes=是
contact=回報
contact.chat=官方群組
contact.chat.discord=Discord 伺服器
contact.chat.discord.statement=歡迎加入 Discord 伺服器,加入後請遵守討論區規則
contact.chat.qq_group=使用者 QQ 群組
contact.chat.qq_group.statement=歡迎加入 HMCL 使用者 QQ 群組,加入後請遵守群組規則
contact.feedback=回報管道
contact.feedback.github=GitHub Issues
contact.feedback.github.statement=提交一個 GitHub Issue
@@ -390,9 +385,9 @@ download.provider.mojang.desc=注意:OptiFine 的下載由 BMCLAPI 提供
download.provider.official=盡量使用官方來源
download.provider.official.desc=優先從官方來源獲取資訊
download.provider.balanced=選取載入速度快的下載來源
download.provider.balanced.desc=根據你的網路連線狀況選用最佳來源
download.provider.balanced.desc=國內優先鏡像;海外優先官方源
download.provider.mirror=盡量使用鏡像源
download.provider.mirror.desc=推薦中國大陸使用者使用
download.provider.mirror.desc=多國內鏡像入口自動測速排序,檔案併發下載並依來源回退
download.java=下載 Java
download.java.process=下載 Java
download.javafx=正在下載必要的執行時元件
@@ -432,9 +427,6 @@ fatal.config_loading_failure=HMCL 無法載入設定檔案。\n請確保 HMCL
fatal.config_loading_failure.unix=HMCL 無法載入設定檔案,因為設定檔案是由使用者「%1$s」建立的。\n請使用 root 帳戶開啟 HMCL (不推薦),或在終端中執行以下指令將設定檔案的所有權變更為目前使用者:\n%2$s
fatal.config_unsupported_version=目前設定檔案是由更高版本的 HMCL 建立的,目前版本的 HMCL 無法正常載入。請更新並重新啟動 HMCL。\n在更新啟動器之前,你所做的所有設定更改都不會被儲存。
fatal.mac_app_translocation=由於 macOS 的安全機制,HMCL 被系統隔離至暫存目錄中。\n請將 HMCL 移動到其他目錄後再嘗試開啟,否則你的設定和遊戲資料可能會在重啟後遺失。\n是否繼續開啟?
fatal.migration_requires_manual_reboot=HMCL 即將升級完成,請重新開啟 HMCL。
fatal.apply_update_failure=我們很抱歉 HMCL 無法自動完成升級程式,因為出現了一些問題。\n但你依然可以從 %s 處手動下載 HMCL 來完成升級。
fatal.apply_update_need_win7=HMCL 無法在 Windows XP/Vista 上進行自動更新。請從 %s 處手動下載 HMCL 來完成升級。
fatal.deprecated_java_version=HMCL 未來需要 Java 17 或更高版本才能執行,但依然支援使用 Java 6~16 啟動遊戲。建議安裝最新版本的 Java 以確保 HMCL 正常執行。\n你可以繼續保留舊版本 Java。HMCL 能夠識別與管理多個 Java,並會自動根據遊戲版本為你選取合適的 Java。
fatal.deprecated_java_version.download_link=下載 Java %d
fatal.samba=如果您正在透過 Samba 共亯的目錄中開啟 HMCL,啟動器可能無法正常工作,請嘗試更新您的 Java 或在本機目錄內開啟 HMCL。
@@ -602,10 +594,10 @@ install.success=安裝成功
instance=遊戲
instance.name=遊戲實例名稱
instance.empty=沒有遊戲實例
instance.empty.add=進入下載頁安裝遊戲
instance.empty.launch=沒有可啟動的遊戲。\n你可以前往「下載」頁面下載新遊戲,或在「實例清單」切換遊戲目錄。
instance.empty.launch.goto_download=前往下載頁面
instance.empty.hint=沒有已安裝的遊戲。\n你可以切換其他遊戲目錄,或者點擊此處進入遊戲下載頁面。
instance.empty.add=安裝伺服器整合包
instance.empty.launch=沒有可啟動的遊戲。\n請安裝內建的伺服器整合包,或在「實例清單」切換遊戲目錄。
instance.empty.launch.goto_download=安裝整合包
instance.empty.hint=沒有已安裝的遊戲。\n你可以切換其他遊戲目錄,或者點擊此處安裝伺服器整合包。
instance.game.all=全部
instance.game.april_fools=愚人節
instance.game.old=遠古版
@@ -620,7 +612,7 @@ instance.launch=啟動遊戲
instance.launch_and_enter_world=進入世界
instance.launch.empty=開始遊戲
instance.launch.empty.installing=安裝遊戲
instance.launch.empty.tooltip=安裝並啟動最新正式版遊戲
instance.launch.empty.tooltip=安裝並啟動伺服器整合包
instance.launch.test=測試遊戲
instance.switch=切換實例
instance.launch_script=生成啟動指令碼
@@ -762,10 +754,8 @@ launcher.cache_directory.choose=選取檔案下載快取目錄
launcher.cache_directory.default=預設 ("%APPDATA%/.minecraft" 或 "~/.minecraft")
launcher.cache_directory.disabled=停用
launcher.cache_directory.invalid=無法建立自訂的快取目錄。已還原至預設設定。
launcher.contact=聯絡我們
launcher.crash=HMCL 遇到了無法處理的錯誤。請複製下列內容並透過 GitHub、Discord 或 HMCL QQ 群回報問題。
launcher.crash=HMCL 遇到了無法處理的錯誤。請複製下列內容並透過 GitHub 或其他 Minecraft 論壇回報問題。
launcher.crash.java_internal_error=HMCL 由於目前 Java 損壞而無法繼續執行。請移除目前 Java,點擊 <a href="https://bell-sw.com/pages/downloads/#downloads">此處</a> 安裝合適的 Java 版本。
launcher.crash.hmcl_out_dated=HMCL 遇到了無法處理的錯誤。已偵測到你的啟動器不是最新版本,請更新後重試!
launcher.title=Hello Minecraft! Launcher %s
launcher.title.modified=Hello Minecraft! Launcher %s (已修改)
launcher.title.unofficial=Hello Minecraft! Launcher %s (非官方建置)
@@ -779,12 +769,11 @@ logwindow.always_on_top=置頂視窗
logwindow.show_lines=顯示行數
logwindow.terminate_game=結束遊戲處理程式
logwindow.title=日誌
logwindow.help=你可以前往 HMCL 社群,向他人尋求幫助
logwindow.autoscroll=自動滾動
logwindow.wrap_text=自動換行
logwindow.export_game_crash_logs=匯出遊戲崩潰資訊
logwindow.export_dump=匯出遊戲執行堆疊
logwindow.export_dump.no_dependency=你的 Java 不包含用於建立遊戲執行堆疊的相依元件。請前往 Discord 或 HMCL QQ 群尋求幫助。
logwindow.export_dump.no_dependency=你的 Java 不包含用於建立遊戲執行堆疊的相依元件。請前往 GitHub 或其他 Minecraft 論壇尋求幫助。
message.cancelled=操作被取消
@@ -807,15 +796,10 @@ modpack.choose.local=匯入本機模組包檔案
modpack.choose.local.detail=你可以將整合包檔案拖到此處以安裝
modpack.choose.remote=從網路下載模組包
modpack.choose.remote.detail=需要提供模組包的下載連結
modpack.choose.repository=從 CurseForge/Modrinth 下載模組包
modpack.choose.repository.detail=可直接在跳轉到的頁面安裝模組包
modpack.choose.remote.tooltip=要下載的模組包的連結
modpack.completion=下載模組包相關檔案
modpack.desc=描述你要製作的模組包,比如模組包注意事項和更新紀錄。支援 Markdown (圖片請上傳至網路)。
modpack.description=模組包描述
modpack.download=下載模組包
modpack.download.completed=模組包下載完成
modpack.download.title=模組包下載 - %1s
modpack.export=匯出模組包
modpack.export.as=請選取模組包類型
modpack.file_api=模組包下載連結前綴
@@ -984,8 +968,6 @@ mods.add.success=成功新增模組「%s」。
mods.add.title=選取要新增的模組檔案
mods.delete.failed=刪除模組失敗,模組檔案可能已被其他程式佔用。
mods.disable=停用
mods.download=下載模組
mods.download.title=模組下載 - %1s
mods.empty=沒有模組
mods.enable=啟用
mods.game.version=遊戲版本
@@ -1058,8 +1040,6 @@ world.datapack=資料包管理
world.datetime=上一次遊戲時間: %s
world.delete=刪除此世界
world.delete.failed=刪除世界失敗。\n%s
world.download=下載世界
world.download.title=世界下載 - %1s
world.empty=沒有世界
world.export=匯出此世界
world.export.title=選取該世界的儲存位置
@@ -1168,7 +1148,6 @@ 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 個子項
@@ -1480,87 +1459,6 @@ task.list.empty=目前沒有任務。
task.queued=等待其他任務完成
task.show=顯示進度
terracotta=多人遊戲
terracotta.terracotta=Terracotta | 陶瓦聯機
terracotta.status=聯機大廳
terracotta.back=退出
terracotta.feedback.title=填寫回饋表
terracotta.feedback.desc=我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.feedback.desc.update=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
terracotta.difficulty.easiest=目前網路狀況極佳:稍等一下就成功!
terracotta.difficulty.simple=目前網路狀況較好:建立連線需要一段時間…
terracotta.difficulty.medium=目前網路狀態中等:已啟用抗干擾備用線路,連線可能失敗
terracotta.difficulty.tough=目前網路狀態極差:已啟用抗干擾備用線路,連線可能失敗
terracotta.difficulty.estimate_only=連線成功率由房主和房客的 NAT 類型推算得到,僅供參考。
terracotta.from_local.title=線上核心第三方下載管道
terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。
terracotta.from_local.file_name_mismatch=您應該下載名為 %1$s 的線上核心包,而非 %2$s
terracotta.export_log=匯出線上核心日誌
terracotta.export_log.desc=為分析錯誤提供更多資訊
terracotta.status.bootstrap=正在收集資訊
terracotta.status.uninitialized.not_exist=未下載聯機核心
terracotta.status.uninitialized.not_exist.title=下載聯機核心 (約 8MiB)
terracotta.status.uninitialized.update=需更新聯機核心
terracotta.status.uninitialized.update.title=更新聯機核心 (約 8MiB)
terracotta.status.uninitialized.desc=您承諾,在多人聯機全過程中,您將嚴格遵守您所在國家或地區的全部法律法規
terracotta.confirm.title=使用者須知
terracotta.confirm.desc=陶瓦聯機是第三方開源自由軟體,您在使用過程中所遇到的問題請透過相關管道進行回饋。\n\
陶瓦聯機使用 P2P 技術,連線成功後房間內使用者之間將直接連接,不會使用第三方伺服器對您的流量進行轉發。最終聯機體驗和參與連線者的網路情況有較大關係。\n\
在多人聯機全過程中,您必須嚴格遵守您所在國家與地區的全部法律法規。
terracotta.status.preparing=正在下載聯機核心 (請勿退出啟動器)
terracotta.status.launching=正在初始化聯機核心
terracotta.status.unknown=正在初始化聯機核心
terracotta.status.waiting=聯機核心已就緒
terracotta.status.waiting.host.title=我想當房主
terracotta.status.waiting.host.desc=建立房間並產生邀請碼,與好友一起暢玩
terracotta.status.waiting.host.launch.title=您似乎忘記啟動遊戲了
terracotta.status.waiting.host.launch.desc=未能找到正在執行的遊戲
terracotta.status.waiting.host.launch.skip=遊戲已啟動
terracotta.status.waiting.guest.title=我想當房客
terracotta.status.waiting.guest.desc=輸入房主提供的邀請碼加入遊戲世界
terracotta.status.waiting.guest.prompt.title=請輸入房主提供的邀請碼
terracotta.status.waiting.guest.prompt.invalid=邀請碼錯誤
terracotta.status.scanning=正在掃描區域網路世界
terracotta.status.scanning.desc=請<a href="hmcl://game/launch">啟動遊戲</a>,進入單人世界,按 ESC 鍵,選擇「在區網上公開」,點擊「開始區網世界」。
terracotta.status.scanning.back=這將同時停止掃描區域網路世界。
terracotta.status.host_starting=正在建立房間
terracotta.status.host_starting.back=這將會取消建立房間。
terracotta.status.host_ok=已建立房間
terracotta.status.host_ok.code=邀請碼 (已自動複製到剪貼簿)
terracotta.status.host_ok.code.copy=複製邀請碼
terracotta.status.host_ok.code.copy.toast=已將邀請碼複製到剪貼簿
terracotta.status.host_ok.code.desc=請提醒您的朋友在 HMCL 或 PCL CE 多人遊戲功能中選擇房客模式,並輸入該邀請碼。
terracotta.status.host_ok.back=這將同時徹底關閉房間,其他房客將退出並不再能重新加入該房間。
terracotta.status.guest_starting=正在加入房間
terracotta.status.guest_starting.back=這不會影響其他房客加入目前房間。
terracotta.status.guest_ok=已加入房間
terracotta.status.guest_ok.back=這不會影響其他房客加入目前房間。
terracotta.status.guest_ok.title=請啟動遊戲,選擇多人遊戲,雙擊進入陶瓦聯機大廳。
terracotta.status.guest_ok.desc=備用連線位址:%s
terracotta.status.exception.back=可再試一次
terracotta.status.exception.desc.ping_host_fail=加入房間失敗:房間已關閉或網路不穩定
terracotta.status.exception.desc.ping_host_rst=房間連線中斷:房間已關閉或網路不穩定
terracotta.status.exception.desc.guest_et_crash=加入房間失敗:EasyTier 已崩潰,請向開發者回報該問題
terracotta.status.exception.desc.host_et_crash=建立房間失敗:EasyTier 已崩潰,請向開發者回報問題
terracotta.status.exception.desc.ping_server_rst=房間已關閉:您已退出遊戲世界,房間已自動關閉
terracotta.status.exception.desc.scaffolding_invalid_response=協議錯誤:房主發送了錯誤的回應資料,請向開發者回報該問題
terracotta.status.fatal.retry=重試
terracotta.status.fatal.network=未能下載線上核心。請檢查網路連接,然後再試一次
terracotta.status.fatal.install=嚴重錯誤:無法安裝線上核心
terracotta.status.fatal.terracotta=嚴重錯誤:無法與線上核心通訊
terracotta.status.fatal.unknown=嚴重錯誤:原因未知
terracotta.player_list=玩家清單
terracotta.player_anonymous=匿名玩家
terracotta.player_kind.host=房主
terracotta.player_kind.local=你
terracotta.player_kind.guest=房客
terracotta.unsupported=多人聯機功能尚未支援目前平台。
terracotta.unsupported.os.windows.old=多人聯機功能需要 Windows 10 或更高版本。請更新系統。
terracotta.unsupported.arch.32bit=多人聯機功能不支援 32 位元系統。請更新至 64 位元系統。
terracotta.unsupported.arch.loongarch64_ow=多人聯機功能不支援 Linux LoongArch64 舊世界發行版,請更新至新世界發行版 (如 AOSC OC)。
terracotta.unsupported.region=多人聯機功能目前僅為中國內地使用者提供服務,在您所在地區可能不可用。
theme_pack.appearance.follow_theme.tooltip=目前跟隨所選主題。點擊後使用自訂啟動器設定。
theme_pack.appearance.custom.tooltip=目前使用自訂啟動器設定。點擊後跟隨所選主題。
@@ -1598,37 +1496,6 @@ theme_pack.version=版本:%s
unofficial.hint=你正在使用非官方構建的 HMCL。我們無法保證其安全性,請注意甄別。
update=啟動器更新
update.accept=更新
update.changelog=更新日誌
update.changelog.empty=暫未上傳更新日誌
update.channel.dev=開發版
update.channel.dev.hint=你正在使用 HMCL 開發版。開發版包含一些未在穩定版中包含的測試性功能,僅用於體驗新功能。開發版功能未受充分驗證,使用起來可能不穩定!\n\
\n\
如果你遇到了使用問題,可以透過設定中 <a href="hmcl://settings/feedback">回報頁面</a> 提供的管道進行回報。歡迎關注 Bilibili <a href="https://space.bilibili.com/1445341">@huanghongxun</a> 以關注 HMCL 的重要動態,或關注 <a href="https://space.bilibili.com/20314891">@Glavo</a> 以瞭解 HMCL 的開發進展。
update.channel.dev.title=開發版提示
update.channel.nightly=預覽版
update.channel.nightly.hint=你正在使用 HMCL 預覽版。預覽版更新較為頻繁,包含一些未在穩定版和開發版中包含的測試性功能,僅用於體驗新功能。預覽版功能未受充分驗證,使用起來可能不穩定!\n\
\n\
如果你遇到了使用問題,可以透過設定中 <a href="hmcl://settings/feedback">回報頁面</a> 提供的管道進行回報。歡迎關注 Bilibili <a href="https://space.bilibili.com/1445341">@huanghongxun</a> 以關注 HMCL 的重要動態,或關注 <a href="https://space.bilibili.com/20314891">@Glavo</a> 以瞭解 HMCL 的開發進展。
update.channel.nightly.title=預覽版提示
update.channel.stable=穩定版
update.checking=正在檢查更新
update.check_failed=檢查更新失敗 %s
update.disable_auto_show_update_dialog=不自動顯示更新彈窗
update.disable_auto_show_update_dialog.subtitle=啟用此選項,HMCL 將不會自動彈出更新彈窗。
update.failed=更新失敗
update.found=發現到更新
update.newest_version=最新版本為:%s
update.bubble.title=發現更新:%s
update.bubble.subtitle=點選此處進行升級
update.note.stable=適合優先追求軟體穩定性的使用者使用。\n新功能在經過充分測試後才會被添加到穩定版中。
update.note.dev=適合希望優先體驗新功能的使用者使用。\n開發版會包含最新功能和錯誤修復,但未經充分測試,可能會存在更多問題。
update.latest=目前版本為最新版本
update.tooltip=更新
update.preview=提前測試 HMCL 預覽版本
update.preview.subtitle=啟用此選項,你將可以提前取得 HMCL 的新版本,以便在正式發布前進行測試。
update.unverified=更新不可用,因為無法驗證此啟動器例項完整性
warning.java_interpreted_mode=HMCL 正在執行於直譯器模式的 Java 環境中,效能將會受到很大影響。\n我們建議你使用支援 JIT 的 Java 開啟 HMCL 以獲得最佳體驗。
warning.software_rendering=HMCL 正在使用軟體繪製,效能將會受到很大影響。
@@ -1,90 +0,0 @@
{
"__comment__": "THIS FILE IS MACHINE GENERATED! DO NOT EDIT!",
"version_latest": "0.4.2",
"packages": {
"windows-x86_64": {
"hash": "6a98f524d4f00373696517306af8aa50d01d55ce4eadb27e9e4bc2f882707a0b5f20d5d4c33371d1459dcf5bf144ffed9beb414202d9ccf32b11dbbfcf19d650",
"files": {
"VCRUNTIME140.DLL": "3d4b24061f72c0e957c7b04a0c4098c94c8f1afb4a7e159850b9939c7210d73398be6f27b5ab85073b4e8c999816e7804fef0f6115c39cd061f4aaeb4dcda8cf",
"terracotta-0.4.2-windows-x86_64.exe": "6e98d1f2380ed22fb5a2dd4aafce6c773e9cf69100c8bb8e49e7d6983756bdb9a31f80e06bcfbe5a2742144fe806d3d687dec54d8f09d87c659341f99dd9fd80"
}
},
"windows-arm64": {
"hash": "fc1077247014ac0c712469498bde2ef7f6d881d5fcb7bdd5e11ebe20218fed365be19afdb8d453a79d77b729f866058522b910741767f4df947faa891434b463",
"files": {
"VCRUNTIME140.DLL": "5cb5ce114614101d260f4754c09e8a0dd57e4da885ebb96b91e274326f3e1dd95ed0ade9f542f1922fad0ed025e88a1f368e791e1d01fae69718f0ec3c7b98c8",
"terracotta-0.4.2-windows-arm64.exe": "30a15c5c53e5817c5a3634532172559327474741d3b2c7ef4e8a30acc6f59cdcf3570bf5f583e3cbe9e2abc8253e977c1abda1e9f36c88c4e99240da257347d0"
}
},
"macos-x86_64": {
"hash": "a762e4b2d6f84e899292b9e3856d009411a516d3c47f54575f843ce082f63dff2baa68ba0faa844b8b64fb12e91017386f15f5e7f975f8ee605bf8d4217cb091",
"files": {
"terracotta-0.4.2-macos-x86_64": "24efb85390eff88a538ed7e503fb1488e5e622730ca30c741a0e8b4c8f4e8d4868a2f9f38da8de540aeb535af2fd1e41c7081dae9c700e8a1a03b6c540218164",
"terracotta-0.4.2-macos-x86_64.pkg": "0f80437061231018ec0f860f875aba02cae9e0b36d21f2db8e99d25948806e1119f7844a4d4acca01d6124d7079718762cdebe3756b005a55632de7029fe0d24"
}
},
"macos-arm64": {
"hash": "09e444fea2d9fd19f3e5cb62e29055228345be163924cbd408d947646fafed1012cf48508ee6a155ede3d571e2ffaa72d09ceeb1493c8a60feb05e0699f19ba3",
"files": {
"terracotta-0.4.2-macos-arm64": "8e59a9d78acd57702dc044d6f2799c6af586b075a262f7d4dbbf0876e1af8d8271e04783c24ff820b801e3b14cd0190ab8403d097f3f2d98b6d911f95ed1e972",
"terracotta-0.4.2-macos-arm64.pkg": "b0b72f8883767c359f0700e958bd859dd5932c4674fbdf2a32f7de189fb9f54822d4825a754dc856416c8760338eb8ffca80b40c0a57c608fbe6ae9928888993"
}
},
"linux-x86_64": {
"hash": "d326ad95815d04568d485b5038e40ffc47ca54292fa0925eee6f5cea014024f901d661708aac2a743037b990882ad82b4d0b7bb03dc3b2fe720dbf0f3efe1c98",
"files": {
"terracotta-0.4.2-linux-x86_64": "fac328ba8957a711b03557bb913940f22d61b76608cd203fdf51024b6f94b19f5bc91c9b8a9fa80baf6968e1e6873c1880fd4cf54a2f8e3c6cf1e6ac161f8d0c"
}
},
"linux-arm64": {
"hash": "57c08f48d9535e93ad547d2dfc852d267992cc164a7208b42a2da0a6cbc2f21862f610e02a746b4b67150f4dec26b86a4f96eb9bd2f58d124d5b40ba50c6d55e",
"files": {
"terracotta-0.4.2-linux-arm64": "d807744c2041c98686e4b505324713badea7a0f31e8810be49ae053a63fb6dfc474ac58d678fb93eea0dd5cccff7372d9ec6135a1046f4b306cad35cd90ecacd"
}
},
"linux-loongarch64": {
"hash": "a19f4ad4fa1fd2f3c1c867f8743178ab0524d1634d63e122ffc080623e4f14207ecab58830fa0d888696ef28ce4cfa189e6ffbe92c6e1aa1836652617a96a801",
"files": {
"terracotta-0.4.2-linux-loongarch64": "7d4896b275207def0861e544cf2afc9501c1946f1bc8bb2bad9ef4510dd915eabb63eeedff725e69565cdf5c27c146b52dd75d24115d5141909c0865f89cd7b9"
}
},
"linux-riscv64": {
"hash": "ef3dfcf3c64dfcaf94adc518f0184d017a32a924f67ef33ffb8128f20669cf177cd345df3be8c00225a275bd44f70657b94fb43c60afeb21238000ac7a7f1721",
"files": {
"terracotta-0.4.2-linux-riscv64": "afddc55f16f5cf3103548d13f7a3238964c8899dba8eb0e1fdc9e354da65e91fa4486de4b8680a7a3d9326eacf18c1ddb6b5b1f9459908f35f77b36f769172ea"
}
},
"freebsd-x86_64": {
"hash": "a4d02ebbb1419f4e9265f5b24060432799151a27e0d1871690d2f3a51f40bcfd6d92c176001a15a62caa9bf31ea1b67a7b2da10e3351bfcdd36bfac1377d08d9",
"files": {
"terracotta-0.4.2-freebsd-x86_64": "a50da02752cef73d21804b26eb02261568ed3053c8fef1074014d50226843810f71e6eda39a53af3bad8e173cec40826631a4c8877af460d14a307f1b0a62519"
}
}
},
"downloads": [
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"downloads_CN": [
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"links": [
{
"desc": {
"default": "GitHub Release",
"zh": "GitHub 发布页",
"zh-Hant": "GitHub 發布頁"
},
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
},
{
"desc": {
"default": "Tencent QQ Group",
"zh": "QQ 群",
"zh-Hant": "QQ 群"
},
"link": "https://qm.qq.com/cgi-bin/qm/qr?k\u003dnIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5\u0026jump_from\u003dwebapi\u0026authKey\u003dsXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
}
]
}
@@ -13,13 +13,12 @@ import static org.junit.jupiter.api.Assertions.*;
/// Tests for detached authlib-injector server lists.
@NotNullByDefault
public final class AuthlibInjectorServerListTest {
/// Tests that newly created server lists contain LittleSkin by default.
/// Tests that newly created server lists start empty.
@Test
public void defaultListContainsLittleSkin() {
public void defaultListIsEmpty() {
AuthlibInjectorServerList list = AuthlibInjectorServerList.createDefault();
assertEquals(1, list.getServers().size());
assertEquals(AuthlibInjectorServerList.LITTLE_SKIN_URL, list.getServers().get(0).getUrl());
assertTrue(list.getServers().isEmpty());
}
/// Tests that server list serialization does not include derived metadata.
@@ -68,9 +67,9 @@ public final class AuthlibInjectorServerListTest {
assertEquals(AuthlibInjectorServerList.CURRENT_SCHEMA, list.getSchema());
}
/// Tests that legacy configs without `addedLittleSkin` receive LittleSkin during migration.
/// Tests that legacy configs without `addedLittleSkin` do not receive LittleSkin.
@Test
public void addsLittleSkinWhenLegacyFlagIsMissing() {
public void doesNotAddLittleSkinWhenLegacyFlagIsMissing() {
JsonObject settings = JsonParser.parseString("""
{
"authlibInjectorServers": [
@@ -83,14 +82,13 @@ public final class AuthlibInjectorServerListTest {
AuthlibInjectorServerList list = LegacyConfigMigrator.extractAuthlibInjectorServers(settings);
assertEquals(2, list.getServers().size());
assertEquals(1, list.getServers().size());
assertEquals("https://example.com/api/yggdrasil/", list.getServers().get(0).getUrl());
assertEquals(AuthlibInjectorServerList.LITTLE_SKIN_URL, list.getServers().get(1).getUrl());
}
/// Tests that legacy configs with `addedLittleSkin=false` do not add a duplicate LittleSkin entry.
/// Tests that an existing LittleSkin entry is preserved during migration.
@Test
public void addsLittleSkinOnlyOnceWhenLegacyFlagIsFalse() {
public void preservesExistingLittleSkinDuringMigration() {
JsonObject settings = JsonParser.parseString("""
{
"authlibInjectorServers": [
@@ -346,18 +346,14 @@ public final class GameDirectoriesTest {
GameDirectoryManager.init();
GameDirectory localGameDirectory = assertSingleDefaultGameDirectory(localDirectories, PortablePath.of(".minecraft"));
GameDirectory userGameDirectory = assertSingleDefaultGameDirectory(
userDirectories,
PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY));
assertTrue(userDirectories.getGameDirectories().isEmpty());
assertEquals(GameDirectoryManager.DEFAULT_GAME_DIRECTORY_ID, localGameDirectory.getId());
assertEquals(GameDirectoryManager.HOME_GAME_DIRECTORY_ID, userGameDirectory.getId());
assertEquals(2, GameDirectoryManager.getGameDirectories().size());
assertEquals(1, GameDirectoryManager.getGameDirectories().size());
assertTrue(GameDirectoryManager.getGameDirectories().contains(localGameDirectory));
assertTrue(GameDirectoryManager.getGameDirectories().contains(userGameDirectory));
}
}
/// Tests that newly created stores use the stable IDs for their built-in game directories.
/// Tests that newly created stores use the stable ID for the fixed workspace game directory.
@Test
public void createsBuiltInGameDirectoriesWithStableIdsForNewStores() throws ReflectiveOperationException {
GameDirectories userDirectories = new GameDirectories();
@@ -371,11 +367,8 @@ public final class GameDirectoriesTest {
GameDirectoryManager.init();
GameDirectory localGameDirectory = assertSingleDefaultGameDirectory(localDirectories, PortablePath.of(".minecraft"));
GameDirectory userGameDirectory = assertSingleDefaultGameDirectory(
userDirectories,
PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY));
assertTrue(userDirectories.getGameDirectories().isEmpty());
assertEquals(GameDirectoryManager.DEFAULT_GAME_DIRECTORY_ID, localGameDirectory.getId());
assertEquals(GameDirectoryManager.HOME_GAME_DIRECTORY_ID, userGameDirectory.getId());
}
}
@@ -40,7 +40,6 @@ public final class LauncherStateTest {
assertFalse(state.shouldSaveImmediately(state.widthProperty()));
assertFalse(state.shouldSaveImmediately(state.heightProperty()));
assertTrue(state.shouldSaveImmediately(state.schemaProperty()));
assertTrue(state.shouldSaveImmediately(state.promptedVersionProperty()));
assertTrue(state.shouldSaveImmediately(state.getShownTips()));
}
@@ -53,7 +52,6 @@ public final class LauncherStateTest {
"y": 0.5,
"width": 1280.0,
"height": 720.0,
"promptedVersion": "3.6.15",
"shownTips": {
"javaVersionTip": 21
},
@@ -68,7 +66,6 @@ public final class LauncherStateTest {
assertFalse(settings.has("y"));
assertFalse(settings.has("width"));
assertFalse(settings.has("height"));
assertFalse(settings.has("promptedVersion"));
assertFalse(settings.has("shownTips"));
assertTrue(settings.has("logLines"));
assertTrue(settings.has("localization"));
@@ -77,7 +74,6 @@ public final class LauncherStateTest {
assertEquals(0.5, state.getY(), 1e-9);
assertEquals(1264.0, state.getWidth());
assertEquals(704.0, state.getHeight());
assertEquals("3.6.15", state.getPromptedVersion());
assertEquals(21.0, state.getShownTips().get("javaVersionTip"));
assertEquals(LauncherState.CURRENT_SCHEMA, state.getSchema());
@@ -29,7 +29,6 @@ public final class UserSettingsTest {
Files.writeString(legacyConfig, """
{
"agreementVersion": 1,
"terracottaAgreementVersion": 2,
"platformPromptVersion": 3,
"logRetention": 7,
"enableOfflineAccount": true,
@@ -51,7 +50,6 @@ public final class UserSettingsTest {
assertEquals(UserSettings.CURRENT_SCHEMA, settings.getSchema());
assertEquals(UserState.CURRENT_SCHEMA, state.getSchema());
assertEquals(1, state.agreementVersionProperty().get());
assertEquals(2, state.terracottaAgreementVersionProperty().get());
assertEquals(3, state.platformPromptVersionProperty().get());
assertEquals(7, settings.logRetentionProperty().get());
assertTrue(settings.enableOfflineAccountProperty().get());
@@ -63,7 +61,6 @@ public final class UserSettingsTest {
assertEquals(UserSettings.CURRENT_SCHEMA.url(),
serializedSettings.get(JsonSchema.PROPERTY_SCHEMA).getAsString());
assertFalse(serializedSettings.has("agreementVersion"));
assertFalse(serializedSettings.has("terracottaAgreementVersion"));
assertFalse(serializedSettings.has("platformPromptVersion"));
assertFalse(serializedSettings.has("selectedAccount"));
assertFalse(serializedSettings.has("accounts"));
-35
View File
@@ -1,35 +0,0 @@
{
"__comment__": "Run upgradeTerracottaConfig task with Gradle to resolve this template.",
"version_latest": "@script_generated",
"packages": "@script_generated",
"downloads": [
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"downloads_CN": [
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz",
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz"
],
"links": [
{
"desc": {
"default": "GitHub Release",
"zh": "GitHub 发布页",
"zh-Hant": "GitHub 發布頁"
},
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
},
{
"desc": {
"default": "Tencent QQ Group",
"zh": "QQ 群",
"zh-Hant": "QQ 群"
},
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
}
]
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more