Restrict launcher to bundled server modpack installs
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 5s
Check Codes / build (pull_request) Failing after 53s
Java CI / build (push) Failing after 2m30s
PR Size Label / label (pull_request_target) Failing after 15s
Java CI / build (pull_request) Failing after 2m34s

Replace free game download with bundled modpack install, drop Help/Feedback
and upstream About links, empty the default authlib list, and remove offline
account creation while keeping Microsoft and custom authlib logins.
This commit is contained in:
浩 刘 committed 2026-11-07 18:40:26 +00:00
1 parent f4893d9f75
commit 1e45711006
30 files changed
+103 -442

No files matched your search

@@ -53,7 +53,10 @@ public final class Accounts {
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
/// Account factories offered when creating a new account from the UI.
///
/// Offline login remains loadable for existing accounts, but is no longer offered for creation.
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
// ==== login type / account factory mapping ====
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
@@ -26,9 +26,8 @@ import java.util.Objects;
@NotNullByDefault
@JsonSerializable
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
/// The LittleSkin Yggdrasil API endpoint used only when recognizing an existing entry.
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
/// The JSON schema supported by this authlib-injector server list.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
@@ -40,17 +39,10 @@ public final class AuthlibInjectorServerList extends ObservableSetting implement
}
/// Creates the default authlib-injector server list for a newly created file.
///
/// The default list is empty; operators add their own authentication server when needed.
public static AuthlibInjectorServerList createDefault() {
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
result.addLittleSkinIfAbsent();
return result;
}
/// Adds the bundled LittleSkin server when it is not already present.
public void addLittleSkinIfAbsent() {
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
}
return new AuthlibInjectorServerList();
}
/// The schema used by this authlib-injector server list file.
@@ -425,18 +425,12 @@ public final class LegacyConfigMigrator {
if (authlibInjectorServers != null) {
servers.add("servers", authlibInjectorServers);
}
JsonElement addedLittleSkin = json.remove("addedLittleSkin");
boolean shouldAddLittleSkin = !(addedLittleSkin instanceof JsonPrimitive primitive
&& primitive.isBoolean()
&& primitive.getAsBoolean());
json.remove("addedLittleSkin");
AuthlibInjectorServerList result = JsonUtils.GSON.fromJson(servers, AuthlibInjectorServerList.class);
if (result == null) {
result = new AuthlibInjectorServerList();
}
if (shouldAddLittleSkin) {
result.addLittleSkinIfAbsent();
}
return result;
}
@@ -37,7 +37,6 @@ import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.instances.GameListPage;
@@ -223,26 +222,6 @@ public final class Controllers {
return accountListPage;
}
/// The cached download page.
private static @Nullable DownloadPage downloadPage;
/// Creates and attaches the download page for a layout pulse unless already cached.
@FXThread
public static void prepareDownloadPage() {
if (downloadPage == null) {
LOG.info("Preparing download page");
downloadPage = getDecorator().prewarmPage(new DownloadPage());
}
}
/// Returns the cached download page, creating it if necessary.
@FXThread
public static DownloadPage getDownloadPage() {
if (downloadPage == null) {
downloadPage = new DownloadPage();
}
return downloadPage;
}
/// Returns the initialized main-window decorator.
@@ -711,10 +690,6 @@ public final class Controllers {
public static void onHyperlinkAction(String href) {
if (href.startsWith("hmcl://")) {
switch (href) {
case "hmcl://settings/feedback":
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
break;
case "hmcl://game/launch":
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep);
@@ -758,7 +733,6 @@ public final class Controllers {
rootPage = null;
gameInstancePage = null;
gameListPage = null;
downloadPage = null;
accountListPage = null;
settingsPage = null;
decorator = null;
@@ -99,25 +99,6 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
.startCategory(i18n("account.create").toUpperCase(Locale.ROOT))
.add(microsoftItem);
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
offlineItem.setTitle(i18n("account.methods.offline"));
offlineItem.setLeftIcon(SVG.PERSON);
if (restricted) {
offlineItem.setActionDisabled(true);
FXUtils.installFastTooltip(offlineItem, i18n("account.login.restricted"));
} else {
offlineItem.setOnAction(e -> {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE)));
} else {
Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE));
}
});
}
getSidebar().add(offlineItem);
for (AuthlibInjectorServer server : authServers) {
AdvancedListItem item = new AdvancedListItem();
item.getStyleClass().add("navigation-drawer-item");
@@ -89,8 +89,11 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
String preferred = settings().preferredLoginTypeProperty().get();
try {
factory = Accounts.getAccountFactory(preferred);
if (factory == Accounts.FACTORY_OFFLINE) {
factory = Accounts.FACTORY_MICROSOFT;
}
} catch (IllegalArgumentException e) {
factory = Accounts.FACTORY_OFFLINE;
factory = Accounts.FACTORY_MICROSOFT;
}
}
} else {
@@ -155,7 +155,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), this::rename, managementPopup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), this::duplicate, managementPopup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), this::remove, managementPopup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), this::export, managementPopup),
new MenuSeparator(),
// Due to the sidebar height limit, we temporarily place the entries for these tabs
// in the management menu; they will be moved back to the sidebar after a future UI refactor.
@@ -350,13 +349,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}
}
private void export() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
Instances.exportInstance(gameInstance);
}
}
private void rename() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
@@ -184,7 +184,6 @@ public final class GameListCell extends ListCell<GameListItem> {
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), item::remove, popup),
new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), item::export, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
return popup;
@@ -47,10 +47,6 @@ public class GameListItem extends GameItem {
Instances.deleteInstance(gameInstance);
}
public void export() {
Instances.exportInstance(gameInstance);
}
public void browse() {
Instances.openFolder(gameInstance);
}
@@ -127,7 +127,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
GameDirectoryManager.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Instances.addNewGame());
setOnFailedAction(e -> Instances.installServerModpack());
}
@FXThread
@@ -232,7 +232,7 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.new_game"), SVG.DOWNLOAD, Instances::addNewGame), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::importModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::installServerModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
@@ -5,7 +5,6 @@ package org.jackhuang.hmcl.ui.instances;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLGameInstance;
@@ -13,12 +12,8 @@ import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.download.UpdateInstallerWizardProvider;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -32,10 +27,6 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
/// @param instanceContext the parent page's instance property
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> Set.of("jar", "exe").contains(FileUtils.getExtension(it)), mods -> {
if (!mods.isEmpty())
doInstallOffline(mods.get(0));
});
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
@@ -87,10 +78,6 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
component.versionProperty().set(null);
}
component.setOnInstall(() -> {
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion));
});
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
@@ -129,33 +116,6 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
}
}
public void installOffline() {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.modloader.installer"), "*.jar", "*.exe"));
Path file = Controllers.showOpenDialog(chooser);
if (file != null) doInstallOffline(file);
}
/// Runs a local component installer for the current modifiable instance and displays its result.
private void doInstallOffline(Path file) {
if (gameInstance == null || !gameInstance.getManifest().isModifiable()) {
return;
}
HMCLGameRepository repository = gameInstance.getRepository();
Task<?> task = repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().installComponentLocalAsync(publishedInstance, file));
task.setName(i18n("install.installer.install_offline"));
Controllers.taskDialog(task.whenComplete(Schedulers.javafx(), (@Nullable Exception exception) -> {
if (exception == null) {
reloadCurrentInstance();
Controllers.dialog(i18n("install.success"));
} else {
UpdateInstallerWizardProvider.alertFailureMessage(exception, null);
}
}), i18n("install.installer.install_offline"), TaskCancellationAction.NO_CANCEL);
}
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
@@ -165,9 +125,7 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
@Override
protected List<Node> initializeToolbar(InstallerListPage skinnable) {
return Collections.singletonList(
createToolbarButton2(i18n("install.installer.install_offline"), SVG.ADD, skinnable::installOffline)
);
return Collections.emptyList();
}
}
}
@@ -5,6 +5,7 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
@@ -23,7 +24,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.export.ExportWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -46,9 +46,24 @@ public final class Instances {
private Instances() {
}
public static void addNewGame() {
Controllers.getDownloadPage().showGameDownloads();
Controllers.navigate(Controllers.getDownloadPage());
/// Installs or updates the server client from the launcher-bundled modpack when present.
///
/// Falls back to the local/remote modpack import wizard when no bundled package is found.
public static void installServerModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
return;
}
Path bundled = Metadata.findBundledModpackFile();
if (bundled != null) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, bundled),
i18n("install.modpack"));
return;
}
importModpack();
}
public static void importModpack() {
@@ -58,6 +73,11 @@ public final class Instances {
}
}
/// @deprecated use [#installServerModpack()]
public static void addNewGame() {
installServerModpack();
}
public static void deleteInstance(HMCLGameInstance gameInstance) {
HMCLGameRepository repository = gameInstance.getRepository();
@@ -107,9 +127,6 @@ public final class Instances {
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString())));
}
public static void exportInstance(HMCLGameInstance gameInstance) {
Controllers.getDecorator().startWizard(new ExportWizardProvider(gameInstance), i18n("modpack.wizard"));
}
public static void openFolder(HMCLGameInstance gameInstance) {
FXUtils.openFolder(gameInstance.getRunDirectory());
@@ -294,13 +311,13 @@ public final class Instances {
@SafeVarargs
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
if (gameInstance == null) {
JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download"));
gotoDownload.getStyleClass().add("dialog-accept");
gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
JFXButton installModpack = new JFXButton(i18n("install.modpack"));
installModpack.getStyleClass().add("dialog-accept");
installModpack.setOnAction(e -> installServerModpack());
Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"),
MessageDialogPane.MessageType.ERROR,
gotoDownload,
installModpack,
null);
return;
}
@@ -187,13 +187,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
}
}
private void onDownload() {
if (gameInstance == null) {
return;
}
Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(gameInstance.getId());
Controllers.navigate(Controllers.getDownloadPage());
}
private void onOpenFolder() {
if (resourcePackDirectory != null) {
@@ -371,7 +364,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
),
createToolbarButton2(i18n("resourcepack.download"), SVG.DOWNLOAD, control::onDownload),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
@@ -52,10 +52,10 @@ public final class AboutPage extends SpinnerPane {
ComponentList about = new ComponentList();
{
var launcher = LineButton.createExternalLinkButton(Metadata.REPOSITORY_URL);
var launcher = new LinePane();
launcher.setLargeTitle(true);
launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
launcher.setTitle("Hello Minecraft! Launcher");
launcher.setTitle(Metadata.FULL_NAME);
launcher.setSubtitle(Metadata.VERSION);
about.getContent().setAll(launcher);
@@ -229,17 +229,7 @@ public final class AboutPage extends SpinnerPane {
copyright.setTitle(i18n("about.copyright"));
copyright.setSubtitle(i18n("about.copyright.statement"));
var claim = LineButton.createExternalLinkButton(Metadata.EULA_URL);
claim.setLargeTitle(true);
claim.setTitle(i18n("about.claim"));
claim.setSubtitle(i18n("about.claim.statement"));
var openSource = LineButton.createExternalLinkButton("https://github.com/HMCL-dev/HMCL");
openSource.setLargeTitle(true);
openSource.setTitle(i18n("about.open_source"));
openSource.setSubtitle(i18n("about.open_source.statement"));
legal.getContent().setAll(copyright, claim, openSource);
legal.getContent().setAll(copyright);
}
content.getChildren().setAll(
@@ -1,49 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class FeedbackPage extends SpinnerPane {
private final WeakListenerHolder holder = new WeakListenerHolder();
public FeedbackPage() {
VBox content = new VBox();
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
ComponentList feedback = new ComponentList();
{
var github = LineButton.createExternalLinkButton("https://github.com/HMCL-dev/HMCL/issues/new/choose");
github.setLargeTitle(true);
github.setTitle(i18n("contact.feedback.github"));
github.setSubtitle(i18n("contact.feedback.github.statement"));
holder.add(FXUtils.onWeakChangeAndOperate(Themes.darkModeProperty(), darkMode -> {
github.setLeading(darkMode
? FXUtils.newBuiltinImage("/assets/img/github-white.png")
: FXUtils.newBuiltinImage("/assets/img/github.png"));
}));
feedback.getContent().setAll(github);
}
content.getChildren().addAll(
ComponentList.createComponentListTitle(i18n("contact.feedback")),
feedback
);
}
}
@@ -1,108 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import com.google.gson.annotations.SerializedName;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.io.HttpRequest;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class HelpPage extends SpinnerPane {
private final VBox content;
public HelpPage() {
content = new VBox();
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
var docPane = LineButton.createExternalLinkButton(Metadata.DOCS_URL);
docPane.setLargeTitle(true);
docPane.setTitle(i18n("help.doc"));
docPane.setSubtitle(i18n("help.detail"));
ComponentList doc = new ComponentList();
doc.getContent().setAll(docPane);
content.getChildren().add(doc);
loadHelp();
}
private void loadHelp() {
showSpinner();
Task.supplyAsync(() -> HttpRequest.GET(Metadata.DOCS_URL + "/index.json").getJson(listTypeOf(HelpCategory.class)))
.whenComplete(Schedulers.javafx(), (helpCategories, exception) -> {
if (exception != null) {
LOG.warning("Failed to load help page: ", exception);
showLoadFailure();
return;
}
try {
List<Node> nodes = new ArrayList<>();
for (HelpCategory category : helpCategories) {
ComponentList categoryPane = new ComponentList();
for (Help help : category.items()) {
var item = LineButton.createExternalLinkButton(help.url());
item.setLargeTitle(true);
item.setTitle(help.title());
item.setSubtitle(help.subtitle());
categoryPane.getContent().add(item);
}
nodes.add(ComponentList.createComponentListTitle(category.title()));
nodes.add(categoryPane);
}
content.getChildren().addAll(nodes);
hideSpinner();
} catch (Exception e) {
LOG.warning("Failed to display help page: ", e);
showLoadFailure();
}
}).start();
}
private void showLoadFailure() {
setFailedReason(i18n("download.failed.refresh"));
setOnFailedAction(e -> loadHelp());
setLoading(false);
}
@JsonSerializable
private record HelpCategory(
@SerializedName("title") String title,
@SerializedName("items") List<Help> items) {
}
@JsonSerializable
private record Help(
@SerializedName("title") String title,
@SerializedName("subtitle") String subtitle,
@SerializedName("url") String url) {
}
}
@@ -33,12 +33,8 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
private final TabItem<PersonalizationPage> personalizationTab;
/// The tab for download settings.
private final TabItem<DownloadSettingsPage> downloadTab;
/// The tab displaying launcher help.
private final TabItem<HelpPage> helpTab;
/// The tab displaying launcher information.
private final TabItem<AboutPage> aboutTab;
/// The tab displaying feedback and contact information.
private final TabItem<FeedbackPage> feedbackTab;
private final TransitionPane transitionPane = new TransitionPane();
/// Creates the settings tabs and fills the shared navigation sidebar.
@@ -48,8 +44,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
settingsTab = new TabItem<>(SettingsPage::new);
personalizationTab = new TabItem<>(PersonalizationPage::new);
downloadTab = new TabItem<>(DownloadSettingsPage::new);
helpTab = new TabItem<>(HelpPage::new);
feedbackTab = new TabItem<>(FeedbackPage::new);
aboutTab = new TabItem<>(AboutPage::new);
tabController = new TabController(transitionPane,
gameTab,
@@ -57,8 +51,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
settingsTab,
personalizationTab,
downloadTab,
helpTab,
feedbackTab,
aboutTab);
tabController.selectTab(gameTab);
@@ -71,9 +63,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
.addNavigationDrawerTab(tabController, personalizationTab, i18n("settings.launcher.appearance"), SVG.STYLE, SVG.STYLE_FILL)
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, helpTab, i18n("help"), SVG.HELP, SVG.HELP_FILL)
.addNavigationDrawerTab(tabController, feedbackTab, i18n("contact"), SVG.FEEDBACK, SVG.FEEDBACK_FILL)
.startCategory(i18n("about").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
setContent(transitionPane);
@@ -94,10 +84,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
tabController.selectTab(gameTab, false);
}
public void showFeedback() {
tabController.selectTab(feedbackTab, false);
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
@@ -22,37 +22,25 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.download.ComponentRemoteVersion.Type.RELEASE;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays the launcher home controls for the currently selected game repository.
public final class MainPage extends StackPane implements DecoratorPage {
@@ -205,48 +193,14 @@ public final class MainPage extends StackPane implements DecoratorPage {
Instances.launch(repository.getSelectedInstance());
}
/// Installs the latest supported release in the currently selected repository, then launches
/// that instance even if the selected repository or instance changes during installation.
/// Opens the server modpack install/update flow when no instance is selected.
private void launchNoGame() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
Task<?> task = downloadProvider.getVersionsAsync(GameComponentType.GAME, null, false)
.thenApplyAsync(versions -> versions.stream()
.filter(it -> it.getVersionType() == RELEASE)
.filter(it -> NativePatcher.checkSupportedStatus(it.getGameVersion(), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
.sorted()
.findFirst()
.orElseThrow(() -> new IOException("No versions found")))
.thenComposeAsync(version -> {
HMCLDependencyManager dependency = repository.getDependency();
String gameVersion = version.getGameVersion().toString();
GameInstanceID instanceId = new GameInstanceID(gameVersion);
try (HMCLGameBuilder builder = dependency.newGameBuilder(instanceId)) {
return builder
.component(GameComponentType.GAME, gameVersion)
.buildAsync()
.thenApplyAsync(ignored -> repository.getInstance(instanceId));
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
repository.setSelectedInstance(result);
Instances.launch(result);
} else if (!(exception instanceof CancellationException)) {
LOG.warning("Failed to install game", exception);
Controllers.dialog(StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.WARNING);
}
});
Controllers.taskDialog(task, i18n("instance.launch.empty.installing"), TaskCancellationAction.NORMAL);
Instances.installServerModpack();
}
@Override
public ReadOnlyObjectWrapper<State> stateProperty() {
return state;
@@ -86,16 +86,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
}
// forth item in left sidebar
AdvancedListItem downloadItem = new AdvancedListItem();
downloadItem.setLeftIcon(SVG.DOWNLOAD);
downloadItem.setTitle(i18n("download"));
downloadItem.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
if (AnimationUtils.isAnimationEnabled()) {
downloadItem.setPrepareAction(Controllers::prepareDownloadPage);
}
// fifth item in left sidebar
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
@@ -116,7 +106,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
.startCategory(i18n("instance").toUpperCase(Locale.ROOT))
.add(gameInstanceItem)
.add(gameListItem)
.add(downloadItem)
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
.add(launcherSettingsItem);
@@ -777,10 +777,10 @@ install.success=Successfully installed.
instance=Games
instance.name=Instance Name
instance.empty=No Instances
instance.empty.add=Add new instance
instance.empty.launch=No available instances.\nYou can go to the "Download" page to get the game, or switch the game directory in the "All Instances" page.
instance.empty.launch.goto_download=Go to Download Page
instance.empty.hint=There are no Minecraft instances here.\nYou can try switching to another game directory or clicking here to download one.
instance.empty.add=Install the server modpack
instance.empty.launch=No available instances.\nInstall the bundled server modpack, or switch the game directory in the "All Instances" page.
instance.empty.launch.goto_download=Install Modpack
instance.empty.hint=There are no Minecraft instances here.\nYou can try switching to another game directory or clicking here to install the server modpack.
instance.game.all=All
instance.game.april_fools=April Fools
instance.game.old=Historical
@@ -795,7 +795,7 @@ instance.launch=Launch Game
instance.launch_and_enter_world=Play World
instance.launch.empty=Start Game
instance.launch.empty.installing=Installing Game
instance.launch.empty.tooltip=Install and launch the latest official release
instance.launch.empty.tooltip=Install and launch the bundled server modpack
instance.launch.test=Test Launch
instance.switch=Switch Instance
instance.launch_script=Export Launch Script
@@ -778,10 +778,10 @@ install.success=تم التثبيت بنجاح.
instance=الألعاب
instance.name=اسم النسخة
instance.empty=لا توجد نسخ
instance.empty.add=إضافة نسخة جديدة
instance.empty.launch=لا توجد نسخ متاحة.\nيمكنك الانتقال إلى صفحة "التنزيل" للحصول على اللعبة، أو تغيير مجلد اللعبة في صفحة "جميع النسخ".
instance.empty.launch.goto_download=الانتقال إلى صفحة التنزيل
instance.empty.hint=لا توجد نسخ Minecraft هنا.\nيمكنك محاولة التبديل إلى مجلد لعبة آخر أو النقر هنا لتنزيل واحدة.
instance.empty.add=تثبيت حزمة خادم التعديلات
instance.empty.launch=لا توجد نسخ متاحة.\nثبّت حزمة خادم التعديلات المدمجة، أو غيّر مجلد اللعبة في صفحة "جميع النسخ".
instance.empty.launch.goto_download=تثبيت الحزمة
instance.empty.hint=لا توجد نسخ Minecraft هنا.\nيمكنك محاولة التبديل إلى مجلد لعبة آخر أو النقر هنا لتثبيت حزمة خادم التعديلات.
instance.game.all=الكل
instance.game.april_fools=كذبة أبريل
instance.game.old=تاريخي
@@ -796,7 +796,7 @@ instance.launch=تشغيل اللعبة
instance.launch_and_enter_world=لعب في العالم
instance.launch.empty=تشغيل اللعبة
instance.launch.empty.installing=جارٍ تثبيت اللعبة
instance.launch.empty.tooltip=تثبيت وتشغيل أحدث إصدار رسمي
instance.launch.empty.tooltip=تثبيت وتشغيل حزمة خادم التعديلات المدمجة
instance.launch.test=تشغيل تجريبي
instance.switch=تبديل النسخة
instance.launch_script=تصدير سكريبت التشغيل
@@ -776,10 +776,10 @@ install.success=Erfolgreich installiert.
instance=Spiele
instance.name=Instanzname
instance.empty=Keine Instanzen
instance.empty.add=Neue Instanz hinzufügen
instance.empty.launch=Keine verfügbaren Instanzen.\nSie können zur Seite "Herunterladen" gehen, um das Spiel zu erhalten, oder das Spielverzeichnis auf der Seite "Alle Instanzen" wechseln.
instance.empty.launch.goto_download=Zur Download-Seite gehen
instance.empty.hint=Hier gibt es keine Minecraft-Instanzen.\nSie können versuchen, in ein anderes Spielverzeichnis zu wechseln, oder hier klicken, um eine herunterzuladen.
instance.empty.add=Server-Modpack installieren
instance.empty.launch=Keine verfügbaren Instanzen.\nInstallieren Sie das mitgelieferte Server-Modpack, oder wechseln Sie das Spielverzeichnis auf der Seite "Alle Instanzen".
instance.empty.launch.goto_download=Modpack installieren
instance.empty.hint=Hier gibt es keine Minecraft-Instanzen.\nSie können versuchen, in ein anderes Spielverzeichnis zu wechseln, oder hier klicken, um das Server-Modpack zu installieren.
instance.game.all=Alle
instance.game.april_fools=Aprilscherz
instance.game.old=Historisch
@@ -794,7 +794,7 @@ instance.launch=Spiel starten
instance.launch_and_enter_world=Welt spielen
instance.launch.empty=Spiel starten
instance.launch.empty.installing=Installiere Spiel
instance.launch.empty.tooltip=Die neueste offizielle Release installieren und starten
instance.launch.empty.tooltip=Das mitgelieferte Server-Modpack installieren und starten
instance.launch.test=Teststart
instance.switch=Instanz wechseln
instance.launch_script=Startskript exportieren
@@ -770,10 +770,10 @@ install.success=Instalado con éxito.
instance=Juegos
instance.name=Nombre de instancia
instance.empty=No hay instancias
instance.empty.add=Añadir una instancia
instance.empty.add=Instalar el modpack del servidor
# instance.empty.launch=
# instance.empty.launch.goto_download=
instance.empty.hint=No hay instancias de Minecraft aquí.\nPuedes intentar cambiar a otro directorio del juego o hacer clic aquí para descargar una.
instance.empty.hint=No hay instancias de Minecraft aquí.\nPuedes intentar cambiar a otro directorio del juego o hacer clic aquí para instalar el modpack del servidor.
instance.game.all=Todos
instance.game.april_fools=Día de los Inocentes
instance.game.old=Histórico
@@ -788,7 +788,7 @@ instance.launch=Iniciar Minecraft
# instance.launch_and_enter_world=
instance.launch.empty=Iniciar Minecraft
instance.launch.empty.installing=Instalando el juego
instance.launch.empty.tooltip=Instala y ejecuta la última versión oficial del juego.
instance.launch.empty.tooltip=Instala y ejecuta el modpack del servidor incluido.
instance.launch.test=Probar instalación
instance.switch=Cambiar Instancia
instance.launch_script=Exportar script de ejecución
@@ -586,10 +586,10 @@ install.success=正常にインストールされました
instance=ゲーム
# instance.name=
instance.empty=ゲームなし
instance.empty.add=起動構成を作成
instance.empty.add=サーバー用統合パックをインストール
# instance.empty.launch=
# instance.empty.launch.goto_download=
instance.empty.hint=インストールされているゲームバージョンはありません。新しいゲームをダウンロードするには、ここをクリックしてください。
instance.empty.hint=インストールされているゲームバージョンはありません。サーバー用統合パックをインストールするには、ここをクリックしてください。
# instance.game.all=
# instance.game.april_fools=
instance.game.old=Old
@@ -597,10 +597,10 @@ install.success=裝成
instance=戲
instance.name=戲例之名
instance.empty=無游戲例
instance.empty.add=入引頁以裝戲
instance.empty.add=裝伺服器整合包
# instance.empty.launch=
# instance.empty.launch.goto_download=
instance.empty.hint=無畢裝者。\n君且更他戲夾,抑擊斯以往戲之引頁。
instance.empty.hint=無畢裝者。\n君且更他戲夾,抑擊斯以裝伺服器整合包。
instance.game.all=全覽
instance.game.april_fools=愚人節
instance.game.old=古典版
@@ -615,7 +615,7 @@ instance.launch=戲
# instance.launch_and_enter_world=
instance.launch.empty=啟戲
instance.launch.empty.installing=裝戲
instance.launch.empty.tooltip=裝啟至新之當版
instance.launch.empty.tooltip=裝啟伺服器整合包
instance.launch.test=試其戲
instance.switch=迭例
instance.launch_script=成啟戲角本
@@ -711,10 +711,10 @@ install.success=Успешно установлено.
instance=Игры
instance.name=Название сборки
instance.empty=Нет сборок
instance.empty.add=Добавить новую сборку
instance.empty.launch=Нет доступных сборок.\nСкачайте игру на странице «Скачать» или смените папку с игрой на странице «Все сборки».
instance.empty.launch.goto_download=Перейти к скачиванию
instance.empty.hint=Нет сборок.\nВы можете попробовать перейти в другую папку с игрой или нажать здесь, чтобы скачать игру.
instance.empty.add=Установить серверный модпак
instance.empty.launch=Нет доступных сборок.\nУстановите встроенный серверный модпак или смените папку с игрой на странице «Все сборки».
instance.empty.launch.goto_download=Установить модпак
instance.empty.hint=Нет сборок.\nВы можете попробовать перейти в другую папку с игрой или нажать здесь, чтобы установить серверный модпак.
instance.game.all=Все
instance.game.april_fools=День смеха
instance.game.old=Исторические
@@ -729,7 +729,7 @@ instance.launch=Играть
instance.launch_and_enter_world=Играть в этом мире
instance.launch.empty=Играть
instance.launch.empty.installing=Установка игры
instance.launch.empty.tooltip=Установить и запустить последнюю официальную версию игры.
instance.launch.empty.tooltip=Установить и запустить встроенный серверный модпак.
instance.launch.test=Тестовый запуск
instance.switch=Сменить сборку
instance.launch_script=Создать сценарий запуска
@@ -733,10 +733,10 @@ install.success=Успішно встановлено.
instance=Ігри
instance.name=Назва екземпляра
instance.empty=Немає екземплярів
instance.empty.add=Додати новий екземпляр
instance.empty.launch=Немає доступних екземплярів.\nВи можете перейти на сторінку "Завантажити", щоб отримати гру, або переключити ігровий каталог на сторінці "Усі екземпляри".
instance.empty.launch.goto_download=Перейти до сторінки завантаження
instance.empty.hint=Тут немає екземплярів Minecraft. Ви можете спробувати переключитися на інший ігровий каталог або натиснути тут, щоб завантажити один.
instance.empty.add=Встановити серверний модпак
instance.empty.launch=Немає доступних екземплярів.\nВстановіть вбудований серверний модпак або переключіть ігровий каталог на сторінці "Усі екземпляри".
instance.empty.launch.goto_download=Встановити модпак
instance.empty.hint=Тут немає екземплярів Minecraft.\nВи можете спробувати переключитися на інший ігровий каталог або натиснути тут, щоб встановити серверний модпак.
instance.game.all=Усі
instance.game.april_fools=Перший квітня
instance.game.old=Історичні
@@ -751,7 +751,7 @@ instance.launch=Запустити гру
instance.launch_and_enter_world=Грати у світ
instance.launch.empty=Почати гру
instance.launch.empty.installing=Встановлення гри
instance.launch.empty.tooltip=Встановити та запустити останній офіційний реліз
instance.launch.empty.tooltip=Встановити та запустити вбудований серверний модпак
instance.launch.test=Тестовий запуск
instance.switch=Переключити екземпляр
instance.launch_script=Експортувати скрипт запуску
@@ -600,10 +600,10 @@ install.success=安装成功
instance=游戏
instance.name=游戏实例名称
instance.empty=没有游戏实例
instance.empty.add=进入下载页安装游戏
instance.empty.launch=没有可启动的游戏。\n你可以前往下载页面下载新游戏,或在“实例列表”中切换游戏文件夹。
instance.empty.launch.goto_download=前往下载页面
instance.empty.hint=没有已安装的游戏。\n你可以切换其他游戏文件夹,或者点击此处进入游戏下载页面。
instance.empty.add=安装服务器整合包
instance.empty.launch=没有可启动的游戏。\n请安装自带的服务器整合包,或在“实例列表”中切换游戏文件夹。
instance.empty.launch.goto_download=安装整合包
instance.empty.hint=没有已安装的游戏。\n你可以切换其他游戏文件夹,或者点击此处安装服务器整合包。
instance.game.all=全部
instance.game.april_fools=愚人节
instance.game.old=远古版
@@ -618,7 +618,7 @@ instance.launch=启动游戏
instance.launch_and_enter_world=进入世界
instance.launch.empty=开始游戏
instance.launch.empty.installing=安装游戏
instance.launch.empty.tooltip=安装并启动最新正式版游戏
instance.launch.empty.tooltip=安装并启动服务器整合包
instance.launch.test=测试游戏
instance.switch=切换实例
instance.launch_script=生成启动脚本
@@ -594,10 +594,10 @@ install.success=安裝成功
instance=遊戲
instance.name=遊戲實例名稱
instance.empty=沒有遊戲實例
instance.empty.add=進入下載頁安裝遊戲
instance.empty.launch=沒有可啟動的遊戲。\n你可以前往「下載」頁面下載新遊戲,或在「實例清單」切換遊戲目錄。
instance.empty.launch.goto_download=前往下載頁面
instance.empty.hint=沒有已安裝的遊戲。\n你可以切換其他遊戲目錄,或者點擊此處進入遊戲下載頁面。
instance.empty.add=安裝伺服器整合包
instance.empty.launch=沒有可啟動的遊戲。\n請安裝內建的伺服器整合包,或在「實例清單」切換遊戲目錄。
instance.empty.launch.goto_download=安裝整合包
instance.empty.hint=沒有已安裝的遊戲。\n你可以切換其他遊戲目錄,或者點擊此處安裝伺服器整合包。
instance.game.all=全部
instance.game.april_fools=愚人節
instance.game.old=遠古版
@@ -612,7 +612,7 @@ instance.launch=啟動遊戲
instance.launch_and_enter_world=進入世界
instance.launch.empty=開始遊戲
instance.launch.empty.installing=安裝遊戲
instance.launch.empty.tooltip=安裝並啟動最新正式版遊戲
instance.launch.empty.tooltip=安裝並啟動伺服器整合包
instance.launch.test=測試遊戲
instance.switch=切換實例
instance.launch_script=生成啟動指令碼
@@ -13,13 +13,12 @@ import static org.junit.jupiter.api.Assertions.*;
/// Tests for detached authlib-injector server lists.
@NotNullByDefault
public final class AuthlibInjectorServerListTest {
/// Tests that newly created server lists contain LittleSkin by default.
/// Tests that newly created server lists start empty.
@Test
public void defaultListContainsLittleSkin() {
public void defaultListIsEmpty() {
AuthlibInjectorServerList list = AuthlibInjectorServerList.createDefault();
assertEquals(1, list.getServers().size());
assertEquals(AuthlibInjectorServerList.LITTLE_SKIN_URL, list.getServers().get(0).getUrl());
assertTrue(list.getServers().isEmpty());
}
/// Tests that server list serialization does not include derived metadata.
@@ -68,9 +67,9 @@ public final class AuthlibInjectorServerListTest {
assertEquals(AuthlibInjectorServerList.CURRENT_SCHEMA, list.getSchema());
}
/// Tests that legacy configs without `addedLittleSkin` receive LittleSkin during migration.
/// Tests that legacy configs without `addedLittleSkin` do not receive LittleSkin.
@Test
public void addsLittleSkinWhenLegacyFlagIsMissing() {
public void doesNotAddLittleSkinWhenLegacyFlagIsMissing() {
JsonObject settings = JsonParser.parseString("""
{
"authlibInjectorServers": [
@@ -83,14 +82,13 @@ public final class AuthlibInjectorServerListTest {
AuthlibInjectorServerList list = LegacyConfigMigrator.extractAuthlibInjectorServers(settings);
assertEquals(2, list.getServers().size());
assertEquals(1, list.getServers().size());
assertEquals("https://example.com/api/yggdrasil/", list.getServers().get(0).getUrl());
assertEquals(AuthlibInjectorServerList.LITTLE_SKIN_URL, list.getServers().get(1).getUrl());
}
/// Tests that legacy configs with `addedLittleSkin=false` do not add a duplicate LittleSkin entry.
/// Tests that an existing LittleSkin entry is preserved during migration.
@Test
public void addsLittleSkinOnlyOnceWhenLegacyFlagIsFalse() {
public void preservesExistingLittleSkinDuringMigration() {
JsonObject settings = JsonParser.parseString("""
{
"authlibInjectorServers": [