diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java index ab0c15dd72..6d8f4c2bab 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java @@ -31,8 +31,12 @@ import org.jackhuang.hmcl.auth.ServerResponseMalformedException; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.auth.offline.Skin; -import org.jackhuang.hmcl.auth.yggdrasil.*; +import org.jackhuang.hmcl.auth.offline.OfflineSkinConfig; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.FXUtils; @@ -45,7 +49,10 @@ import java.lang.ref.WeakReference; import java.nio.file.Files; import java.nio.file.Path; -import java.util.*; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -65,29 +72,18 @@ private TexturesLoader() { } // ==== Texture Loading ==== - public static class LoadedTexture { - private final Image image; - private final Map metadata; - + public record LoadedTexture(Image image, Map metadata) { public LoadedTexture(Image image, Map metadata) { this.image = requireNonNull(image); this.metadata = requireNonNull(metadata); } - - public Image getImage() { - return image; - } - - public Map getMetadata() { - return metadata; - } } private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS); private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins"); private static Path getTexturePath(Texture texture) { - String url = texture.getUrl(); + String url = texture.url(); int slash = url.lastIndexOf('/'); int dot = url.lastIndexOf('.'); if (dot < slash) { @@ -99,7 +95,7 @@ private static Path getTexturePath(Texture texture) { } public static LoadedTexture loadTexture(Texture texture) throws Throwable { - if (StringUtils.isBlank(texture.getUrl())) { + if (StringUtils.isBlank(texture.url())) { throw new IOException("Texture url is empty"); } @@ -107,14 +103,14 @@ public static LoadedTexture loadTexture(Texture texture) throws Throwable { if (!Files.isRegularFile(file)) { // download it try { - new FileDownloadTask(texture.getUrl(), file).run(); - LOG.info("Texture downloaded: " + texture.getUrl()); + new FileDownloadTask(texture.url(), file).run(); + LOG.info("Texture downloaded: " + texture.url()); } catch (Exception e) { if (Files.isRegularFile(file)) { // concurrency conflict? - LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e); + LOG.warning("Failed to download texture " + texture.url() + ", but the file is available", e); } else { - throw new IOException("Failed to download texture " + texture.getUrl()); + throw new IOException("Failed to download texture " + texture.url()); } } } @@ -127,7 +123,7 @@ public static LoadedTexture loadTexture(Texture texture) throws Throwable { if (img.isError()) throw img.getException(); - Map metadata = texture.getMetadata(); + Map metadata = texture.metadata(); if (metadata == null) { metadata = emptyMap(); } @@ -158,7 +154,7 @@ public static LoadedTexture getDefaultSkin(UUID uuid) { } public static TextureModel getDefaultModel(UUID uuid) { - return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model")) + return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).metadata().get("model")) ? TextureModel.WIDE : TextureModel.SLIM; } @@ -176,7 +172,7 @@ public static ObjectBinding skinBinding(YggdrasilService service, } }) .flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN))) - .filter(it -> StringUtils.isNotBlank(it.getUrl()))) + .filter(it -> StringUtils.isNotBlank(it.url()))) .asyncMap(it -> { if (it.isPresent()) { Texture texture = it.get(); @@ -184,7 +180,7 @@ public static ObjectBinding skinBinding(YggdrasilService service, try { return loadTexture(texture); } catch (Throwable e) { - LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); + LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e); return uuidFallback; } }, POOL); @@ -196,28 +192,26 @@ public static ObjectBinding skinBinding(YggdrasilService service, public static ObservableValue skinBinding(Account account) { LoadedTexture uuidFallback = getDefaultSkin(account.getUUID()); - if (account instanceof OfflineAccount) { - OfflineAccount offlineAccount = (OfflineAccount) account; + if (account instanceof OfflineAccount offlineAccount) { SimpleObjectProperty binding = new SimpleObjectProperty<>(); InvalidationListener listener = o -> { - Skin skin = offlineAccount.getSkin(); - String username = offlineAccount.getUsername(); + OfflineSkinConfig skin = offlineAccount.getSkin(); binding.set(uuidFallback); if (skin != null) { - skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> { + skin.load().setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception != null) { LOG.warning("Failed to load texture", exception); - } else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) { + } else if (result != null && result.skin() != null && result.skin().image() != null) { Map metadata; - if (result.getModel() != null) { - metadata = singletonMap("model", result.getModel().modelName); + if (result.model() != null) { + metadata = singletonMap("model", result.model().modelName); } else { metadata = emptyMap(); } - binding.set(new LoadedTexture(result.getSkin().getImage(), metadata)); + binding.set(new LoadedTexture(result.skin().image(), metadata)); } }).start(); } @@ -234,12 +228,12 @@ public static ObservableValue skinBinding(Account account) { .asyncMap(textures -> { if (textures.isPresent()) { Texture texture = textures.get().get(TextureType.SKIN); - if (texture != null && StringUtils.isNotBlank(texture.getUrl())) { + if (texture != null && StringUtils.isNotBlank(texture.url())) { return CompletableFuture.supplyAsync(() -> { try { return loadTexture(texture); } catch (Throwable e) { - LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); + LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e); return uuidFallback; } }, POOL); @@ -275,15 +269,12 @@ private static void drawAvatar(GraphicsContext g, Image skin, int size, int scal 0, 0, size, size); } - private static final class SkinBindingChangeListener implements ChangeListener { + private record SkinBindingChangeListener(WeakReference canvasRef, + ObservableValue binding) implements ChangeListener { static final WeakHashMap hole = new WeakHashMap<>(); - final WeakReference canvasRef; - final ObservableValue binding; - - SkinBindingChangeListener(Canvas canvas, ObservableValue binding) { - this.canvasRef = new WeakReference<>(canvas); - this.binding = binding; + private SkinBindingChangeListener(Canvas canvasRef, ObservableValue binding) { + this(new WeakReference<>(canvasRef), binding); } @Override 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 13a67943c1..d704425fd2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -38,6 +38,7 @@ public enum SVG { ARROW_DROP_DOWN("M12 15 7 10H17L12 15Z"), ARROW_DROP_UP("M7 14 12 9 17 14H7Z"), ARROW_FORWARD("M16.175 13H4V11H16.175L10.575 5.4 12 4 20 12 12 20 10.575 18.6 16.175 13Z"), + APPAREL("m6 10.95l-1.875 1.025l-2.975-5.2L7.75 3H10v1q0 .825.588 1.413T12 6t1.413-.587T14 4V3h2.25l6.6 3.775l-2.95 5.15l-1.9-.95V21H6zM8 7.6V19h8V7.6l3.1 1.7l1.05-1.75l-4.3-2.5q-.375 1.275-1.412 2.113T12 8t-2.437-.837T8.15 5.05l-4.3 2.5L4.9 9.3zm4 4.425"), BETA_CIRCLE("M15,10.5C15,11.3 14.3,12 13.5,12C14.3,12 15,12.7 15,13.5V15A2,2 0 0,1 13,17H9V7H13A2,2 0 0,1 15,9V10.5M13,15V13H11V15H13M13,11V9H11V11H13M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material CANCEL("M8.4 17 12 13.4 15.6 17 17 15.6 13.4 12 17 8.4 15.6 7 12 10.6 8.4 7 7 8.4 10.6 12 7 15.6 8.4 17ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), CHAT("M6 14H14V12H6V14ZM6 11H18V9H6V11ZM6 8H18V6H6V8ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"), @@ -49,6 +50,7 @@ public enum SVG { CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"), CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v3H7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"), CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"), + CROP_9_16("M9 21q-.825 0-1.412-.587T7 19V5q0-.825.588-1.412T9 3h6q.825 0 1.413.588T17 5v14q0 .825-.587 1.413T15 21zM9 5v14h6V5zm0 0v14z"), DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"), DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"), DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"), 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 fa5de7480a..0306255930 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 @@ -34,12 +34,13 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.DialogController; +import org.jackhuang.hmcl.ui.account.skin.OfflineAccountSkinPage; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.skin.InvalidSkinException; @@ -55,8 +56,8 @@ import static java.util.Collections.emptySet; import static javafx.beans.binding.Bindings.createBooleanBinding; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class AccountListItem extends RadioButton { @@ -138,7 +139,7 @@ public ObservableBooleanValue canUploadSkin() { @Nullable public Task uploadSkin() { if (account instanceof OfflineAccount) { - Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account)); + Controllers.navigate(new OfflineAccountSkinPage((OfflineAccount) account)); return null; } if (!account.canUploadSkin()) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java deleted file mode 100644 index 25467e7e15..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java +++ /dev/null @@ -1,238 +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.JFXDialogLayout; -import com.jfoenix.controls.JFXTextField; -import javafx.application.Platform; -import javafx.beans.InvalidationListener; -import javafx.geometry.Insets; -import javafx.geometry.VPos; -import javafx.scene.control.Label; -import javafx.scene.input.DragEvent; -import javafx.scene.input.TransferMode; -import javafx.scene.layout.*; -import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.auth.offline.Skin; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; -import org.jackhuang.hmcl.game.TexturesLoader; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.ui.skin.SkinCanvas; -import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning; -import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms; -import org.jackhuang.hmcl.util.io.FileUtils; - -import java.nio.file.Path; -import java.util.Arrays; -import java.util.UUID; - -import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; -import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public class OfflineAccountSkinPane extends StackPane { - private final OfflineAccount account; - - private final MultiFileItem skinItem = new MultiFileItem<>(); - private final JFXTextField cslApiField = new JFXTextField(); - private final JFXComboBox modelCombobox = new JFXComboBox<>(); - private final FileSelector skinSelector = new FileSelector(); - private final FileSelector capeSelector = new FileSelector(); - - private final InvalidationListener skinBinding; - - public OfflineAccountSkinPane(OfflineAccount account) { - this.account = account; - - getStyleClass().add("skin-pane"); - - JFXDialogLayout layout = new JFXDialogLayout(); - getChildren().setAll(layout); - layout.setHeading(new Label(i18n("account.skin"))); - - BorderPane pane = new BorderPane(); - - SkinCanvas canvas = new SkinCanvas(TexturesLoader.getDefaultSkinImage(), 300, 300, true); - StackPane canvasPane = new StackPane(canvas); - canvasPane.setPrefWidth(300); - canvasPane.setPrefHeight(300); - pane.setCenter(canvas); - canvas.getAnimationPlayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); - canvas.enableRotation(.5); - - canvas.addEventHandler(DragEvent.DRAG_OVER, e -> { - if (e.getDragboard().hasFiles()) { - Path file = e.getDragboard().getFiles().get(0).toPath(); - if (FileUtils.getName(file).endsWith(".png")) - e.acceptTransferModes(TransferMode.COPY); - } - }); - canvas.addEventHandler(DragEvent.DRAG_DROPPED, e -> { - if (e.isAccepted()) { - Path skin = e.getDragboard().getFiles().get(0).toPath(); - Platform.runLater(() -> { - skinSelector.setValue(FileUtils.getAbsolutePath(skin)); - skinItem.setSelectedData(Skin.Type.LOCAL_FILE); - }); - } - }); - - StackPane skinOptionPane = new StackPane(); - skinOptionPane.setMaxWidth(300); - VBox optionPane = new VBox(skinItem, skinOptionPane); - pane.setRight(optionPane); - - skinSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); - capeSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); - - layout.setBody(pane); - - cslApiField.setPromptText(i18n("account.skin.type.csl_api.location.hint")); - cslApiField.setValidators(new URLValidator()); - FXUtils.setValidateWhileTextChanged(cslApiField, true); - - skinItem.loadChildren(Arrays.asList( - new MultiFileItem.Option<>(i18n("message.default"), Skin.Type.DEFAULT), - new MultiFileItem.Option<>(i18n("account.skin.type.steve"), Skin.Type.STEVE), - new MultiFileItem.Option<>(i18n("account.skin.type.alex"), Skin.Type.ALEX), - new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), Skin.Type.LOCAL_FILE), - new MultiFileItem.Option<>(i18n("account.skin.type.little_skin"), Skin.Type.LITTLE_SKIN), - new MultiFileItem.Option<>(i18n("account.skin.type.csl_api"), Skin.Type.CUSTOM_SKIN_LOADER_API) - )); - - modelCombobox.setConverter(stringConverter(model -> i18n("account.skin.model." + model.modelName))); - modelCombobox.getItems().setAll(TextureModel.WIDE, TextureModel.SLIM); - - if (account.getSkin() == null) { - skinItem.setSelectedData(Skin.Type.DEFAULT); - modelCombobox.setValue(TextureModel.WIDE); - } else { - skinItem.setSelectedData(account.getSkin().getType()); - cslApiField.setText(account.getSkin().getCslApi()); - modelCombobox.setValue(account.getSkin().getTextureModel()); - skinSelector.setValue(account.getSkin().getLocalSkinPath()); - capeSelector.setValue(account.getSkin().getLocalCapePath()); - } - - skinBinding = FXUtils.observeWeak(() -> { - getSkin().load(account.getUsername()) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - if (exception != null) { - LOG.warning("Failed to load skin", exception); - Controllers.showToast(i18n("message.failed")); - } else { - UUID uuid = this.account.getUUID(); - if (result == null || result.getSkin() == null && result.getCape() == null) { - canvas.updateSkin( - TexturesLoader.getDefaultSkin(uuid).getImage(), - TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM, - null - ); - return; - } - canvas.updateSkin( - result.getSkin() != null ? result.getSkin().getImage() : TexturesLoader.getDefaultSkin(uuid).getImage(), - result.getModel() == TextureModel.SLIM, - result.getCape() != null ? result.getCape().getImage() : null); - } - }).start(); - }, skinItem.selectedDataProperty(), cslApiField.textProperty(), modelCombobox.valueProperty(), skinSelector.valueProperty(), capeSelector.valueProperty()); - - FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { - GridPane gridPane = new GridPane(); - // Increase bottom padding to prevent the prompt from overlapping with the dialog action area - - gridPane.setPadding(new Insets(0, 0, 45, 10)); - gridPane.setHgap(16); - gridPane.setVgap(8); - gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); - - switch (selectedData) { - case DEFAULT: - case STEVE: - case ALEX: - break; - case LITTLE_SKIN: - HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO); - hint.setText(i18n("account.skin.type.little_skin.hint")); - - // Spanning two columns and expanding horizontally - GridPane.setColumnSpan(hint, 2); - GridPane.setHgrow(hint, Priority.ALWAYS); - hint.setMaxWidth(Double.MAX_VALUE); - - // Force top alignment within cells (to avoid vertical offset caused by the baseline) - GridPane.setValignment(hint, VPos.TOP); - - // Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space. - hint.setMaxHeight(Region.USE_PREF_SIZE); - hint.setMinHeight(Region.USE_PREF_SIZE); - - gridPane.addRow(0, hint); - break; - case LOCAL_FILE: - gridPane.setPadding(new Insets(0, 0, 0, 10)); - gridPane.addRow(0, new Label(i18n("account.skin.model")), modelCombobox); - gridPane.addRow(1, new Label(i18n("account.skin")), skinSelector); - gridPane.addRow(2, new Label(i18n("account.cape")), capeSelector); - break; - case CUSTOM_SKIN_LOADER_API: - gridPane.addRow(0, new Label(i18n("account.skin.type.csl_api.location")), cslApiField); - break; - } - - skinOptionPane.getChildren().setAll(gridPane); - }); - - JFXButton acceptButton = new JFXButton(i18n("button.ok")); - acceptButton.getStyleClass().add("dialog-accept"); - acceptButton.setOnAction(e -> { - account.setSkin(getSkin()); - fireEvent(new DialogCloseEvent()); - }); - - JFXHyperlink littleSkinLink = new JFXHyperlink(i18n("account.skin.type.little_skin")); - littleSkinLink.setOnAction(e -> FXUtils.openLink("https://littleskin.cn/")); - JFXButton cancelButton = new JFXButton(i18n("button.cancel")); - cancelButton.getStyleClass().add("dialog-cancel"); - cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); - onEscPressed(this, cancelButton::fire); - - acceptButton.disableProperty().bind( - skinItem.selectedDataProperty().isEqualTo(Skin.Type.CUSTOM_SKIN_LOADER_API) - .and(cslApiField.activeValidatorProperty().isNotNull())); - - layout.setActions(littleSkinLink, acceptButton, cancelButton); - } - - private Skin getSkin() { - Skin.Type type = skinItem.getSelectedData(); - if (type == Skin.Type.LOCAL_FILE) { - return new Skin(type, cslApiField.getText(), modelCombobox.getValue(), skinSelector.getValue(), capeSelector.getValue()); - } else { - String cslApi = type == Skin.Type.CUSTOM_SKIN_LOADER_API ? cslApiField.getText() : null; - return new Skin(type, cslApi, null, null, null); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/OfflineAccountSkinPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/OfflineAccountSkinPage.java new file mode 100644 index 0000000000..6d32cca4ca --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/OfflineAccountSkinPage.java @@ -0,0 +1,185 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.skin; + +import com.jfoenix.controls.JFXComboBox; +import javafx.beans.InvalidationListener; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.auth.offline.OfflineSkinConfig; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.game.skin.Skin; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.game.skin.TextureObject; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.FileSelector; +import org.jackhuang.hmcl.ui.construct.MultiFileItem; + +import java.util.Arrays; +import java.util.UUID; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public class OfflineAccountSkinPage extends SkinPageBase { + private ReadOnlyObjectWrapper skinProperty; + + private final MultiFileItem skinItem = new MultiFileItem<>(); + private final JFXComboBox modelCombobox = new JFXComboBox<>(); + private final FileSelector skinSelector = new FileSelector(); + private final FileSelector capeSelector = new FileSelector(); + + public OfflineAccountSkinPage(OfflineAccount account) { + super(account, null); + + skinItem.loadChildren(Arrays.asList( + new MultiFileItem.Option<>(i18n("message.default"), OfflineSkinConfig.Type.DEFAULT), + new MultiFileItem.Option<>(i18n("account.skin.type.steve"), OfflineSkinConfig.Type.STEVE), + new MultiFileItem.Option<>(i18n("account.skin.type.alex"), OfflineSkinConfig.Type.ALEX), + new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), OfflineSkinConfig.Type.LOCAL_FILE) + )); + + modelCombobox.setConverter(FXUtils.stringConverter(model -> i18n("account.skin.model." + model.modelName))); + modelCombobox.getItems().setAll(TextureModel.WIDE, TextureModel.SLIM); + + OfflineSkinConfig config = account.getSkin(); + if (config == null) { + skinItem.setSelectedData(OfflineSkinConfig.Type.DEFAULT); + modelCombobox.setValue(TextureModel.WIDE); + } else { + skinItem.setSelectedData(config.type()); + modelCombobox.setValue(config.textureModel() != null ? config.textureModel() : TextureModel.WIDE); + skinSelector.setValue(config.localSkinPath()); + capeSelector.setValue(config.localCapePath()); + } + + StackPane contentPane = super.skinManage.leftRegion; + + VBox settingsBox = new VBox(20); + GridPane grid = new GridPane(); + grid.setAlignment(Pos.CENTER); + grid.setHgap(16); + grid.setVgap(10); + + ChangeListener listener = (obs, oldVal, newVal) -> { + grid.getChildren().clear(); + if (newVal == OfflineSkinConfig.Type.LOCAL_FILE) { + grid.addRow(0, new Label(i18n("account.skin.model")), modelCombobox); + grid.addRow(1, new Label(i18n("account.skin")), skinSelector); + grid.addRow(2, new Label(i18n("account.cape")), capeSelector); + } + }; + + listener.changed(null, null, skinItem.getSelectedData()); + skinItem.selectedDataProperty().addListener(listener); + + settingsBox.getChildren().addAll(skinItem, grid); + contentPane.getChildren().setAll(settingsBox); + StackPane.setAlignment(settingsBox, Pos.CENTER); + settingsBox.setAlignment(Pos.CENTER); + +// super.skinManage.setOnDragOver(e -> { +// if (e.getDragboard().hasFiles()) { +// Path file = e.getDragboard().getFiles().get(0).toPath(); +// if (FileUtils.getName(file).endsWith(".png")) { +// e.acceptTransferModes(TransferMode.COPY); +// } +// } +// }); +// super.skinManage.setOnDragDropped(e -> { +// if (e.isAccepted()) { +// Path skin = e.getDragboard().getFiles().get(0).toPath(); +// Platform.runLater(() -> { +// skinSelector.setValue(FileUtils.getAbsolutePath(skin)); +// skinItem.setSelectedData(OfflineSkinConfig.Type.LOCAL_FILE); +// }); +// } +// }); + + InvalidationListener invalidationListener = (e) -> { + account.setSkin(getConfig()); + loadSkinPreview(); + }; + + skinItem.selectedDataProperty().addListener(invalidationListener); + modelCombobox.valueProperty().addListener(invalidationListener); + skinSelector.valueProperty().addListener(invalidationListener); + capeSelector.valueProperty().addListener(invalidationListener); + + loadSkinPreview(); + } + + private OfflineSkinConfig getConfig() { + OfflineSkinConfig.Type type = skinItem.getSelectedData(); + if (type == null) type = OfflineSkinConfig.Type.DEFAULT; + TextureModel model = modelCombobox.getValue(); + + var textureModel = switch (type) { + case ALEX -> TextureModel.SLIM; + case STEVE -> TextureModel.WIDE; + case DEFAULT -> TexturesLoader.getDefaultModel(account.getUUID()); + default -> model; + }; + + return new OfflineSkinConfig(type, textureModel, skinSelector.getValue(), capeSelector.getValue()); + } + + private void loadSkinPreview() { + OfflineSkinConfig config = getConfig(); + config.load().whenComplete(Schedulers.javafx(), (loadedSkin, throwable) -> { + if (throwable != null) { + LOG.warning("Failed to load skin for preview", throwable); + Controllers.showToast(i18n("message.failed")); + return; + } + + UUID uuid = account.getUUID(); + TextureModel model = TextureModel.WIDE; + TextureObject skinTex = null; + TextureObject capeTex = null; + + if (loadedSkin != null) { + model = loadedSkin.model(); + skinTex = loadedSkin.skin() != null ? new TextureObject(loadedSkin.skin().image(), "") : null; + capeTex = loadedSkin.cape() != null ? new TextureObject(loadedSkin.cape().image(), "") : null; + } + + if (skinTex == null) { + skinTex = new TextureObject(TexturesLoader.getDefaultSkin(uuid).image(), ""); + model = TexturesLoader.getDefaultModel(uuid); + } + + skinProperty.set(new Skin(model, skinTex, capeTex)); + }).start(); + } + + @Override + protected ReadOnlyObjectProperty skinObjectProperty() { + if (skinProperty == null) skinProperty = new ReadOnlyObjectWrapper<>(); + return skinProperty.getReadOnlyProperty(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/SkinPageBase.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/SkinPageBase.java new file mode 100644 index 0000000000..6ae38633a9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/skin/SkinPageBase.java @@ -0,0 +1,174 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.skin; + +import com.jfoenix.controls.JFXPopup; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.Insets; +import javafx.scene.layout.*; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.game.skin.Skin; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.ui.skin.SkinCanvas; +import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning; +import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.SwingFXUtils; +import org.jetbrains.annotations.Nullable; + +import javax.imageio.ImageIO; +import java.awt.image.RenderedImage; +import java.io.File; +import java.io.IOException; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public abstract class SkinPageBase extends DecoratorAnimatedPage implements DecoratorPage, PageAware { + protected final T account; + @Nullable + private final String url; + private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); + private final BooleanProperty loadingProperty = new SimpleBooleanProperty(true); + private final TabHeader tab; + private final TabHeader.Tab manageTab = new TabHeader.Tab<>("manageTab"); + private final TransitionPane transitionPane = new TransitionPane(); + + protected final SkinManage skinManage; + + protected SkinPageBase(T account, @Nullable String url) { + this.url = url; + this.account = account; + + tab = new TabHeader(transitionPane, manageTab); + skinManage = new SkinManage(); + manageTab.setNodeSupplier(() -> skinManage); + tab.select(manageTab); + + BorderPane left = new BorderPane(); + FXUtils.setLimitWidth(left, 200); + VBox.setVgrow(left, Priority.ALWAYS); + setLeft(left); + + AdvancedListBox sideBar = new AdvancedListBox().addNavigationDrawerTab(tab, manageTab, i18n("account.skin"), SVG.CHECKROOM); + left.setTop(sideBar); + + PopupMenu saveList = new PopupMenu(); + JFXPopup savePopup = new JFXPopup(saveList); + + var capeItem = new IconedMenuItem(SVG.CROP_9_16, i18n("account.skin.manage.save.cape"), () -> { + var fxCapeImage = skinObjectProperty().get().cape().image(); + var bufferedCapeImage = SwingFXUtils.fromFXImage(fxCapeImage, null); + try { + savePng(bufferedCapeImage, "cape"); + } catch (Exception e) { + LOG.warning("Failed to export skin img", e); + Controllers.dialog(i18n("message.failed") + "\n" + StringUtils.getStackTrace(e), i18n("message.failed"), MessageDialogPane.MessageType.ERROR); + } + }, savePopup); + + saveList.getContent().setAll(new IconedMenuItem(SVG.APPAREL, i18n("account.skin.manage.save.skin"), () -> { + var fxSkinImage = skinObjectProperty().get().skin().image(); + var bufferedSkinImage = SwingFXUtils.fromFXImage(fxSkinImage, null); + try { + savePng(bufferedSkinImage, "skin"); + } catch (Exception e) { + LOG.warning("Failed to export skin img", e); + Controllers.dialog(i18n("message.failed") + "\n" + StringUtils.getStackTrace(e), i18n("message.failed"), MessageDialogPane.MessageType.ERROR); + } + }, savePopup), capeItem); + + skinObjectProperty().addListener((observable, oldValue, newValue) -> { + capeItem.setDisable(newValue.cape() == null); + }); + AdvancedListBox toolbar = new AdvancedListBox().addNavigationDrawerItem(i18n("button.save"), SVG.OUTPUT, null, item -> { + item.setOnAction(e -> savePopup.show(item, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, item.getWidth(), 0)); + }); + BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); + left.setBottom(toolbar); + + setCenter(transitionPane); + + this.state.set(State.fromTitle(i18n("account.skin.manage", account.getUsername()))); + } + + public void savePng(RenderedImage image, String name) throws IOException { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("button.save_as")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("file"), "*.png")); + fileChooser.setInitialFileName(name + ".png"); + File target = fileChooser.showSaveDialog(Controllers.getStage()); + if (target == null) return; + ImageIO.write(image, "png", target); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state.getReadOnlyProperty(); + } + + protected abstract ReadOnlyObjectProperty skinObjectProperty(); + + protected final class SkinManage extends HBox { + protected StackPane leftRegion = new StackPane(); + private final BorderPane rightRegion = new BorderPane(); + + private SkinManage() { + setSpacing(10); + setPadding(new Insets(10, 10, 10, 10)); + + leftRegion.getStyleClass().add("card-non-transparent"); + HBox.setHgrow(leftRegion, Priority.ALWAYS); + + rightRegion.getStyleClass().add("card-non-transparent"); + FXUtils.setLimitWidth(rightRegion, 250); + + + var uuid = account.getUUID(); + var skin = TexturesLoader.getDefaultSkin(uuid).image(); + var slim = TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM; + + SkinCanvas canvas = new SkinCanvas(skin, 250, 400, true); + canvas.getScale().setX(1.25); + canvas.getScale().setY(1.25); + canvas.updateSkin(skin, slim, null); + skinObjectProperty().addListener((obs, oldSkin, newSkin) -> { + canvas.updateSkin(newSkin.skin().image(), newSkin.model().isSlim(), newSkin.cape() != null ? newSkin.cape().image() : null); + }); + StackPane canvasPane = new StackPane(canvas); + canvasPane.setPrefWidth(300); + rightRegion.setCenter(canvasPane); + canvas.getAnimationPlayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); + canvas.enableRotation(.5); + + getChildren().setAll(leftRegion, rightRegion); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java index e45aa02af1..cb05752118 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java @@ -23,7 +23,7 @@ public class IconedItem extends RipplerContainer { - private Label label; + private final Label label; public IconedItem(Node icon, String text) { this(icon); @@ -34,6 +34,16 @@ public IconedItem(Node icon) { super(createHBox(icon)); label = ((Label) lookup("#label")); getStyleClass().setAll("iconed-item"); + + this.disabledProperty().addListener((observable, oldValue, newValue) -> { + if (newValue) { + this.setOpacity(0.4); + this.setMouseTransparent(true); // 确保不可点击 + } else { + this.setOpacity(1.0); + this.setMouseTransparent(false); + } + }); } private static HBox createHBox(Node icon) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java index 5bf3cc4c1c..ddd0c39422 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/skin/SkinCanvas.java @@ -113,6 +113,10 @@ public Image getSkin() { return skin; } + public Scale getScale() { + return scale; + } + public void updateSkin(Image skin, boolean isSlim, final @Nullable Image cape) { if (SkinHelper.isNoRequest(skin) && SkinHelper.isSkin(skin)) { this.srcSkin = skin; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java new file mode 100644 index 0000000000..17f569e5b4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/SwingFXUtils.java @@ -0,0 +1,210 @@ +// Copy from javafx.swing +/* + * Copyright (c) 2012, 2018, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package org.jackhuang.hmcl.util; + +import javafx.scene.image.Image; +import javafx.scene.image.PixelFormat; +import javafx.scene.image.PixelReader; +import javafx.scene.image.WritablePixelFormat; +import javafx.scene.paint.Color; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.awt.image.SampleModel; +import java.awt.image.SinglePixelPackedSampleModel; +import java.nio.IntBuffer; + +/** + * This class provides utility methods for converting data types between + * Swing/AWT and JavaFX formats. + * + * @since JavaFX 2.2 + */ +public final class SwingFXUtils { + private SwingFXUtils() { + } // no instances + + /** + * Determine the optimal BufferedImage type to use for the specified + * {@code fxFormat} allowing for the specified {@code bimg} to be used + * as a potential default storage space if it is not null and is compatible. + * + * @param fxFormat the PixelFormat of the source FX Image + * @param bimg an optional existing {@code BufferedImage} to be used + * for storage if it is compatible, or null + * @return + */ + static int getBestBufferedImageType(PixelFormat fxFormat, BufferedImage bimg, boolean isOpaque) { + if (bimg != null) { + int bimgType = bimg.getType(); + if (bimgType == BufferedImage.TYPE_INT_ARGB || bimgType == BufferedImage.TYPE_INT_ARGB_PRE || (isOpaque && (bimgType == BufferedImage.TYPE_INT_BGR || bimgType == BufferedImage.TYPE_INT_RGB))) { + // We will allow the caller to give us a BufferedImage + // that has an alpha channel, but we might not otherwise + // construct one ourselves. + // We will also allow them to choose their own premultiply + // type which may not match the image. + // If left to our own devices we might choose a more specific + // format as indicated by the choices below. + return bimgType; + } + } + switch (fxFormat.getType()) { + case BYTE_BGRA: + case INT_ARGB: + return BufferedImage.TYPE_INT_ARGB; + case BYTE_RGB: + return BufferedImage.TYPE_INT_RGB; + case BYTE_INDEXED: + return (fxFormat.isPremultiplied() ? BufferedImage.TYPE_INT_ARGB_PRE : BufferedImage.TYPE_INT_ARGB); + case BYTE_BGRA_PRE: + case INT_ARGB_PRE: + default: + return BufferedImage.TYPE_INT_ARGB_PRE; + } + } + + /** + * Determine the appropriate {@link WritablePixelFormat} type that can + * be used to transfer data into the indicated BufferedImage. + * + * @param bimg the BufferedImage that will be used as a destination for + * a {@code PixelReader#getPixels()} operation. + * @return + */ + private static WritablePixelFormat getAssociatedPixelFormat(BufferedImage bimg) { + switch (bimg.getType()) { + // We lie here for xRGB, but we vetted that the src data was opaque + // so we can ignore the alpha. We use ArgbPre instead of Argb + // just to get a loop that does not have divides in it if the + // PixelReader happens to not know the data is opaque. + case BufferedImage.TYPE_INT_RGB: + case BufferedImage.TYPE_INT_ARGB_PRE: + return PixelFormat.getIntArgbPreInstance(); + case BufferedImage.TYPE_INT_ARGB: + return PixelFormat.getIntArgbInstance(); + default: + // Should not happen... + throw new InternalError("Failed to validate BufferedImage type"); + } + } + + private static boolean checkFXImageOpaque(PixelReader pr, int iw, int ih) { + for (int x = 0; x < iw; x++) { + for (int y = 0; y < ih; y++) { + Color color = pr.getColor(x, y); + if (color.getOpacity() != 1.0) { + return false; + } + } + } + return true; + } + + /** + * Snapshots the specified JavaFX {@link Image} object and stores a + * copy of its pixels into a {@link BufferedImage} object, creating + * a new object if needed. + * The method will only convert a JavaFX {@code Image} that is readable + * as per the conditions on the + * {@link Image#getPixelReader() Image.getPixelReader()} + * method. + * If the {@code Image} is not readable, as determined by its + * {@code getPixelReader()} method, then this method will return null. + * If the {@code Image} is a writable, or other dynamic image, then + * the {@code BufferedImage} will only be set to the current state of + * the pixels in the image as determined by its {@link PixelReader}. + * Further changes to the pixels of the {@code Image} will not be + * reflected in the returned {@code BufferedImage}. + *

+ * The optional {@code BufferedImage} parameter may be reused to store + * the copy of the pixels. + * A new {@code BufferedImage} will be created if the supplied object + * is null, is too small or of a type which the image pixels cannot + * be easily converted into. + * + * @param img the JavaFX {@code Image} to be converted + * @param bimg an optional {@code BufferedImage} object that may be + * used to store the returned pixel data + * @return a {@code BufferedImage} containing a snapshot of the JavaFX + * {@code Image}, or null if the {@code Image} is not readable. + * @since JavaFX 2.2 + */ + public static BufferedImage fromFXImage(Image img, BufferedImage bimg) { + PixelReader pr = img.getPixelReader(); + if (pr == null) { + return null; + } + int iw = (int) img.getWidth(); + int ih = (int) img.getHeight(); + PixelFormat fxFormat = pr.getPixelFormat(); + boolean srcPixelsAreOpaque = false; + switch (fxFormat.getType()) { + case INT_ARGB_PRE: + case INT_ARGB: + case BYTE_BGRA_PRE: + case BYTE_BGRA: + // Check fx image opacity only if + // supplied BufferedImage is without alpha channel + if (bimg != null && (bimg.getType() == BufferedImage.TYPE_INT_BGR || bimg.getType() == BufferedImage.TYPE_INT_RGB)) { + srcPixelsAreOpaque = checkFXImageOpaque(pr, iw, ih); + } + break; + case BYTE_RGB: + srcPixelsAreOpaque = true; + break; + } + int prefBimgType = getBestBufferedImageType(pr.getPixelFormat(), bimg, srcPixelsAreOpaque); + if (bimg != null) { + int bw = bimg.getWidth(); + int bh = bimg.getHeight(); + if (bw < iw || bh < ih || bimg.getType() != prefBimgType) { + bimg = null; + } else if (iw < bw || ih < bh) { + Graphics2D g2d = bimg.createGraphics(); + g2d.setComposite(AlphaComposite.Clear); + g2d.fillRect(0, 0, bw, bh); + g2d.dispose(); + } + } + if (bimg == null) { + bimg = new BufferedImage(iw, ih, prefBimgType); + } + DataBufferInt db = (DataBufferInt) bimg.getRaster().getDataBuffer(); + int[] data = db.getData(); + int offset = bimg.getRaster().getDataBuffer().getOffset(); + int scan = 0; + SampleModel sm = bimg.getRaster().getSampleModel(); + if (sm instanceof SinglePixelPackedSampleModel) { + scan = ((SinglePixelPackedSampleModel) sm).getScanlineStride(); + } + + WritablePixelFormat pf = getAssociatedPixelFormat(bimg); + pr.getPixels(0, 0, iw, ih, pf, data, offset, scan); + return bimg; + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index f02e5f3bf4..1658878765 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -152,6 +152,9 @@ account.not_logged_in=Not Logged in account.password=Password account.portable=Portable account.skin=Skin +account.skin.manage=Skin Management - %1s +account.skin.manage.save.skin=Save Skin +account.skin.manage.save.cape=Save Cape account.skin.file=Skin File account.skin.model=Model account.skin.model.default=Classic diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index facce5c2a7..f4ad0e2d86 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -150,6 +150,9 @@ account.not_logged_in=未登入 account.password=密碼 account.portable=可攜式帳戶 account.skin=外觀 +account.skin.manage=外觀管理 - %1s +account.skin.manage.save.skin=保存外觀 +account.skin.manage.save.cape=保存披风 account.skin.file=外觀圖片檔案 account.skin.model=模型 account.skin.model.default=寬型 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 ad5b57486e..6b98363750 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -152,6 +152,9 @@ account.not_logged_in=未登录 account.password=密码 account.portable=便携账户 account.skin=皮肤 +account.skin.manage=皮肤管理 - %1s +account.skin.manage.save.skin=保存皮肤 +account.skin.manage.save.cape=保存披风 account.skin.file=皮肤图片文件 account.skin.model=模型 account.skin.model.default=宽型 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java index 3d40c983fe..38753913bc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java @@ -26,7 +26,7 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.javafx.ObservableHelper; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java index 6be37269ab..99e05bce8d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java @@ -19,13 +19,14 @@ import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilSession; import org.jackhuang.hmcl.game.Arguments; import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.function.ExceptionalSupplier; + import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java index aa06138053..878a5ae0d9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java @@ -20,8 +20,8 @@ import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java index 883d2d0b74..ec59385fb3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java @@ -28,7 +28,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.*; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/HashedTexture.java similarity index 84% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/HashedTexture.java index be9690f309..55232f71a0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/HashedTexture.java @@ -30,30 +30,19 @@ import static java.util.Objects.requireNonNull; -public final class Texture { - private final String hash; - private final Image image; - - public Texture(String hash, Image image) { +public record HashedTexture(String hash, Image image) { + public HashedTexture(String hash, Image image) { this.hash = requireNonNull(hash); this.image = requireNonNull(image); } - public String getHash() { - return hash; - } - - public Image getImage() { - return image; - } - - private static final Map textures = new HashMap<>(); + private static final Map textures = new HashMap<>(); public static boolean hasTexture(String hash) { return textures.containsKey(hash); } - public static Texture getTexture(String hash) { + public static HashedTexture getTexture(String hash) { return textures.get(hash); } @@ -100,7 +89,7 @@ private static void putInt(byte[] array, int offset, int x) { array[offset + 3] = (byte) (x >> 0 & 0xff); } - public static Texture loadTexture(InputStream in) throws IOException { + public static HashedTexture loadTexture(InputStream in) throws IOException { if (in == null) return null; Image img; try (InputStream is = in) { @@ -113,17 +102,17 @@ public static Texture loadTexture(InputStream in) throws IOException { return loadTexture(img); } - public static Texture loadTexture(Image image) { + public static HashedTexture loadTexture(Image image) { if (image == null) return null; String hash = computeTextureHash(image); - Texture existent = textures.get(hash); + HashedTexture existent = textures.get(hash); if (existent != null) { return existent; } - Texture texture = new Texture(hash, image); + HashedTexture texture = new HashedTexture(hash, image); existent = textures.putIfAbsent(hash, texture); if (existent != null) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/LoadedOfflineSkin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/LoadedOfflineSkin.java new file mode 100644 index 0000000000..30fbdf77be --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/LoadedOfflineSkin.java @@ -0,0 +1,23 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.auth.offline; + +import org.jackhuang.hmcl.game.skin.TextureModel; + +public record LoadedOfflineSkin(TextureModel model, HashedTexture skin, HashedTexture cape) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index 7e93fa9019..b8cb924fa5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -25,9 +25,9 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.game.Arguments; import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; @@ -53,9 +53,9 @@ public class OfflineAccount extends Account { private final AuthlibInjectorArtifactProvider downloader; private final String username; private final UUID uuid; - private Skin skin; + private OfflineSkinConfig skin; - protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Skin skin) { + protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, OfflineSkinConfig skin) { this.downloader = requireNonNull(downloader); this.username = requireNonNull(username); this.uuid = requireNonNull(uuid); @@ -90,17 +90,17 @@ public String getIdentifier() { return username + ":" + username; } - public Skin getSkin() { + public OfflineSkinConfig getSkin() { return skin; } - public void setSkin(Skin skin) { + public void setSkin(OfflineSkinConfig skin) { this.skin = skin; invalidate(); } - protected boolean loadAuthlibInjector(Skin skin) { - return skin != null && skin.getType() != Skin.Type.DEFAULT; + protected boolean loadAuthlibInjector(OfflineSkinConfig skin) { + return skin != null && skin.type() != OfflineSkinConfig.Type.DEFAULT; } public AuthInfo logInWithoutSkin() throws AuthenticationException { @@ -164,7 +164,7 @@ public Arguments getLaunchArguments(LaunchOptions options) throws IOException { try { server.addCharacter(new YggdrasilServer.Character(uuid, username, - skin != null ? skin.load(username).run() : null)); + skin != null ? skin.load().run() : null)); } catch (IOException e) { // ignore } catch (Exception e) { @@ -220,9 +220,8 @@ public int hashCode() { @Override public boolean equals(Object obj) { - if (!(obj instanceof OfflineAccount)) + if (!(obj instanceof OfflineAccount another)) return false; - OfflineAccount another = (OfflineAccount) obj; return isPortable() == another.isPortable() && username.equals(another.username); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java index e06dbdbb9e..634b4f09f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccountFactory.java @@ -52,7 +52,7 @@ public OfflineAccount create(String username, UUID uuid) { public OfflineAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) { AdditionalData data; UUID uuid; - Skin skin; + OfflineSkinConfig skin; if (additionalData != null) { data = (AdditionalData) additionalData; uuid = data.uuid == null ? getUUIDFromUserName(username) : data.uuid; @@ -71,7 +71,7 @@ public OfflineAccount fromStorage(Map storage) { UUID uuid = tryCast(storage.get("uuid"), String.class) .map(UUIDTypeAdapter::fromString) .orElse(getUUIDFromUserName(username)); - Skin skin = Skin.fromStorage(tryCast(storage.get("skin"), Map.class).orElse(null)); + OfflineSkinConfig skin = OfflineSkinConfig.fromStorage(tryCast(storage.get("skin"), Map.class).orElse(null)); return new OfflineAccount(downloader, username, uuid, skin); } @@ -82,9 +82,9 @@ public static UUID getUUIDFromUserName(String username) { public static class AdditionalData { private final UUID uuid; - private final Skin skin; + private final OfflineSkinConfig skin; - public AdditionalData(UUID uuid, Skin skin) { + public AdditionalData(UUID uuid, OfflineSkinConfig skin) { this.uuid = uuid; this.skin = skin; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineSkinConfig.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineSkinConfig.java new file mode 100644 index 0000000000..5111586e8e --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineSkinConfig.java @@ -0,0 +1,129 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 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.auth.offline; + +import javafx.scene.image.Image; +import org.jackhuang.hmcl.game.skin.TextureModel; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import static org.jackhuang.hmcl.util.Lang.mapOf; +import static org.jackhuang.hmcl.util.Lang.tryCast; +import static org.jackhuang.hmcl.util.Pair.pair; + +public record OfflineSkinConfig(Type type, TextureModel textureModel, String localSkinPath, String localCapePath) { + + public enum Type { + DEFAULT, + ALEX, + ARI, + EFE, + KAI, + MAKENA, + NOOR, + STEVE, + SUNNY, + ZURI, + LOCAL_FILE; + + public static Type fromStorage(String type) { + return switch (type) { + case "default" -> DEFAULT; + case "alex" -> ALEX; + case "ari" -> ARI; + case "efe" -> EFE; + case "kai" -> KAI; + case "makena" -> MAKENA; + case "noor" -> NOOR; + case "steve" -> STEVE; + case "sunny" -> SUNNY; + case "zuri" -> ZURI; + case "local_file" -> LOCAL_FILE; + default -> null; + }; + } + } + + @Override + public TextureModel textureModel() { + return textureModel == null ? TextureModel.WIDE : textureModel; + } + + public Task load() { + switch (type) { + case DEFAULT: + return Task.supplyAsync(() -> null); + case ALEX: + case ARI: + case EFE: + case KAI: + case MAKENA: + case NOOR: + case STEVE: + case SUNNY: + case ZURI: + TextureModel model = this.textureModel != null ? this.textureModel : type == Type.ALEX ? TextureModel.SLIM : TextureModel.WIDE; + String resource = (model == TextureModel.SLIM ? "/assets/img/skin/slim/" : "/assets/img/skin/wide/") + type.name().toLowerCase(Locale.ROOT) + ".png"; + + return Task.supplyAsync(() -> new LoadedOfflineSkin( + model, + HashedTexture.loadTexture(new Image(resource)), + null + )); + case LOCAL_FILE: + return Task.supplyAsync(() -> { + HashedTexture skin = null, cape = null; + Optional skinPath = FileUtils.tryGetPath(localSkinPath); + Optional capePath = FileUtils.tryGetPath(localCapePath); + if (skinPath.isPresent()) skin = HashedTexture.loadTexture(Files.newInputStream(skinPath.get())); + if (capePath.isPresent()) cape = HashedTexture.loadTexture(Files.newInputStream(capePath.get())); + return new LoadedOfflineSkin(textureModel(), skin, cape); + }); + default: + throw new UnsupportedOperationException(); + } + } + + public Map toStorage() { + return mapOf( + pair("type", type.name().toLowerCase(Locale.ROOT)), + pair("textureModel", textureModel().modelName), + pair("localSkinPath", localSkinPath), + pair("localCapePath", localCapePath) + ); + } + + public static OfflineSkinConfig fromStorage(Map storage) { + if (storage == null) return null; + + Type type = tryCast(storage.get("type"), String.class).flatMap(t -> Optional.ofNullable(Type.fromStorage(t))) + .orElse(Type.DEFAULT); + String textureModel = tryCast(storage.get("textureModel"), String.class).orElse("default"); + String localSkinPath = tryCast(storage.get("localSkinPath"), String.class).orElse(null); + String localCapePath = tryCast(storage.get("localCapePath"), String.class).orElse(null); + + return new OfflineSkinConfig(type, "slim".equals(textureModel) ? TextureModel.SLIM : TextureModel.WIDE, localSkinPath, localCapePath); + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java deleted file mode 100644 index eac62d2b40..0000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java +++ /dev/null @@ -1,372 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 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.auth.offline; - -import com.google.gson.annotations.SerializedName; -import javafx.scene.image.Image; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; -import org.jackhuang.hmcl.task.FetchTask; -import org.jackhuang.hmcl.task.GetTask; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.io.NetworkUtils; -import org.jetbrains.annotations.Nullable; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; - -import static org.jackhuang.hmcl.util.Lang.mapOf; -import static org.jackhuang.hmcl.util.Lang.tryCast; -import static org.jackhuang.hmcl.util.Pair.pair; - -public class Skin { - - public enum Type { - DEFAULT, - ALEX, - ARI, - EFE, - KAI, - MAKENA, - NOOR, - STEVE, - SUNNY, - ZURI, - LOCAL_FILE, - LITTLE_SKIN, - CUSTOM_SKIN_LOADER_API, - YGGDRASIL_API; - - public static Type fromStorage(String type) { - switch (type) { - case "default": - return DEFAULT; - case "alex": - return ALEX; - case "ari": - return ARI; - case "efe": - return EFE; - case "kai": - return KAI; - case "makena": - return MAKENA; - case "noor": - return NOOR; - case "steve": - return STEVE; - case "sunny": - return SUNNY; - case "zuri": - return ZURI; - case "local_file": - return LOCAL_FILE; - case "little_skin": - return LITTLE_SKIN; - case "custom_skin_loader_api": - return CUSTOM_SKIN_LOADER_API; - case "yggdrasil_api": - return YGGDRASIL_API; - default: - return null; - } - } - } - - private final Type type; - private final String cslApi; - private final TextureModel textureModel; - private final String localSkinPath; - private final String localCapePath; - - public Skin(Type type, String cslApi, TextureModel textureModel, String localSkinPath, String localCapePath) { - this.type = type; - this.cslApi = cslApi; - this.textureModel = textureModel; - this.localSkinPath = localSkinPath; - this.localCapePath = localCapePath; - } - - public Type getType() { - return type; - } - - public String getCslApi() { - return cslApi; - } - - public TextureModel getTextureModel() { - return textureModel == null ? TextureModel.WIDE : textureModel; - } - - public String getLocalSkinPath() { - return localSkinPath; - } - - public String getLocalCapePath() { - return localCapePath; - } - - public Task load(String username) { - switch (type) { - case DEFAULT: - return Task.supplyAsync(() -> null); - case ALEX: - case ARI: - case EFE: - case KAI: - case MAKENA: - case NOOR: - case STEVE: - case SUNNY: - case ZURI: - TextureModel model = this.textureModel != null ? this.textureModel : type == Type.ALEX ? TextureModel.SLIM : TextureModel.WIDE; - String resource = (model == TextureModel.SLIM ? "/assets/img/skin/slim/" : "/assets/img/skin/wide/") + type.name().toLowerCase(Locale.ROOT) + ".png"; - - return Task.supplyAsync(() -> new LoadedSkin( - model, - Texture.loadTexture(new Image(resource)), - null - )); - case LOCAL_FILE: - return Task.supplyAsync(() -> { - Texture skin = null, cape = null; - Optional skinPath = FileUtils.tryGetPath(localSkinPath); - Optional capePath = FileUtils.tryGetPath(localCapePath); - if (skinPath.isPresent()) skin = Texture.loadTexture(Files.newInputStream(skinPath.get())); - if (capePath.isPresent()) cape = Texture.loadTexture(Files.newInputStream(capePath.get())); - return new LoadedSkin(getTextureModel(), skin, cape); - }); - case LITTLE_SKIN: - case CUSTOM_SKIN_LOADER_API: - String realCslApi = type == Type.LITTLE_SKIN - ? "https://littleskin.cn/csl" - : NetworkUtils.addHttpsIfMissing(StringUtils.removeSuffix(Lang.requireNonNullElse(cslApi, ""), "/")); - return Task.composeAsync(() -> new GetTask(String.format("%s/%s.json", realCslApi, username))) - .thenComposeAsync(json -> { - SkinJson result = JsonUtils.GSON.fromJson(json, SkinJson.class); - - if (!result.hasSkin()) { - return Task.supplyAsync(() -> null); - } - - return Task.allOf( - Task.supplyAsync(result::getModel), - result.getHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(String.format("%s/textures/%s", realCslApi, result.getHash())), - result.getCapeHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(String.format("%s/textures/%s", realCslApi, result.getCapeHash())) - ); - }).thenApplyAsync(result -> { - if (result == null) { - return null; - } - - Texture skin, cape; - if (result.get(1) != null) { - skin = Texture.loadTexture((InputStream) result.get(1)); - } else { - skin = null; - } - - if (result.get(2) != null) { - cape = Texture.loadTexture((InputStream) result.get(2)); - } else { - cape = null; - } - - return new LoadedSkin((TextureModel) result.get(0), skin, cape); - }); - default: - throw new UnsupportedOperationException(); - } - } - - public Map toStorage() { - return mapOf( - pair("type", type.name().toLowerCase(Locale.ROOT)), - pair("cslApi", cslApi), - pair("textureModel", getTextureModel().modelName), - pair("localSkinPath", localSkinPath), - pair("localCapePath", localCapePath) - ); - } - - public static Skin fromStorage(Map storage) { - if (storage == null) return null; - - Type type = tryCast(storage.get("type"), String.class).flatMap(t -> Optional.ofNullable(Type.fromStorage(t))) - .orElse(Type.DEFAULT); - String cslApi = tryCast(storage.get("cslApi"), String.class).orElse(null); - String textureModel = tryCast(storage.get("textureModel"), String.class).orElse("default"); - String localSkinPath = tryCast(storage.get("localSkinPath"), String.class).orElse(null); - String localCapePath = tryCast(storage.get("localCapePath"), String.class).orElse(null); - - return new Skin(type, cslApi, "slim".equals(textureModel) ? TextureModel.SLIM : TextureModel.WIDE, localSkinPath, localCapePath); - } - - private static class FetchBytesTask extends FetchTask { - - public FetchBytesTask(String uri) { - super(List.of(NetworkUtils.toURI(uri))); - } - - @Override - protected void useCachedResult(Path cachedFile) throws IOException { - setResult(Files.newInputStream(cachedFile)); - } - - @Override - protected EnumCheckETag shouldCheckETag() { - return EnumCheckETag.CHECK_E_TAG; - } - - @Override - protected Context getContext(HttpResponse response, boolean checkETag, String bmclapiHash) throws IOException { - return new Context() { - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - @Override - public void write(byte[] buffer, int offset, int len) { - baos.write(buffer, offset, len); - } - - @Override - public void close() throws IOException { - if (!isSuccess()) return; - - setResult(new ByteArrayInputStream(baos.toByteArray())); - - if (checkETag) { - repository.cacheBytes(response, baos.toByteArray()); - } - } - }; - } - } - - public static class LoadedSkin { - private final TextureModel model; - private final Texture skin; - private final Texture cape; - - public LoadedSkin(TextureModel model, Texture skin, Texture cape) { - this.model = model; - this.skin = skin; - this.cape = cape; - } - - public TextureModel getModel() { - return model; - } - - public Texture getSkin() { - return skin; - } - - public Texture getCape() { - return cape; - } - } - - private static class SkinJson { - private final String username; - private final String skin; - private final String cape; - private final String elytra; - - @SerializedName(value = "textures", alternate = { "skins" }) - private final TextureJson textures; - - public SkinJson(String username, String skin, String cape, String elytra, TextureJson textures) { - this.username = username; - this.skin = skin; - this.cape = cape; - this.elytra = elytra; - this.textures = textures; - } - - public boolean hasSkin() { - return StringUtils.isNotBlank(username); - } - - @Nullable - public TextureModel getModel() { - if (textures != null && textures.slim != null) { - return TextureModel.SLIM; - } else if (textures != null && textures.defaultSkin != null) { - return TextureModel.WIDE; - } else { - return null; - } - } - - public String getAlexModelHash() { - if (textures != null && textures.slim != null) { - return textures.slim; - } else { - return null; - } - } - - public String getSteveModelHash() { - if (textures != null && textures.defaultSkin != null) { - return textures.defaultSkin; - } else return skin; - } - - public String getHash() { - TextureModel model = getModel(); - if (model == TextureModel.SLIM) - return getAlexModelHash(); - else if (model == TextureModel.WIDE) - return getSteveModelHash(); - else - return null; - } - - public String getCapeHash() { - if (textures != null && textures.cape != null) { - return textures.cape; - } else return cape; - } - - public static class TextureJson { - @SerializedName("default") - private final String defaultSkin; - - private final String slim; - private final String cape; - private final String elytra; - - public TextureJson(String defaultSkin, String slim, String cape, String elytra) { - this.defaultSkin = defaultSkin; - this.slim = slim; - this.cape = cape; - this.elytra = elytra; - } - } - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java index 70a9976d32..3eaf69ff7f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java @@ -19,7 +19,7 @@ import org.glavo.png.javafx.PNGJavaFXUtils; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; -import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; +import org.jackhuang.hmcl.game.skin.TextureModel; import org.jackhuang.hmcl.util.KeyUtils; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Pair; @@ -127,9 +127,9 @@ private Response profile(Request request) { private Response texture(Request request) { String hash = request.getPathVariables().group("hash"); - if (Texture.hasTexture(hash)) { - Texture texture = Texture.getTexture(hash); - byte[] data = PNGJavaFXUtils.writeImageToArray(texture.getImage()); + if (HashedTexture.hasTexture(hash)) { + HashedTexture texture = HashedTexture.getTexture(hash); + byte[] data = PNGJavaFXUtils.writeImageToArray(texture.image()); Response response = newFixedLengthResponse(Response.Status.OK, "image/png", new ByteArrayInputStream(data), data.length); response.addHeader("Etag", String.format("\"%s\"", hash)); response.addHeader("Cache-Control", "max-age=2592000, public"); @@ -148,48 +148,31 @@ private Optional findCharacterByName(String uuid) { } public void addCharacter(Character character) { - charactersByUuid.put(character.getUUID(), character); - charactersByName.put(character.getName(), character); + charactersByUuid.put(character.uuid(), character); + charactersByName.put(character.name(), character); } - public static class Character { - private final UUID uuid; - private final String name; - private final Skin.LoadedSkin skin; - - public Character(UUID uuid, String name, Skin.LoadedSkin skin) { - this.uuid = uuid; - this.name = name; - this.skin = skin; - } - - public UUID getUUID() { - return uuid; - } - - public String getName() { - return name; - } - + public record Character(UUID uuid, String name, LoadedOfflineSkin skin) { public GameProfile toSimpleResponse() { return new GameProfile(uuid, name); } public Object toCompleteResponse(String rootUrl) { Map realTextures = new HashMap<>(); - if (skin != null && skin.getSkin() != null) { - if (skin.getModel() == TextureModel.SLIM) { + if (skin != null && skin.skin() != null) { + String url = rootUrl + "/textures/" + skin.skin().hash(); + if (skin.model() == TextureModel.SLIM) { realTextures.put("SKIN", mapOf( - pair("url", rootUrl + "/textures/" + skin.getSkin().getHash()), + pair("url", url), pair("metadata", mapOf( pair("model", "slim") )))); } else { - realTextures.put("SKIN", mapOf(pair("url", rootUrl + "/textures/" + skin.getSkin().getHash()))); + realTextures.put("SKIN", mapOf(pair("url", url))); } } - if (skin != null && skin.getCape() != null) { - realTextures.put("CAPE", mapOf(pair("url", rootUrl + "/textures/" + skin.getCape().getHash()))); + if (skin != null && skin.cape() != null) { + realTextures.put("CAPE", mapOf(pair("url", rootUrl + "/textures/" + skin.cape().hash()))); } Map textureResponse = mapOf( diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java index 8542b471b3..8f6534ff20 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/Texture.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 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 @@ -23,27 +23,8 @@ import java.util.Map; @Immutable -public final class Texture { - - private final String url; - private final Map metadata; - +public record Texture(@Nullable String url, @Nullable Map metadata) { public Texture() { this(null, null); } - - public Texture(String url, Map metadata) { - this.url = url; - this.metadata = metadata; - } - - @Nullable - public String getUrl() { - return url; - } - - @Nullable - public Map getMetadata() { - return metadata; - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java index f3c09d8775..a7da58c039 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/User.java @@ -18,46 +18,26 @@ package org.jackhuang.hmcl.auth.yggdrasil; import com.google.gson.JsonParseException; - -import java.util.Map; - import com.google.gson.annotations.JsonAdapter; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.Validation; import org.jetbrains.annotations.Nullable; +import java.util.Map; + /** * * @author huang */ @Immutable -public final class User implements Validation { - - private final String id; - - @Nullable - @JsonAdapter(PropertyMapSerializer.class) - private final Map properties; +public record User(String id, + @JsonAdapter(PropertyMapSerializer.class) @Nullable Map properties) implements Validation { public User(String id) { this(id, null); } - public User(String id, @Nullable Map properties) { - this.id = id; - this.properties = properties; - } - - public String getId() { - return id; - } - - @Nullable - public Map getProperties() { - return properties; - } - @Override public void validate() throws JsonParseException { if (StringUtils.isBlank(id)) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java index f253eb40b3..f2b15bc33a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java @@ -19,11 +19,15 @@ import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; -import java.util.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.logging.Logger.LOG; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java index edbad40a8a..5234c78afe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.ServerDisconnectException; import org.jackhuang.hmcl.auth.ServerResponseMalformedException; +import org.jackhuang.hmcl.game.skin.TextureType; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.gson.ValidationTypeAdapterFactory; @@ -45,8 +46,8 @@ import static java.util.Collections.unmodifiableList; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.threadPool; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class YggdrasilService { @@ -211,7 +212,7 @@ private static YggdrasilSession handleAuthenticationResponse(String responseText response.accessToken, response.selectedProfile, response.availableProfiles == null ? null : unmodifiableList(response.availableProfiles), - response.user == null ? null : response.user.getProperties()); + response.user == null ? null : response.user.properties()); } private static void requireEmpty(String response) throws AuthenticationException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/Skin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/Skin.java new file mode 100644 index 0000000000..0f5e1cf97b --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/Skin.java @@ -0,0 +1,24 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.game.skin; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public record Skin(@NotNull TextureModel model, @NotNull TextureObject skin, @Nullable TextureObject cape) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureModel.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureModel.java similarity index 81% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureModel.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureModel.java index eb9545a90e..593a946806 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureModel.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureModel.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 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 @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.jackhuang.hmcl.auth.yggdrasil; +package org.jackhuang.hmcl.game.skin; public enum TextureModel { WIDE("default"), SLIM("slim"); @@ -25,4 +25,8 @@ public enum TextureModel { TextureModel(String modelName) { this.modelName = modelName; } + + public boolean isSlim() { + return modelName.equals("slim"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureObject.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureObject.java new file mode 100644 index 0000000000..cdcc1fb161 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureObject.java @@ -0,0 +1,25 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.game.skin; + +import javafx.scene.image.Image; +import org.jetbrains.annotations.NotNull; + +public record TextureObject(@NotNull Image image, @NotNull String url) { + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureType.java similarity index 85% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureType.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureType.java index db100eeaae..1e0dc5afba 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/TextureType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/skin/TextureType.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 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 @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.jackhuang.hmcl.auth.yggdrasil; +package org.jackhuang.hmcl.game.skin; public enum TextureType { SKIN, CAPE diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index bce609927d..94e73fbc8d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -490,7 +490,7 @@ public static List listFilesByExtension(Path file, String extension) { } public static Optional tryGetPath(String first, String... more) { - if (first == null) return Optional.empty(); + if (first == null || first.isEmpty()) return Optional.empty(); try { return Optional.of(Paths.get(first, more)); } catch (InvalidPathException e) { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 37553e27c7..e4b0c13571 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -131,7 +131,7 @@ - +