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 77fad1055..bd4019560 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -186,4 +186,8 @@ public final class SVG { public static Node wrench(ObjectBinding fill, double width, double height) { return createSVGPath("M22.7,19L13.6,9.9C14.5,7.6 14,4.9 12.1,3C10.1,1 7.1,0.6 4.7,1.7L9,6L6,9L1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1C4.8,14 7.5,14.5 9.8,13.6L18.9,22.7C19.3,23.1 19.9,23.1 20.3,22.7L22.6,20.4C23.1,20 23.1,19.3 22.7,19Z", fill, width, height); } + + public static Node upload(ObjectBinding fill, double width, double height) { + return createSVGPath("M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z", 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 3f1d450a9..8bb073249 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 @@ -23,23 +23,28 @@ import javafx.beans.property.*; import javafx.scene.control.RadioButton; import javafx.scene.control.Skin; import javafx.scene.image.Image; +import javafx.stage.FileChooser; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.CredentialExpiredException; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.DialogController; +import org.jackhuang.hmcl.ui.construct.PromptDialogPane; + +import java.io.File; +import java.util.concurrent.CancellationException; +import java.util.logging.Level; import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import java.util.concurrent.CancellationException; -import java.util.logging.Level; - public class AccountListItem extends RadioButton { private final Account account; @@ -96,6 +101,36 @@ public class AccountListItem extends RadioButton { }); } + public boolean canUploadSkin() { + return account instanceof YggdrasilAccount && !(account instanceof AuthlibInjectorAccount); + } + + public void uploadSkin() { + if (!(account instanceof YggdrasilAccount)) { + return; + } + + FileChooser chooser = new FileChooser(); + chooser.setTitle(i18n("account.skin.upload")); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png")); + File selectedFile = chooser.showOpenDialog(Controllers.getStage()); + if (selectedFile == null) { + return; + } + + Controllers.prompt(new PromptDialogPane.Builder(i18n("account.skin.upload"), (questions, resolve, reject) -> { + PromptDialogPane.Builder.CandidatesQuestion q = (PromptDialogPane.Builder.CandidatesQuestion) questions.get(0); + String model = q.getValue() == 0 ? "" : "slim"; + try { + ((YggdrasilAccount) account).uploadSkin(model, selectedFile.toPath()); + resolve.run(); + } catch (AuthenticationException e) { + reject.accept(AddAccountPane.accountException(e)); + } + }).addQuestion(new PromptDialogPane.Builder.CandidatesQuestion(i18n("account.skin.model"), + i18n("account.skin.model.default"), i18n("account.skin.model.slim")))); + } + public void remove() { Accounts.getAccounts().remove(account); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java index 6da83205f..5230e5123 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java @@ -84,6 +84,7 @@ public class AccountListItemSkin extends SkinBase { HBox right = new HBox(); right.setAlignment(Pos.CENTER_RIGHT); + JFXButton btnRefresh = new JFXButton(); btnRefresh.setOnMouseClicked(e -> skinnable.refresh()); btnRefresh.getStyleClass().add("toggle-icon4"); @@ -91,6 +92,15 @@ public class AccountListItemSkin extends SkinBase { runInFX(() -> FXUtils.installFastTooltip(btnRefresh, i18n("button.refresh"))); right.getChildren().add(btnRefresh); + if (skinnable.canUploadSkin()) { + JFXButton btnUpload = new JFXButton(); + btnUpload.setOnMouseClicked(e -> skinnable.uploadSkin()); + btnUpload.getStyleClass().add("toggle-icon4"); + btnUpload.setGraphic(SVG.upload(Theme.blackFillBinding(), -1, -1)); + runInFX(() -> FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload"))); + right.getChildren().add(btnUpload); + } + JFXButton btnRemove = new JFXButton(); btnRemove.setOnMouseClicked(e -> skinnable.remove()); btnRemove.getStyleClass().add("toggle-icon4"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java index dcf35b252..076206251 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java @@ -148,6 +148,7 @@ public class MultiFileItem extends ComponentSublist { pane.setLeft(left); Label right = new Label(subtitle); + right.setWrapText(true); right.getStyleClass().add("subtitle-label"); right.setStyle("-fx-font-size: 10;"); pane.setRight(right); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java index 7d1512d58..abc3ba858 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java @@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXTextField; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.binding.Bindings; @@ -81,6 +82,18 @@ public class PromptDialogPane extends StackPane { checkBox.selectedProperty().addListener((a, b, newValue) -> ((Builder.BooleanQuestion) question).value = newValue); checkBox.setText(question.question); vbox.getChildren().add(hBox); + } else if (question instanceof Builder.CandidatesQuestion) { + HBox hBox = new HBox(); + JFXComboBox comboBox = new JFXComboBox<>(); + hBox.getChildren().setAll(comboBox); + comboBox.getItems().setAll(((Builder.CandidatesQuestion) question).candidates); + comboBox.getSelectionModel().selectedIndexProperty().addListener((a, b, newValue) -> + ((Builder.CandidatesQuestion) question).value = newValue.intValue()); + comboBox.getSelectionModel().select(0); + if (StringUtils.isNotBlank(question.question)) { + vbox.getChildren().add(new Label(question.question)); + } + vbox.getChildren().add(hBox); } } @@ -146,6 +159,19 @@ public class PromptDialogPane extends StackPane { } } + public static class CandidatesQuestion extends Question { + protected final List candidates; + + public CandidatesQuestion(String question, String... candidates) { + super(question); + this.value = null; + if (candidates == null || candidates.length == 0) { + throw new IllegalArgumentException("At least one candidate required"); + } + this.candidates = new ArrayList<>(Arrays.asList(candidates)); + } + } + public static class BooleanQuestion extends Question { public BooleanQuestion(String question, boolean defaultValue) { diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 0b478dfb8..b03b9d6ab 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -62,6 +62,11 @@ account.methods.yggdrasil=Mojang account.missing=No Account account.missing.add=Click here to add account.password=Password +account.skin.file=Skin file +account.skin.model=Character model +account.skin.model.default=Steve +account.skin.model.slim=Alex +account.skin.upload=Upload skin account.username=Name archive.author=Authors diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 2eaa6113e..5a6131500 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -62,6 +62,11 @@ account.methods.yggdrasil=正版登入 account.missing=沒有遊戲帳戶 account.missing.add=按一下此處加入帳戶 account.password=密碼 +account.skin.file=皮膚圖片檔案 +account.skin.model=人物模型 +account.skin.model.default=Steve +account.skin.model.slim=Alex +account.skin.upload=上傳皮膚 account.username=使用者名稱 archive.author=作者 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 c9d17e02a..fbcf6f54f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -62,6 +62,11 @@ account.methods.yggdrasil=正版登录 account.missing=没有游戏账户 account.missing.add=点击此处添加账户 account.password=密码 +account.skin.file=皮肤图片文件 +account.skin.model=人物模型 +account.skin.model.default=Steve +account.skin.model.slim=Alex +account.skin.upload=上传皮肤 account.username=用户名 archive.author=作者 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java index 9f876e98e..6fcb53c09 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java @@ -53,6 +53,11 @@ public class AuthlibInjectorProvider implements YggdrasilProvider { return toURL(apiRoot + "authserver/invalidate"); } + @Override + public URL getSkinUploadURL(UUID uuid) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + @Override public URL getProfilePropertiesURL(UUID uuid) throws AuthenticationException { return toURL(apiRoot + "sessionserver/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid)); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/MojangYggdrasilProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/MojangYggdrasilProvider.java index 0c514b0d5..ea6237ad3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/MojangYggdrasilProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/MojangYggdrasilProvider.java @@ -21,7 +21,7 @@ import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URL; -import java.util.UUID; +import java.util.*; public class MojangYggdrasilProvider implements YggdrasilProvider { @@ -45,6 +45,11 @@ public class MojangYggdrasilProvider implements YggdrasilProvider { return NetworkUtils.toURL("https://authserver.mojang.com/invalidate"); } + @Override + public URL getSkinUploadURL(UUID uuid) throws UnsupportedOperationException { + return NetworkUtils.toURL("https://api.mojang.com/user/profile/" + UUIDTypeAdapter.fromUUID(uuid) + "/skin"); + } + @Override public URL getProfilePropertiesURL(UUID uuid) { return NetworkUtils.toURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid)); 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 377e78996..8200b52d0 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 @@ -17,13 +17,7 @@ */ package org.jackhuang.hmcl.auth.yggdrasil; -import static java.util.Objects.requireNonNull; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - +import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.AuthenticationException; @@ -34,7 +28,10 @@ import org.jackhuang.hmcl.auth.NoCharacterException; import org.jackhuang.hmcl.auth.ServerResponseMalformedException; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; -import javafx.beans.binding.ObjectBinding; +import java.nio.file.Path; +import java.util.*; + +import static java.util.Objects.requireNonNull; public class YggdrasilAccount extends Account { @@ -193,6 +190,10 @@ public class YggdrasilAccount extends Account { service.getProfileRepository().invalidate(characterUUID); } + public void uploadSkin(String model, Path file) throws AuthenticationException, UnsupportedOperationException { + service.uploadSkin(characterUUID, model, file); + } + private static String randomClientToken() { return UUIDTypeAdapter.fromUUID(UUID.randomUUID()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java index 35b2b02a4..00c8f6681 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java @@ -35,6 +35,24 @@ public interface YggdrasilProvider { URL getInvalidationURL() throws AuthenticationException; + /** + * URL to upload skin. + * + * Headers: + * Authentication: Bearer <access token> + * + * Payload: + * The payload for this API consists of multipart form data. There are two parts (order does not matter b/c of boundary): + * model: Empty string for the default model and "slim" for the slim model + * file: Raw image file data + * + * @see https://wiki.vg/Mojang_API#Upload_Skin + * @return url to upload skin + * @throws AuthenticationException if url cannot be generated. e.g. some parameter or query is malformed. + * @throws UnsupportedOperationException if the Yggdrasil provider does not support third-party skin uploading. + */ + URL getSkinUploadURL(UUID uuid) throws AuthenticationException, UnsupportedOperationException; + URL getProfilePropertiesURL(UUID uuid) throws AuthenticationException; } 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 2973c0c6d..3eebb77c1 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 @@ -26,11 +26,17 @@ import org.jackhuang.hmcl.auth.ServerResponseMalformedException; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.gson.ValidationTypeAdapterFactory; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.HttpMultipartRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -145,6 +151,22 @@ public class YggdrasilService { requireEmpty(request(provider.getInvalidationURL(), createRequestWithCredentials(accessToken, clientToken))); } + public void uploadSkin(UUID uuid, String model, Path file) throws AuthenticationException, UnsupportedOperationException { + try { + HttpURLConnection con = NetworkUtils.createHttpConnection(provider.getSkinUploadURL(uuid)); + con.setRequestMethod("PUT"); + con.setDoOutput(true); + try (HttpMultipartRequest request = new HttpMultipartRequest(con)) { + try (InputStream fis = Files.newInputStream(file)) { + request.file("file", FileUtils.getName(file), "image/" + FileUtils.getExtension(file), fis); + } + request.param("model", model); + } + } catch (IOException e) { + throw new AuthenticationException(e); + } + } + /** * Get complete game profile. * diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpMultipartRequest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpMultipartRequest.java new file mode 100644 index 000000000..dfc03ac6f --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpMultipartRequest.java @@ -0,0 +1,56 @@ +package org.jackhuang.hmcl.util.io; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class HttpMultipartRequest implements Closeable { + private final String boundary = "*****" + System.currentTimeMillis() + "*****"; + private final HttpURLConnection urlConnection; + private final ByteArrayOutputStream stream; + private final String endl = "\r\n"; + + public HttpMultipartRequest(HttpURLConnection urlConnection) throws IOException { + this.urlConnection = urlConnection; + urlConnection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + + stream = new ByteArrayOutputStream(); + } + + private void addLine(String content) throws IOException { + stream.write(content.getBytes(UTF_8)); + stream.write(endl.getBytes(UTF_8)); + } + + public HttpMultipartRequest file(String name, String filename, String contentType, InputStream inputStream) throws IOException { + addLine("--" + boundary); + addLine(String.format("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"", name, filename)); + addLine("Content-Type: " + contentType); + addLine("Content-Transfer-Encoding: binary"); + addLine(""); + IOUtils.copyTo(inputStream, stream); + return this; + } + + public HttpMultipartRequest param(String name, String value) throws IOException { + addLine("--" + boundary); + addLine(String.format("Content-Disposition: form-data; name=\"%s\"", name)); + addLine(""); + addLine(value); + return this; + } + + @Override + public void close() throws IOException { + addLine("--" + boundary + "--"); + urlConnection.setRequestProperty("Content-Length", "" + stream.size()); + try (OutputStream os = urlConnection.getOutputStream()) { + IOUtils.write(stream.toByteArray(), os); + } + } +}