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..635587faf8 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ClientSyncProgress.java @@ -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 = 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> 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 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; + 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); + } +} 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 ce19a563f9..26de5d0cf9 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 396a609175..ad396531f7 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)); @@ -195,7 +201,103 @@ public final class MainPage extends StackPane implements DecoratorPage { } }); - getChildren().addAll(art, hero, bottom); + // 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. @@ -265,7 +367,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 c29ff38b38..e819cd6ace 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -808,6 +808,26 @@ 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 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? @@ -815,7 +835,7 @@ home.restart.generic=The game asked to be restarted. Update the client and start 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 @@ -1564,6 +1584,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 7627593875..f2f656d855 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -631,6 +631,26 @@ 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=复制诊断信息 home.motd=服务器公告 home.restart.title=重启游戏 home.restart.update=游戏为应用更新已退出。现在更新客户端并重新启动游戏吗? @@ -638,7 +658,7 @@ home.restart.generic=游戏请求重启。现在更新客户端并重新启动 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=测试游戏 @@ -1381,6 +1401,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 089164cc9a..de3d1cfc15 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -625,6 +625,26 @@ 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=複製診斷資訊 home.motd=伺服器公告 home.restart.title=重啟遊戲 home.restart.update=遊戲為套用更新已結束。現在更新客戶端並重新啟動遊戲嗎? @@ -632,7 +652,7 @@ home.restart.generic=遊戲要求重啟。現在更新客戶端並重新啟動 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=測試遊戲 @@ -1375,6 +1395,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=雜項