add: upload skin

This commit is contained in:
huanghongxun 2020-05-28 23:16:56 +08:00
parent 476c41b29b
commit bfb55a3813
14 changed files with 210 additions and 12 deletions

View File

@ -186,4 +186,8 @@ public final class SVG {
public static Node wrench(ObjectBinding<? extends Paint> 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<? extends Paint> fill, double width, double height) {
return createSVGPath("M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z", fill, width, height);
}
}

View File

@ -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);
}

View File

@ -84,6 +84,7 @@ public class AccountListItemSkin extends SkinBase<AccountListItem> {
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<AccountListItem> {
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");

View File

@ -148,6 +148,7 @@ public class MultiFileItem<T> 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);

View File

@ -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<String> 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<Integer> {
protected final List<String> 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<Boolean> {
public BooleanQuestion(String question, boolean defaultValue) {

View File

@ -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

View File

@ -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=作者

View File

@ -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=作者

View File

@ -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));

View File

@ -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));

View File

@ -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());
}

View File

@ -35,6 +35,24 @@ public interface YggdrasilProvider {
URL getInvalidationURL() throws AuthenticationException;
/**
* URL to upload skin.
*
* Headers:
* Authentication: Bearer &lt;access token&gt;
*
* 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 <a href="https://wiki.vg/Mojang_API#Upload_Skin">https://wiki.vg/Mojang_API#Upload_Skin</a>
* @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;
}

View File

@ -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.
*

View File

@ -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);
}
}
}