From 26187e6f61f44fde733c6f5656fbd6498366a691 Mon Sep 17 00:00:00 2001 From: Layue13 Date: Wed, 11 Nov 2026 12:14:47 +0800 Subject: [PATCH 1/2] feat: UI refresh batch 1 (home progress card, single settings page, slim about) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home: join/update button turns the bottom card into an in-place progress view (percent, thin bar, downloaded size/speed, cancel, four-step indicator, error + retry). ClientSyncProgress mirrors the existing task executor. - Label "Start Game" -> "进入服务器" (en: Join Server) in en/zh_Hans/zh_Hant. - Settings merged into one page: General / Game / Help cards; Java management and About open as sub pages. - About trimmed; system/device/runtime info moved into "Copy diagnostics". Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TiBPbVGBHNHdzqfq1L5tch --- .../hmcl/ui/instances/ClientSyncProgress.java | 178 +++++++++++ .../hmcl/ui/instances/Instances.java | 114 +++++-- .../org/jackhuang/hmcl/ui/main/AboutPage.java | 298 +++++++----------- .../hmcl/ui/main/LauncherSettingsPage.java | 55 +--- .../org/jackhuang/hmcl/ui/main/MainPage.java | 105 +++++- .../jackhuang/hmcl/ui/main/SettingsPage.java | 242 ++++++++++---- HMCL/src/main/resources/assets/css/root.css | 59 ++++ .../resources/assets/lang/I18N.properties | 25 +- .../assets/lang/I18N_zh_Hans.properties | 25 +- .../assets/lang/I18N_zh_Hant.properties | 25 +- 10 files changed, 785 insertions(+), 341 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java new file mode 100644 index 0000000000..156ee2b5b2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java @@ -0,0 +1,178 @@ +// 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.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 the number of finished tasks, so it never moves backwards but +/// may advance unevenly while new tasks are discovered. +@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 = 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; + private int seenTasks; + private int finishedTasks; + private boolean cancelled; + + /// Keeps the weakly registered speed listener alive. + @SuppressWarnings({"unused", "FieldCanBeLocal"}) + private final Consumer 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 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; + seenTasks = 0; + finishedTasks = 0; + 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; + } + + /// 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() { + @Override + public void onReady(Task ready) { + FXUtils.runInFX(() -> seenTasks++); + } + + @Override + public void onFinished(Task finishedTask) { + FXUtils.runInFX(() -> { + finishedTasks++; + if (executor != taskExecutor || seenTasks <= 0) + return; + double value = Math.min(0.99, (double) finishedTasks / seenTasks); + if (value > progress.get()) + progress.set(value); + }); + } + }); + TaskManager.getInstance().submit(taskExecutor, null); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java index aac6ac6b40..fb5ac67062 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java @@ -100,6 +100,14 @@ public final class Instances { /// 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(); if (!repository.isLoaded()) { return; @@ -113,33 +121,68 @@ public final class Instances { Path bundled = Metadata.findBundledModpackFile(); if (bundled != null) { - installBundledThenJoin(repository, bundled); + installBundledThenJoin(repository, bundled, progress); return; } - Controllers.taskDialog( - new GetTask(WebURL.parse(Metadata.getClientManifestUrl())) - .thenComposeAsync(Schedulers.javafx(), result -> { - ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson( - result, ServerModpackManifest.class); - if (manifest == null) { - throw new IOException(i18n("modpack.type.server.malformed")); - } - return ensureRemoteClientInstalled(repository, manifest); - }) - .whenComplete(Schedulers.javafx(), (ignored, exception) -> { - if (exception != null) { - LOG.warning("Failed to install or update TDLM client", exception); - Controllers.dialog( - StringUtils.getStackTrace(exception), - i18n("install.failed"), - MessageDialogPane.MessageType.ERROR); - return; - } - joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance()); - }), - i18n("modpack.installing"), - TaskCancellationAction.NORMAL); + if (progress != null) { + progress.begin(); + } + Task task = new GetTask(WebURL.parse(Metadata.getClientManifestUrl())) + .thenComposeAsync(Schedulers.javafx(), result -> { + ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson( + result, ServerModpackManifest.class); + if (manifest == null) { + throw new IOException(i18n("modpack.type.server.malformed")); + } + if (progress != null) { + // Java is prepared by the launch step; mark it as passed once the manifest is known. + progress.advanceTo(ClientSyncProgress.STEP_SYNC); + } + return ensureRemoteClientInstalled(repository, manifest); + }) + .whenComplete(Schedulers.javafx(), (ignored, exception) -> { + if (exception != null) { + LOG.warning("Failed to install or update TDLM client", exception); + reportFailure(progress, exception); + 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); + } + + /// Runs `task` in the progress card when `progress` is set, otherwise in the task dialog. + private static void runSyncTask(Task task, @Nullable ClientSyncProgress progress, TaskCancellationAction cancellation) { + if (progress != null) { + progress.submit(task); + } else { + Controllers.taskDialog(task, i18n("modpack.installing"), cancellation); + } + } + + /// 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. @@ -191,9 +234,12 @@ public final class Instances { TaskCancellationAction.NORMAL); } - private static void installBundledThenJoin(HMCLGameRepository repository, Path bundled) { - Controllers.taskDialog( - Task.composeAsync(Schedulers.io(), () -> { + 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)) { @@ -213,16 +259,16 @@ public final class Instances { .whenComplete(Schedulers.javafx(), (ignored, exception) -> { if (exception != null) { LOG.warning("Failed to install bundled modpack", exception); - Controllers.dialog( - StringUtils.getStackTrace(exception), - i18n("install.failed"), - MessageDialogPane.MessageType.ERROR); + reportFailure(progress, exception); return; } joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance()); - }), - i18n("modpack.installing"), - TaskCancellationAction.NO_CANCEL); + 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. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java index 76ea90f31e..2c9eba2286 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java @@ -3,11 +3,18 @@ package org.jackhuang.hmcl.ui.main; import com.google.gson.*; +import com.jfoenix.controls.JFXButton; +import javafx.geometry.Insets; import javafx.scene.control.ScrollPane; import javafx.scene.image.Image; import javafx.scene.layout.VBox; 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.ui.Controllers; +import org.jackhuang.hmcl.ui.instances.Instances; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; 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.DeviceInfo; 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.Nullable; 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.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 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. 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() { - VBox content = new VBox(); + VBox content = new VBox(13); + content.setPadding(new Insets(21)); content.getStyleClass().add("spinner-pane-content"); ScrollPane scrollPane = new ScrollPane(content); scrollPane.setFitToWidth(true); @@ -56,196 +68,112 @@ public final class AboutPage extends SpinnerPane { launcher.setLargeTitle(true); launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png")); 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); } - ComponentList systemInfo = new ComponentList(); - { - 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 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 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"); - + // The HMCL GPLv3 notice and the third-party dependency list must stay reachable. ComponentList legal = new ComponentList(); { - var copyright = new LinePane(); - copyright.setLargeTitle(true); - copyright.setTitle(i18n("about.copyright")); - copyright.setSubtitle(i18n("about.copyright.statement")); + var license = new LinePane(); + license.setLargeTitle(true); + license.setTitle(i18n("about.open_source")); + 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.createComponentListTitle(i18n("about")), - about, - ComponentList.createComponentListTitle(i18n("about.system")), - systemInfo, - ComponentList.createComponentListTitle(i18n("about.device")), - deviceInfo, - ComponentList.createComponentListTitle(i18n("about.runtime")), - runtimeInfo, - ComponentList.createComponentListTitle(i18n("about.dependency")), - deps, - ComponentList.createComponentListTitle(i18n("about.legal")), - legal - ); + ComponentList support = new ComponentList(); + { + var diagnostics = new LineButton(); + diagnostics.setLargeTitle(true); + diagnostics.setTitle(i18n("about.copy_diagnostics")); + diagnostics.setTrailingIcon(SVG.CONTENT_COPY); + diagnostics.setOnAction(e -> FXUtils.copyText(buildDiagnostics())); + support.getContent().add(diagnostics); + } + + content.getChildren().setAll(about, legal, support); + } + + /// 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 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) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java index 5a4e971039..af5034368e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java @@ -4,60 +4,21 @@ package org.jackhuang.hmcl.ui.main; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; -import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.animation.TransitionPane; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; +import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; - +import org.jetbrains.annotations.NotNullByDefault; 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 = 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 javaManagementTab; - /// The tab for general launcher settings. - private final TabItem settingsTab; - /// The tab for download settings. - private final TabItem downloadTab; - /// The tab displaying launcher information. - private final TabItem 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() { - javaManagementTab = new TabItem<>(JavaManagementPage::new); - 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(); + getChildren().setAll(new SettingsPage()); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 9807400fea..d96ce46515 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -6,6 +6,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXPopup; import javafx.beans.property.*; import javafx.beans.value.ObservableValue; +import javafx.css.PseudoClass; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; @@ -13,6 +14,7 @@ import javafx.geometry.Pos; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; 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.PopupMenu; 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.task.Schedulers; import org.jackhuang.hmcl.util.ServerAddress; @@ -61,6 +64,9 @@ public final class MainPage extends StackPane implements DecoratorPage { /// 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); titleNode.setPadding(new Insets(0, 0, 0, 2)); @@ -183,7 +189,102 @@ 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()); + card.visibleProperty().bind(progressCard.visibleProperty().not()); + card.managedProperty().bind(card.visibleProperty()); + + getChildren().addAll(art, hero, card, 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. @@ -240,7 +341,7 @@ public final class MainPage extends StackPane implements DecoratorPage { /// Installs or updates the client from fileApi when needed, then launches into the server. private void launch() { - Instances.ensureClientAndJoin(); + Instances.ensureClientAndJoin(syncProgress); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index 715ecf9fde..4e9a8c58df 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java @@ -3,7 +3,13 @@ package org.jackhuang.hmcl.ui.main; 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.scene.Node; +import javafx.scene.layout.StackPane; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; @@ -11,6 +17,14 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; 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.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -33,6 +47,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.concurrent.CompletableFuture; 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.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 { + + /// 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() { this.setFitToWidth(true); - VBox rootPane = new VBox(10); - rootPane.setPadding(new Insets(10)); + VBox rootPane = new VBox(13); + rootPane.setPadding(new Insets(21)); this.setContent(rootPane); FXUtils.smoothScrolling(this); - { - ComponentList languagePaneList = new ComponentList(); + rootPane.getChildren().addAll( + ComponentList.createComponentListTitle(i18n("settings.launcher.general")), createGeneralCard(), + ComponentList.createComponentListTitle(i18n("settings.group.game")), createGameCard(), + ComponentList.createComponentListTitle(i18n("settings.group.help")), createHelpCard()); + } - { - var chooseLanguagePane = new LineSelectButton(); - chooseLanguagePane.setTitle(i18n("settings.launcher.language")); - chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart")); + /// Creates the card with the language and download source choices. + private static ComponentList createGeneralCard() { + ComponentList list = new ComponentList(); - 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()); + var chooseLanguagePane = new LineSelectButton(); + chooseLanguagePane.setTitle(i18n("settings.launcher.language")); + chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart")); - 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(); + 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; + + /// 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); } - { - ComponentList miscPaneList = 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); - - miscPaneList.getContent().add(debugPane); - } - - rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList); + @Override + public ReadOnlyObjectProperty stateProperty() { + return state.getReadOnlyProperty(); } } diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index fe1c38ee83..f6e403a84c 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -2237,6 +2237,65 @@ -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 { -fx-stroke: -monet-outline-variant; -fx-stroke-width: 1.5px; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index b4d4b2384e..b90637607a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -808,10 +808,30 @@ first_run.microsoft=Sign in with Microsoft first_run.invalid=Use 1 to 16 letters, digits or underscores. home.server=Server 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 server.join=Join Server server.update=Update 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.tooltip=Install and launch the bundled server modpack instance.launch.test=Test Launch @@ -1560,6 +1580,9 @@ settings.launcher.download.threads=Download Threads settings.launcher.download.threads.auto=Automatically Determine settings.launcher.download.threads.custom=Custom 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.default_addon_source=Default Addon Source settings.launcher.misc=Misc diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties index ccfe52bb9c..5995d590a2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -631,10 +631,30 @@ first_run.microsoft=登录正版账号 first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。 home.server=服务器 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=复制诊断信息 server.join=进入服务器 server.update=更新 instance.launch_and_enter_world=进入世界 -instance.launch.empty=开始游戏 +instance.launch.empty=进入服务器 instance.launch.empty.installing=安装游戏 instance.launch.empty.tooltip=安装并启动服务器整合包 instance.launch.test=测试游戏 @@ -1377,6 +1397,9 @@ settings.launcher.download.threads=下载线程数 settings.launcher.download.threads.auto=自动选择线程数 settings.launcher.download.threads.custom=自定义线程数 settings.launcher.download_source=下载源 +settings.download_source.default=自动 +settings.download_source.official=官方源 +settings.download_source.mirror=镜像源 settings.launcher.download_source.auto=自动选择下载源 settings.launcher.default_addon_source=附加内容默认下载源 settings.launcher.misc=杂项 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties index a59141ecad..12c7bd3df9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -625,10 +625,30 @@ first_run.microsoft=登入正版帳號 first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。 home.server=伺服器 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=複製診斷資訊 server.join=進入伺服器 server.update=更新 instance.launch_and_enter_world=進入世界 -instance.launch.empty=開始遊戲 +instance.launch.empty=進入伺服器 instance.launch.empty.installing=安裝遊戲 instance.launch.empty.tooltip=安裝並啟動伺服器整合包 instance.launch.test=測試遊戲 @@ -1371,6 +1391,9 @@ settings.launcher.download.threads=下載執行緒數 settings.launcher.download.threads.auto=自動選取執行緒數 settings.launcher.download.threads.custom=自訂執行緒數 settings.launcher.download_source=下載來源 +settings.download_source.default=自動 +settings.download_source.official=官方來源 +settings.download_source.mirror=鏡像來源 settings.launcher.download_source.auto=自動選取下載來源 settings.launcher.default_addon_source=附加內容預設下載來源 settings.launcher.misc=雜項 -- 2.47.3 From bcefa87e2705ebe8636fa9a7a9df41d3e3402fae Mon Sep 17 00:00:00 2001 From: Layue13 Date: Wed, 11 Nov 2026 12:36:56 +0800 Subject: [PATCH 2/2] fix: base home sync progress on announced download total The percentage used finished/seen task counts, which hit the 99% cap as soon as the first tasks finished and then stayed there while new tasks were still being discovered. Use the "total" property announced by the sync task as the denominator and count finished file downloads; stay indeterminate until known. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TiBPbVGBHNHdzqfq1L5tch --- .../hmcl/ui/instances/ClientSyncProgress.java | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java index 156ee2b5b2..635587faf8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java @@ -4,6 +4,7 @@ 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; @@ -19,8 +20,7 @@ import java.util.function.Consumer; /// /// 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 the number of finished tasks, so it never moves backwards but -/// may advance unevenly while new tasks are discovered. +/// 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 { @@ -53,8 +53,12 @@ public final class ClientSyncProgress { private @Nullable TaskExecutor executor; private long downloadedBytes; - private int seenTasks; - private int finishedTasks; + /// 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> announcedTasks = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); private boolean cancelled; /// Keeps the weakly registered speed listener alive. @@ -101,8 +105,9 @@ public final class ClientSyncProgress { executor = null; cancelled = false; downloadedBytes = 0; - seenTasks = 0; - finishedTasks = 0; + expectedFiles = 0; + finishedFiles = 0; + announcedTasks.clear(); progress.set(-1); detail.set(""); error.set(""); @@ -149,6 +154,15 @@ public final class ClientSyncProgress { 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 @@ -156,21 +170,28 @@ public final class ClientSyncProgress { 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 onReady(Task ready) { - FXUtils.runInFX(() -> seenTasks++); + 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) { - FXUtils.runInFX(() -> { - finishedTasks++; - if (executor != taskExecutor || seenTasks <= 0) - return; - double value = Math.min(0.99, (double) finishedTasks / seenTasks); - if (value > progress.get()) - progress.set(value); - }); + if (finishedTask instanceof FileDownloadTask) { + FXUtils.runInFX(() -> { + finishedFiles++; + refreshProgress(taskExecutor); + }); + } } }); TaskManager.getInstance().submit(taskExecutor, null); -- 2.47.3