支持在启动器内上传微软账号皮肤 (#3375)

* update

* Fix clearCache
This commit is contained in:
Glavo
2024-10-24 20:03:21 +08:00
committed by GitHub
parent e857f19b18
commit eae2670855
6 changed files with 68 additions and 30 deletions

View File

@@ -32,17 +32,14 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CredentialExpiredException; import org.jackhuang.hmcl.auth.CredentialExpiredException;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogController; import org.jackhuang.hmcl.ui.DialogController;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.skin.InvalidSkinException; import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import org.jackhuang.hmcl.util.skin.NormalizedSkin; import org.jackhuang.hmcl.util.skin.NormalizedSkin;
@@ -128,7 +125,7 @@ public class AccountListItem extends RadioButton {
.orElse(emptySet()); .orElse(emptySet());
return uploadableTextures.contains(TextureType.SKIN); return uploadableTextures.contains(TextureType.SKIN);
}, profile); }, profile);
} else if (account instanceof OfflineAccount || account instanceof MicrosoftAccount) { } else if (account instanceof OfflineAccount || account.canUploadSkin()) {
return createBooleanBinding(() -> true); return createBooleanBinding(() -> true);
} else { } else {
return createBooleanBinding(() -> false); return createBooleanBinding(() -> false);
@@ -144,11 +141,7 @@ public class AccountListItem extends RadioButton {
Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account)); Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account));
return null; return null;
} }
if (account instanceof MicrosoftAccount) { if (!account.canUploadSkin()) {
FXUtils.openLink("https://www.minecraft.net/msaprofile/mygames/editskin");
return null;
}
if (!(account instanceof YggdrasilAccount)) {
return null; return null;
} }
@@ -174,7 +167,7 @@ public class AccountListItem extends RadioButton {
NormalizedSkin skin = new NormalizedSkin(skinImg); NormalizedSkin skin = new NormalizedSkin(skinImg);
String model = skin.isSlim() ? "slim" : ""; String model = skin.isSlim() ? "slim" : "";
LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]"); LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]");
((YggdrasilAccount) account).uploadSkin(model, selectedFile.toPath()); account.uploadSkin(skin.isSlim(), selectedFile.toPath());
}) })
.thenComposeAsync(refreshAsync()) .thenComposeAsync(refreshAsync())
.whenComplete(Schedulers.javafx(), e -> { .whenComplete(Schedulers.javafx(), e -> {

View File

@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.javafx.ObservableHelper; import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import java.nio.file.Path;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@@ -69,6 +70,14 @@ public abstract class Account implements Observable {
*/ */
public abstract AuthInfo playOffline() throws AuthenticationException; public abstract AuthInfo playOffline() throws AuthenticationException;
public boolean canUploadSkin() {
return false;
}
public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException {
throw new UnsupportedOperationException("Unsupported Operation");
}
public abstract Map<Object, Object> toStorage(); public abstract Map<Object, Object> toStorage();
public void clearCache() { public void clearCache() {

View File

@@ -24,6 +24,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.nio.file.Path;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@@ -32,7 +33,7 @@ import java.util.UUID;
import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNull;
import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class MicrosoftAccount extends OAuthAccount { public final class MicrosoftAccount extends OAuthAccount {
protected final MicrosoftService service; protected final MicrosoftService service;
protected UUID characterUUID; protected UUID characterUUID;
@@ -125,6 +126,16 @@ public class MicrosoftAccount extends OAuthAccount {
return session.toAuthInfo(); return session.toAuthInfo();
} }
@Override
public boolean canUploadSkin() {
return true;
}
@Override
public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException {
service.uploadSkin(session.getAccessToken(), isSlim, file);
}
@Override @Override
public Map<Object, Object> toStorage() { public Map<Object, Object> toStorage() {
return session.toStorage(); return session.toStorage();
@@ -150,6 +161,7 @@ public class MicrosoftAccount extends OAuthAccount {
@Override @Override
public void clearCache() { public void clearCache() {
authenticated = false; authenticated = false;
service.getProfileRepository().invalidate(characterUUID);
} }
@Override @Override

View File

@@ -25,19 +25,18 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth; import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.auth.ServerDisconnectException; import org.jackhuang.hmcl.auth.ServerDisconnectException;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException; import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.*;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.*;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -261,6 +260,33 @@ public class MicrosoftService {
return Optional.ofNullable(GSON.fromJson(request(NetworkUtils.toURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid)), null), CompleteGameProfile.class)); return Optional.ofNullable(GSON.fromJson(request(NetworkUtils.toURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid)), null), CompleteGameProfile.class));
} }
public void uploadSkin(String accessToken, boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException {
try {
HttpURLConnection con = NetworkUtils.createHttpConnection(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile/skins"));
con.setRequestMethod("POST");
con.setRequestProperty("Authorization", "Bearer " + accessToken);
con.setDoOutput(true);
try (HttpMultipartRequest request = new HttpMultipartRequest(con)) {
request.param("variant", isSlim ? "slim" : "classic");
try (InputStream fis = Files.newInputStream(file)) {
request.file("file", FileUtils.getName(file), "image/" + FileUtils.getExtension(file), fis);
}
}
String response = NetworkUtils.readData(con);
if (StringUtils.isBlank(response)) {
if (con.getResponseCode() / 100 != 2)
throw new ResponseCodeException(con.getURL(), con.getResponseCode());
} else {
MinecraftErrorResponse profileResponse = GSON.fromJson(response, MinecraftErrorResponse.class);
if (StringUtils.isNotBlank(profileResponse.errorMessage) || con.getResponseCode() / 100 != 2)
throw new AuthenticationException("Failed to upload skin, response code: " + con.getResponseCode() + ", response: " + response);
}
} catch (IOException | JsonParseException e) {
throw new AuthenticationException(e);
}
}
private static String request(URL url, Object payload) throws AuthenticationException { private static String request(URL url, Object payload) throws AuthenticationException {
try { try {
if (payload == null) if (payload == null)
@@ -272,14 +298,6 @@ public class MicrosoftService {
} }
} }
private static <T> T fromJson(String text, Class<T> typeOfT) throws ServerResponseMalformedException {
try {
return GSON.fromJson(text, typeOfT);
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(text, e);
}
}
public static class XboxAuthorizationException extends AuthenticationException { public static class XboxAuthorizationException extends AuthenticationException {
private final long errorCode; private final long errorCode;
private final String redirect; private final String redirect;

View File

@@ -203,8 +203,14 @@ public abstract class YggdrasilAccount extends ClassicAccount {
} }
public void uploadSkin(String model, Path file) throws AuthenticationException, UnsupportedOperationException { @Override
service.uploadSkin(characterUUID, session.getAccessToken(), model, file); public boolean canUploadSkin() {
return true;
}
@Override
public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException {
service.uploadSkin(characterUUID, session.getAccessToken(), isSlim, file);
} }
private static String randomClientToken() { private static String randomClientToken() {

View File

@@ -148,14 +148,14 @@ public class YggdrasilService {
requireEmpty(request(provider.getInvalidationURL(), createRequestWithCredentials(accessToken, clientToken))); requireEmpty(request(provider.getInvalidationURL(), createRequestWithCredentials(accessToken, clientToken)));
} }
public void uploadSkin(UUID uuid, String accessToken, String model, Path file) throws AuthenticationException, UnsupportedOperationException { public void uploadSkin(UUID uuid, String accessToken, boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException {
try { try {
HttpURLConnection con = NetworkUtils.createHttpConnection(provider.getSkinUploadURL(uuid)); HttpURLConnection con = NetworkUtils.createHttpConnection(provider.getSkinUploadURL(uuid));
con.setRequestMethod("PUT"); con.setRequestMethod("PUT");
con.setRequestProperty("Authorization", "Bearer " + accessToken); con.setRequestProperty("Authorization", "Bearer " + accessToken);
con.setDoOutput(true); con.setDoOutput(true);
try (HttpMultipartRequest request = new HttpMultipartRequest(con)) { try (HttpMultipartRequest request = new HttpMultipartRequest(con)) {
request.param("model", model); request.param("model", isSlim ? "slim" : "");
try (InputStream fis = Files.newInputStream(file)) { try (InputStream fis = Files.newInputStream(file)) {
request.file("file", FileUtils.getName(file), "image/" + FileUtils.getExtension(file), fis); request.file("file", FileUtils.getName(file), "image/" + FileUtils.getExtension(file), fis);
} }