diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index e6030a767..91beb47db 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -27,16 +27,22 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AccountFactory; import org.jackhuang.hmcl.auth.AuthenticationException; +import org.jackhuang.hmcl.auth.CharacterDeletedException; +import org.jackhuang.hmcl.auth.NoCharacterException; +import org.jackhuang.hmcl.auth.ServerDisconnectException; +import org.jackhuang.hmcl.auth.ServerResponseMalformedException; import org.jackhuang.hmcl.auth.authlibinjector.*; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory; import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory; +import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory; import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer; import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.util.skin.InvalidSkinException; import java.io.IOException; import java.nio.file.Paths; @@ -322,4 +328,47 @@ public final class Accounts { .orElseThrow(() -> new IllegalArgumentException("Unrecognized account factory"))); } // ==== + + public static String localizeErrorMessage(Exception exception) { + if (exception instanceof NoCharacterException) { + return i18n("account.failed.no_character"); + } else if (exception instanceof ServerDisconnectException) { + return i18n("account.failed.connect_authentication_server"); + } else if (exception instanceof ServerResponseMalformedException) { + return i18n("account.failed.server_response_malformed"); + } else if (exception instanceof RemoteAuthenticationException) { + RemoteAuthenticationException remoteException = (RemoteAuthenticationException) exception; + String remoteMessage = remoteException.getRemoteMessage(); + if ("ForbiddenOperationException".equals(remoteException.getRemoteName()) && remoteMessage != null) { + if (remoteMessage.contains("Invalid credentials")) + return i18n("account.failed.invalid_credentials"); + else if (remoteMessage.contains("Invalid token")) + return i18n("account.failed.invalid_token"); + else if (remoteMessage.contains("Invalid username or password")) + return i18n("account.failed.invalid_password"); + else + return remoteMessage; + } + return exception.getMessage(); + } else if (exception instanceof AuthlibInjectorDownloadException) { + return i18n("account.failed.injector_download_failure"); + } else if (exception instanceof CharacterDeletedException) { + return i18n("account.failed.character_deleted"); + } else if (exception instanceof InvalidSkinException) { + return i18n("account.skin.invalid_skin"); + } else if (exception instanceof MicrosoftService.XboxAuthorizationException) { + long errorCode = ((MicrosoftService.XboxAuthorizationException) exception).getErrorCode(); + if (errorCode == MicrosoftService.XboxAuthorizationException.ADD_FAMILY) { + return i18n("account.methods.microsoft.error.add_family"); + } else if (errorCode == MicrosoftService.XboxAuthorizationException.MISSING_XBOX_ACCOUNT) { + return i18n("account.methods.microsoft.error.missing_xbox_account"); + } else { + return i18n("account.methods.microsoft.error.unknown", errorCode); + } + } else if (exception.getClass() == AuthenticationException.class) { + return exception.getLocalizedMessage(); + } else { + return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index 5c98633a2..5273d9765 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -426,4 +426,10 @@ public final class SVG { "M13 17H17V14L22 18.5L17 23V20H13V17M14 12.8C13.5 12.31 12.78 12 12 12C10.34 12 9 13.34 9 15C9 16.31 9.84 17.41 11 17.82C11.07 15.67 12.27 13.8 14 12.8M11.09 19H5V5H16.17L19 7.83V12.35C19.75 12.61 20.42 13 21 13.54V7L17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H11.81C11.46 20.39 11.21 19.72 11.09 19M6 10H15V6H6V10Z", fill, width, height); } + + public static Node account(ObjectBinding fill, double width, double height) { + return createSVGPath( + "M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z", + fill, width, height); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java index 520ea3e68..fcdb3efda 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -176,7 +176,7 @@ public class AccountListItem extends RadioButton { .thenComposeAsync(refreshAsync()) .whenComplete(Schedulers.javafx(), e -> { if (e != null) { - Controllers.dialog(AddAccountPane.accountException(e), i18n("account.skin.upload.failed"), MessageType.ERROR); + Controllers.dialog(Accounts.localizeErrorMessage(e), i18n("account.skin.upload.failed"), MessageType.ERROR); } }); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java index dc2f46553..3885df38e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java @@ -27,10 +27,8 @@ import javafx.scene.control.SkinBase; import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.ListPageBase; -import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.ui.*; import org.jackhuang.hmcl.ui.construct.AdvancedListBox; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.util.javafx.MappedObservableList; @@ -75,10 +73,31 @@ public class AccountListPage extends ListPageBase implements De { AdvancedListBox sideBar = new AdvancedListBox() + .startCategory(i18n("account.create")) + .addNavigationDrawerItem(settingsItem -> { + settingsItem.setTitle(i18n("account.methods.offline")); + settingsItem.setLeftGraphic(wrap(SVG.account(null, 20, 20))); + settingsItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE))); + }) + .addNavigationDrawerItem(settingsItem -> { + settingsItem.setTitle(i18n("account.methods.yggdrasil")); + settingsItem.setLeftGraphic(wrap(SVG.mojang(null, 20, 20))); + settingsItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MOJANG))); + }) + .addNavigationDrawerItem(settingsItem -> { + settingsItem.setTitle(i18n("account.methods.microsoft")); + settingsItem.setLeftGraphic(wrap(SVG.microsoft(null, 20, 20))); + settingsItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT))); + }) + .addNavigationDrawerItem(settingsItem -> { + settingsItem.setTitle(i18n("account.methods.authlib_injector")); + settingsItem.setLeftGraphic(wrap(SVG.gear(null, 20, 20))); + settingsItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_AUTHLIB_INJECTOR))); + }) .addNavigationDrawerItem(settingsItem -> { settingsItem.setTitle(i18n("account.create")); - settingsItem.setLeftGraphic(wrap(SVG.plusCircleOutline(null, 20, 20))); - settingsItem.setOnAction(e -> Controllers.dialog(new AddAccountPane())); + settingsItem.setLeftGraphic(wrap(SVG.plus(null, 20, 20))); + settingsItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane())); }); FXUtils.setLimitWidth(sideBar, 200); root.setLeft(sideBar); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountLoginPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountLoginPane.java index e029708b2..f6df368a5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountLoginPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountLoginPane.java @@ -25,6 +25,7 @@ import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.NoSelectedCharacterException; +import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.FXUtils; @@ -74,7 +75,7 @@ public class AccountLoginPane extends StackPane { if (e instanceof NoSelectedCharacterException) { fireEvent(new DialogCloseEvent()); } else { - lblCreationWarning.setText(AddAccountPane.accountException(e)); + lblCreationWarning.setText(Accounts.localizeErrorMessage(e)); } progressBar.setVisible(false); }).start(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java deleted file mode 100644 index fb860f81a..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java +++ /dev/null @@ -1,404 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.account; - -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXComboBox; -import com.jfoenix.controls.JFXPasswordField; -import com.jfoenix.controls.JFXTextField; -import javafx.application.Platform; -import javafx.beans.binding.Bindings; -import javafx.beans.property.ListProperty; -import javafx.beans.property.ReadOnlyObjectProperty; -import javafx.beans.property.SimpleListProperty; -import javafx.collections.FXCollections; -import javafx.fxml.FXML; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.Hyperlink; -import javafx.scene.control.Label; -import javafx.scene.image.ImageView; -import javafx.scene.layout.*; -import org.jackhuang.hmcl.auth.*; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; -import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; -import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; -import org.jackhuang.hmcl.game.TexturesLoader; -import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.task.TaskExecutor; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.javafx.BindingMapping; -import org.jackhuang.hmcl.util.skin.InvalidSkinException; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; - -import static java.util.Collections.emptyList; -import static java.util.Collections.unmodifiableList; -import static java.util.Objects.requireNonNull; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.ui.FXUtils.*; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public class AddAccountPane extends StackPane { - - @FXML - private JFXTextField txtUsername; - @FXML - private JFXPasswordField txtPassword; - @FXML - private Label lblCreationWarning; - @FXML - private Label lblPassword; - @FXML - private Label lblUsername; - @FXML - private StackPane tabHeaderPane; - @FXML - private JFXComboBox cboServers; - @FXML - private Label lblInjectorServer; - @FXML - private JFXButton btnAccept; - @FXML - private JFXButton btnAddServer; - @FXML - private JFXButton btnManageServer; - @FXML - private SpinnerPane acceptPane; - @FXML - private HBox linksContainer; - @FXML - private GridPane body; - - private final TabHeader tabHeader; - private TaskExecutor loginTask; - - private ListProperty links = new SimpleListProperty<>(); - - private final Map, TabControl.Tab> tabMap = new HashMap<>(); - - public AddAccountPane() { - FXUtils.loadFXML(this, "/assets/fxml/account-add.fxml"); - - List> tabs = new ArrayList<>(); - for (AccountFactory factory : Accounts.FACTORIES) { - TabControl.Tab tab = new TabControl.Tab<>(factory.getLoginType().name(), Accounts.getLocalizedLoginTypeName(factory)); - tab.setUserData(factory); - tabMap.put(factory, tab); - tabs.add(tab); - } - - tabHeader = new TabHeader(tabs.toArray(new TabControl.Tab[0])); - tabHeader.getStyleClass().add("add-account-tab-header"); - // try selecting the preferred login type - tabHeader.getSelectionModel().select( - tabMap.get(tabMap.keySet().stream().filter(factory -> Accounts.getLoginType(factory).equals(config().getPreferredLoginType())) - .findFirst().orElse(Accounts.FACTORY_OFFLINE)) - ); - - tabHeaderPane.getChildren().setAll(tabHeader); - tabHeader.setMinWidth(Region.USE_PREF_SIZE); - - - cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl()))); - cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName)); - Bindings.bindContent(cboServers.getItems(), config().getAuthlibInjectorServers()); - cboServers.getItems().addListener(onInvalidating(this::resetServerSelection)); - resetServerSelection(); - - btnAddServer.visibleProperty().bind(cboServers.visibleProperty()); - btnManageServer.visibleProperty().bind(cboServers.visibleProperty()); - - cboServers.getItems().addListener(onInvalidating(this::checkIfNoServer)); - checkIfNoServer(); - - ReadOnlyObjectProperty> loginType = tabHeader.getSelectionModel().selectedItemProperty(); - - // remember the last used login type - loginType.addListener((observable, oldValue, newValue) -> config().setPreferredLoginType(Accounts.getLoginType((AccountFactory) newValue.getUserData()))); - - txtUsername.visibleProperty().bind(Bindings.createBooleanBinding(() -> ((AccountFactory) loginType.get().getUserData()).getLoginType().requiresUsername, loginType)); - lblUsername.visibleProperty().bind(txtUsername.visibleProperty()); - txtPassword.visibleProperty().bind(Bindings.createBooleanBinding(() -> ((AccountFactory) loginType.get().getUserData()).getLoginType().requiresPassword, loginType)); - lblPassword.visibleProperty().bind(txtPassword.visibleProperty()); - - cboServers.visibleProperty().bind(loginType.isEqualTo(tabMap.get(Accounts.FACTORY_AUTHLIB_INJECTOR))); - lblInjectorServer.visibleProperty().bind(cboServers.visibleProperty()); - - txtUsername.getValidators().add(new Validator(i18n("input.email"), this::validateUsername)); - - btnAccept.disableProperty().bind(Bindings.createBooleanBinding( - () -> !( // consider the opposite situation: input is valid - (!txtUsername.isVisible() || txtUsername.validate()) && - // invisible means the field is not needed, neither should it be validated - (!txtPassword.isVisible() || txtPassword.validate()) && - (!cboServers.isVisible() || cboServers.getSelectionModel().getSelectedItem() != null) - ), - txtUsername.textProperty(), txtPassword.textProperty(), - loginType, cboServers.getSelectionModel().selectedItemProperty(), - txtPassword.visibleProperty(), cboServers.visibleProperty())); - - // authlib-injector links - links.bind(BindingMapping.of(cboServers.getSelectionModel().selectedItemProperty()) - .map(AddAccountPane::createHyperlinks) - .map(FXCollections::observableList)); - Bindings.bindContent(linksContainer.getChildren(), links); - linksContainer.visibleProperty().bind(cboServers.visibleProperty()); - - onEscPressed(this, this::onCreationCancel); - } - - private boolean validateUsername(String username) { - AccountFactory loginType = ((AccountFactory) tabHeader.getSelectionModel().getSelectedItem().getUserData()); - if (loginType == Accounts.FACTORY_OFFLINE) { - return true; - } else if (loginType == Accounts.FACTORY_AUTHLIB_INJECTOR) { - AuthlibInjectorServer server = cboServers.getSelectionModel().getSelectedItem(); - if (server != null && server.isNonEmailLogin()) { - return true; - } - } - - return username.contains("@"); - } - - private static final String[] ALLOWED_LINKS = {"register"}; - - public static List createHyperlinks(AuthlibInjectorServer server) { - if (server == null) { - return emptyList(); - } - - Map links = server.getLinks(); - List result = new ArrayList<>(); - for (String key : ALLOWED_LINKS) { - String value = links.get(key); - if (value != null) { - Hyperlink link = new Hyperlink(i18n("account.injector.link." + key)); - FXUtils.installSlowTooltip(link, value); - link.setOnAction(e -> FXUtils.openLink(value)); - result.add(link); - } - } - return unmodifiableList(result); - } - - private void resetServerSelection() { - if (!cboServers.getItems().isEmpty()) { - Platform.runLater(() -> { - // the selection will not be updated as expected - // if we call it immediately - cboServers.getSelectionModel().selectFirst(); - }); - } - } - - private void checkIfNoServer() { - if (cboServers.getItems().isEmpty()) - cboServers.getStyleClass().setAll("jfx-combo-box-warning"); - else - cboServers.getStyleClass().setAll("jfx-combo-box"); - } - - /** - * Gets the additional data that needs to be passed into {@link AccountFactory#create(CharacterSelector, String, String, Object)}. - */ - private Object getAuthAdditionalData() { - AccountFactory factory = ((AccountFactory) tabHeader.getSelectionModel().getSelectedItem().getUserData()); - if (factory == Accounts.FACTORY_AUTHLIB_INJECTOR) { - return requireNonNull(cboServers.getSelectionModel().getSelectedItem(), "selected server cannot be null"); - } - return null; - } - - @FXML - private void onCreationAccept() { - if (btnAccept.isDisabled()) - return; - - acceptPane.showSpinner(); - lblCreationWarning.setText(""); - body.setDisable(true); - - String username = txtUsername.getText(); - String password = txtPassword.getText(); - AccountFactory factory = ((AccountFactory) tabHeader.getSelectionModel().getSelectedItem().getUserData()); - Object additionalData = getAuthAdditionalData(); - - loginTask = Task.supplyAsync(() -> factory.create(new Selector(), username, password, additionalData)) - .whenComplete(Schedulers.javafx(), account -> { - int oldIndex = Accounts.getAccounts().indexOf(account); - if (oldIndex == -1) { - Accounts.getAccounts().add(account); - } else { - // adding an already-added account - // instead of discarding the new account, we first remove the existing one then add the new one - Accounts.getAccounts().remove(oldIndex); - Accounts.getAccounts().add(oldIndex, account); - } - - // select the new account - Accounts.setSelectedAccount(account); - - acceptPane.hideSpinner(); - fireEvent(new DialogCloseEvent()); - }, exception -> { - if (exception instanceof NoSelectedCharacterException) { - fireEvent(new DialogCloseEvent()); - } else { - lblCreationWarning.setText(accountException(exception)); - } - setDisable(false); - acceptPane.hideSpinner(); - }).executor(true); - } - - @FXML - private void onCreationCancel() { - if (loginTask != null) { - loginTask.cancel(); - } - fireEvent(new DialogCloseEvent()); - } - - @FXML - private void onManageInjecterServers() { - fireEvent(new DialogCloseEvent()); - Controllers.navigate(Controllers.getServersPage()); - } - - @FXML - private void onAddInjecterServer() { - Controllers.dialog(new AddAuthlibInjectorServerPane()); - } - - private static class Selector extends BorderPane implements CharacterSelector { - - private final AdvancedListBox listBox = new AdvancedListBox(); - private final JFXButton cancel = new JFXButton(); - - private final CountDownLatch latch = new CountDownLatch(1); - private GameProfile selectedProfile = null; - - public Selector() { - setStyle("-fx-padding: 8px;"); - - cancel.setText(i18n("button.cancel")); - StackPane.setAlignment(cancel, Pos.BOTTOM_RIGHT); - cancel.setOnMouseClicked(e -> latch.countDown()); - - listBox.startCategory(i18n("account.choose")); - - setCenter(listBox); - - HBox hbox = new HBox(); - hbox.setAlignment(Pos.CENTER_RIGHT); - hbox.getChildren().add(cancel); - setBottom(hbox); - - onEscPressed(this, cancel::fire); - } - - @Override - public GameProfile select(YggdrasilService service, List profiles) throws NoSelectedCharacterException { - Platform.runLater(() -> { - for (GameProfile profile : profiles) { - ImageView portraitView = new ImageView(); - portraitView.setSmooth(false); - portraitView.imageProperty().bind(TexturesLoader.fxAvatarBinding(service, profile.getId(), 32)); - FXUtils.limitSize(portraitView, 32, 32); - - IconedItem accountItem = new IconedItem(portraitView, profile.getName()); - accountItem.setOnMouseClicked(e -> { - selectedProfile = profile; - latch.countDown(); - }); - listBox.add(accountItem); - } - Controllers.dialog(this); - }); - - try { - latch.await(); - - if (selectedProfile == null) - throw new NoSelectedCharacterException(); - - return selectedProfile; - } catch (InterruptedException ignore) { - throw new NoSelectedCharacterException(); - } finally { - runInFX(() -> Selector.this.fireEvent(new DialogCloseEvent())); - } - } - } - - public static String accountException(Exception exception) { - if (exception instanceof NoCharacterException) { - return i18n("account.failed.no_character"); - } else if (exception instanceof ServerDisconnectException) { - return i18n("account.failed.connect_authentication_server"); - } else if (exception instanceof ServerResponseMalformedException) { - return i18n("account.failed.server_response_malformed"); - } else if (exception instanceof RemoteAuthenticationException) { - RemoteAuthenticationException remoteException = (RemoteAuthenticationException) exception; - String remoteMessage = remoteException.getRemoteMessage(); - if ("ForbiddenOperationException".equals(remoteException.getRemoteName()) && remoteMessage != null) { - if (remoteMessage.contains("Invalid credentials")) - return i18n("account.failed.invalid_credentials"); - else if (remoteMessage.contains("Invalid token")) - return i18n("account.failed.invalid_token"); - else if (remoteMessage.contains("Invalid username or password")) - return i18n("account.failed.invalid_password"); - else - return remoteMessage; - } - return exception.getMessage(); - } else if (exception instanceof AuthlibInjectorDownloadException) { - return i18n("account.failed.injector_download_failure"); - } else if (exception instanceof CharacterDeletedException) { - return i18n("account.failed.character_deleted"); - } else if (exception instanceof InvalidSkinException) { - return i18n("account.skin.invalid_skin"); - } else if (exception instanceof MicrosoftService.XboxAuthorizationException) { - long errorCode = ((MicrosoftService.XboxAuthorizationException) exception).getErrorCode(); - if (errorCode == MicrosoftService.XboxAuthorizationException.ADD_FAMILY) { - return i18n("account.methods.microsoft.error.add_family"); - } else if (errorCode == MicrosoftService.XboxAuthorizationException.MISSING_XBOX_ACCOUNT) { - return i18n("account.methods.microsoft.error.missing_xbox_account"); - } else { - return i18n("account.methods.microsoft.error.unknown", errorCode); - } - } else if (exception.getClass() == AuthenticationException.class) { - return exception.getLocalizedMessage(); - } else { - return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java new file mode 100644 index 000000000..9d68190d3 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -0,0 +1,514 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account; + +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; +import static javafx.beans.binding.Bindings.bindContent; +import static javafx.beans.binding.Bindings.createBooleanBinding; +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.ui.FXUtils.jfxListCellFactory; +import static org.jackhuang.hmcl.ui.FXUtils.onChange; +import static org.jackhuang.hmcl.ui.FXUtils.onChangeAndOperate; +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; +import static org.jackhuang.hmcl.ui.FXUtils.setValidateWhileTextChanged; +import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; + +import org.jackhuang.hmcl.auth.AccountFactory; +import org.jackhuang.hmcl.auth.CharacterSelector; +import org.jackhuang.hmcl.auth.NoSelectedCharacterException; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccountFactory; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.task.TaskExecutor; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.AdvancedListBox; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.IconedItem; +import org.jackhuang.hmcl.ui.construct.RequiredValidator; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.ui.construct.TabControl; +import org.jackhuang.hmcl.ui.construct.TabHeader; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.ui.construct.Validator; +import org.jetbrains.annotations.Nullable; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXComboBox; +import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXPasswordField; +import com.jfoenix.controls.JFXTextField; + +import javafx.application.Platform; +import javafx.beans.binding.BooleanBinding; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; + +public class CreateAccountPane extends JFXDialogLayout { + + private boolean showMethodSwitcher; + private AccountFactory factory; + + private Label lblErrorMessage; + private JFXButton btnAccept; + private SpinnerPane spinner; + private JFXButton btnCancel; + private Node body; + + private Node detailsPane; // AccountDetailsInputPane for Offline / Mojang / authlib-injector, Label for Microsoft + private Pane detailsContainer; + + private TaskExecutor loginTask; + + public CreateAccountPane() { + this(null); + } + + public CreateAccountPane(AccountFactory factory) { + if (factory == null) { + showMethodSwitcher = true; + String preferred = config().getPreferredLoginType(); + try { + factory = Accounts.getAccountFactory(preferred); + } catch (IllegalArgumentException e) { + factory = Accounts.FACTORY_OFFLINE; + } + } else { + showMethodSwitcher = false; + } + this.factory = factory; + + { + String title; + if (showMethodSwitcher) { + title = "account.create"; + } else { + title = "account.create." + Accounts.getLoginType(factory); + } + setHeading(new Label(i18n(title))); + } + + { + lblErrorMessage = new Label(); + + btnAccept = new JFXButton(i18n("button.ok")); + btnAccept.getStyleClass().add("dialog-accept"); + btnAccept.setOnAction(e -> onAccept()); + + spinner = new SpinnerPane(); + spinner.getStyleClass().add("small-spinner-pane"); + spinner.setContent(btnAccept); + + btnCancel = new JFXButton(i18n("button.cancel")); + btnCancel.getStyleClass().add("dialog-cancel"); + btnCancel.setOnAction(e -> onCancel()); + onEscPressed(this, btnCancel::fire); + + setActions(lblErrorMessage, new HBox(spinner, btnCancel)); + } + + if (showMethodSwitcher) { + TabControl.Tab[] tabs = new TabControl.Tab[Accounts.FACTORIES.size()]; + TabControl.Tab selected = null; + for (int i = 0; i < tabs.length; i++) { + AccountFactory f = Accounts.FACTORIES.get(i); + tabs[i] = new TabControl.Tab<>(Accounts.getLoginType(f), Accounts.getLocalizedLoginTypeName(f)); + tabs[i].setUserData(f); + if (factory == f) { + selected = tabs[i]; + } + } + + TabHeader tabHeader = new TabHeader(tabs); + tabHeader.getStyleClass().add("add-account-tab-header"); + tabHeader.setMinWidth(USE_PREF_SIZE); + tabHeader.setMaxWidth(USE_PREF_SIZE); + tabHeader.getSelectionModel().select(selected); + onChange(tabHeader.getSelectionModel().selectedItemProperty(), + newItem -> { + if (newItem == null) + return; + AccountFactory newMethod = (AccountFactory) newItem.getUserData(); + config().setPreferredLoginType(Accounts.getLoginType(newMethod)); + this.factory = newMethod; + initDetailsPane(); + }); + + detailsContainer = new StackPane(); + detailsContainer.setPadding(new Insets(15, 0, 0, 0)); + + VBox boxBody = new VBox(tabHeader, detailsContainer); + boxBody.setAlignment(Pos.CENTER); + body = boxBody; + setBody(body); + + } else { + detailsContainer = new StackPane(); + detailsContainer.setPadding(new Insets(10, 0, 0, 0)); + body = detailsContainer; + setBody(body); + } + initDetailsPane(); + + setPrefWidth(560); + } + + private void onAccept() { + spinner.showSpinner(); + lblErrorMessage.setText(""); + body.setDisable(true); + + String username; + String password; + Object additionalData; + if (detailsPane instanceof AccountDetailsInputPane) { + AccountDetailsInputPane details = (AccountDetailsInputPane) detailsPane; + username = details.getUsername(); + password = details.getPassword(); + additionalData = details.getAuthServer(); + } else { + username = null; + password = null; + additionalData = null; + } + + loginTask = Task.supplyAsync(() -> factory.create(new DialogCharacterSelector(), username, password, additionalData)) + .whenComplete(Schedulers.javafx(), account -> { + int oldIndex = Accounts.getAccounts().indexOf(account); + if (oldIndex == -1) { + Accounts.getAccounts().add(account); + } else { + // adding an already-added account + // instead of discarding the new account, we first remove the existing one then add the new one + Accounts.getAccounts().remove(oldIndex); + Accounts.getAccounts().add(oldIndex, account); + } + + // select the new account + Accounts.setSelectedAccount(account); + + spinner.hideSpinner(); + fireEvent(new DialogCloseEvent()); + }, exception -> { + if (exception instanceof NoSelectedCharacterException) { + fireEvent(new DialogCloseEvent()); + } else { + lblErrorMessage.setText(Accounts.localizeErrorMessage(exception)); + } + body.setDisable(false); + spinner.hideSpinner(); + }).executor(true); + } + + private void onCancel() { + if (loginTask != null) { + loginTask.cancel(); + } + fireEvent(new DialogCloseEvent()); + } + + private void initDetailsPane() { + if (detailsPane != null) { + btnAccept.disableProperty().unbind(); + detailsContainer.getChildren().remove(detailsPane); + lblErrorMessage.setText(""); + } + if (factory == Accounts.FACTORY_MICROSOFT) { + Label lblTip = new Label(i18n("account.methods.microsoft.manual")); // TODO + lblTip.setWrapText(true); + detailsPane = lblTip; + btnAccept.setDisable(false); + } else { + detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire); + btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not()); + } + detailsContainer.getChildren().add(detailsPane); + } + + private static class AccountDetailsInputPane extends GridPane { + + // ==== authlib-injector hyperlinks ==== + private static final String[] ALLOWED_LINKS = { "register" }; + + private static List createHyperlinks(AuthlibInjectorServer server) { + if (server == null) { + return emptyList(); + } + + Map links = server.getLinks(); + List result = new ArrayList<>(); + for (String key : ALLOWED_LINKS) { + String value = links.get(key); + if (value != null) { + Hyperlink link = new Hyperlink(i18n("account.injector.link." + key)); + FXUtils.installSlowTooltip(link, value); + link.setOnAction(e -> FXUtils.openLink(value)); + result.add(link); + } + } + return unmodifiableList(result); + } + // ===== + + private AccountFactory factory; + private @Nullable JFXComboBox cboServers; + private @Nullable JFXTextField txtUsername; + private @Nullable JFXPasswordField txtPassword; + private BooleanBinding valid; + + public AccountDetailsInputPane(AccountFactory factory, Runnable onAction) { + this.factory = factory; + + setVgap(15); + setHgap(15); + setAlignment(Pos.CENTER); + + ColumnConstraints col0 = new ColumnConstraints(); + col0.setMinWidth(USE_PREF_SIZE); + getColumnConstraints().add(col0); + ColumnConstraints col1 = new ColumnConstraints(); + col1.setHgrow(Priority.ALWAYS); + getColumnConstraints().add(col1); + + int rowIndex = 0; + + if (factory instanceof AuthlibInjectorAccountFactory) { + Label lblServers = new Label(i18n("account.injector.server")); + setHalignment(lblServers, HPos.LEFT); + add(lblServers, 0, rowIndex); + + cboServers = new JFXComboBox<>(); + cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl()))); + cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName)); + bindContent(cboServers.getItems(), config().getAuthlibInjectorServers()); + cboServers.getItems().addListener(onInvalidating( + () -> Platform.runLater( // the selection will not be updated as expected if we call it immediately + cboServers.getSelectionModel()::selectFirst))); + cboServers.getSelectionModel().selectFirst(); + cboServers.setPromptText(i18n("account.injector.empty")); + BooleanBinding noServers = createBooleanBinding(cboServers.getItems()::isEmpty, cboServers.getItems()); + classPropertyFor(cboServers, "jfx-combo-box-warning").bind(noServers); + classPropertyFor(cboServers, "jfx-combo-box").bind(noServers.not()); + HBox.setHgrow(cboServers, Priority.ALWAYS); + cboServers.setMaxWidth(Double.MAX_VALUE); + + HBox linksContainer = new HBox(); + linksContainer.setAlignment(Pos.CENTER); + linksContainer.setPadding(new Insets(0, 5, 0, 15)); + onChangeAndOperate(cboServers.valueProperty(), server -> linksContainer.getChildren().setAll(createHyperlinks(server))); + linksContainer.setMinWidth(USE_PREF_SIZE); + + JFXButton btnAddServer = new JFXButton(); + btnAddServer.setGraphic(SVG.plus(null, 20, 20)); + btnAddServer.getStyleClass().add("toggle-icon4"); + btnAddServer.setOnAction(e -> { + Controllers.dialog(new AddAuthlibInjectorServerPane()); + }); + + JFXButton btnManageServers = new JFXButton(); + btnManageServers.setGraphic(SVG.gear(null, 20, 20)); + btnManageServers.getStyleClass().add("toggle-icon4"); + btnManageServers.setOnAction(e -> { + fireEvent(new DialogCloseEvent()); + Controllers.navigate(Controllers.getServersPage()); + }); + + HBox boxServers = new HBox(cboServers, linksContainer, btnAddServer, btnManageServers); + add(boxServers, 1, rowIndex); + + rowIndex++; + } + + if (factory.getLoginType().requiresUsername) { + Label lblUsername = new Label(i18n("account.username")); + setHalignment(lblUsername, HPos.LEFT); + add(lblUsername, 0, rowIndex); + + txtUsername = new JFXTextField(); + txtUsername.setValidators( + new RequiredValidator(), + new Validator(i18n("input.email"), username -> { + if (requiresEmailAsUsername()) { + return username.contains("@"); + } else { + return true; + } + })); + setValidateWhileTextChanged(txtUsername, true); + txtUsername.setOnAction(e -> onAction.run()); + add(txtUsername, 1, rowIndex); + + rowIndex++; + } + + if (factory.getLoginType().requiresPassword) { + Label lblPassword = new Label(i18n("account.password")); + setHalignment(lblPassword, HPos.LEFT); + add(lblPassword, 0, rowIndex); + + txtPassword = new JFXPasswordField(); + txtPassword.setValidators(new RequiredValidator()); + setValidateWhileTextChanged(txtPassword, true); + txtPassword.setOnAction(e -> onAction.run()); + add(txtPassword, 1, rowIndex); + + rowIndex++; + } + + valid = new BooleanBinding() { + { + if (cboServers != null) + bind(cboServers.valueProperty()); + if (txtUsername != null) + bind(txtUsername.textProperty()); + if (txtPassword != null) + bind(txtPassword.textProperty()); + } + + @Override + protected boolean computeValue() { + if (cboServers != null && cboServers.getValue() == null) + return false; + if (txtUsername != null && !txtUsername.validate()) + return false; + if (txtPassword != null && !txtPassword.validate()) + return false; + return true; + } + }; + } + + private boolean requiresEmailAsUsername() { + if (factory instanceof YggdrasilAccountFactory) { + return true; + } else if ((factory instanceof AuthlibInjectorAccountFactory) && cboServers != null) { + AuthlibInjectorServer server = cboServers.getValue(); + if (server != null && !server.isNonEmailLogin()) { + return true; + } + } + return false; + } + + public @Nullable AuthlibInjectorServer getAuthServer() { + return cboServers == null ? null : cboServers.getValue(); + } + + public @Nullable String getUsername() { + return txtUsername == null ? null : txtUsername.getText(); + } + + public @Nullable String getPassword() { + return txtPassword == null ? null : txtPassword.getText(); + } + + public BooleanBinding validProperty() { + return valid; + } + } + + private static class DialogCharacterSelector extends BorderPane implements CharacterSelector { + + private final AdvancedListBox listBox = new AdvancedListBox(); + private final JFXButton cancel = new JFXButton(); + + private final CountDownLatch latch = new CountDownLatch(1); + private GameProfile selectedProfile = null; + + public DialogCharacterSelector() { + setStyle("-fx-padding: 8px;"); + + cancel.setText(i18n("button.cancel")); + StackPane.setAlignment(cancel, Pos.BOTTOM_RIGHT); + cancel.setOnAction(e -> latch.countDown()); + + listBox.startCategory(i18n("account.choose")); + + setCenter(listBox); + + HBox hbox = new HBox(); + hbox.setAlignment(Pos.CENTER_RIGHT); + hbox.getChildren().add(cancel); + setBottom(hbox); + + onEscPressed(this, cancel::fire); + } + + @Override + public GameProfile select(YggdrasilService service, List profiles) throws NoSelectedCharacterException { + Platform.runLater(() -> { + for (GameProfile profile : profiles) { + ImageView portraitView = new ImageView(); + portraitView.setSmooth(false); + portraitView.imageProperty().bind(TexturesLoader.fxAvatarBinding(service, profile.getId(), 32)); + FXUtils.limitSize(portraitView, 32, 32); + + IconedItem accountItem = new IconedItem(portraitView, profile.getName()); + accountItem.setOnMouseClicked(e -> { + selectedProfile = profile; + latch.countDown(); + }); + listBox.add(accountItem); + } + Controllers.dialog(this); + }); + + try { + latch.await(); + + if (selectedProfile == null) + throw new NoSelectedCharacterException(); + + return selectedProfile; + } catch (InterruptedException ignored) { + throw new NoSelectedCharacterException(); + } finally { + Platform.runLater(() -> fireEvent(new DialogCloseEvent())); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index 2b0e09501..d5f3c39e0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -34,7 +34,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem; -import org.jackhuang.hmcl.ui.account.AddAccountPane; +import org.jackhuang.hmcl.ui.account.CreateAccountPane; import org.jackhuang.hmcl.ui.construct.AdvancedListBox; import org.jackhuang.hmcl.ui.construct.AdvancedListItem; import org.jackhuang.hmcl.ui.construct.TabHeader; @@ -144,7 +144,7 @@ public class RootPage extends DecoratorTabPage { Controllers.navigate(Controllers.getAccountListPage()); if (Accounts.getAccounts().isEmpty()) { - Controllers.dialog(new AddAccountPane()); + Controllers.dialog(new CreateAccountPane()); } }); accountListItem.accountProperty().bind(Accounts.selectedAccountProperty()); @@ -227,7 +227,7 @@ public class RootPage extends DecoratorTabPage { } private void addNewAccount() { - Controllers.dialog(new AddAccountPane()); + Controllers.dialog(new CreateAccountPane()); } // ==== diff --git a/HMCL/src/main/resources/assets/fxml/account-add.fxml b/HMCL/src/main/resources/assets/fxml/account-add.fxml deleted file mode 100644 index eee2b10de..000000000 --- a/HMCL/src/main/resources/assets/fxml/account-add.fxml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 6e4758e73..dc08a5428 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -42,7 +42,11 @@ about.open_source.statement=GPL v3 (https://github.com/huanghongxun/HMCL/) account=账户 account.character=角色 account.choose=选择一个角色 -account.create=新建账户 +account.create=添加账户 +account.create.microsoft=添加微软账户 +account.create.yggdrasil=添加 Mojang 账户 +account.create.offline=添加离线模式账户 +account.create.authlibInjector=添加外置登录账户 (authlib-injector) account.email=邮箱 account.failed.character_deleted=此角色已被删除 account.failed.connect_authentication_server=无法连接认证服务器,可能是网络问题 @@ -65,7 +69,7 @@ account.injector.server_name=服务器名称 account.manage=账户列表 account.methods=登录方式 account.methods.authlib_injector=外置登录 (authlib-injector) -account.methods.microsoft=微软登录 +account.methods.microsoft=微软账户 account.methods.microsoft.close_page=已完成微软账号授权,接下来启动器还需要完成剩余登录步骤。你已经可以关闭本页面了。 account.methods.microsoft.error.add_family=由于你未满 18 岁,你的账号必须被加入到家庭中才能登录游戏。 account.methods.microsoft.error.missing_xbox_account=你的微软账号尚未关联 XBox 账号,你必须先创建 XBox 账号,才能登录游戏。 diff --git a/HMCL/src/main/resources/assets/svg/arrow-left.fxml b/HMCL/src/main/resources/assets/svg/arrow-left.fxml deleted file mode 100644 index a83d31a4b..000000000 --- a/HMCL/src/main/resources/assets/svg/arrow-left.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/HMCL/src/main/resources/assets/svg/close-black.fxml b/HMCL/src/main/resources/assets/svg/close-black.fxml deleted file mode 100644 index f5af5ccbd..000000000 --- a/HMCL/src/main/resources/assets/svg/close-black.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/HMCL/src/main/resources/assets/svg/close.fxml b/HMCL/src/main/resources/assets/svg/close.fxml deleted file mode 100644 index 54e896a9f..000000000 --- a/HMCL/src/main/resources/assets/svg/close.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/HMCL/src/main/resources/assets/svg/folder-open.fxml b/HMCL/src/main/resources/assets/svg/folder-open.fxml deleted file mode 100644 index 6a734b3d7..000000000 --- a/HMCL/src/main/resources/assets/svg/folder-open.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/HMCL/src/main/resources/assets/svg/gear.fxml b/HMCL/src/main/resources/assets/svg/gear.fxml deleted file mode 100644 index 9a30e8405..000000000 --- a/HMCL/src/main/resources/assets/svg/gear.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/HMCL/src/main/resources/assets/svg/plus.fxml b/HMCL/src/main/resources/assets/svg/plus.fxml deleted file mode 100644 index 66b83fe0f..000000000 --- a/HMCL/src/main/resources/assets/svg/plus.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/HMCL/src/main/resources/assets/svg/refresh.fxml b/HMCL/src/main/resources/assets/svg/refresh.fxml deleted file mode 100644 index b22a628ce..000000000 --- a/HMCL/src/main/resources/assets/svg/refresh.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/svg/wrench-white.fxml b/HMCL/src/main/resources/assets/svg/wrench-white.fxml deleted file mode 100644 index f3f4f9faf..000000000 --- a/HMCL/src/main/resources/assets/svg/wrench-white.fxml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ExtendedProperties.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ExtendedProperties.java index a0541eab9..57e4b85c7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ExtendedProperties.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ExtendedProperties.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,6 +22,7 @@ import javafx.beans.WeakInvalidationListener; import javafx.beans.property.ObjectProperty; import javafx.beans.property.Property; import javafx.collections.ObservableList; +import javafx.scene.Node; import javafx.scene.control.*; import java.util.Objects; @@ -30,6 +31,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; +import static javafx.beans.binding.Bindings.createBooleanBinding; import static org.jackhuang.hmcl.util.Pair.pair; /** @@ -130,6 +132,28 @@ public final class ExtendedProperties { } // ==== + // ==== General ==== + @SuppressWarnings("unchecked") + public static ObjectProperty classPropertyFor(Node node, String cssClass) { + return (ObjectProperty) node.getProperties().computeIfAbsent( + PROP_PREFIX + ".cssClass." + cssClass, + any -> { + ObservableList classes = node.getStyleClass(); + return new ReadWriteComposedProperty<>(node, "extra.cssClass." + cssClass, + createBooleanBinding(() -> classes.contains(cssClass), classes), + state -> { + if (state) { + if (!classes.contains(cssClass)) { + classes.add(cssClass); + } + } else { + classes.remove(cssClass); + } + }); + }); + } + // ==== + private ExtendedProperties() { } }