Compare commits
20
Commits
2026.11.10-beta1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f183b4a5dc | ||
|
|
8eec436e23 | ||
|
|
df319745fc | ||
|
|
d02fb95bd4 | ||
|
|
8d1c01aed6 | ||
|
|
4ee5c0984f | ||
|
|
bcefa87e27 | ||
|
|
26187e6f61 | ||
|
|
eb7fb914cb | ||
|
|
adbdc85735 | ||
|
|
8dd4ef09aa | ||
|
|
5ca7bcaad0 | ||
|
|
040dceb9ed | ||
|
|
c905b49102 | ||
|
|
23ded32db1 | ||
|
|
9e66c9dbc7 | ||
|
|
07fffc4b01 | ||
|
|
141df8c7b8 | ||
|
|
f5d739711d | ||
|
|
a19679fd00 |
No files matched your search
@@ -138,6 +138,17 @@ jobs:
|
|||||||
|
|
||||||
def put(name: str, data: bytes):
|
def put(name: str, data: bytes):
|
||||||
url = upload_root + "/" + urllib.parse.quote(name, safe="")
|
url = upload_root + "/" + urllib.parse.quote(name, safe="")
|
||||||
|
# Gitea refuses to overwrite a generic package file (HTTP 409), so the previous copy of this one
|
||||||
|
# file is deleted right before it is uploaded again (keeps the gap without a file as short as possible).
|
||||||
|
delete = urllib.request.Request(url, method="DELETE")
|
||||||
|
delete.add_header("Authorization", "token " + token)
|
||||||
|
delete.add_header("User-Agent", "tdlm-hmcl-publish")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(delete, timeout=300) as response:
|
||||||
|
response.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code != 404:
|
||||||
|
raise SystemExit("HTTP %s deleting the old %s: %s" % (exc.code, name, exc.read()[:300]))
|
||||||
request = urllib.request.Request(url, data=data, method="PUT")
|
request = urllib.request.Request(url, data=data, method="PUT")
|
||||||
request.add_header("Authorization", "token " + token)
|
request.add_header("Authorization", "token " + token)
|
||||||
request.add_header("Content-Type", "application/octet-stream")
|
request.add_header("Content-Type", "application/octet-stream")
|
||||||
@@ -204,7 +215,7 @@ jobs:
|
|||||||
"- %s/HMCL.jar\n\n"
|
"- %s/HMCL.jar\n\n"
|
||||||
"TDLMPack does not Maven-resolve this launcher; point the pack README / install docs at these URLs "
|
"TDLMPack does not Maven-resolve this launcher; point the pack README / install docs at these URLs "
|
||||||
"instead of hand-copying exe/jar.\n"
|
"instead of hand-copying exe/jar.\n"
|
||||||
"Client pack fileApi default: https://packages.dandanton.com/tdlm-client/latest\n"
|
"Client pack fileApi default: http://159.75.5.225:19527/tdlm-client\n"
|
||||||
"(Pack must publish/mirror the same layout; see docs/TDLM_CONSUME.md).\n"
|
"(Pack must publish/mirror the same layout; see docs/TDLM_CONSUME.md).\n"
|
||||||
) % (package_root, package_root)
|
) % (package_root, package_root)
|
||||||
|
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ public final class Metadata {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default client Server Modpack fileApi (CDN mirror of Pack's `tdlm-client/latest` layout).
|
/// Default client Server Modpack fileApi: self-hosted static mirror on the TDLM VPS (nginx, port 19527).
|
||||||
public static final String DEFAULT_CLIENT_FILE_API =
|
public static final String DEFAULT_CLIENT_FILE_API =
|
||||||
"https://packages.dandanton.com/tdlm-client/latest";
|
"http://159.75.5.225:19527/tdlm-client";
|
||||||
|
|
||||||
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
|
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package org.jackhuang.hmcl.download;
|
package org.jackhuang.hmcl.download;
|
||||||
|
|
||||||
import org.glavo.url.WebURL;
|
import org.glavo.url.WebURL;
|
||||||
|
import org.jackhuang.hmcl.Metadata;
|
||||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||||
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||||
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
|
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
|
||||||
@@ -132,6 +133,13 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
|||||||
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
|
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
|
||||||
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
|
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
|
||||||
this.rules = List.of(
|
this.rules = List.of(
|
||||||
|
// The self-hosted client mirror carries the pack's full dependency set. BMCLAPI's
|
||||||
|
// redirect CDNs (sakura.sld.tw, cernet) and the Mojang/Forge origins are TLS-blocked
|
||||||
|
// on many mainland networks, so the VPS must be tried before any of them.
|
||||||
|
new SimpleRule("https://piston-meta.mojang.com", Metadata.getClientFileApi() + "/piston-meta", "https://bmclapi2.bangbang93.com"),
|
||||||
|
new SimpleRule("https://libraries.minecraft.net", Metadata.getClientFileApi() + "/libraries", "https://bmclapi2.bangbang93.com/libraries"),
|
||||||
|
new SimpleRule("https://maven.minecraftforge.net", Metadata.getClientFileApi() + "/forge-maven", "https://bmclapi2.bangbang93.com/maven"),
|
||||||
|
new SimpleRule("https://launcher.mojang.com", Metadata.getClientFileApi() + "/launcher-mojang", "https://bmclapi2.bangbang93.com"),
|
||||||
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
|
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
|
||||||
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
|
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
|
||||||
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
|
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
|
||||||
@@ -183,6 +191,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
|||||||
new SimpleRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
|
new SimpleRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
|
||||||
new SimpleRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
|
new SimpleRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
|
||||||
|
|
||||||
|
// files.prismlauncher.org is unreachable from mainland China (TLS handshake reset) and BMCLAPI
|
||||||
|
// does not mirror its repackaged log4j-fixed artifacts. Serve them from the self-hosted client
|
||||||
|
// mirror (alongside the fileApi root); Prism stays as the fallback for reachable networks.
|
||||||
|
new SimpleRule("https://files.prismlauncher.org/maven", Metadata.getClientFileApi() + "/maven"),
|
||||||
|
|
||||||
// https://github.com/mcmod-info-mirror/mcim-rust-api
|
// https://github.com/mcmod-info-mirror/mcim-rust-api
|
||||||
new SimpleRule("https://api.modrinth.com", null, "https://mod.mcimirror.top/modrinth"),
|
new SimpleRule("https://api.modrinth.com", null, "https://mod.mcimirror.top/modrinth"),
|
||||||
new SimpleRule("https://cdn.modrinth.com", null, "https://mod.mcimirror.top"),
|
new SimpleRule("https://cdn.modrinth.com", null, "https://mod.mcimirror.top"),
|
||||||
@@ -295,11 +308,18 @@ public final class HMCLDownloadProvider extends DownloadProvider {
|
|||||||
@Override
|
@Override
|
||||||
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
|
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
|
||||||
String location = assetObject.getLocation();
|
String location = assetObject.getLocation();
|
||||||
return getCandidates(
|
WebURL officialUrl = WebURL.parse("https://resources.download.minecraft.net/" + location);
|
||||||
fileSource,
|
List<WebURL> mirrors = mirrorUrls("/assets/" + location);
|
||||||
WebURL.parse("https://resources.download.minecraft.net/" + location),
|
if (preferMirror(fileSource)) {
|
||||||
mirrorUrls("/assets/" + location)
|
// Keep the VPS strictly first: spreadPrimary would otherwise rotate BMCLAPI ahead of
|
||||||
);
|
// it, and BMCLAPI's asset CDN is unreachable on blocked networks (per-file timeouts).
|
||||||
|
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 2);
|
||||||
|
candidates.add(DownloadCandidate.of(WebURL.parse(Metadata.getClientFileApi() + "/assets/" + location)));
|
||||||
|
candidates.addAll(spreadPrimary(mirrors.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
|
||||||
|
candidates.add(DownloadCandidate.of(officialUrl));
|
||||||
|
return DownloadCandidates.of(List.copyOf(candidates));
|
||||||
|
}
|
||||||
|
return getCandidates(fileSource, officialUrl, mirrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNullByDefault
|
@NotNullByDefault
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import org.jackhuang.hmcl.setting.JavaVersionType;
|
|||||||
import org.jackhuang.hmcl.setting.LauncherVisibility;
|
import org.jackhuang.hmcl.setting.LauncherVisibility;
|
||||||
import org.jackhuang.hmcl.task.*;
|
import org.jackhuang.hmcl.task.*;
|
||||||
import org.jackhuang.hmcl.ui.*;
|
import org.jackhuang.hmcl.ui.*;
|
||||||
|
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||||
@@ -255,32 +256,12 @@ public final class LauncherHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
||||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
|
// Register both java.exe and javaw.exe: the game may be started through either one.
|
||||||
try {
|
applyHighPerformanceGpuPreference(context.javaExecutable);
|
||||||
WinReg reg = WinReg.INSTANCE;
|
Path sibling = context.javaExecutable.resolveSibling(
|
||||||
if (reg != null) {
|
FileUtils.getName(context.javaExecutable).equalsIgnoreCase("javaw.exe") ? "java.exe" : "javaw.exe");
|
||||||
Object current = reg.queryValue(
|
if (Files.isRegularFile(sibling)) {
|
||||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
applyHighPerformanceGpuPreference(sibling);
|
||||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
|
||||||
javaPathGpuReg
|
|
||||||
);
|
|
||||||
if (current == null) {
|
|
||||||
boolean result = reg.setValue(
|
|
||||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
|
||||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
|
||||||
javaPathGpuReg,
|
|
||||||
"GpuPreference=2;"
|
|
||||||
);
|
|
||||||
if (result)
|
|
||||||
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
|
|
||||||
else
|
|
||||||
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
|
|
||||||
} else {
|
|
||||||
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOG.warning("Failed to apply high performance GPU preference", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,11 +1029,44 @@ public final class LauncherHelper {
|
|||||||
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
|
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exitType == ExitType.NORMAL) {
|
||||||
|
// The game asked to be restarted (hot update): offer to update and relaunch.
|
||||||
|
String restartReason = RestartRequest.consume(gameInstance.getRunDirectory());
|
||||||
|
if (restartReason != null) {
|
||||||
|
LOG.info("Game requested a restart, reason: " + restartReason);
|
||||||
|
runLater(() -> Instances.offerRestart(gameInstance, restartReason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
checkExit();
|
checkExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks Windows to run `executable` on the high-performance GPU (`GpuPreference=2`) unless the user
|
||||||
|
/// already has a preference stored for it.
|
||||||
|
private static void applyHighPerformanceGpuPreference(Path executable) {
|
||||||
|
String path = FileUtils.getAbsolutePath(executable);
|
||||||
|
try {
|
||||||
|
WinReg reg = WinReg.INSTANCE;
|
||||||
|
if (reg == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object current = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER,
|
||||||
|
"Software\\Microsoft\\DirectX\\UserGpuPreferences", path);
|
||||||
|
if (current != null) {
|
||||||
|
LOG.info("GPU preference for %s already exists: %s".formatted(path, current));
|
||||||
|
} else if (reg.setValue(WinReg.HKEY.HKEY_CURRENT_USER,
|
||||||
|
"Software\\Microsoft\\DirectX\\UserGpuPreferences", path, "GpuPreference=2;")) {
|
||||||
|
LOG.info("Successfully applied high performance GPU preference for java: " + path);
|
||||||
|
} else {
|
||||||
|
LOG.warning("Failed to apply high performance GPU preference for java: " + path);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to apply high performance GPU preference", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||||
|
|
||||||
public static int countMangedProcesses() {
|
public static int countMangedProcesses() {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/// The "please restart me" handshake between the game and the launcher.
|
||||||
|
///
|
||||||
|
/// When the game wants to be restarted (for example after a hot update), it writes
|
||||||
|
/// [#FILE_NAME] into its run directory as `{"reason":"update"}` and exits normally. After the
|
||||||
|
/// process ends the launcher calls [#consume(Path)], deletes the file and offers to update and relaunch.
|
||||||
|
@NotNullByDefault
|
||||||
|
public final class RestartRequest {
|
||||||
|
private RestartRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marker file name inside the game's run directory.
|
||||||
|
public static final String FILE_NAME = "tdlm-restart-request.json";
|
||||||
|
|
||||||
|
/// Reads and deletes the restart request in `runDirectory`.
|
||||||
|
///
|
||||||
|
/// A malformed file still counts as a request with reason `"unknown"`, because the file's presence is
|
||||||
|
/// the signal. The file is always removed so a request cannot trigger twice.
|
||||||
|
///
|
||||||
|
/// @param runDirectory the game's run directory
|
||||||
|
/// @return the reason, or `null` if no request was pending
|
||||||
|
public static @Nullable String consume(Path runDirectory) {
|
||||||
|
Path file = runDirectory.resolve(FILE_NAME);
|
||||||
|
if (!Files.isRegularFile(file)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String reason = "unknown";
|
||||||
|
try {
|
||||||
|
JsonObject object = JsonUtils.GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), JsonObject.class);
|
||||||
|
if (object != null && object.has("reason") && object.get("reason").isJsonPrimitive()) {
|
||||||
|
reason = object.get("reason").getAsString();
|
||||||
|
}
|
||||||
|
} catch (IOException | RuntimeException ignored) {
|
||||||
|
// The presence of the file is the signal; keep the default reason.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// Nothing sensible to do; the worst case is one more prompt on the next exit.
|
||||||
|
}
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -645,9 +645,12 @@ public sealed abstract class GameSettings extends ObservableSetting {
|
|||||||
public static final String PROPERTY_HIGH_PERFORMANCE = "highPerformance";
|
public static final String PROPERTY_HIGH_PERFORMANCE = "highPerformance";
|
||||||
|
|
||||||
/// Whether to launch games with high-performance GPU.
|
/// Whether to launch games with high-performance GPU.
|
||||||
|
///
|
||||||
|
/// On by default: on a laptop with an integrated and a discrete GPU, Windows otherwise picks the
|
||||||
|
/// integrated one for java. Machines with a single GPU are unaffected by the preference.
|
||||||
@SerializedName(PROPERTY_HIGH_PERFORMANCE)
|
@SerializedName(PROPERTY_HIGH_PERFORMANCE)
|
||||||
private final InheritableProperty<Boolean> highPerformance =
|
private final InheritableProperty<Boolean> highPerformance =
|
||||||
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, false);
|
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, true);
|
||||||
|
|
||||||
/// Returns the high performance GPU preference property.
|
/// Returns the high performance GPU preference property.
|
||||||
public InheritableProperty<Boolean> highPerformanceProperty() {
|
public InheritableProperty<Boolean> highPerformanceProperty() {
|
||||||
|
|||||||
@@ -27,16 +27,11 @@ import org.jackhuang.hmcl.game.LauncherHelper;
|
|||||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||||
import org.jackhuang.hmcl.java.JavaManager;
|
import org.jackhuang.hmcl.java.JavaManager;
|
||||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||||
import org.glavo.url.WebURL;
|
|
||||||
import org.jackhuang.hmcl.modpack.Modpack;
|
import org.jackhuang.hmcl.modpack.Modpack;
|
||||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
|
||||||
import org.jackhuang.hmcl.setting.*;
|
import org.jackhuang.hmcl.setting.*;
|
||||||
import org.jackhuang.hmcl.task.GetTask;
|
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.task.Task;
|
import org.jackhuang.hmcl.task.Task;
|
||||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||||
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
|
||||||
import org.jackhuang.hmcl.ui.account.AccountListPage;
|
import org.jackhuang.hmcl.ui.account.AccountListPage;
|
||||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||||
import org.jackhuang.hmcl.ui.construct.*;
|
import org.jackhuang.hmcl.ui.construct.*;
|
||||||
@@ -359,8 +354,8 @@ public final class Controllers {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs a launcher-bundled modpack when present; otherwise, when the repository has no
|
/// Installs a launcher-bundled modpack when present. First-run remote install is deferred to the
|
||||||
/// instances yet, pulls the org client [Metadata#getClientManifestUrl] and opens the install wizard.
|
/// home Start button ([Instances#ensureClientAndJoin]) so players never enter the install wizard.
|
||||||
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
|
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
|
||||||
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
|
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
|
||||||
if (modpackFile != null) {
|
if (modpackFile != null) {
|
||||||
@@ -384,35 +379,7 @@ public final class Controllers {
|
|||||||
}
|
}
|
||||||
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
|
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repository.getDisplayInstances().findAny().isPresent()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String manifestUrl = Metadata.getClientManifestUrl();
|
|
||||||
LOG.info("No instances yet; fetching client Server Modpack from " + manifestUrl);
|
|
||||||
Controllers.taskDialog(
|
|
||||||
new GetTask(WebURL.parse(manifestUrl))
|
|
||||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
|
||||||
if (exception != null) {
|
|
||||||
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ServerModpackManifest manifest =
|
|
||||||
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
|
|
||||||
if (manifest == null) {
|
|
||||||
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Controllers.getDecorator().startWizard(
|
|
||||||
new ModpackInstallWizardProvider(repository, manifest),
|
|
||||||
i18n("install.modpack"));
|
|
||||||
}),
|
|
||||||
i18n("message.downloading"),
|
|
||||||
TaskCancellationAction.NO_CANCEL
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the content in the main-window dialog stack without waiting for it to close.
|
/// Shows the content in the main-window dialog stack without waiting for it to close.
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.ui.instances;
|
||||||
|
|
||||||
|
import javafx.beans.property.*;
|
||||||
|
import org.jackhuang.hmcl.task.FetchTask;
|
||||||
|
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||||
|
import org.jackhuang.hmcl.task.TaskListener;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jackhuang.hmcl.util.TaskManager;
|
||||||
|
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/// Observable progress of the home button's "install / update the client, then join" workflow.
|
||||||
|
///
|
||||||
|
/// The model is only a view of the task executor started by [Instances#ensureClientAndJoin(ClientSyncProgress)]:
|
||||||
|
/// it never changes what is downloaded. All properties must be read and written on the JavaFX thread.
|
||||||
|
/// Overall percentage is estimated from finished file downloads against the total announced by the sync task, and stays indeterminate until that total is known.
|
||||||
|
@NotNullByDefault
|
||||||
|
public final class ClientSyncProgress {
|
||||||
|
|
||||||
|
/// Lifecycle of the workflow.
|
||||||
|
public enum State {
|
||||||
|
/// Nothing is running; the home card shows its normal facts and button.
|
||||||
|
IDLE,
|
||||||
|
/// A task is running; the home card shows the progress state.
|
||||||
|
RUNNING,
|
||||||
|
/// The last run failed; the home card shows [#errorProperty()] and a retry button.
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index of the step that checks the remote manifest.
|
||||||
|
public static final int STEP_CHECK = 0;
|
||||||
|
/// Index of the step that prepares Java.
|
||||||
|
public static final int STEP_JAVA = 1;
|
||||||
|
/// Index of the step that synchronises the modpack.
|
||||||
|
public static final int STEP_SYNC = 2;
|
||||||
|
/// Index of the final step that joins the server.
|
||||||
|
public static final int STEP_JOIN = 3;
|
||||||
|
|
||||||
|
private final ObjectProperty<State> state = new SimpleObjectProperty<>(this, "state", State.IDLE);
|
||||||
|
/// The first step that is not yet finished; equals 4 once every step is done.
|
||||||
|
private final IntegerProperty step = new SimpleIntegerProperty(this, "step", STEP_CHECK);
|
||||||
|
/// Fraction in `[0, 1]`, or `-1` while unknown.
|
||||||
|
private final DoubleProperty progress = new SimpleDoubleProperty(this, "progress", -1);
|
||||||
|
private final StringProperty detail = new SimpleStringProperty(this, "detail", "");
|
||||||
|
private final StringProperty error = new SimpleStringProperty(this, "error", "");
|
||||||
|
|
||||||
|
private @Nullable TaskExecutor executor;
|
||||||
|
private long downloadedBytes;
|
||||||
|
/// Total number of downloads announced by tasks so far.
|
||||||
|
private int expectedFiles;
|
||||||
|
/// Number of finished file downloads.
|
||||||
|
private int finishedFiles;
|
||||||
|
/// Tasks whose `total` has already been added to [#expectedFiles].
|
||||||
|
private final java.util.Set<Task<?>> announcedTasks = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||||
|
private boolean cancelled;
|
||||||
|
|
||||||
|
/// Keeps the weakly registered speed listener alive.
|
||||||
|
@SuppressWarnings({"unused", "FieldCanBeLocal"})
|
||||||
|
private final Consumer<FetchTask.SpeedEvent> speedHandler;
|
||||||
|
|
||||||
|
/// Creates an idle progress model.
|
||||||
|
public ClientSyncProgress() {
|
||||||
|
speedHandler = FetchTask.SPEED_EVENT.registerWeak(event -> FXUtils.runInFX(() -> {
|
||||||
|
if (state.get() != State.RUNNING || event.getSpeed() <= 0)
|
||||||
|
return;
|
||||||
|
downloadedBytes += event.getSpeed();
|
||||||
|
detail.set(String.format("%.0f MB", downloadedBytes / 1048576.0) + " · " + I18n.formatSpeed(event.getSpeed()));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The workflow state.
|
||||||
|
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first unfinished step index (0–3), or 4 when everything is done.
|
||||||
|
public ReadOnlyIntegerProperty stepProperty() {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overall fraction in `[0, 1]`, or `-1` when it cannot be estimated yet.
|
||||||
|
public ReadOnlyDoubleProperty progressProperty() {
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer details such as downloaded size and speed; empty when unknown.
|
||||||
|
public ReadOnlyStringProperty detailProperty() {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The failure message of the last run; empty unless the state is [State#FAILED].
|
||||||
|
public ReadOnlyStringProperty errorProperty() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a new run: resets counters and shows the progress state at the first step.
|
||||||
|
void begin() {
|
||||||
|
executor = null;
|
||||||
|
cancelled = false;
|
||||||
|
downloadedBytes = 0;
|
||||||
|
expectedFiles = 0;
|
||||||
|
finishedFiles = 0;
|
||||||
|
announcedTasks.clear();
|
||||||
|
progress.set(-1);
|
||||||
|
detail.set("");
|
||||||
|
error.set("");
|
||||||
|
step.set(STEP_CHECK);
|
||||||
|
state.set(State.RUNNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the indicator forward to `newStep`; it never moves backwards.
|
||||||
|
void advanceTo(int newStep) {
|
||||||
|
if (newStep > step.get())
|
||||||
|
step.set(newStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns to the idle state, for example after the game was launched or the run was cancelled.
|
||||||
|
void finish() {
|
||||||
|
executor = null;
|
||||||
|
state.set(State.IDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows `message` in the card together with the retry button.
|
||||||
|
void fail(String message) {
|
||||||
|
executor = null;
|
||||||
|
error.set(message);
|
||||||
|
state.set(State.FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses a failure and returns to the idle state.
|
||||||
|
public void dismiss() {
|
||||||
|
if (state.get() == State.FAILED)
|
||||||
|
state.set(State.IDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the running task, if any, and returns to the idle state.
|
||||||
|
public void cancel() {
|
||||||
|
TaskExecutor current = executor;
|
||||||
|
cancelled = true;
|
||||||
|
if (current != null)
|
||||||
|
current.cancel();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the last run was cancelled by the user.
|
||||||
|
boolean isCancelled() {
|
||||||
|
return cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes the fraction from finished downloads; stays indeterminate until a total is known.
|
||||||
|
private void refreshProgress(TaskExecutor current) {
|
||||||
|
if (executor != current || expectedFiles <= 0)
|
||||||
|
return;
|
||||||
|
double value = Math.min(0.99, (double) finishedFiles / expectedFiles);
|
||||||
|
if (value > progress.get())
|
||||||
|
progress.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submits `task` to the serial task queue and mirrors its task count into the progress.
|
||||||
|
///
|
||||||
|
/// @param task the task to run
|
||||||
|
void submit(Task<?> task) {
|
||||||
|
TaskExecutor taskExecutor = task.executor();
|
||||||
|
executor = taskExecutor;
|
||||||
|
taskExecutor.addTaskListener(new TaskListener() {
|
||||||
|
/// Tasks announce how many downloads they will start through the `total` property
|
||||||
|
/// (set once the file list is known), so the denominator never grows with discovered tasks.
|
||||||
|
@Override
|
||||||
|
public void onPropertiesUpdate(Task<?> updated) {
|
||||||
|
Object total = updated.getProperties().get("total");
|
||||||
|
if (total instanceof Integer count && count > 0) {
|
||||||
|
FXUtils.runInFX(() -> {
|
||||||
|
if (announcedTasks.add(updated))
|
||||||
|
expectedFiles += count;
|
||||||
|
refreshProgress(taskExecutor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFinished(Task<?> finishedTask) {
|
||||||
|
if (finishedTask instanceof FileDownloadTask) {
|
||||||
|
FXUtils.runInFX(() -> {
|
||||||
|
finishedFiles++;
|
||||||
|
refreshProgress(taskExecutor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
TaskManager.getInstance().submit(taskExecutor, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import org.jackhuang.hmcl.download.game.GameLibrariesTask;
|
|||||||
import org.jackhuang.hmcl.game.*;
|
import org.jackhuang.hmcl.game.*;
|
||||||
import org.jackhuang.hmcl.setting.*;
|
import org.jackhuang.hmcl.setting.*;
|
||||||
import org.glavo.url.WebURL;
|
import org.glavo.url.WebURL;
|
||||||
|
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||||
import org.jackhuang.hmcl.task.GetTask;
|
import org.jackhuang.hmcl.task.GetTask;
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
@@ -25,21 +26,26 @@ import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
|||||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||||
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
||||||
import org.jackhuang.hmcl.ui.construct.Validator;
|
import org.jackhuang.hmcl.ui.construct.Validator;
|
||||||
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
import org.jackhuang.hmcl.ui.download.LocalModpackPage;
|
||||||
import org.jackhuang.hmcl.util.StringUtils;
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||||
|
import org.jackhuang.hmcl.modpack.Modpack;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
@@ -48,63 +54,244 @@ public final class Instances {
|
|||||||
private Instances() {
|
private Instances() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs or updates the TDLM client.
|
/// Returns whether the installed server modpack is behind the remote `server-manifest.json`.
|
||||||
///
|
///
|
||||||
/// Prefers a launcher-bundled zip, then the org [Metadata#getClientManifestUrl] Server Modpack,
|
/// Compares the local embedded manifest version (and file path/hash set) with
|
||||||
/// then the generic local/remote import wizard.
|
/// `fileApi/server-manifest.json`. Network or parse failures return `false` so the home button
|
||||||
public static void installServerModpack() {
|
/// stays on "join".
|
||||||
|
public static boolean isClientUpdateAvailable(@Nullable HMCLGameInstance instance) {
|
||||||
|
if (instance == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path configurationFile = instance.getModpackConfigurationFile();
|
||||||
|
if (Files.notExists(configurationFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ModpackConfiguration<ServerModpackManifest> configuration = JsonUtils.fromJsonFile(
|
||||||
|
configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
|
||||||
|
ServerModpackManifest local = configuration.getManifest();
|
||||||
|
if (local == null || StringUtils.isBlank(local.getFileApi())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String manifestUrl = StringUtils.removeSuffix(local.getFileApi().trim(), "/") + "/server-manifest.json";
|
||||||
|
ServerModpackManifest remote = JsonUtils.fromNonNullJson(
|
||||||
|
NetworkUtils.doGet(manifestUrl), ServerModpackManifest.class);
|
||||||
|
if (!Objects.equals(local.getVersion(), remote.getVersion())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !fileHashes(local).equals(fileHashes(remote));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to check TDLM client update", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> fileHashes(ServerModpackManifest manifest) {
|
||||||
|
return manifest.getFiles().stream().collect(Collectors.toMap(
|
||||||
|
ModpackConfiguration.FileInformation::getPath,
|
||||||
|
ModpackConfiguration.FileInformation::getHash,
|
||||||
|
(left, right) -> right));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs or updates the TDLM client from fileApi / bundled zip without opening the install UI,
|
||||||
|
/// then joins the configured server when ready.
|
||||||
|
///
|
||||||
|
/// Prefer this for the home Start / Join button. Existing server-modpack instances get differential
|
||||||
|
/// updates during launch via [org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask].
|
||||||
|
public static void ensureClientAndJoin() {
|
||||||
|
ensureClientAndJoin(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [#ensureClientAndJoin()], but reports task progress to `progress` instead of opening a
|
||||||
|
/// task dialog, so the home card can show it in place. Errors are shown through `progress` too.
|
||||||
|
///
|
||||||
|
/// @param progress the progress model to drive, or `null` to use the task dialog
|
||||||
|
public static void ensureClientAndJoin(@Nullable ClientSyncProgress progress) {
|
||||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||||
if (!repository.isLoaded()) {
|
if (!repository.isLoaded()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Path bundled = Metadata.findBundledModpackFile();
|
// Already installed: join immediately. Launch runs ServerModpackCompletionTask for updates.
|
||||||
if (bundled != null) {
|
if (repository.getSelectedInstance() != null && Metadata.findBundledModpackFile() == null) {
|
||||||
Controllers.getDecorator().startWizard(
|
joinServer(repository.getSelectedInstance());
|
||||||
new ModpackInstallWizardProvider(repository, bundled),
|
|
||||||
i18n("install.modpack"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Controllers.taskDialog(
|
Path bundled = Metadata.findBundledModpackFile();
|
||||||
new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
|
if (bundled != null) {
|
||||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
installBundledThenJoin(repository, bundled, progress);
|
||||||
if (exception != null) {
|
return;
|
||||||
Controllers.dialog(
|
}
|
||||||
exception.getMessage(),
|
|
||||||
i18n("message.error"),
|
if (progress != null) {
|
||||||
MessageDialogPane.MessageType.ERROR);
|
progress.begin();
|
||||||
importModpack();
|
}
|
||||||
return;
|
Task<?> task = new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
|
||||||
}
|
.thenComposeAsync(Schedulers.javafx(), result -> {
|
||||||
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
|
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
|
||||||
result, ServerModpackManifest.class);
|
result, ServerModpackManifest.class);
|
||||||
if (manifest == null) {
|
if (manifest == null) {
|
||||||
Controllers.dialog(
|
throw new IOException(i18n("modpack.type.server.malformed"));
|
||||||
i18n("modpack.type.server.malformed"),
|
}
|
||||||
i18n("message.error"),
|
if (progress != null) {
|
||||||
MessageDialogPane.MessageType.ERROR);
|
// Java is prepared by the launch step; mark it as passed once the manifest is known.
|
||||||
importModpack();
|
progress.advanceTo(ClientSyncProgress.STEP_SYNC);
|
||||||
return;
|
}
|
||||||
}
|
return ensureRemoteClientInstalled(repository, manifest);
|
||||||
Controllers.getDecorator().startWizard(
|
})
|
||||||
new ModpackInstallWizardProvider(repository, manifest),
|
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
|
||||||
i18n("install.modpack"));
|
if (exception != null) {
|
||||||
}),
|
LOG.warning("Failed to install or update TDLM client", exception);
|
||||||
i18n("message.downloading"),
|
reportFailure(progress, exception);
|
||||||
TaskCancellationAction.NORMAL);
|
return;
|
||||||
|
}
|
||||||
|
if (progress != null) {
|
||||||
|
progress.advanceTo(ClientSyncProgress.STEP_JOIN);
|
||||||
|
}
|
||||||
|
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
|
||||||
|
if (progress != null) {
|
||||||
|
progress.advanceTo(ClientSyncProgress.STEP_JOIN + 1);
|
||||||
|
progress.finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
runSyncTask(task, progress, TaskCancellationAction.NORMAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void importModpack() {
|
/// Runs `task` in the progress card when `progress` is set, otherwise in the task dialog.
|
||||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
private static void runSyncTask(Task<?> task, @Nullable ClientSyncProgress progress, TaskCancellationAction cancellation) {
|
||||||
if (repository.isLoaded()) {
|
if (progress != null) {
|
||||||
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository), i18n("install.modpack"));
|
progress.submit(task);
|
||||||
|
} else {
|
||||||
|
Controllers.taskDialog(task, i18n("modpack.installing"), cancellation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @deprecated use [#installServerModpack()]
|
/// Shows a failure in the progress card, or in an error dialog when there is no card.
|
||||||
|
/// A user-requested cancellation is not reported as an error.
|
||||||
|
private static void reportFailure(@Nullable ClientSyncProgress progress, Throwable exception) {
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isCancelled()) {
|
||||||
|
progress.finish();
|
||||||
|
} else {
|
||||||
|
progress.fail(StringUtils.getStackTrace(exception));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Controllers.dialog(
|
||||||
|
StringUtils.getStackTrace(exception),
|
||||||
|
i18n("install.failed"),
|
||||||
|
MessageDialogPane.MessageType.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silent first-install / update from the org client manifest — no wizard UI.
|
||||||
|
public static void installServerModpack() {
|
||||||
|
ensureClientAndJoin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install wizard is disabled for the dedicated server-client launcher.
|
||||||
|
public static void importModpack() {
|
||||||
|
LOG.info("Ignoring importModpack: install UI is disabled; use Start to install/update");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @deprecated use [#ensureClientAndJoin()]
|
||||||
public static void addNewGame() {
|
public static void addNewGame() {
|
||||||
installServerModpack();
|
ensureClientAndJoin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a local modpack zip without the wizard, then selects the new instance.
|
||||||
|
public static void installLocalModpackSilently(HMCLGameRepository repository, Path file) {
|
||||||
|
if (!repository.isLoaded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Controllers.taskDialog(
|
||||||
|
Task.composeAsync(Schedulers.io(), () -> {
|
||||||
|
Modpack modpack = ModpackHelper.readModpackManifest(file);
|
||||||
|
String name = LocalModpackPage.processModpackName(modpack.getName());
|
||||||
|
if (StringUtils.isBlank(name)) {
|
||||||
|
name = "TDLM";
|
||||||
|
}
|
||||||
|
GameInstanceID instanceId = new GameInstanceID(name);
|
||||||
|
if (repository.hasInstance(instanceId)) {
|
||||||
|
instanceId = new GameInstanceID(name + "-" + System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
GameInstanceID id = instanceId;
|
||||||
|
return ModpackHelper.getInstallTask(repository, file, id, modpack, null)
|
||||||
|
.thenRunAsync(Schedulers.javafx(), () ->
|
||||||
|
repository.setSelectedInstance(repository.getInstance(id)));
|
||||||
|
})
|
||||||
|
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
|
||||||
|
if (exception != null) {
|
||||||
|
LOG.warning("Failed to install local modpack", exception);
|
||||||
|
Controllers.dialog(
|
||||||
|
StringUtils.getStackTrace(exception),
|
||||||
|
i18n("install.failed"),
|
||||||
|
MessageDialogPane.MessageType.ERROR);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
i18n("modpack.installing"),
|
||||||
|
TaskCancellationAction.NORMAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void installBundledThenJoin(HMCLGameRepository repository, Path bundled, @Nullable ClientSyncProgress progress) {
|
||||||
|
if (progress != null) {
|
||||||
|
progress.begin();
|
||||||
|
progress.advanceTo(ClientSyncProgress.STEP_SYNC);
|
||||||
|
}
|
||||||
|
Task<?> task = Task.composeAsync(Schedulers.io(), () -> {
|
||||||
|
Modpack modpack = ModpackHelper.readModpackManifest(bundled);
|
||||||
|
String name = LocalModpackPage.processModpackName(modpack.getName());
|
||||||
|
if (StringUtils.isBlank(name)) {
|
||||||
|
name = "TDLM";
|
||||||
|
}
|
||||||
|
GameInstanceID instanceId = new GameInstanceID(name);
|
||||||
|
return ModpackHelper.getInstallTask(repository, bundled, instanceId, modpack, null)
|
||||||
|
.thenRunAsync(Schedulers.javafx(), () -> {
|
||||||
|
repository.setSelectedInstance(repository.getInstance(instanceId));
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(bundled);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Failed to delete bundled modpack: " + bundled, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
|
||||||
|
if (exception != null) {
|
||||||
|
LOG.warning("Failed to install bundled modpack", exception);
|
||||||
|
reportFailure(progress, exception);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
|
||||||
|
if (progress != null) {
|
||||||
|
progress.advanceTo(ClientSyncProgress.STEP_JOIN + 1);
|
||||||
|
progress.finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
runSyncTask(task, progress, TaskCancellationAction.NO_CANCEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs when missing; selects an existing instance otherwise.
|
||||||
|
///
|
||||||
|
/// Differential updates for an installed server modpack run during launch via
|
||||||
|
/// [org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask].
|
||||||
|
private static Task<?> ensureRemoteClientInstalled(
|
||||||
|
HMCLGameRepository repository, ServerModpackManifest manifest) throws IOException {
|
||||||
|
Modpack modpack = manifest.toModpack();
|
||||||
|
String name = LocalModpackPage.processModpackName(manifest.getName());
|
||||||
|
if (StringUtils.isBlank(name)) {
|
||||||
|
name = "TDLM";
|
||||||
|
}
|
||||||
|
GameInstanceID instanceId = new GameInstanceID(name);
|
||||||
|
|
||||||
|
if (repository.hasInstance(instanceId)) {
|
||||||
|
repository.setSelectedInstance(repository.getInstance(instanceId));
|
||||||
|
return Task.completed(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ModpackHelper.getInstallTask(repository, manifest, instanceId, modpack)
|
||||||
|
.thenRunAsync(Schedulers.javafx(), () ->
|
||||||
|
repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void deleteInstance(HMCLGameInstance gameInstance) {
|
public static void deleteInstance(HMCLGameInstance gameInstance) {
|
||||||
@@ -257,8 +444,25 @@ public final class Instances {
|
|||||||
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
|
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks whether to update and relaunch after the game requested a restart (see [RestartRequest]).
|
||||||
|
///
|
||||||
|
/// Yes runs the Start flow, which syncs the client pack and joins the server again; No leaves the
|
||||||
|
/// launcher as it is. The request file is already deleted, so the prompt shows only once.
|
||||||
|
///
|
||||||
|
/// @param gameInstance the instance that just exited
|
||||||
|
/// @param reason the reason from the request file, for example `update`
|
||||||
|
public static void offerRestart(HMCLGameInstance gameInstance, String reason) {
|
||||||
|
Controllers.confirm(
|
||||||
|
i18n("home.restart." + ("update".equals(reason) ? "update" : "generic")),
|
||||||
|
i18n("home.restart.title"),
|
||||||
|
() -> updateInstance(gameInstance),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates via fileApi through the Start flow — no install wizard.
|
||||||
public static void updateInstance(HMCLGameInstance gameInstance) {
|
public static void updateInstance(HMCLGameInstance gameInstance) {
|
||||||
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(gameInstance.getRepository(), gameInstance.getId()));
|
gameInstance.getRepository().setSelectedInstance(gameInstance);
|
||||||
|
ensureClientAndJoin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queues an asset check with a fresh asset index for the supplied instance.
|
/// Queues an asset check with a fresh asset index for the supplied instance.
|
||||||
@@ -338,14 +542,7 @@ public final class Instances {
|
|||||||
@SafeVarargs
|
@SafeVarargs
|
||||||
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
|
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
|
||||||
if (gameInstance == null) {
|
if (gameInstance == null) {
|
||||||
JFXButton installModpack = new JFXButton(i18n("install.modpack"));
|
ensureClientAndJoin();
|
||||||
installModpack.getStyleClass().add("dialog-accept");
|
|
||||||
installModpack.setOnAction(e -> installServerModpack());
|
|
||||||
|
|
||||||
Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"),
|
|
||||||
MessageDialogPane.MessageType.ERROR,
|
|
||||||
installModpack,
|
|
||||||
null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,11 +609,10 @@ public final class Instances {
|
|||||||
Controllers.navigate(Controllers.getSettingsPage());
|
Controllers.navigate(Controllers.getSettingsPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Instance management UI is disabled for the dedicated server-client launcher.
|
||||||
public static void modifyGameSettings(HMCLGameInstance gameInstance) {
|
public static void modifyGameSettings(HMCLGameInstance gameInstance) {
|
||||||
Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository());
|
LOG.info("Ignoring modifyGameSettings for " + gameInstance.getId()
|
||||||
Controllers.getGameInstancePage().showInstanceSettings();
|
+ ": instance management UI is disabled");
|
||||||
// VersionPage.loadVersion will be invoked after navigation
|
|
||||||
Controllers.navigate(Controllers.getGameInstancePage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,18 @@
|
|||||||
package org.jackhuang.hmcl.ui.main;
|
package org.jackhuang.hmcl.ui.main;
|
||||||
|
|
||||||
import com.google.gson.*;
|
import com.google.gson.*;
|
||||||
|
import com.jfoenix.controls.JFXButton;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
import javafx.scene.control.ScrollPane;
|
import javafx.scene.control.ScrollPane;
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||||
|
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||||
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.theme.Themes;
|
import org.jackhuang.hmcl.theme.Themes;
|
||||||
|
import org.jackhuang.hmcl.ui.Controllers;
|
||||||
|
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||||
import org.jackhuang.hmcl.ui.FXUtils;
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
import org.jackhuang.hmcl.ui.SVG;
|
import org.jackhuang.hmcl.ui.SVG;
|
||||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||||
@@ -20,7 +27,6 @@ import org.jackhuang.hmcl.util.platform.SystemInfo;
|
|||||||
import org.jackhuang.hmcl.util.platform.hardware.CentralProcessor;
|
import org.jackhuang.hmcl.util.platform.hardware.CentralProcessor;
|
||||||
import org.jackhuang.hmcl.util.platform.hardware.DeviceInfo;
|
import org.jackhuang.hmcl.util.platform.hardware.DeviceInfo;
|
||||||
import org.jackhuang.hmcl.util.platform.hardware.GraphicsCard;
|
import org.jackhuang.hmcl.util.platform.hardware.GraphicsCard;
|
||||||
import org.jackhuang.hmcl.util.platform.hardware.PageFileConfiguration;
|
|
||||||
import org.jetbrains.annotations.NotNullByDefault;
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
import org.jetbrains.annotations.Unmodifiable;
|
import org.jetbrains.annotations.Unmodifiable;
|
||||||
@@ -34,16 +40,22 @@ import static org.jackhuang.hmcl.util.i18n.I18n.getTranslator;
|
|||||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
/// Displays launcher, system, device, runtime, and project information.
|
/// Displays a compact about page: identity, update check, licenses, source link and diagnostics.
|
||||||
|
///
|
||||||
|
/// System, device and runtime details are not listed; they are collected by "Copy diagnostics".
|
||||||
@NotNullByDefault
|
@NotNullByDefault
|
||||||
public final class AboutPage extends SpinnerPane {
|
public final class AboutPage extends SpinnerPane {
|
||||||
|
|
||||||
|
/// The upstream project that this launcher is based on, and the source of its GPL-licensed code.
|
||||||
|
private static final String SOURCE_URL = "https://github.com/HMCL-dev/HMCL";
|
||||||
|
|
||||||
/// Keeps theme listeners alive while the page is visible.
|
/// Keeps theme listeners alive while the page is visible.
|
||||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||||
|
|
||||||
/// Builds the page from cached hardware information and bundled project lists.
|
/// Builds the page from bundled project lists.
|
||||||
public AboutPage() {
|
public AboutPage() {
|
||||||
VBox content = new VBox();
|
VBox content = new VBox(13);
|
||||||
|
content.setPadding(new Insets(21));
|
||||||
content.getStyleClass().add("spinner-pane-content");
|
content.getStyleClass().add("spinner-pane-content");
|
||||||
ScrollPane scrollPane = new ScrollPane(content);
|
ScrollPane scrollPane = new ScrollPane(content);
|
||||||
scrollPane.setFitToWidth(true);
|
scrollPane.setFitToWidth(true);
|
||||||
@@ -56,196 +68,112 @@ public final class AboutPage extends SpinnerPane {
|
|||||||
launcher.setLargeTitle(true);
|
launcher.setLargeTitle(true);
|
||||||
launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
|
launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
|
||||||
launcher.setTitle(Metadata.FULL_NAME);
|
launcher.setTitle(Metadata.FULL_NAME);
|
||||||
launcher.setSubtitle(Metadata.VERSION);
|
launcher.setSubtitle(Metadata.VERSION + " · " + i18n("about.based_on"));
|
||||||
|
|
||||||
|
JFXButton checkUpdate = FXUtils.newBorderButton(i18n("about.check_update"));
|
||||||
|
checkUpdate.setOnAction(e -> checkClientUpdate(checkUpdate));
|
||||||
|
launcher.setRight(checkUpdate);
|
||||||
|
|
||||||
about.getContent().setAll(launcher);
|
about.getContent().setAll(launcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
ComponentList systemInfo = new ComponentList();
|
// The HMCL GPLv3 notice and the third-party dependency list must stay reachable.
|
||||||
{
|
|
||||||
var system = new LinePane();
|
|
||||||
system.setLargeTitle(true);
|
|
||||||
system.setLeading(SVG.LUCIDE_APP_WINDOW.createIcon(32));
|
|
||||||
system.setTitle(i18n("about.system.os"));
|
|
||||||
system.setSubtitle((OperatingSystem.OS_RELEASE_PRETTY_NAME == null
|
|
||||||
? "%s %s".formatted(OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())
|
|
||||||
: "%s (%s %s)".formatted(OperatingSystem.OS_RELEASE_PRETTY_NAME, OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())));
|
|
||||||
systemInfo.getContent().add(system);
|
|
||||||
|
|
||||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemInfo.getDesktopEnvironment() != null) {
|
|
||||||
var desktop = new LinePane();
|
|
||||||
desktop.setLargeTitle(true);
|
|
||||||
desktop.setLeading(SVG.LUCIDE_PANELS_TOP_LEFT.createIcon(32));
|
|
||||||
desktop.setTitle(i18n("about.system.desktop"));
|
|
||||||
desktop.setSubtitle(SystemInfo.getDesktopEnvironment());
|
|
||||||
systemInfo.getContent().add(desktop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ComponentList deviceInfo = new ComponentList();
|
|
||||||
{
|
|
||||||
@Nullable DeviceInfo deviceIdentity = SystemInfo.getDeviceInfo();
|
|
||||||
if (deviceIdentity != null
|
|
||||||
&& !"Virtual Machine".equals(deviceIdentity.name())
|
|
||||||
&& OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
|
||||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS || !Architecture.SYSTEM_ARCH.isX86())) {
|
|
||||||
var device = new LinePane();
|
|
||||||
device.setLargeTitle(true);
|
|
||||||
device.setLeading(SVG.HOST.createIcon(32));
|
|
||||||
device.setTitle(i18n("about.device.model"));
|
|
||||||
device.setSubtitle(getTranslator().formatDeviceName(deviceIdentity));
|
|
||||||
deviceInfo.getContent().add(device);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Unmodifiable List<CentralProcessor> processors = SystemInfo.getCentralProcessors();
|
|
||||||
for (int i = 0; i < processors.size(); i++) {
|
|
||||||
String title = processors.size() == 1
|
|
||||||
? i18n("about.device.cpu")
|
|
||||||
: i18n("about.device.cpu.n", i + 1);
|
|
||||||
CentralProcessor processor = processors.get(i);
|
|
||||||
String name = processor.getName();
|
|
||||||
@Nullable CentralProcessor.Cores cores = processor.getCores();
|
|
||||||
String subtitle;
|
|
||||||
if (cores == null)
|
|
||||||
subtitle = i18n("about.device.cpu.name", name);
|
|
||||||
else if (cores.efficiency() > 0) {
|
|
||||||
subtitle = cores.logical() == cores.physical()
|
|
||||||
? i18n("about.device.cpu.name.cores.hybrid", name, cores.performance(), cores.efficiency())
|
|
||||||
: i18n("about.device.cpu.name.cores.hybrid.threads", name, cores.performance(), cores.efficiency(), cores.logical());
|
|
||||||
} else {
|
|
||||||
subtitle = cores.logical() == cores.physical()
|
|
||||||
? i18n("about.device.cpu.name.cores", name, cores.physical())
|
|
||||||
: i18n("about.device.cpu.name.cores.threads", name, cores.physical(), cores.logical());
|
|
||||||
}
|
|
||||||
|
|
||||||
var pane = new LinePane();
|
|
||||||
pane.setLargeTitle(true);
|
|
||||||
pane.setTitle(title);
|
|
||||||
pane.setSubtitle(subtitle);
|
|
||||||
pane.setLeading(SVG.LUCIDE_CPU.createIcon(32));
|
|
||||||
pane.getTags().add(new Tag(Architecture.SYSTEM_ARCH.getDisplayName()));
|
|
||||||
deviceInfo.getContent().add(pane);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Unmodifiable List<GraphicsCard> visibleGraphicsCards = SystemInfo.getGraphicsCards().stream()
|
|
||||||
.filter(card -> card.getType() != GraphicsCard.Type.DISPLAY_ONLY && !"Unknown".equals(card.getName()))
|
|
||||||
.sorted(Comparator.comparing(GraphicsCard::getType))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (int i = 0; i < visibleGraphicsCards.size(); i++) {
|
|
||||||
GraphicsCard graphicsCard = visibleGraphicsCards.get(i);
|
|
||||||
|
|
||||||
String title = visibleGraphicsCards.size() == 1
|
|
||||||
? i18n("about.device.gpu")
|
|
||||||
: i18n("about.device.gpu.n", i + 1);
|
|
||||||
|
|
||||||
var button = new LinePane();
|
|
||||||
button.setLargeTitle(true);
|
|
||||||
button.setLeading(SVG.LUCIDE_GPU.createIcon(32));
|
|
||||||
button.setTitle(title);
|
|
||||||
|
|
||||||
StringBuilder subtitle = new StringBuilder(graphicsCard.getName());
|
|
||||||
|
|
||||||
if (graphicsCard.getDriver() != null || graphicsCard.getDriverVersion() != null) {
|
|
||||||
subtitle.append(" (");
|
|
||||||
if (graphicsCard.getDriver() != null) {
|
|
||||||
subtitle.append(graphicsCard.getDriver());
|
|
||||||
if (graphicsCard.getDriverVersion() != null) {
|
|
||||||
subtitle.append(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (graphicsCard.getDriverVersion() != null) {
|
|
||||||
subtitle.append(graphicsCard.getDriverVersion());
|
|
||||||
}
|
|
||||||
subtitle.append(')');
|
|
||||||
}
|
|
||||||
|
|
||||||
button.setSubtitle(subtitle.toString());
|
|
||||||
|
|
||||||
switch (graphicsCard.getType()) {
|
|
||||||
case INTEGRATED -> button.getTags().add(new Tag(i18n("about.device.gpu.type.integrated")));
|
|
||||||
case DISCRETE -> button.getTags().add(new Tag(i18n("about.device.gpu.type.discrete")));
|
|
||||||
case VIRTUAL -> button.getTags().add(new Tag(i18n("about.device.gpu.type.virtual")));
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceInfo.getContent().add(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SystemInfo.getTotalMemorySize() > 0) {
|
|
||||||
String subtitle = i18n("about.device.memory.system",
|
|
||||||
"%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())));
|
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
|
||||||
long pageFileSize = SystemInfo.getTotalPageFileSize();
|
|
||||||
if (pageFileSize < 0) {
|
|
||||||
subtitle = i18n("about.device.memory.page_files.unknown", subtitle);
|
|
||||||
} else {
|
|
||||||
String pageFileCapacity = "%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(pageFileSize));
|
|
||||||
PageFileConfiguration configuration = SystemInfo.getPageFileConfiguration();
|
|
||||||
subtitle = switch (configuration.mode()) {
|
|
||||||
case MANUAL -> i18n("about.device.memory.page_files.maximum",
|
|
||||||
subtitle,
|
|
||||||
pageFileCapacity,
|
|
||||||
"%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(configuration.maximumSize())));
|
|
||||||
case SYSTEM_MANAGED, MIXED, UNKNOWN -> i18n("about.device.memory.page_files", subtitle, pageFileCapacity);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var memory = new LinePane();
|
|
||||||
memory.setLargeTitle(true);
|
|
||||||
memory.setLeading(SVG.LUCIDE_MEMORY_STICK.createIcon(32));
|
|
||||||
memory.setTitle(i18n("about.device.memory"));
|
|
||||||
memory.setSubtitle(subtitle);
|
|
||||||
deviceInfo.getContent().add(memory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ComponentList runtimeInfo = new ComponentList();
|
|
||||||
{
|
|
||||||
var java = new LinePane();
|
|
||||||
java.setLargeTitle(true);
|
|
||||||
java.setLeading(SVG.LOCAL_CAFE.createIcon(32));
|
|
||||||
java.setTitle("Java");
|
|
||||||
java.setSubtitle("%s, %s, %s".formatted(
|
|
||||||
Runtime.version().toString(),
|
|
||||||
System.getProperty("java.vm.name"),
|
|
||||||
System.getProperty("java.vendor")));
|
|
||||||
runtimeInfo.getContent().add(java);
|
|
||||||
|
|
||||||
var javaFX = new LinePane();
|
|
||||||
javaFX.setLargeTitle(true);
|
|
||||||
javaFX.setLeading(SVG.LOCAL_CAFE.createIcon(32));
|
|
||||||
javaFX.setTitle("JavaFX");
|
|
||||||
javaFX.setSubtitle(System.getProperty("javafx.runtime.version"));
|
|
||||||
runtimeInfo.getContent().add(javaFX);
|
|
||||||
}
|
|
||||||
|
|
||||||
ComponentList deps = loadIconedTwoLineList("/assets/about/deps.json");
|
|
||||||
|
|
||||||
ComponentList legal = new ComponentList();
|
ComponentList legal = new ComponentList();
|
||||||
{
|
{
|
||||||
var copyright = new LinePane();
|
var license = new LinePane();
|
||||||
copyright.setLargeTitle(true);
|
license.setLargeTitle(true);
|
||||||
copyright.setTitle(i18n("about.copyright"));
|
license.setTitle(i18n("about.open_source"));
|
||||||
copyright.setSubtitle(i18n("about.copyright.statement"));
|
license.setSubtitle(i18n("about.open_source.statement") + "\n" + i18n("about.copyright.statement"));
|
||||||
|
legal.getContent().add(license);
|
||||||
|
|
||||||
legal.getContent().setAll(copyright);
|
var source = new LineButton();
|
||||||
|
source.setLargeTitle(true);
|
||||||
|
source.setTitle(i18n("about.source_code"));
|
||||||
|
source.setSubtitle(SOURCE_URL);
|
||||||
|
source.setTrailingIcon(SVG.OPEN_IN_NEW);
|
||||||
|
source.setOnAction(e -> FXUtils.openLink(SOURCE_URL));
|
||||||
|
legal.getContent().add(source);
|
||||||
|
|
||||||
|
ComponentList deps = loadIconedTwoLineList("/assets/about/deps.json");
|
||||||
|
var depsSublist = new ComponentSublist(() -> List.of(deps));
|
||||||
|
depsSublist.setTitle(i18n("about.dependency"));
|
||||||
|
legal.getContent().add(depsSublist);
|
||||||
}
|
}
|
||||||
|
|
||||||
content.getChildren().setAll(
|
ComponentList support = new ComponentList();
|
||||||
ComponentList.createComponentListTitle(i18n("about")),
|
{
|
||||||
about,
|
var diagnostics = new LineButton();
|
||||||
ComponentList.createComponentListTitle(i18n("about.system")),
|
diagnostics.setLargeTitle(true);
|
||||||
systemInfo,
|
diagnostics.setTitle(i18n("about.copy_diagnostics"));
|
||||||
ComponentList.createComponentListTitle(i18n("about.device")),
|
diagnostics.setTrailingIcon(SVG.CONTENT_COPY);
|
||||||
deviceInfo,
|
diagnostics.setOnAction(e -> FXUtils.copyText(buildDiagnostics()));
|
||||||
ComponentList.createComponentListTitle(i18n("about.runtime")),
|
support.getContent().add(diagnostics);
|
||||||
runtimeInfo,
|
}
|
||||||
ComponentList.createComponentListTitle(i18n("about.dependency")),
|
|
||||||
deps,
|
content.getChildren().setAll(about, legal, support);
|
||||||
ComponentList.createComponentListTitle(i18n("about.legal")),
|
}
|
||||||
legal
|
|
||||||
);
|
/// Checks whether the installed client pack is behind the remote manifest and tells the user.
|
||||||
|
///
|
||||||
|
/// @param button the button to disable while the check runs
|
||||||
|
private static void checkClientUpdate(JFXButton button) {
|
||||||
|
HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
|
||||||
|
if (instance == null) {
|
||||||
|
Controllers.showToast(i18n("about.check_update.not_installed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.setDisable(true);
|
||||||
|
Schedulers.io().execute(() -> {
|
||||||
|
boolean available = Instances.isClientUpdateAvailable(instance);
|
||||||
|
FXUtils.runInFX(() -> {
|
||||||
|
button.setDisable(false);
|
||||||
|
Controllers.showToast(i18n(available ? "about.check_update.available" : "about.check_update.latest"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects launcher, system, device and runtime information as plain text for bug reports.
|
||||||
|
private static String buildDiagnostics() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(Metadata.FULL_NAME).append(' ').append(Metadata.VERSION).append('\n');
|
||||||
|
sb.append("OS: ").append(OperatingSystem.OS_RELEASE_PRETTY_NAME == null
|
||||||
|
? "%s %s".formatted(OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())
|
||||||
|
: "%s (%s %s)".formatted(OperatingSystem.OS_RELEASE_PRETTY_NAME, OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion()))
|
||||||
|
.append(" (").append(Architecture.SYSTEM_ARCH.getDisplayName()).append(")\n");
|
||||||
|
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemInfo.getDesktopEnvironment() != null)
|
||||||
|
sb.append("Desktop: ").append(SystemInfo.getDesktopEnvironment()).append('\n');
|
||||||
|
|
||||||
|
@Nullable DeviceInfo deviceIdentity = SystemInfo.getDeviceInfo();
|
||||||
|
if (deviceIdentity != null && !"Virtual Machine".equals(deviceIdentity.name()))
|
||||||
|
sb.append("Device: ").append(getTranslator().formatDeviceName(deviceIdentity)).append('\n');
|
||||||
|
|
||||||
|
for (CentralProcessor processor : SystemInfo.getCentralProcessors())
|
||||||
|
sb.append("CPU: ").append(processor.getName()).append('\n');
|
||||||
|
|
||||||
|
@Unmodifiable List<GraphicsCard> cards = SystemInfo.getGraphicsCards().stream()
|
||||||
|
.filter(card -> card.getType() != GraphicsCard.Type.DISPLAY_ONLY && !"Unknown".equals(card.getName()))
|
||||||
|
.sorted(Comparator.comparing(GraphicsCard::getType))
|
||||||
|
.toList();
|
||||||
|
for (GraphicsCard card : cards) {
|
||||||
|
sb.append("GPU: ").append(card.getName());
|
||||||
|
if (card.getDriver() != null || card.getDriverVersion() != null) {
|
||||||
|
sb.append(" (");
|
||||||
|
if (card.getDriver() != null)
|
||||||
|
sb.append(card.getDriver());
|
||||||
|
if (card.getDriverVersion() != null)
|
||||||
|
sb.append(card.getDriver() != null ? " " : "").append(card.getDriverVersion());
|
||||||
|
sb.append(')');
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SystemInfo.getTotalMemorySize() > 0)
|
||||||
|
sb.append("Memory: %.1f GiB%n".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())));
|
||||||
|
|
||||||
|
sb.append("Java: %s, %s, %s%n".formatted(
|
||||||
|
Runtime.version(), System.getProperty("java.vm.name"), System.getProperty("java.vendor")));
|
||||||
|
sb.append("JavaFX: ").append(System.getProperty("javafx.runtime.version")).append('\n');
|
||||||
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Image loadImage(String url) {
|
private static Image loadImage(String url) {
|
||||||
|
|||||||
@@ -4,60 +4,21 @@ package org.jackhuang.hmcl.ui.main;
|
|||||||
|
|
||||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
import org.jackhuang.hmcl.ui.SVG;
|
import javafx.scene.layout.StackPane;
|
||||||
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.decorator.DecoratorPage;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
|
||||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
|
|
||||||
public class LauncherSettingsPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
|
/// The launcher settings destination: a single [SettingsPage] without sidebar tabs.
|
||||||
|
@NotNullByDefault
|
||||||
|
public class LauncherSettingsPage extends StackPane implements DecoratorPage {
|
||||||
|
/// The page state holding the "Settings" title.
|
||||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings")));
|
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings")));
|
||||||
/// Manages tab selection and content transitions independently of the sidebar.
|
|
||||||
private final TabController tabController;
|
|
||||||
/// The tab whose content manages installed Java runtimes.
|
|
||||||
private final TabItem<JavaManagementPage> javaManagementTab;
|
|
||||||
/// The tab for general launcher settings.
|
|
||||||
private final TabItem<SettingsPage> settingsTab;
|
|
||||||
/// The tab for download settings.
|
|
||||||
private final TabItem<DownloadSettingsPage> downloadTab;
|
|
||||||
/// The tab displaying launcher information.
|
|
||||||
private final TabItem<AboutPage> aboutTab;
|
|
||||||
private final TransitionPane transitionPane = new TransitionPane();
|
|
||||||
|
|
||||||
/// Creates the settings tabs and fills the shared navigation sidebar.
|
/// Creates the page with the single settings view.
|
||||||
public LauncherSettingsPage() {
|
public LauncherSettingsPage() {
|
||||||
javaManagementTab = new TabItem<>(JavaManagementPage::new);
|
getChildren().setAll(new SettingsPage());
|
||||||
settingsTab = new TabItem<>(SettingsPage::new);
|
|
||||||
downloadTab = new TabItem<>(DownloadSettingsPage::new);
|
|
||||||
aboutTab = new TabItem<>(AboutPage::new);
|
|
||||||
tabController = new TabController(transitionPane,
|
|
||||||
javaManagementTab,
|
|
||||||
settingsTab,
|
|
||||||
downloadTab,
|
|
||||||
aboutTab);
|
|
||||||
|
|
||||||
tabController.selectTab(settingsTab);
|
|
||||||
|
|
||||||
getSidebar()
|
|
||||||
.addNavigationDrawerTab(tabController, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL)
|
|
||||||
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
|
|
||||||
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
|
|
||||||
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
|
|
||||||
|
|
||||||
setContent(transitionPane);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPageShown() {
|
|
||||||
tabController.onPageShown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPageHidden() {
|
|
||||||
tabController.onPageHidden();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.jfoenix.controls.JFXButton;
|
|||||||
import com.jfoenix.controls.JFXPopup;
|
import com.jfoenix.controls.JFXPopup;
|
||||||
import javafx.beans.property.*;
|
import javafx.beans.property.*;
|
||||||
import javafx.beans.value.ObservableValue;
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.css.PseudoClass;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
@@ -13,6 +14,7 @@ import javafx.geometry.Pos;
|
|||||||
import javafx.scene.Cursor;
|
import javafx.scene.Cursor;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ProgressBar;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
@@ -26,6 +28,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
|
|||||||
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
|
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
|
||||||
import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
||||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||||
|
import org.jackhuang.hmcl.ui.instances.ClientSyncProgress;
|
||||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.util.ServerAddress;
|
import org.jackhuang.hmcl.util.ServerAddress;
|
||||||
@@ -58,6 +61,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
|||||||
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
|
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
|
||||||
.flatMap(HMCLGameRepository::snapshotProperty);
|
.flatMap(HMCLGameRepository::snapshotProperty);
|
||||||
|
|
||||||
|
/// Bumped when the home join button starts a new remote-update check, so stale results are ignored.
|
||||||
|
private int updateCheckGeneration;
|
||||||
|
|
||||||
|
/// Progress of the install / update workflow, shown in place of the home card while it runs.
|
||||||
|
private final ClientSyncProgress syncProgress = new ClientSyncProgress();
|
||||||
|
|
||||||
{
|
{
|
||||||
HBox titleNode = new HBox(8);
|
HBox titleNode = new HBox(8);
|
||||||
titleNode.setPadding(new Insets(0, 0, 0, 2));
|
titleNode.setPadding(new Insets(0, 0, 0, 2));
|
||||||
@@ -133,26 +142,40 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
|||||||
card.getStyleClass().add("home-card");
|
card.getStyleClass().add("home-card");
|
||||||
card.setAlignment(Pos.CENTER_LEFT);
|
card.setAlignment(Pos.CENTER_LEFT);
|
||||||
card.setMaxHeight(Region.USE_PREF_SIZE);
|
card.setMaxHeight(Region.USE_PREF_SIZE);
|
||||||
StackPane.setAlignment(card, Pos.BOTTOM_CENTER);
|
// Server announcement (the server's MOTD), shown above the card only when there is one.
|
||||||
|
Label motdLabel = new Label();
|
||||||
|
motdLabel.getStyleClass().add("home-subtitle");
|
||||||
|
motdLabel.setWrapText(true);
|
||||||
|
motdLabel.setMaxHeight(Region.USE_PREF_SIZE);
|
||||||
|
Label motdTitle = factLabel(i18n("home.motd"));
|
||||||
|
VBox motdBox = new VBox(3, motdTitle, motdLabel);
|
||||||
|
motdBox.setPadding(new Insets(0, 0, 14, 4));
|
||||||
|
motdBox.setVisible(false);
|
||||||
|
motdBox.setManaged(false);
|
||||||
|
VBox bottom = new VBox(motdBox, card);
|
||||||
|
bottom.setMaxHeight(Region.USE_PREF_SIZE);
|
||||||
|
StackPane.setAlignment(bottom, Pos.BOTTOM_CENTER);
|
||||||
|
|
||||||
|
// Start always installs/updates from fileApi then joins — never opens install or instance UI.
|
||||||
|
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
|
||||||
|
|
||||||
FXUtils.onChangeAndOperate(currentGameProperty(), currentGame -> {
|
FXUtils.onChangeAndOperate(currentGameProperty(), currentGame -> {
|
||||||
|
refreshJoinButton(joinButton, currentGame);
|
||||||
if (currentGame == null) {
|
if (currentGame == null) {
|
||||||
joinButton.setText(i18n("instance.launch.empty"));
|
|
||||||
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launchNoGame);
|
|
||||||
packValue.setText(i18n("home.not_installed"));
|
packValue.setText(i18n("home.not_installed"));
|
||||||
routeFact.setVisible(false);
|
routeFact.setVisible(false);
|
||||||
routeFact.setManaged(false);
|
routeFact.setManaged(false);
|
||||||
serverFact.setVisible(false);
|
serverFact.setVisible(false);
|
||||||
serverFact.setManaged(false);
|
serverFact.setManaged(false);
|
||||||
|
routeFact.setOnMouseClicked(null);
|
||||||
|
routeFact.setCursor(Cursor.DEFAULT);
|
||||||
} else {
|
} else {
|
||||||
joinButton.setText(i18n("server.join"));
|
|
||||||
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
|
|
||||||
packValue.setText(currentGame.getId().toString());
|
packValue.setText(currentGame.getId().toString());
|
||||||
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(currentGame.getModpackConfigurationFile());
|
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(currentGame.getModpackConfigurationFile());
|
||||||
routeFact.setVisible(route != null);
|
routeFact.setVisible(route != null);
|
||||||
routeFact.setManaged(route != null);
|
routeFact.setManaged(route != null);
|
||||||
routeValue.setText(route != null ? route.name() : "");
|
routeValue.setText(route != null ? route.name() : "");
|
||||||
refreshServerStatus(route, serverFact, serverValue);
|
refreshServerStatus(route, serverFact, serverValue, motdBox, motdLabel);
|
||||||
|
|
||||||
// With more than one route (for example direct and CDN), the route can be switched.
|
// With more than one route (for example direct and CDN), the route can be switched.
|
||||||
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
|
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
|
||||||
@@ -169,7 +192,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
|||||||
items.add(new IconedMenuItem(null, candidate.name(), () -> {
|
items.add(new IconedMenuItem(null, candidate.name(), () -> {
|
||||||
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
|
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
|
||||||
routeValue.setText(candidate.name() + " ▾");
|
routeValue.setText(candidate.name() + " ▾");
|
||||||
refreshServerStatus(candidate, serverFact, serverValue);
|
refreshServerStatus(candidate, serverFact, serverValue, motdBox, motdLabel);
|
||||||
}, popup));
|
}, popup));
|
||||||
}
|
}
|
||||||
list.getContent().setAll(items);
|
list.getContent().setAll(items);
|
||||||
@@ -178,32 +201,160 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
getChildren().addAll(art, hero, card);
|
// While the client is installed or updated the card turns into a progress view in place.
|
||||||
|
VBox progressCard = createProgressCard();
|
||||||
|
StackPane.setAlignment(progressCard, Pos.BOTTOM_CENTER);
|
||||||
|
progressCard.setMaxHeight(Region.USE_PREF_SIZE);
|
||||||
|
progressCard.visibleProperty().bind(syncProgress.stateProperty().isNotEqualTo(ClientSyncProgress.State.IDLE));
|
||||||
|
progressCard.managedProperty().bind(progressCard.visibleProperty());
|
||||||
|
// The whole bottom block (announcement + card) gives way to the progress card.
|
||||||
|
bottom.visibleProperty().bind(progressCard.visibleProperty().not());
|
||||||
|
bottom.managedProperty().bind(bottom.visibleProperty());
|
||||||
|
|
||||||
|
getChildren().addAll(art, hero, bottom, progressCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the progress state of the home card: title with percentage, a thin progress bar,
|
||||||
|
/// transfer details, a cancel / retry button and the four-step indicator.
|
||||||
|
private VBox createProgressCard() {
|
||||||
|
Label title = new Label();
|
||||||
|
title.getStyleClass().add("home-progress-title");
|
||||||
|
ProgressBar bar = new ProgressBar();
|
||||||
|
bar.getStyleClass().add("home-progress-bar");
|
||||||
|
bar.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
bar.progressProperty().bind(syncProgress.progressProperty());
|
||||||
|
Label detail = new Label();
|
||||||
|
detail.getStyleClass().add("home-fact-label");
|
||||||
|
detail.textProperty().bind(syncProgress.detailProperty());
|
||||||
|
Label error = new Label();
|
||||||
|
error.getStyleClass().add("home-progress-error");
|
||||||
|
error.setWrapText(true);
|
||||||
|
error.setMaxHeight(55);
|
||||||
|
error.textProperty().bind(syncProgress.errorProperty());
|
||||||
|
|
||||||
|
VBox status = new VBox(8, title, bar, detail, error);
|
||||||
|
status.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
HBox.setHgrow(status, Priority.ALWAYS);
|
||||||
|
|
||||||
|
JFXButton action = new JFXButton();
|
||||||
|
action.getStyleClass().add("home-progress-button");
|
||||||
|
action.setOnAction(e -> {
|
||||||
|
if (syncProgress.stateProperty().get() == ClientSyncProgress.State.FAILED) {
|
||||||
|
Instances.ensureClientAndJoin(syncProgress);
|
||||||
|
} else {
|
||||||
|
syncProgress.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// A failed run can also be dismissed to get back to the normal card.
|
||||||
|
JFXButton dismiss = new JFXButton(i18n("button.cancel"));
|
||||||
|
dismiss.getStyleClass().add("home-progress-button");
|
||||||
|
dismiss.setOnAction(e -> syncProgress.dismiss());
|
||||||
|
dismiss.visibleProperty().bind(syncProgress.stateProperty().isEqualTo(ClientSyncProgress.State.FAILED));
|
||||||
|
dismiss.managedProperty().bind(dismiss.visibleProperty());
|
||||||
|
|
||||||
|
HBox top = new HBox(21, status, new HBox(8, dismiss, action));
|
||||||
|
top.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
String[] stepKeys = {"home.progress.step.check", "home.progress.step.java", "home.progress.step.sync", "home.progress.step.join"};
|
||||||
|
HBox steps = new HBox(21);
|
||||||
|
Label[] stepLabels = new Label[stepKeys.length];
|
||||||
|
for (int i = 0; i < stepKeys.length; i++) {
|
||||||
|
stepLabels[i] = new Label();
|
||||||
|
stepLabels[i].getStyleClass().add("home-progress-step");
|
||||||
|
steps.getChildren().add(stepLabels[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Runnable refresh = () -> {
|
||||||
|
ClientSyncProgress.State state = syncProgress.stateProperty().get();
|
||||||
|
boolean failed = state == ClientSyncProgress.State.FAILED;
|
||||||
|
int current = syncProgress.stepProperty().get();
|
||||||
|
for (int i = 0; i < stepKeys.length; i++) {
|
||||||
|
boolean done = i < current;
|
||||||
|
boolean active = i == current && !failed;
|
||||||
|
stepLabels[i].setText((done ? "✓ " : active ? "● " : "○ ") + i18n(stepKeys[i]));
|
||||||
|
stepLabels[i].pseudoClassStateChanged(PseudoClass.getPseudoClass("done"), done);
|
||||||
|
stepLabels[i].pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), active);
|
||||||
|
}
|
||||||
|
int percent = (int) Math.round(Math.max(0, syncProgress.progressProperty().get()) * 100);
|
||||||
|
if (failed) {
|
||||||
|
title.setText(i18n("home.progress.failed"));
|
||||||
|
action.setText(i18n("home.progress.retry"));
|
||||||
|
} else {
|
||||||
|
title.setText(current <= ClientSyncProgress.STEP_JAVA
|
||||||
|
? i18n("home.progress.checking")
|
||||||
|
: i18n("home.progress.syncing", percent));
|
||||||
|
action.setText(i18n("button.cancel"));
|
||||||
|
}
|
||||||
|
error.setVisible(failed);
|
||||||
|
error.setManaged(failed);
|
||||||
|
detail.setVisible(!failed);
|
||||||
|
detail.setManaged(!failed);
|
||||||
|
};
|
||||||
|
syncProgress.stateProperty().addListener(o -> refresh.run());
|
||||||
|
syncProgress.stepProperty().addListener(o -> refresh.run());
|
||||||
|
syncProgress.progressProperty().addListener(o -> refresh.run());
|
||||||
|
refresh.run();
|
||||||
|
|
||||||
|
VBox box = new VBox(21, top, steps);
|
||||||
|
box.getStyleClass().add("home-card");
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the home button to install / update / join based on the selected instance and remote manifest.
|
||||||
|
private void refreshJoinButton(JFXButton joinButton, @Nullable HMCLGameInstance currentGame) {
|
||||||
|
int generation = ++updateCheckGeneration;
|
||||||
|
if (currentGame == null) {
|
||||||
|
joinButton.setText(i18n("instance.launch.empty"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
joinButton.setText(i18n("server.join"));
|
||||||
|
Schedulers.io().execute(() -> {
|
||||||
|
boolean updateAvailable = Instances.isClientUpdateAvailable(currentGame);
|
||||||
|
FXUtils.runInFX(() -> {
|
||||||
|
if (generation != updateCheckGeneration || getCurrentGame() != currentGame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
joinButton.setText(i18n(updateAvailable ? "server.update" : "server.join"));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
|
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
|
||||||
///
|
///
|
||||||
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
|
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
|
||||||
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue) {
|
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue,
|
||||||
|
Node motdBox, Label motdLabel) {
|
||||||
String address = route == null ? "" : route.address().trim();
|
String address = route == null ? "" : route.address().trim();
|
||||||
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
|
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
|
||||||
serverFact.setVisible(pingable);
|
serverFact.setVisible(pingable);
|
||||||
serverFact.setManaged(pingable);
|
serverFact.setManaged(pingable);
|
||||||
|
motdBox.setVisible(false);
|
||||||
|
motdBox.setManaged(false);
|
||||||
if (!pingable) {
|
if (!pingable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
serverValue.setText("…");
|
serverValue.setText("…");
|
||||||
Schedulers.io().execute(() -> {
|
Schedulers.io().execute(() -> {
|
||||||
String text;
|
String text;
|
||||||
|
String motd = "";
|
||||||
try {
|
try {
|
||||||
ServerAddress parsed = ServerAddress.parse(address);
|
ServerAddress parsed = ServerAddress.parse(address);
|
||||||
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
|
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
|
||||||
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
|
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
|
||||||
|
motd = status.motd();
|
||||||
} catch (IOException | IllegalArgumentException e) {
|
} catch (IOException | IllegalArgumentException e) {
|
||||||
text = i18n("home.server.unreachable");
|
text = i18n("home.server.unreachable");
|
||||||
}
|
}
|
||||||
String result = text;
|
String result = text;
|
||||||
FXUtils.runInFX(() -> serverValue.setText(result));
|
String announcement = motd;
|
||||||
|
FXUtils.runInFX(() -> {
|
||||||
|
serverValue.setText(result);
|
||||||
|
// Unreachable or empty MOTD: stay silent, the announcement area stays hidden.
|
||||||
|
boolean show = !announcement.isEmpty();
|
||||||
|
motdLabel.setText(announcement);
|
||||||
|
motdBox.setVisible(show);
|
||||||
|
motdBox.setManaged(show);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,14 +365,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs or updates the client from fileApi when needed, then launches into the server.
|
||||||
private void launch() {
|
private void launch() {
|
||||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
Instances.ensureClientAndJoin(syncProgress);
|
||||||
Instances.joinServer(repository.getSelectedInstance());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the server modpack install/update flow when no instance is selected.
|
|
||||||
private void launchNoGame() {
|
|
||||||
Instances.installServerModpack();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package org.jackhuang.hmcl.ui.main;
|
|||||||
|
|
||||||
import com.jfoenix.controls.JFXPopup;
|
import com.jfoenix.controls.JFXPopup;
|
||||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
|
||||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||||
import org.jackhuang.hmcl.setting.Accounts;
|
import org.jackhuang.hmcl.setting.Accounts;
|
||||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||||
@@ -17,8 +16,6 @@ import org.jackhuang.hmcl.ui.account.AccountListPopupMenu;
|
|||||||
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
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.Instances;
|
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
@@ -43,20 +40,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
|||||||
accountListItem.setOnAction(e -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -accountListItem.getHeight()));
|
accountListItem.setOnAction(e -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -accountListItem.getHeight()));
|
||||||
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
|
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
|
||||||
|
|
||||||
// second item in left sidebar
|
// Instance management entry removed: Start on the home page installs/updates and joins.
|
||||||
GameAdvancedListItem gameInstanceItem = new GameAdvancedListItem();
|
|
||||||
gameInstanceItem.setOnAction(e -> {
|
|
||||||
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
|
|
||||||
if (instance == null) {
|
|
||||||
Instances.installServerModpack();
|
|
||||||
} else {
|
|
||||||
Instances.modifyGameSettings(instance);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (AnimationUtils.isAnimationEnabled()) {
|
|
||||||
gameInstanceItem.setPrepareAction(Controllers::prepareGameInstancePage);
|
|
||||||
}
|
|
||||||
|
|
||||||
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
|
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
|
||||||
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
|
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
|
||||||
launcherSettingsItem.setTitle(i18n("settings"));
|
launcherSettingsItem.setTitle(i18n("settings"));
|
||||||
@@ -68,9 +52,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
|||||||
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
|
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sidebar holds the modpack and settings entries; the account sits at the bottom-left.
|
// Sidebar: settings only; account sits at the bottom-left.
|
||||||
getSidebar()
|
getSidebar()
|
||||||
.add(gameInstanceItem)
|
|
||||||
.add(launcherSettingsItem);
|
.add(launcherSettingsItem);
|
||||||
setSidebarBottom(accountListItem);
|
setSidebarBottom(accountListItem);
|
||||||
|
|
||||||
@@ -92,9 +75,9 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
|||||||
modpacks -> {
|
modpacks -> {
|
||||||
Path file = modpacks.get(0);
|
Path file = modpacks.get(0);
|
||||||
if (ModpackHelper.isFileModpackByExtension(file)) {
|
if (ModpackHelper.isFileModpackByExtension(file)) {
|
||||||
Controllers.getDecorator().startWizard(
|
// Silent install — no modpack wizard UI.
|
||||||
new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file),
|
Instances.installLocalModpackSilently(
|
||||||
i18n("install.modpack"));
|
GameDirectoryManager.getSelectedRepository(), file);
|
||||||
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
|
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
|
||||||
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
|
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
package org.jackhuang.hmcl.ui.main;
|
package org.jackhuang.hmcl.ui.main;
|
||||||
|
|
||||||
import com.jfoenix.controls.JFXButton;
|
import com.jfoenix.controls.JFXButton;
|
||||||
|
import com.jfoenix.controls.JFXSlider;
|
||||||
|
import javafx.beans.binding.Bindings;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.ScrollPane;
|
import javafx.scene.control.ScrollPane;
|
||||||
@@ -11,6 +17,14 @@ import javafx.scene.layout.BorderPane;
|
|||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
import org.jackhuang.hmcl.setting.DownloadSource;
|
||||||
|
import org.jackhuang.hmcl.setting.GameSettings;
|
||||||
|
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||||
|
import org.jackhuang.hmcl.ui.SVG;
|
||||||
|
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||||
|
import org.jackhuang.hmcl.util.DataSizeUnit;
|
||||||
|
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.ui.Controllers;
|
import org.jackhuang.hmcl.ui.Controllers;
|
||||||
import org.jackhuang.hmcl.ui.FXUtils;
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
@@ -33,6 +47,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.zip.GZIPInputStream;
|
import java.util.zip.GZIPInputStream;
|
||||||
@@ -43,90 +58,177 @@ import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
|||||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
/// The single launcher settings page: general, game and help cards.
|
||||||
|
///
|
||||||
|
/// Java management and the about page are reachable from the game and help cards and open as sub pages.
|
||||||
public final class SettingsPage extends ScrollPane {
|
public final class SettingsPage extends ScrollPane {
|
||||||
|
|
||||||
|
/// Smallest selectable maximum game memory in MiB.
|
||||||
|
private static final int MIN_MEMORY_MIB = 1024;
|
||||||
|
/// Step of the memory slider in MiB.
|
||||||
|
private static final int MEMORY_STEP_MIB = 256;
|
||||||
|
|
||||||
|
/// Builds the three setting cards.
|
||||||
public SettingsPage() {
|
public SettingsPage() {
|
||||||
this.setFitToWidth(true);
|
this.setFitToWidth(true);
|
||||||
|
|
||||||
VBox rootPane = new VBox(10);
|
VBox rootPane = new VBox(13);
|
||||||
rootPane.setPadding(new Insets(10));
|
rootPane.setPadding(new Insets(21));
|
||||||
this.setContent(rootPane);
|
this.setContent(rootPane);
|
||||||
FXUtils.smoothScrolling(this);
|
FXUtils.smoothScrolling(this);
|
||||||
|
|
||||||
{
|
rootPane.getChildren().addAll(
|
||||||
ComponentList languagePaneList = new ComponentList();
|
ComponentList.createComponentListTitle(i18n("settings.launcher.general")), createGeneralCard(),
|
||||||
|
ComponentList.createComponentListTitle(i18n("settings.group.game")), createGameCard(),
|
||||||
|
ComponentList.createComponentListTitle(i18n("settings.group.help")), createHelpCard());
|
||||||
|
}
|
||||||
|
|
||||||
{
|
/// Creates the card with the language and download source choices.
|
||||||
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
|
private static ComponentList createGeneralCard() {
|
||||||
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
|
ComponentList list = new ComponentList();
|
||||||
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
|
|
||||||
|
|
||||||
SupportedLocale currentLocale = I18n.getLocale();
|
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
|
||||||
chooseLanguagePane.setNullSafeConverter(locale -> {
|
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
|
||||||
if (locale.isDefault())
|
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
|
||||||
return locale.getDisplayName(currentLocale);
|
|
||||||
else if (locale.isSameLanguage(currentLocale))
|
|
||||||
return locale.getDisplayName(locale);
|
|
||||||
else
|
|
||||||
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
|
|
||||||
});
|
|
||||||
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
|
|
||||||
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
|
|
||||||
|
|
||||||
languagePaneList.getContent().add(chooseLanguagePane);
|
SupportedLocale currentLocale = I18n.getLocale();
|
||||||
|
chooseLanguagePane.setNullSafeConverter(locale -> {
|
||||||
|
if (locale.isDefault())
|
||||||
|
return locale.getDisplayName(currentLocale);
|
||||||
|
else if (locale.isSameLanguage(currentLocale))
|
||||||
|
return locale.getDisplayName(locale);
|
||||||
|
else
|
||||||
|
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
|
||||||
|
});
|
||||||
|
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
|
||||||
|
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
|
||||||
|
list.getContent().add(chooseLanguagePane);
|
||||||
|
|
||||||
}
|
// One choice drives both the file download source and the version list source.
|
||||||
|
var downloadSourcePane = new LineSelectButton<DownloadSource>();
|
||||||
|
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
|
||||||
|
downloadSourcePane.setNullSafeConverter(source -> i18n("settings.download_source." + source.name().toLowerCase(Locale.ROOT)));
|
||||||
|
downloadSourcePane.setItems(List.of(DownloadSource.values()));
|
||||||
|
downloadSourcePane.valueProperty().bindBidirectional(settings().fileDownloadSourceProperty());
|
||||||
|
downloadSourcePane.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (newValue != null)
|
||||||
|
settings().versionListSourceProperty().set(newValue);
|
||||||
|
});
|
||||||
|
list.getContent().add(downloadSourcePane);
|
||||||
|
|
||||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList);
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the card with the maximum memory slider and the Java summary row.
|
||||||
|
private static ComponentList createGameCard() {
|
||||||
|
ComponentList list = new ComponentList();
|
||||||
|
GameSettings.Preset preset = SettingsManager.getDefaultGameSettingsPresetOrCreate();
|
||||||
|
|
||||||
|
int totalMiB = Math.max(MIN_MEMORY_MIB, (int) DataSizeUnit.MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()));
|
||||||
|
JFXSlider slider = new JFXSlider(MIN_MEMORY_MIB, totalMiB, MIN_MEMORY_MIB);
|
||||||
|
slider.setBlockIncrement(MEMORY_STEP_MIB);
|
||||||
|
slider.setPrefWidth(280);
|
||||||
|
slider.setValueFactory(s -> Bindings.createStringBinding(
|
||||||
|
() -> "%.1f GB".formatted(s.getValue() / 1024), s.valueProperty()));
|
||||||
|
@Nullable Integer current = preset.maxMemoryProperty().getValue();
|
||||||
|
slider.setValue(current != null && current > 0 ? Math.min(current, totalMiB) : Math.min(4096, totalMiB));
|
||||||
|
// Moving the slider switches off automatic allocation and stores the chosen limit.
|
||||||
|
slider.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
int mib = (int) (Math.round(newValue.doubleValue() / MEMORY_STEP_MIB) * MEMORY_STEP_MIB);
|
||||||
|
preset.autoMemoryProperty().setValue(false);
|
||||||
|
preset.maxMemoryProperty().setValue(Math.max(MIN_MEMORY_MIB, mib));
|
||||||
|
});
|
||||||
|
|
||||||
|
BorderPane memoryPane = new BorderPane();
|
||||||
|
Label memoryLabel = new Label(i18n("settings.memory"));
|
||||||
|
BorderPane.setAlignment(memoryLabel, Pos.CENTER_LEFT);
|
||||||
|
memoryPane.setLeft(memoryLabel);
|
||||||
|
memoryPane.setRight(slider);
|
||||||
|
list.getContent().add(memoryPane);
|
||||||
|
|
||||||
|
BorderPane javaPane = new BorderPane();
|
||||||
|
Label javaLabel = new Label(i18n("settings.java.summary"));
|
||||||
|
BorderPane.setAlignment(javaLabel, Pos.CENTER_LEFT);
|
||||||
|
javaPane.setLeft(javaLabel);
|
||||||
|
JFXButton advanced = FXUtils.newBorderButton(i18n("settings.java.advanced"));
|
||||||
|
advanced.setOnAction(e -> Controllers.navigate(new SubPage(i18n("java.management"), new JavaManagementPage())));
|
||||||
|
javaPane.setRight(advanced);
|
||||||
|
list.getContent().add(javaPane);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the card with the log actions and the entry to the about page.
|
||||||
|
private ComponentList createHelpCard() {
|
||||||
|
ComponentList list = new ComponentList();
|
||||||
|
|
||||||
|
BorderPane debugPane = new BorderPane();
|
||||||
|
|
||||||
|
Label left = new Label(i18n("settings.launcher.debug"));
|
||||||
|
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
|
||||||
|
debugPane.setLeft(left);
|
||||||
|
|
||||||
|
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
|
||||||
|
openLogFolderButton.setOnAction(e -> openLogFolder());
|
||||||
|
openLogFolderButton.getStyleClass().add("jfx-button-border");
|
||||||
|
if (LOG.getLogFile() == null)
|
||||||
|
openLogFolderButton.setDisable(true);
|
||||||
|
|
||||||
|
SpinnerPane exportLogPane = new SpinnerPane();
|
||||||
|
|
||||||
|
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
|
||||||
|
exportLogPane.setContent(logButton);
|
||||||
|
logButton.setOnAction(e -> {
|
||||||
|
exportLogPane.showSpinner();
|
||||||
|
onExportLogs().whenCompleteAsync((result, exception) -> {
|
||||||
|
exportLogPane.hideSpinner();
|
||||||
|
if (exception == null) {
|
||||||
|
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
|
||||||
|
FXUtils.showFileInExplorer(result);
|
||||||
|
} else {
|
||||||
|
LOG.warning("Failed to export logs", exception);
|
||||||
|
Controllers.dialog(
|
||||||
|
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
|
||||||
|
null,
|
||||||
|
MessageType.ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, Schedulers.javafx());
|
||||||
|
});
|
||||||
|
|
||||||
|
HBox buttonBox = new HBox();
|
||||||
|
buttonBox.setSpacing(10);
|
||||||
|
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
|
||||||
|
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
|
||||||
|
debugPane.setRight(buttonBox);
|
||||||
|
list.getContent().add(debugPane);
|
||||||
|
|
||||||
|
var about = new LineButton();
|
||||||
|
about.setTitle(i18n("settings.help.about"));
|
||||||
|
about.setTrailingIcon(SVG.ARROW_FORWARD);
|
||||||
|
about.setOnAction(e -> Controllers.navigate(new SubPage(i18n("about"), new AboutPage())));
|
||||||
|
list.getContent().add(about);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A titled page that hosts a settings sub page (Java management, about) inside the main navigator.
|
||||||
|
private static final class SubPage extends StackPane implements DecoratorPage {
|
||||||
|
/// The page state, holding the title shown in the window header.
|
||||||
|
private final ReadOnlyObjectWrapper<State> state;
|
||||||
|
|
||||||
|
/// Creates the page.
|
||||||
|
///
|
||||||
|
/// @param title the title shown in the header
|
||||||
|
/// @param content the page content
|
||||||
|
SubPage(String title, Node content) {
|
||||||
|
this.state = new ReadOnlyObjectWrapper<>(State.fromTitle(title));
|
||||||
|
getChildren().setAll(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
@Override
|
||||||
ComponentList miscPaneList = new ComponentList();
|
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||||
|
return state.getReadOnlyProperty();
|
||||||
{
|
|
||||||
BorderPane debugPane = new BorderPane();
|
|
||||||
|
|
||||||
Label left = new Label(i18n("settings.launcher.debug"));
|
|
||||||
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
|
|
||||||
debugPane.setLeft(left);
|
|
||||||
|
|
||||||
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
|
|
||||||
openLogFolderButton.setOnAction(e -> openLogFolder());
|
|
||||||
openLogFolderButton.getStyleClass().add("jfx-button-border");
|
|
||||||
if (LOG.getLogFile() == null)
|
|
||||||
openLogFolderButton.setDisable(true);
|
|
||||||
|
|
||||||
SpinnerPane exportLogPane = new SpinnerPane();
|
|
||||||
|
|
||||||
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
|
|
||||||
exportLogPane.setContent(logButton);
|
|
||||||
logButton.setOnAction(e -> {
|
|
||||||
exportLogPane.showSpinner();
|
|
||||||
onExportLogs().whenCompleteAsync((result, exception) -> {
|
|
||||||
exportLogPane.hideSpinner();
|
|
||||||
if (exception == null) {
|
|
||||||
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
|
|
||||||
FXUtils.showFileInExplorer(result);
|
|
||||||
} else {
|
|
||||||
LOG.warning("Failed to export logs", exception);
|
|
||||||
Controllers.dialog(
|
|
||||||
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
|
|
||||||
null,
|
|
||||||
MessageType.ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, Schedulers.javafx());
|
|
||||||
});
|
|
||||||
|
|
||||||
HBox buttonBox = new HBox();
|
|
||||||
buttonBox.setSpacing(10);
|
|
||||||
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
|
|
||||||
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
|
|
||||||
debugPane.setRight(buttonBox);
|
|
||||||
|
|
||||||
miscPaneList.getContent().add(debugPane);
|
|
||||||
}
|
|
||||||
|
|
||||||
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2237,6 +2237,65 @@
|
|||||||
-fx-text-fill: -monet-on-primary;
|
-fx-text-fill: -monet-on-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-progress-title {
|
||||||
|
-fx-font-family: "Noto Sans SC";
|
||||||
|
-fx-font-size: 16px;
|
||||||
|
-fx-text-fill: -monet-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-bar {
|
||||||
|
-fx-pref-height: 8px;
|
||||||
|
-fx-min-height: 8px;
|
||||||
|
-fx-max-height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-bar > .track {
|
||||||
|
-fx-background-color: -monet-outline-variant;
|
||||||
|
-fx-background-radius: 8px;
|
||||||
|
-fx-background-insets: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-bar > .bar {
|
||||||
|
-fx-background-color: -monet-primary;
|
||||||
|
-fx-background-radius: 8px;
|
||||||
|
-fx-background-insets: 0;
|
||||||
|
-fx-padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-error {
|
||||||
|
-fx-font-size: 12px;
|
||||||
|
-fx-text-fill: -monet-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-button {
|
||||||
|
-fx-font-family: "Noto Sans SC";
|
||||||
|
-fx-background-color: transparent;
|
||||||
|
-fx-border-color: -monet-primary;
|
||||||
|
-fx-border-radius: 13px;
|
||||||
|
-fx-background-radius: 13px;
|
||||||
|
-fx-text-fill: -monet-primary;
|
||||||
|
-fx-padding: 8px 21px;
|
||||||
|
-fx-cursor: hand;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-button .label {
|
||||||
|
-fx-text-fill: -monet-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-step {
|
||||||
|
-fx-font-family: "Noto Sans SC";
|
||||||
|
-fx-font-size: 12px;
|
||||||
|
-fx-text-fill: -monet-on-surface-variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-step:active {
|
||||||
|
-fx-text-fill: -monet-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-progress-step:done {
|
||||||
|
-fx-text-fill: -monet-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
.home-art-line {
|
.home-art-line {
|
||||||
-fx-stroke: -monet-outline-variant;
|
-fx-stroke: -monet-outline-variant;
|
||||||
-fx-stroke-width: 1.5px;
|
-fx-stroke-width: 1.5px;
|
||||||
|
|||||||
@@ -808,9 +808,34 @@ first_run.microsoft=Sign in with Microsoft
|
|||||||
first_run.invalid=Use 1 to 16 letters, digits or underscores.
|
first_run.invalid=Use 1 to 16 letters, digits or underscores.
|
||||||
home.server=Server
|
home.server=Server
|
||||||
home.server.unreachable=Unreachable
|
home.server.unreachable=Unreachable
|
||||||
|
home.progress.step.check=Check updates
|
||||||
|
home.progress.step.java=Prepare Java
|
||||||
|
home.progress.step.sync=Sync modpack
|
||||||
|
home.progress.step.join=Join server
|
||||||
|
home.progress.checking=Checking for updates…
|
||||||
|
home.progress.syncing=Syncing modpack · %d%%
|
||||||
|
home.progress.failed=Sync failed
|
||||||
|
home.progress.retry=Retry
|
||||||
|
settings.group.game=Game
|
||||||
|
settings.group.help=Help
|
||||||
|
settings.java.summary=Java · managed automatically
|
||||||
|
settings.java.advanced=Advanced…
|
||||||
|
settings.help.about=About
|
||||||
|
about.based_on=Based on HMCL
|
||||||
|
about.check_update=Check for updates
|
||||||
|
about.check_update.latest=The modpack is up to date.
|
||||||
|
about.check_update.available=An update is available. Press "Update" on the home page.
|
||||||
|
about.check_update.not_installed=The modpack is not installed yet.
|
||||||
|
about.source_code=Source code
|
||||||
|
about.copy_diagnostics=Copy diagnostics
|
||||||
|
home.motd=Server announcement
|
||||||
|
home.restart.title=Restart game
|
||||||
|
home.restart.update=The game closed to apply an update. Update the client and start it again now?
|
||||||
|
home.restart.generic=The game asked to be restarted. Update the client and start it again now?
|
||||||
server.join=Join Server
|
server.join=Join Server
|
||||||
|
server.update=Update
|
||||||
instance.launch_and_enter_world=Play World
|
instance.launch_and_enter_world=Play World
|
||||||
instance.launch.empty=Start Game
|
instance.launch.empty=Join Server
|
||||||
instance.launch.empty.installing=Installing Game
|
instance.launch.empty.installing=Installing Game
|
||||||
instance.launch.empty.tooltip=Install and launch the bundled server modpack
|
instance.launch.empty.tooltip=Install and launch the bundled server modpack
|
||||||
instance.launch.test=Test Launch
|
instance.launch.test=Test Launch
|
||||||
@@ -1559,6 +1584,9 @@ settings.launcher.download.threads=Download Threads
|
|||||||
settings.launcher.download.threads.auto=Automatically Determine
|
settings.launcher.download.threads.auto=Automatically Determine
|
||||||
settings.launcher.download.threads.custom=Custom
|
settings.launcher.download.threads.custom=Custom
|
||||||
settings.launcher.download_source=Download Source
|
settings.launcher.download_source=Download Source
|
||||||
|
settings.download_source.default=Automatic
|
||||||
|
settings.download_source.official=Official
|
||||||
|
settings.download_source.mirror=Mirror
|
||||||
settings.launcher.download_source.auto=Automatically Choose Download Sources
|
settings.launcher.download_source.auto=Automatically Choose Download Sources
|
||||||
settings.launcher.default_addon_source=Default Addon Source
|
settings.launcher.default_addon_source=Default Addon Source
|
||||||
settings.launcher.misc=Misc
|
settings.launcher.misc=Misc
|
||||||
|
|||||||
@@ -628,7 +628,12 @@ first_run.microsoft=登正版
|
|||||||
first_run.invalid=一至十六字,限英文數字底綫。
|
first_run.invalid=一至十六字,限英文數字底綫。
|
||||||
home.server=服
|
home.server=服
|
||||||
home.server.unreachable=不通
|
home.server.unreachable=不通
|
||||||
|
home.motd=服公告
|
||||||
|
home.restart.title=重啟遊戲
|
||||||
|
home.restart.update=遊戲爲更新而止。今更新並重啟乎?
|
||||||
|
home.restart.generic=遊戲請重啟。今更新並重啟乎?
|
||||||
server.join=入服
|
server.join=入服
|
||||||
|
server.update=更新
|
||||||
# instance.launch_and_enter_world=
|
# instance.launch_and_enter_world=
|
||||||
instance.launch.empty=啟戲
|
instance.launch.empty=啟戲
|
||||||
instance.launch.empty.installing=裝戲
|
instance.launch.empty.installing=裝戲
|
||||||
|
|||||||
@@ -631,9 +631,34 @@ first_run.microsoft=登录正版账号
|
|||||||
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
|
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
|
||||||
home.server=服务器
|
home.server=服务器
|
||||||
home.server.unreachable=连接不上
|
home.server.unreachable=连接不上
|
||||||
|
home.progress.step.check=检查更新
|
||||||
|
home.progress.step.java=准备 Java
|
||||||
|
home.progress.step.sync=同步整合包
|
||||||
|
home.progress.step.join=进入服务器
|
||||||
|
home.progress.checking=正在检查更新…
|
||||||
|
home.progress.syncing=正在同步整合包 · %d%%
|
||||||
|
home.progress.failed=同步失败
|
||||||
|
home.progress.retry=重试
|
||||||
|
settings.group.game=游戏
|
||||||
|
settings.group.help=帮助
|
||||||
|
settings.java.summary=Java · 自动管理
|
||||||
|
settings.java.advanced=高级…
|
||||||
|
settings.help.about=关于
|
||||||
|
about.based_on=基于 HMCL
|
||||||
|
about.check_update=检查更新
|
||||||
|
about.check_update.latest=整合包已是最新。
|
||||||
|
about.check_update.available=有可用更新,请在首页点击“更新”。
|
||||||
|
about.check_update.not_installed=尚未安装整合包。
|
||||||
|
about.source_code=源代码
|
||||||
|
about.copy_diagnostics=复制诊断信息
|
||||||
|
home.motd=服务器公告
|
||||||
|
home.restart.title=重启游戏
|
||||||
|
home.restart.update=游戏为应用更新已退出。现在更新客户端并重新启动游戏吗?
|
||||||
|
home.restart.generic=游戏请求重启。现在更新客户端并重新启动吗?
|
||||||
server.join=进入服务器
|
server.join=进入服务器
|
||||||
|
server.update=更新
|
||||||
instance.launch_and_enter_world=进入世界
|
instance.launch_and_enter_world=进入世界
|
||||||
instance.launch.empty=开始游戏
|
instance.launch.empty=进入服务器
|
||||||
instance.launch.empty.installing=安装游戏
|
instance.launch.empty.installing=安装游戏
|
||||||
instance.launch.empty.tooltip=安装并启动服务器整合包
|
instance.launch.empty.tooltip=安装并启动服务器整合包
|
||||||
instance.launch.test=测试游戏
|
instance.launch.test=测试游戏
|
||||||
@@ -1376,6 +1401,9 @@ settings.launcher.download.threads=下载线程数
|
|||||||
settings.launcher.download.threads.auto=自动选择线程数
|
settings.launcher.download.threads.auto=自动选择线程数
|
||||||
settings.launcher.download.threads.custom=自定义线程数
|
settings.launcher.download.threads.custom=自定义线程数
|
||||||
settings.launcher.download_source=下载源
|
settings.launcher.download_source=下载源
|
||||||
|
settings.download_source.default=自动
|
||||||
|
settings.download_source.official=官方源
|
||||||
|
settings.download_source.mirror=镜像源
|
||||||
settings.launcher.download_source.auto=自动选择下载源
|
settings.launcher.download_source.auto=自动选择下载源
|
||||||
settings.launcher.default_addon_source=附加内容默认下载源
|
settings.launcher.default_addon_source=附加内容默认下载源
|
||||||
settings.launcher.misc=杂项
|
settings.launcher.misc=杂项
|
||||||
|
|||||||
@@ -625,9 +625,34 @@ first_run.microsoft=登入正版帳號
|
|||||||
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
|
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
|
||||||
home.server=伺服器
|
home.server=伺服器
|
||||||
home.server.unreachable=無法連線
|
home.server.unreachable=無法連線
|
||||||
|
home.progress.step.check=檢查更新
|
||||||
|
home.progress.step.java=準備 Java
|
||||||
|
home.progress.step.sync=同步整合包
|
||||||
|
home.progress.step.join=進入伺服器
|
||||||
|
home.progress.checking=正在檢查更新…
|
||||||
|
home.progress.syncing=正在同步整合包 · %d%%
|
||||||
|
home.progress.failed=同步失敗
|
||||||
|
home.progress.retry=重試
|
||||||
|
settings.group.game=遊戲
|
||||||
|
settings.group.help=說明
|
||||||
|
settings.java.summary=Java · 自動管理
|
||||||
|
settings.java.advanced=進階…
|
||||||
|
settings.help.about=關於
|
||||||
|
about.based_on=基於 HMCL
|
||||||
|
about.check_update=檢查更新
|
||||||
|
about.check_update.latest=整合包已是最新。
|
||||||
|
about.check_update.available=有可用更新,請在首頁點擊「更新」。
|
||||||
|
about.check_update.not_installed=尚未安裝整合包。
|
||||||
|
about.source_code=原始碼
|
||||||
|
about.copy_diagnostics=複製診斷資訊
|
||||||
|
home.motd=伺服器公告
|
||||||
|
home.restart.title=重啟遊戲
|
||||||
|
home.restart.update=遊戲為套用更新已結束。現在更新客戶端並重新啟動遊戲嗎?
|
||||||
|
home.restart.generic=遊戲要求重啟。現在更新客戶端並重新啟動嗎?
|
||||||
server.join=進入伺服器
|
server.join=進入伺服器
|
||||||
|
server.update=更新
|
||||||
instance.launch_and_enter_world=進入世界
|
instance.launch_and_enter_world=進入世界
|
||||||
instance.launch.empty=開始遊戲
|
instance.launch.empty=進入伺服器
|
||||||
instance.launch.empty.installing=安裝遊戲
|
instance.launch.empty.installing=安裝遊戲
|
||||||
instance.launch.empty.tooltip=安裝並啟動伺服器整合包
|
instance.launch.empty.tooltip=安裝並啟動伺服器整合包
|
||||||
instance.launch.test=測試遊戲
|
instance.launch.test=測試遊戲
|
||||||
@@ -1370,6 +1395,9 @@ settings.launcher.download.threads=下載執行緒數
|
|||||||
settings.launcher.download.threads.auto=自動選取執行緒數
|
settings.launcher.download.threads.auto=自動選取執行緒數
|
||||||
settings.launcher.download.threads.custom=自訂執行緒數
|
settings.launcher.download.threads.custom=自訂執行緒數
|
||||||
settings.launcher.download_source=下載來源
|
settings.launcher.download_source=下載來源
|
||||||
|
settings.download_source.default=自動
|
||||||
|
settings.download_source.official=官方來源
|
||||||
|
settings.download_source.mirror=鏡像來源
|
||||||
settings.launcher.download_source.auto=自動選取下載來源
|
settings.launcher.download_source.auto=自動選取下載來源
|
||||||
settings.launcher.default_addon_source=附加內容預設下載來源
|
settings.launcher.default_addon_source=附加內容預設下載來源
|
||||||
settings.launcher.misc=雜項
|
settings.launcher.misc=雜項
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/// Tests [RestartRequest].
|
||||||
|
public final class RestartRequestTest {
|
||||||
|
@Test
|
||||||
|
void noFileMeansNoRequest(@TempDir Path dir) {
|
||||||
|
assertNull(RestartRequest.consume(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsReasonAndDeletesFile(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve(RestartRequest.FILE_NAME);
|
||||||
|
Files.writeString(file, "{\"reason\":\"update\"}");
|
||||||
|
assertEquals("update", RestartRequest.consume(dir));
|
||||||
|
assertFalse(Files.exists(file));
|
||||||
|
assertNull(RestartRequest.consume(dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedFileStillCounts(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve(RestartRequest.FILE_NAME);
|
||||||
|
Files.writeString(file, "not json");
|
||||||
|
assertEquals("unknown", RestartRequest.consume(dir));
|
||||||
|
assertFalse(Files.exists(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -25,6 +25,7 @@ import java.nio.file.attribute.BasicFileAttributes;
|
|||||||
import java.security.DigestInputStream;
|
import java.security.DigestInputStream;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ public final class MojangJavaDownloadTask extends Task<MojangJavaDownloadTask.Re
|
|||||||
private final Path tempDir;
|
private final Path tempDir;
|
||||||
private final Task<MojangJavaRemoteFiles> javaDownloadsTask;
|
private final Task<MojangJavaRemoteFiles> javaDownloadsTask;
|
||||||
private final List<Task<?>> dependencies = new ArrayList<>();
|
private final List<Task<?>> dependencies = new ArrayList<>();
|
||||||
|
private final AtomicInteger completedFiles = new AtomicInteger();
|
||||||
|
private volatile int totalFiles;
|
||||||
|
|
||||||
private volatile MojangJavaDownloads.JavaDownload download;
|
private volatile MojangJavaDownloads.JavaDownload download;
|
||||||
|
|
||||||
@@ -147,6 +150,21 @@ public final class MojangJavaDownloadTask extends Task<MojangJavaDownloadTask.Re
|
|||||||
Files.createSymbolicLink(dest, Paths.get(link.getTarget()));
|
Files.createSymbolicLink(dest, Paths.get(link.getTarget()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report per-file progress so the bar advances through the download, LZMA decompress and
|
||||||
|
// cache-copy phases; otherwise it stays indeterminate while files appear on disk and players
|
||||||
|
// assume the install is frozen. The callbacks run after each file completes and never touch
|
||||||
|
// the download/decompress logic above.
|
||||||
|
totalFiles = dependencies.size();
|
||||||
|
if (totalFiles > 0) {
|
||||||
|
List<Task<?>> tracked = new ArrayList<>(totalFiles);
|
||||||
|
for (Task<?> dependency : dependencies) {
|
||||||
|
tracked.add(dependency.thenRunAsync(() -> updateProgress(completedFiles.incrementAndGet(), totalFiles)));
|
||||||
|
}
|
||||||
|
dependencies.clear();
|
||||||
|
dependencies.addAll(tracked);
|
||||||
|
updateProgress(0, totalFiles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ public class DefaultLauncher extends Launcher {
|
|||||||
env.put("__NV_PRIME_RENDER_OFFLOAD", "1");
|
env.put("__NV_PRIME_RENDER_OFFLOAD", "1");
|
||||||
env.put("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
env.put("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||||
env.put("__VK_LAYER_NV_optimus", "NVIDIA_only");
|
env.put("__VK_LAYER_NV_optimus", "NVIDIA_only");
|
||||||
|
} else if (HardwareVendor.AMD.equals(discreteGraphicsCard.getVendor())
|
||||||
|
|| HardwareVendor.NVIDIA.equals(discreteGraphicsCard.getVendor())) {
|
||||||
|
// Mesa PRIME offload (open source AMD / nouveau drivers)
|
||||||
|
env.put("DRI_PRIME", "1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -213,6 +213,6 @@ public class ServerModpackCompletionTask extends Task<Void> {
|
|||||||
public void postExecute() throws Exception {
|
public void postExecute() throws Exception {
|
||||||
if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return;
|
if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return;
|
||||||
Files.createDirectories(configurationFile.getParent());
|
Files.createDirectories(configurationFile.getParent());
|
||||||
JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles()));
|
JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), remoteManifest.getVersion(), remoteManifest.getFiles()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
package org.jackhuang.hmcl.util;
|
package org.jackhuang.hmcl.util;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
import org.jetbrains.annotations.NotNullByDefault;
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
@@ -28,7 +30,40 @@ public final class ServerStatusPing {
|
|||||||
/// @param online the number of players online
|
/// @param online the number of players online
|
||||||
/// @param max the player limit
|
/// @param max the player limit
|
||||||
/// @param latencyMillis the round-trip time of the ping packet in milliseconds
|
/// @param latencyMillis the round-trip time of the ping packet in milliseconds
|
||||||
public record Status(int online, int max, long latencyMillis) {
|
/// @param motd the server's message of the day as plain text without colour codes; empty if absent
|
||||||
|
public record Status(int online, int max, long latencyMillis, String motd) {
|
||||||
|
/// Creates a status without a message of the day.
|
||||||
|
public Status(int online, int max, long latencyMillis) {
|
||||||
|
this(online, max, latencyMillis, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flattens the `description` field of a status response (a plain string or a chat component with
|
||||||
|
/// `text` and `extra`) into plain text, dropping `§x` colour and format codes.
|
||||||
|
///
|
||||||
|
/// @param description the `description` element, may be `null`
|
||||||
|
/// @return the plain text, trimmed; empty when there is nothing to show
|
||||||
|
public static String motdToPlainText(@Nullable JsonElement description) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
appendComponent(description, builder);
|
||||||
|
return builder.toString().replaceAll("\u00a7.", "").strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendComponent(@Nullable JsonElement element, StringBuilder out) {
|
||||||
|
if (element == null || element.isJsonNull()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.isJsonPrimitive()) {
|
||||||
|
out.append(element.getAsString());
|
||||||
|
} else if (element.isJsonArray()) {
|
||||||
|
for (JsonElement item : element.getAsJsonArray()) {
|
||||||
|
appendComponent(item, out);
|
||||||
|
}
|
||||||
|
} else if (element.isJsonObject()) {
|
||||||
|
JsonObject object = element.getAsJsonObject();
|
||||||
|
appendComponent(object.get("text"), out);
|
||||||
|
appendComponent(object.get("extra"), out);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The protocol number sent in the handshake; Minecraft 1.7.10 uses 5 and servers answer any value.
|
/// The protocol number sent in the handshake; Minecraft 1.7.10 uses 5 and servers answer any value.
|
||||||
@@ -73,9 +108,11 @@ public final class ServerStatusPing {
|
|||||||
in.readFully(json);
|
in.readFully(json);
|
||||||
int online;
|
int online;
|
||||||
int max;
|
int max;
|
||||||
|
String motd;
|
||||||
try {
|
try {
|
||||||
JsonObject players = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class)
|
JsonObject response = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class);
|
||||||
.getAsJsonObject("players");
|
motd = motdToPlainText(response.get("description"));
|
||||||
|
JsonObject players = response.getAsJsonObject("players");
|
||||||
online = players.get("online").getAsInt();
|
online = players.get("online").getAsInt();
|
||||||
max = players.get("max").getAsInt();
|
max = players.get("max").getAsInt();
|
||||||
} catch (JsonParseException | NullPointerException | ClassCastException | IllegalStateException e) {
|
} catch (JsonParseException | NullPointerException | ClassCastException | IllegalStateException e) {
|
||||||
@@ -94,7 +131,7 @@ public final class ServerStatusPing {
|
|||||||
throw new IOException("Unexpected pong packet");
|
throw new IOException("Unexpected pong packet");
|
||||||
}
|
}
|
||||||
in.readLong();
|
in.readLong();
|
||||||
return new Status(online, max, (System.nanoTime() - start) / 1_000_000);
|
return new Status(online, max, (System.nanoTime() - start) / 1_000_000, motd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public final class ServerStatusPingTest {
|
|||||||
var status = ServerStatusPing.ping("127.0.0.1", server.getLocalPort(), 3000);
|
var status = ServerStatusPing.ping("127.0.0.1", server.getLocalPort(), 3000);
|
||||||
assertEquals(12, status.online());
|
assertEquals(12, status.online());
|
||||||
assertEquals(50, status.max());
|
assertEquals(50, status.max());
|
||||||
|
assertEquals("TDLM 公告: 今晚维护", status.motd());
|
||||||
assertTrue(status.latencyMillis() >= 0);
|
assertTrue(status.latencyMillis() >= 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,7 @@ public final class ServerStatusPingTest {
|
|||||||
skipPacket(in); // handshake
|
skipPacket(in); // handshake
|
||||||
skipPacket(in); // status request
|
skipPacket(in); // status request
|
||||||
|
|
||||||
byte[] json = "{\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8);
|
byte[] json = "{\"description\":{\"text\":\"§aTDLM \",\"extra\":[{\"text\":\"公告: 今晚维护\"}]},\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8);
|
||||||
byte[] body = concat(new byte[]{0x00}, varInt(json.length), json);
|
byte[] body = concat(new byte[]{0x00}, varInt(json.length), json);
|
||||||
out.write(varInt(body.length));
|
out.write(varInt(body.length));
|
||||||
out.write(body);
|
out.write(body);
|
||||||
@@ -88,4 +89,11 @@ public final class ServerStatusPingTest {
|
|||||||
for (byte[] part : parts) out.writeBytes(part);
|
for (byte[] part : parts) out.writeBytes(part);
|
||||||
return out.toByteArray();
|
return out.toByteArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flattensPlainStringMotd() {
|
||||||
|
var json = com.google.gson.JsonParser.parseString("\"§l欢迎§r来玩\"");
|
||||||
|
assertEquals("欢迎来玩", ServerStatusPing.motdToPlainText(json));
|
||||||
|
assertEquals("", ServerStatusPing.motdToPlainText(null));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+15
-8
@@ -46,26 +46,33 @@ Assets on each tag: `HMCL.exe`, `HMCL.jar`, plus `.sha256` files.
|
|||||||
In `TDLM/TDLMPack` README §启动, replace the repo-only “安装 TDLM/HMCL” step with one of the URLs above.
|
In `TDLM/TDLMPack` README §启动, replace the repo-only “安装 TDLM/HMCL” step with one of the URLs above.
|
||||||
**Do not** add HMCL as a Gradle/Maven dependency of the pack — pack CI stays pack-only.
|
**Do not** add HMCL as a Gradle/Maven dependency of the pack — pack CI stays pack-only.
|
||||||
|
|
||||||
### 4. Client pack fileApi (CDN)
|
### 4. Client pack fileApi (self-hosted VPS)
|
||||||
|
|
||||||
HMCL default (`Metadata.DEFAULT_CLIENT_FILE_API`):
|
HMCL default (`Metadata.DEFAULT_CLIENT_FILE_API`):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://packages.dandanton.com/tdlm-client/latest
|
http://159.75.5.225:19527/tdlm-client
|
||||||
```
|
```
|
||||||
|
|
||||||
First install / updates fetch `{fileApi}/server-manifest.json` (and `overrides/**` under that root).
|
First install / updates fetch `{fileApi}/server-manifest.json` (and `overrides/**` under that root).
|
||||||
Override at runtime: `-Dhmcl.client.fileApi=…` or jar attribute `hmcl.client.fileApi`.
|
Override at runtime: `-Dhmcl.client.fileApi=…` or jar attribute `hmcl.client.fileApi`.
|
||||||
|
|
||||||
|
The fileApi is a read-only nginx static mirror on the TDLM VPS (`159.75.5.225`, port `19527`,
|
||||||
|
root `/var/www/tdlm-dl`). Players read it anonymously; integrity is verified by the SHA-1 hashes
|
||||||
|
in `server-manifest.json`.
|
||||||
|
|
||||||
**TDLMPack must use the same URL** so `server-manifest.json` embeds it:
|
**TDLMPack must use the same URL** so `server-manifest.json` embeds it:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TDLM_CLIENT_FILE_API=https://packages.dandanton.com/tdlm-client/latest
|
TDLM_CLIENT_FILE_API=http://159.75.5.225:19527/tdlm-client
|
||||||
```
|
```
|
||||||
|
|
||||||
Set that as the Gitea Actions **variable** on `TDLM/TDLMPack` (pack.yml already passes `vars.TDLM_CLIENT_FILE_API`).
|
Set that as the Gitea Actions **variable** on `TDLM/TDLMPack` (pack.yml passes `vars.TDLM_CLIENT_FILE_API`;
|
||||||
|
`PackBuild.DEFAULT_FILE_API` is the build-time fallback).
|
||||||
|
|
||||||
**Publish caveat:** Pack’s current workflow PUTs the client tree to whatever `TDLM_CLIENT_FILE_API` is (upload root = fileApi). Today that defaults to Gitea generic
|
**Publish path (upload origin split from public fileApi):** Pack CI no longer PUTs to the read URL.
|
||||||
`https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest`.
|
The `pack.yml` "Publish the client Server Modpack to fileApi" step rsyncs `build/pack/client/overrides/`
|
||||||
If you only change the HMCL default to the CDN and leave Pack publishing to Gitea, players will 404 unless you **mirror/sync** Gitea → `packages.dandanton.com/tdlm-client/latest`.
|
then `server-manifest.json` (manifest last) over SSH to `[email protected]:/var/www/tdlm-dl/tdlm-client`.
|
||||||
If you set `TDLM_CLIENT_FILE_API` to the CDN URL, Pack CI must be able to PUT to that CDN (or you must split “upload origin” from “public fileApi” in Pack — not implemented today). Do not point HMCL at the CDN without one of those two ops paths.
|
Auth is the repo secret `TDLM_VPS_SSH_KEY`, an ed25519 deploy key confined on the VPS by
|
||||||
|
`command="/usr/bin/rrsync /var/www/tdlm-dl/tdlm-client"` (rsync-only, no shell, no forwarding).
|
||||||
|
The public port 19527 serves GET/HEAD only — there is no public write surface.
|
||||||
Reference in new issue
Block a user