From 64ff3794dc2bbcf74f71e62c82dad79a4af73cca Mon Sep 17 00:00:00 2001 From: BaiPiaoCN Date: Sat, 29 Aug 2026 15:00:36 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=8A=AB=E9=A3=8E?= =?UTF-8?q?=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/ui/SVG.java | 1 + .../hmcl/ui/account/AccountListItemSkin.java | 7 + .../ui/account/MicrosoftAccountCapePane.java | 191 ++++++++++++++++++ .../resources/assets/lang/I18N.properties | 5 + .../resources/assets/lang/I18N_zh.properties | 5 + .../assets/lang/I18N_zh_CN.properties | 5 + .../hmcl/auth/microsoft/MicrosoftAccount.java | 129 ++++++++++-- .../hmcl/auth/microsoft/MicrosoftService.java | 72 ++++++- .../microsoft/MicrosoftServiceCapeTest.java | 105 ++++++++++ 9 files changed, 504 insertions(+), 16 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java 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 9991ac13b85..d344b563de2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -40,6 +40,7 @@ public enum SVG { ARROW_FORWARD("M16.175 13H4V11H16.175L10.575 5.4 12 4 20 12 12 20 10.575 18.6 16.175 13Z"), 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"), + CAPE("M8 5q-.825 0-1.4125-.5875T6 3q0-.825.5875-1.4125T8 1h8q.825 0 1.4125.5875T18 3q0 .825-.5875 1.4125T16 5h-2v3H8V5Zm1 5h6l2 8h-2l-1-6h-2l-1 6H9l2-8Z"), // Not Material 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"), CHECK("M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4 9.55 18Z"), CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"), 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 81dd8f94012..c22676c6ce7 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 @@ -159,6 +159,13 @@ public AccountListItemSkin(AccountListItem skinnable) { spinnerUpload.getStyleClass().add("small-spinner-pane"); right.getChildren().add(spinnerUpload); + if (skinnable.getAccount() instanceof MicrosoftAccount microsoftAccount) { + JFXButton btnCape = FXUtils.newToggleButton4(SVG.CAPE); + btnCape.setOnAction(e -> Controllers.dialog(new MicrosoftAccountCapePane(microsoftAccount))); + FXUtils.installFastTooltip(btnCape, i18n("account.cape.manage")); + right.getChildren().add(btnCape); + } + JFXButton btnCopyUUID = FXUtils.newToggleButton4(SVG.CONTENT_COPY); btnCopyUUID.setOnAction(e -> FXUtils.copyText(skinnable.getAccount().getProfileID().toString())); FXUtils.installFastTooltip(btnCopyUUID, i18n("account.copy_uuid")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java new file mode 100644 index 00000000000..785303504e5 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java @@ -0,0 +1,191 @@ +/* + * 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; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.StackPane; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftService.MinecraftProfileResponseCape; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.construct.AdvancedListBox; +import org.jackhuang.hmcl.ui.construct.AdvancedListItem; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Dialog listing every cape owned by a Microsoft account and letting the user +/// activate one of them or remove the active cape. +/// +/// Cape data is always read from the Minecraft Services profile, never from a +/// locally fabricated list. +@NotNullByDefault +public final class MicrosoftAccountCapePane extends StackPane { + + private final MicrosoftAccount account; + private final AdvancedListBox listBox = new AdvancedListBox(); + private final SpinnerPane spinnerPane = new SpinnerPane(); + + public MicrosoftAccountCapePane(MicrosoftAccount account) { + this.account = account; + + setPrefWidth(480); + + JFXDialogLayout layout = new JFXDialogLayout(); + getChildren().setAll(layout); + layout.setHeading(new Label(i18n("account.cape.manage"))); + + spinnerPane.setContent(listBox); + spinnerPane.setPrefHeight(360); + spinnerPane.setOnFailedAction(e -> reload()); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.getStyleClass().add("dialog-cancel"); + cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + onEscPressed(this, cancelButton::fire); + + layout.setActions(cancelButton); + layout.setBody(spinnerPane); + + reload(); + } + + /// Reloads the cape list from the server and re-renders the item list. + private void reload() { + spinnerPane.showSpinner(); + Task.supplyAsync(account::getCapes) + .whenComplete(Schedulers.javafx(), this::renderCapes) + .start(); + } + + /// Renders the loading result, or the failure reason when loading failed. + private void renderCapes(@Nullable List capes, @Nullable Exception exception) { + spinnerPane.hideSpinner(); + if (exception != null) { + spinnerPane.setFailedReason(Accounts.localizeErrorMessage(exception)); + return; + } + + boolean hasActiveCape = capes.stream().anyMatch(cape -> "ACTIVE".equals(cape.state)); + + listBox.clear(); + listBox.add(buildNoCapeItem(!hasActiveCape)); + for (MinecraftProfileResponseCape cape : capes) { + listBox.add(buildCapeItem(cape)); + } + } + + /// Builds the "no cape" entry used to remove the currently active cape. + private AdvancedListItem buildNoCapeItem(boolean active) { + AdvancedListItem item = new AdvancedListItem(); + item.setTitle(i18n("account.cape.none")); + item.setActive(active); + item.setOnAction(e -> confirmCapeChange(i18n("account.cape.remove.confirm"), () -> applyCape(null))); + return item; + } + + /// Builds the list entry for one cape with a lazily loaded preview. + private AdvancedListItem buildCapeItem(MinecraftProfileResponseCape cape) { + AdvancedListItem item = new AdvancedListItem(); + item.setTitle(StringUtils.isBlank(cape.alias) ? cape.id : cape.alias); + if ("ACTIVE".equals(cape.state)) { + item.setSubtitle(i18n("account.cape.active")); + item.setActive(true); + } + ImageView preview = createPreview(); + item.setLeftGraphic(preview); + item.setOnAction(e -> confirmCapeChange(i18n("account.cape.set.confirm"), () -> applyCape(cape.id))); + bindPreview(preview, cape.url); + return item; + } + + /// Asks the user to confirm before mutating the active cape, then runs `commit`. + private void confirmCapeChange(String message, Runnable commit) { + Controllers.confirm(message, i18n("account.cape.manage"), commit, null); + } + + /// Applies a cape change in the background and reloads the list afterwards. + /// + /// @param capeId the cape to activate, or `null` to remove the active cape + private void applyCape(@Nullable String capeId) { + spinnerPane.showSpinner(); + Task.runAsync(() -> { + if (capeId == null) { + account.hideCape(); + } else { + account.showCape(capeId); + } + }) + .whenComplete(Schedulers.javafx(), exception -> { + spinnerPane.hideSpinner(); + if (exception != null) { + Controllers.showToast(Accounts.localizeErrorMessage(exception)); + } + reload(); + }) + .start(); + } + + /// Creates a sized, aligned preview view for a cape texture. + private static ImageView createPreview() { + ImageView view = new ImageView(); + view.setFitWidth(AdvancedListItem.LEFT_ICON_SIZE); + view.setFitHeight(AdvancedListItem.LEFT_ICON_SIZE); + view.setSmooth(true); + view.setPreserveRatio(true); + BorderPane.setMargin(view, AdvancedListItem.LEFT_ICON_MARGIN); + BorderPane.setAlignment(view, Pos.CENTER); + view.setMouseTransparent(true); + return view; + } + + /// Downloads and displays the cape texture preview in the background. + private static void bindPreview(ImageView view, String url) { + Task.supplyAsync(() -> { + try { + return TexturesLoader.loadTexture(new Texture(url, null)); + } catch (Throwable e) { + LOG.warning("Failed to load cape texture " + url, e); + return null; + } + }) + .whenComplete(Schedulers.javafx(), (loadedTexture, exception) -> { + if (exception == null && loadedTexture != null) { + view.setImage(loadedTexture.image()); + } + }) + .start(); + } +} \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index b06e2c413ef..36147d707d8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -54,6 +54,11 @@ about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL) account=Accounts account.cape=Cape +account.cape.active=In Use +account.cape.manage=Manage Cape +account.cape.none=No Cape +account.cape.remove.confirm=Remove the active cape? +account.cape.set.confirm=Set this cape as the active cape? account.character=Player account.choose=Choose a Player account.create=Add Account diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 3f95224821a..d53b00bbb33 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -53,6 +53,11 @@ about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL/) account=帳戶 account.cape=披風 +account.cape.active=使用中 +account.cape.manage=管理披風 +account.cape.none=不使用披風 +account.cape.remove.confirm=確定取消當前激活的披風嗎? +account.cape.set.confirm=確定將此披風設定為當前披風嗎? account.character=角色 account.choose=請選取角色 account.create=建立帳戶 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 c8c594a4440..69ac0782a08 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -53,6 +53,11 @@ about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL) account=账户 account.cape=披风 +account.cape.active=使用中 +account.cape.manage=管理披风 +account.cape.none=不使用披风 +account.cape.remove.confirm=确定取消当前激活的披风吗? +account.cape.set.confirm=确定将此披风设置为当前披风吗? account.character=角色 account.choose=选择一个角色 account.create=添加账户 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 1d7a1de730b..e023658b034 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 @@ -23,9 +23,12 @@ 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.util.io.ResponseCodeException; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import java.net.HttpURLConnection; import java.nio.file.Path; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -81,24 +84,34 @@ public AuthInfo logIn() throws AuthenticationException { && service.validate(session.notAfter(), session.tokenType(), session.accessToken())) { authenticated = true; } else { - MicrosoftSession acquiredSession = service.refresh(session); - if (!Objects.equals(acquiredSession.profile().id(), session.profile().id())) { - throw new ServerResponseMalformedException("Selected profile changed"); - } - if (!acquiredSession.hasProfileName()) { - throw new ServerResponseMalformedException("Profile name is missing"); - } - - session = acquiredSession; - - authenticated = true; - invalidate(); + refreshSession(); } } return session.toAuthInfo(); } + /// Refreshes the Minecraft access token using the stored refresh token. + /// + /// This is the shared token-refresh path used both by {@link #logIn()} and + /// by the cape operations when the server rejects the current access token. + /// It never opens a browser and therefore does not perform OAuth itself. + /// + /// @throws AuthenticationException when the refresh fails or the selected profile changes + private void refreshSession() throws AuthenticationException { + MicrosoftSession acquiredSession = service.refresh(session); + if (!Objects.equals(acquiredSession.profile().id(), session.profile().id())) { + throw new ServerResponseMalformedException("Selected profile changed"); + } + if (!acquiredSession.hasProfileName()) { + throw new ServerResponseMalformedException("Profile name is missing"); + } + + session = acquiredSession; + authenticated = true; + invalidate(); + } + @Override public AuthInfo logInWhenCredentialsExpired() throws AuthenticationException { MicrosoftSession acquiredSession = service.authenticate(OAuth.GrantFlow.DEVICE); @@ -136,6 +149,98 @@ public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException service.uploadSkin(session.accessToken(), isSlim, file); } + /// Returns every cape owned by this account, as reported by Minecraft Services. + /// + /// The token is handled internally: the session is made valid first and, + /// when the server reports `401 Unauthorized`, the session is refreshed once + /// and the profile is re-fetched. + /// + /// @return the cape list (possibly empty); it is not mutable + /// @throws AuthenticationException when the profile cannot be loaded + public List getCapes() throws AuthenticationException { + logIn(); + try { + return readCapes(); + } catch (AuthenticationException e) { + if (isUnauthorized(e)) { + refreshSession(); + return readCapes(); + } + throw e; + } + } + + /// Activates an owned cape for this account. + /// + /// @param capeId the server-side cape ID to activate + /// @throws AuthenticationException on failure, or when the server rejects the token + public void showCape(String capeId) throws AuthenticationException { + requireNonNull(capeId); + + logIn(); + try { + service.showCape(session.accessToken(), capeId); + } catch (AuthenticationException e) { + if (isUnauthorized(e)) { + refreshSession(); + service.showCape(session.accessToken(), capeId); + } else { + throw e; + } + } + clearProfileCache(); + } + + /// Removes this account's active cape. + /// + /// @throws AuthenticationException on failure, or when the server rejects the token + public void hideCape() throws AuthenticationException { + logIn(); + try { + service.hideCape(session.accessToken()); + } catch (AuthenticationException e) { + if (isUnauthorized(e)) { + refreshSession(); + service.hideCape(session.accessToken()); + } else { + throw e; + } + } + clearProfileCache(); + } + + /// Reads the current cape list straight from the Minecraft Services profile. + private List readCapes() throws AuthenticationException { + return service.getCompleteProfile(session.getAuthorization()) + .map(profile -> profile.capes) + .filter(Objects::nonNull) + .map(capes -> capes.stream().filter(Objects::nonNull).toList()) + .orElse(List.of()); + } + + /// Invalidates the cached profile so the UI re-fetches the server state after a cape change. + private void clearProfileCache() { + service.getProfileRepository().invalidate(profileID); + invalidate(); + } + + /// Reports whether the exception chain contains a `401 Unauthorized` HTTP response. + /// + /// The Minecraft Services cape endpoints are contacted through [MicrosoftService], + /// which wraps a non-2xx HTTP status into [ServerDisconnectException] while keeping + /// the original [ResponseCodeException] in the cause chain. + private static boolean isUnauthorized(Throwable exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof ResponseCodeException responseCodeException + && responseCodeException.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED) { + return true; + } + cause = cause.getCause(); + } + return false; + } + @Override public void writeMetadata(JsonObject metadata) { super.writeMetadata(metadata); 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 b65e2587950..4f0138efbf8 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 @@ -55,6 +55,7 @@ public class MicrosoftService { private static final String SCOPE = "XboxLive.signin offline_access"; + private static final String CAPES_ACTIVE_ENDPOINT = "https://api.minecraftservices.com/minecraft/profile/capes/active"; private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10, TimeUnit.SECONDS); @@ -216,6 +217,14 @@ private static void handleErrorResponse(MinecraftErrorResponse response) throws } } + /// Extracts the skin and active cape textures from a Minecraft Services profile. + /// + /// The cape texture is only emitted when the profile contains a cape whose + /// `state` is `ACTIVE`, following the server-reported state instead of any + /// locally fabricated value. + /// + /// @param profile the profile returned by `GET /minecraft/profile` + /// @return the texture map, possibly without a `CAPE` entry when no active cape exists public static Optional> getTextures(MinecraftProfileResponse profile) { Objects.requireNonNull(profile); @@ -224,9 +233,12 @@ public static Optional> getTextures(MinecraftProfileRe if (!profile.skins.isEmpty()) { textures.put(TextureType.SKIN, new Texture(profile.skins.get(0).url, null)); } - // if (!profile.capes.isEmpty()) { - // textures.put(TextureType.CAPE, new Texture(profile.capes.get(0).url, null); - // } + if (profile.capes != null) { + profile.capes.stream() + .filter(cape -> "ACTIVE".equals(cape.state)) + .findFirst() + .ifPresent(cape -> textures.put(TextureType.CAPE, new Texture(cape.url, null))); + } return Optional.of(textures); } @@ -301,6 +313,48 @@ public void uploadSkin(String accessToken, boolean isSlim, Path file) throws Aut } } + /// Activates an owned cape for the given Minecraft access token. + /// + /// Sends `PUT /minecraft/profile/capes/active` with the JSON body + /// `{"capeId":"..."}`. A non-2xx response is reported as a + /// [ServerDisconnectException] whose cause chain carries the HTTP status + /// code, so callers can distinguish `401 Unauthorized` from other failures. + /// + /// @param accessToken the Minecraft Services access token + /// @param capeId the server-side cape ID to activate + /// @throws AuthenticationException on network failure or a non-2xx response + public void showCape(String accessToken, String capeId) throws AuthenticationException { + requireNonNull(accessToken); + requireNonNull(capeId); + try { + HttpRequest.PUT(CAPES_ACTIVE_ENDPOINT) + .json(mapOf(pair("capeId", capeId))) + .authorization("Bearer " + accessToken) + .accept("application/json") + .getString(); + } catch (IOException e) { + throw new ServerDisconnectException(e); + } + } + + /// Removes the active cape for the given Minecraft access token. + /// + /// Sends `DELETE /minecraft/profile/capes/active` without a request body. + /// + /// @param accessToken the Minecraft Services access token + /// @throws AuthenticationException on network failure or a non-2xx response + public void hideCape(String accessToken) throws AuthenticationException { + requireNonNull(accessToken); + try { + HttpRequest.DELETE(CAPES_ACTIVE_ENDPOINT) + .authorization("Bearer " + accessToken) + .accept("application/json") + .getString(); + } catch (IOException e) { + throw new ServerDisconnectException(e); + } + } + private static String request(String url, Object payload) throws AuthenticationException { try { if (payload == null) @@ -438,8 +492,18 @@ public void validate() throws JsonParseException, TolerableValidationException { } } - public static class MinecraftProfileResponseCape { + public static class MinecraftProfileResponseCape implements Validation { + public String id; + public String state; + public String url; + public String alias; + @Override + public void validate() throws JsonParseException, TolerableValidationException { + Validation.requireNonNull(id, "id cannot be null"); + Validation.requireNonNull(state, "state cannot be null"); + Validation.requireNonNull(url, "url cannot be null"); + } } @JsonSerializable diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java new file mode 100644 index 00000000000..8d20d24979c --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java @@ -0,0 +1,105 @@ +/* + * 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.microsoft; + +import org.jackhuang.hmcl.auth.microsoft.MicrosoftService.MinecraftProfileResponse; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/// Unit tests for the Minecraft Services cape response parsing and texture +/// extraction introduced for the cape management feature. +public final class MicrosoftServiceCapeTest { + + private static final String UUID = "069a79f444e94726a5befca90e38aaf5"; + + /// Builds a profile response with the given JSON `capes` array. + private static MinecraftProfileResponse parseProfile(String capesJson) { + String json = """ + { + "id": "%s", + "name": "TestPlayer", + "skins": [ + {"id": "skin-1", "state": "ACTIVE", "url": "https://example.com/skin.png", "variant": "CLASSIC", "alias": "Steve"} + ], + "capes": %s + } + """.formatted(UUID, capesJson); + return JsonUtils.fromNonNullJson(json, MinecraftProfileResponse.class); + } + + @Test + public void testCapesDeserialization() { + MinecraftProfileResponse profile = parseProfile(""" + [ + {"id": "cape-migrator", "state": "ACTIVE", "url": "https://example.com/migrator.png", "alias": "Migrator"}, + {"id": "cape-vanilla", "state": "INACTIVE", "url": "https://example.com/vanilla.png", "alias": "Vanilla"} + ] + """); + + assertEquals(2, profile.capes.size()); + assertEquals("cape-migrator", profile.capes.get(0).id); + assertEquals("ACTIVE", profile.capes.get(0).state); + assertEquals("Migrator", profile.capes.get(0).alias); + assertEquals("INACTIVE", profile.capes.get(1).state); + } + + @Test + public void testActiveCapeTextureIsSelected() { + MinecraftProfileResponse profile = parseProfile(""" + [ + {"id": "cape-a", "state": "INACTIVE", "url": "https://example.com/a.png", "alias": "A"}, + {"id": "cape-b", "state": "ACTIVE", "url": "https://example.com/b.png", "alias": "B"} + ] + """); + + Optional> textures = MicrosoftService.getTextures(profile); + + assertTrue(textures.isPresent()); + Texture cape = textures.get().get(TextureType.CAPE); + assertNotNull(cape); + assertEquals("https://example.com/b.png", cape.url()); + } + + @Test + public void testInactiveCapeProducesNoCapeTexture() { + MinecraftProfileResponse profile = parseProfile(""" + [{"id": "cape-a", "state": "INACTIVE", "url": "https://example.com/a.png", "alias": "A"}] + """); + + Optional> textures = MicrosoftService.getTextures(profile); + + assertTrue(textures.isPresent()); + assertFalse(textures.get().containsKey(TextureType.CAPE)); + } + + @Test + public void testEmptyCapesProducesNoCapeTexture() { + MinecraftProfileResponse profile = parseProfile("[]"); + Optional> textures = MicrosoftService.getTextures(profile); + + assertTrue(textures.isPresent()); + assertFalse(textures.get().containsKey(TextureType.CAPE)); + } +} \ No newline at end of file From 515ec9e34fce66e056c2c54242a987b4329b0751 Mon Sep 17 00:00:00 2001 From: BaiPiaoCN Date: Sat, 29 Aug 2026 15:15:15 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=AB=E9=A3=8E?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/game/CapePreview.java | 146 ++++++++++++++++++ .../main/java/org/jackhuang/hmcl/ui/SVG.java | 2 +- .../ui/account/MicrosoftAccountCapePane.java | 48 +++--- .../resources/assets/lang/I18N.properties | 1 + .../resources/assets/lang/I18N_zh.properties | 1 + .../assets/lang/I18N_zh_CN.properties | 1 + .../jackhuang/hmcl/game/CapePreviewTest.java | 96 ++++++++++++ 7 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java create mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java new file mode 100644 index 00000000000..2ae41fc38f5 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java @@ -0,0 +1,146 @@ +/* + * 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; + +import javafx.scene.image.Image; +import javafx.scene.image.PixelReader; +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; +import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Generates the local UI preview of a Minecraft cape from its raw UV texture. +/// +/// The raw `capes[].url` texture is a UV unwrap (for example 64x32) and must +/// not be shown as-is. This class downloads and caches that raw texture through +/// [TexturesLoader], then extracts only the cape front face so the launcher +/// renders a correctly proportioned preview. +/// +/// The raw texture itself is never modified: the returned image is only ever +/// used for local display. +@NotNullByDefault +public final class CapePreview { + + /// Display aspect ratio of a cape front face (10x16). + public static final double ASPECT_RATIO = 10.0 / 16.0; + + /// Reference width, in pixels, of the standard 64x32 cape texture. + private static final double BASE_WIDTH = 64.0; + + /// UV region of the cape front face in the standard 64x32 layout. + private static final double FRONT_X = 1.0; + private static final double FRONT_Y = 1.0; + private static final double FRONT_WIDTH = 10.0; + private static final double FRONT_HEIGHT = 16.0; + + private CapePreview() { + } + + /// Downloads and caches the raw cape texture for `url`, then returns the + /// front-face preview image. + /// + /// @param url the raw Minecraft cape texture URL + /// @return the cropped preview, or `null` when the texture is unavailable + public static @Nullable Image load(@Nullable String url) { + if (StringUtils.isBlank(url)) { + return null; + } + + try { + TexturesLoader.LoadedTexture texture = TexturesLoader.loadTexture(new Texture(url, null)); + return extractFrontFace(texture.image()); + } catch (Throwable e) { + LOG.warning("Failed to load cape preview: " + url, e); + return null; + } + } + + /// Extracts the cape front face from an already decoded raw cape texture. + /// + /// The front face is read from the region `(1, 1, 10, 16)` of the standard + /// 64x32 UV layout, scaled by `width / 64` so that higher-resolution + /// textures (128x64, 256x128, ...) are handled proportionally. The + /// rectangle is clamped into the texture bounds, so unknown or irregular + /// sizes degrade gracefully instead of failing. + /// + /// @param texture the decoded raw cape texture + /// @return the cropped front-face preview, or `null` when the texture is unreadable + public static @Nullable Image extractFrontFace(@Nullable Image texture) { + if (texture == null) { + return null; + } + + int width = (int) texture.getWidth(); + int height = (int) texture.getHeight(); + int[] region = computeFrontRegion(width, height); + if (region == null) { + return null; + } + + int x = region[0]; + int y = region[1]; + int w = region[2]; + int h = region[3]; + + PixelReader reader = texture.getPixelReader(); + if (reader == null) { + return null; + } + + WritableImage preview = new WritableImage(w, h); + PixelWriter writer = preview.getPixelWriter(); + for (int py = 0; py < h; py++) { + for (int px = 0; px < w; px++) { + writer.setArgb(px, py, reader.getArgb(x + px, y + py)); + } + } + return preview; + } + + /// Computes the integer pixel rectangle of the cape front face for a texture + /// of the given size. + /// + /// @return `{x, y, width, height}`, or `null` when the size cannot be mapped + static int @Nullable [] computeFrontRegion(int width, int height) { + if (width <= 0 || height <= 0) { + return null; + } + + double scale = width / BASE_WIDTH; + int x = Math.max(0, Math.min((int) Math.round(FRONT_X * scale), width - 1)); + int y = Math.max(0, Math.min((int) Math.round(FRONT_Y * scale), height - 1)); + int w = clamp((int) Math.round(FRONT_WIDTH * scale), 1, width - x); + int h = clamp((int) Math.round(FRONT_HEIGHT * scale), 1, height - y); + return new int[]{x, y, w, h}; + } + + /// Clamps `value` into `[min, max]`. + private static int clamp(int value, int min, int max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } +} \ No newline at end of file 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 d344b563de2..593092c2ea8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -40,7 +40,7 @@ public enum SVG { ARROW_FORWARD("M16.175 13H4V11H16.175L10.575 5.4 12 4 20 12 12 20 10.575 18.6 16.175 13Z"), 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"), - CAPE("M8 5q-.825 0-1.4125-.5875T6 3q0-.825.5875-1.4125T8 1h8q.825 0 1.4125.5875T18 3q0 .825-.5875 1.4125T16 5h-2v3H8V5Zm1 5h6l2 8h-2l-1-6h-2l-1 6H9l2-8Z"), // Not Material + CAPE("M6 4h12v3l2 2v11h-3l-5-3-5 3H4V9l2-2V4Zm2 2v2.2L6 9.5v7.1l4-2.4 2 1.2 2-1.2 4 2.4V9.5l-2-1.3V6H8Z"), 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"), CHECK("M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4 9.55 18Z"), CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java index 785303504e5..dc50399432f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java @@ -26,8 +26,7 @@ import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.microsoft.MicrosoftService.MinecraftProfileResponseCape; -import org.jackhuang.hmcl.auth.yggdrasil.Texture; -import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.game.CapePreview; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -44,7 +43,6 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// Dialog listing every cape owned by a Microsoft account and letting the user /// activate one of them or remove the active cape. @@ -58,6 +56,11 @@ public final class MicrosoftAccountCapePane extends StackPane { private final AdvancedListBox listBox = new AdvancedListBox(); private final SpinnerPane spinnerPane = new SpinnerPane(); + /// Preview display width in the list; the height is derived from the 10:16 + /// cape aspect ratio so the front face is never stretched into a square. + private static final double CAPE_PREVIEW_WIDTH = 20; + private static final double CAPE_PREVIEW_HEIGHT = CAPE_PREVIEW_WIDTH / CapePreview.ASPECT_RATIO; + public MicrosoftAccountCapePane(MicrosoftAccount account) { this.account = account; @@ -120,10 +123,9 @@ private AdvancedListItem buildNoCapeItem(boolean active) { private AdvancedListItem buildCapeItem(MinecraftProfileResponseCape cape) { AdvancedListItem item = new AdvancedListItem(); item.setTitle(StringUtils.isBlank(cape.alias) ? cape.id : cape.alias); - if ("ACTIVE".equals(cape.state)) { - item.setSubtitle(i18n("account.cape.active")); - item.setActive(true); - } + boolean active = "ACTIVE".equals(cape.state); + item.setSubtitle(i18n(active ? "account.cape.active" : "account.cape.inactive")); + item.setActive(active); ImageView preview = createPreview(); item.setLeftGraphic(preview); item.setOnAction(e -> confirmCapeChange(i18n("account.cape.set.confirm"), () -> applyCape(cape.id))); @@ -158,11 +160,14 @@ private void applyCape(@Nullable String capeId) { .start(); } - /// Creates a sized, aligned preview view for a cape texture. + /// Creates a sized, aligned preview view for a cape front face. + /// + /// The view keeps the 10:16 cape aspect ratio, so the extracted front face + /// is never stretched into a square. private static ImageView createPreview() { ImageView view = new ImageView(); - view.setFitWidth(AdvancedListItem.LEFT_ICON_SIZE); - view.setFitHeight(AdvancedListItem.LEFT_ICON_SIZE); + view.setFitWidth(CAPE_PREVIEW_WIDTH); + view.setFitHeight(CAPE_PREVIEW_HEIGHT); view.setSmooth(true); view.setPreserveRatio(true); BorderPane.setMargin(view, AdvancedListItem.LEFT_ICON_MARGIN); @@ -171,19 +176,16 @@ private static ImageView createPreview() { return view; } - /// Downloads and displays the cape texture preview in the background. - private static void bindPreview(ImageView view, String url) { - Task.supplyAsync(() -> { - try { - return TexturesLoader.loadTexture(new Texture(url, null)); - } catch (Throwable e) { - LOG.warning("Failed to load cape texture " + url, e); - return null; - } - }) - .whenComplete(Schedulers.javafx(), (loadedTexture, exception) -> { - if (exception == null && loadedTexture != null) { - view.setImage(loadedTexture.image()); + /// Loads the cape front-face preview in the background and displays it. + /// + /// On any failure (download, decode, size) the view is simply left empty and + /// the item remains fully functional; the cape can still be selected by its + /// alias and switched through its `id`. + private static void bindPreview(ImageView view, @Nullable String url) { + Task.supplyAsync(() -> CapePreview.load(url)) + .whenComplete(Schedulers.javafx(), (preview, exception) -> { + if (exception == null && preview != null) { + view.setImage(preview); } }) .start(); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 36147d707d8..a7239783908 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -55,6 +55,7 @@ about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL) account=Accounts account.cape=Cape account.cape.active=In Use +account.cape.inactive=Not In Use account.cape.manage=Manage Cape account.cape.none=No Cape account.cape.remove.confirm=Remove the active cape? diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index d53b00bbb33..6be0c977b25 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -54,6 +54,7 @@ about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL/) account=帳戶 account.cape=披風 account.cape.active=使用中 +account.cape.inactive=未使用 account.cape.manage=管理披風 account.cape.none=不使用披風 account.cape.remove.confirm=確定取消當前激活的披風嗎? 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 69ac0782a08..0927b0c5b97 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -54,6 +54,7 @@ about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL) account=账户 account.cape=披风 account.cape.active=使用中 +account.cape.inactive=未使用 account.cape.manage=管理披风 account.cape.none=不使用披风 account.cape.remove.confirm=确定取消当前激活的披风吗? diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java new file mode 100644 index 00000000000..e9b93991581 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java @@ -0,0 +1,96 @@ +/* + * 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; + +import javafx.scene.image.Image; +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import static org.junit.jupiter.api.Assertions.*; + +/// Unit tests for the Minecraft cape UV crop logic. +public final class CapePreviewTest { + + private static WritableImage newTexture(int width, int height, int argb) { + WritableImage image = new WritableImage(width, height); + PixelWriter writer = image.getPixelWriter(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + writer.setArgb(x, y, argb); + } + } + return image; + } + + @Test + public void testStandard64x32FrontRegion() { + assertArrayEquals(new int[]{1, 1, 10, 16}, CapePreview.computeFrontRegion(64, 32)); + } + + @Test + public void testHighResolutionFrontRegionsScaleProportionally() { + assertArrayEquals(new int[]{2, 2, 20, 32}, CapePreview.computeFrontRegion(128, 64)); + assertArrayEquals(new int[]{4, 4, 40, 64}, CapePreview.computeFrontRegion(256, 128)); + } + + @Test + public void testUnusualSizeStaysInBounds() { + int[] region = CapePreview.computeFrontRegion(45, 22); + assertNotNull(region); + int x = region[0], y = region[1], w = region[2], h = region[3]; + assertTrue(x >= 0 && y >= 0 && w > 0 && h > 0); + assertTrue(x + w <= 45 && y + h <= 22); + } + + @Test + public void testInvalidSizeReturnsNull() { + assertNull(CapePreview.computeFrontRegion(0, 32)); + assertNull(CapePreview.computeFrontRegion(64, 0)); + } + + @Test + @EnabledIf("org.jackhuang.hmcl.JavaFXLauncher#isStarted") + public void testExtractFrontFaceCopiesPixels() { + int color = 0xFF336699; + + Image preview64 = CapePreview.extractFrontFace(newTexture(64, 32, color)); + assertNotNull(preview64); + assertEquals(10, (int) preview64.getWidth()); + assertEquals(16, (int) preview64.getHeight()); + assertEquals(color, preview64.getPixelReader().getArgb(0, 0)); + + Image preview128 = CapePreview.extractFrontFace(newTexture(128, 64, color)); + assertNotNull(preview128); + assertEquals(20, (int) preview128.getWidth()); + assertEquals(32, (int) preview128.getHeight()); + assertEquals(color, preview128.getPixelReader().getArgb(0, 0)); + + Image preview256 = CapePreview.extractFrontFace(newTexture(256, 128, color)); + assertNotNull(preview256); + assertEquals(40, (int) preview256.getWidth()); + assertEquals(64, (int) preview256.getHeight()); + assertEquals(color, preview256.getPixelReader().getArgb(0, 0)); + } + + @Test + public void testExtractFrontFaceOnNullReturnsNull() { + assertNull(CapePreview.extractFrontFace(null)); + } +} \ No newline at end of file From 2800702e0e009a69647823a0fd30f62728fa0e77 Mon Sep 17 00:00:00 2001 From: BaiPiaoCN Date: Sat, 29 Aug 2026 15:30:54 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E9=A2=91=E7=8E=87=E8=BF=87=E5=BF=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/ui/SVG.java | 2 +- .../ui/account/MicrosoftAccountCapePane.java | 133 ++++++++++++++---- .../hmcl/auth/microsoft/MicrosoftAccount.java | 29 +++- .../hmcl/auth/microsoft/MicrosoftService.java | 73 ++++++++-- 4 files changed, 185 insertions(+), 52 deletions(-) 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 593092c2ea8..2923bee7d11 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -40,7 +40,7 @@ public enum SVG { ARROW_FORWARD("M16.175 13H4V11H16.175L10.575 5.4 12 4 20 12 12 20 10.575 18.6 16.175 13Z"), 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"), - CAPE("M6 4h12v3l2 2v11h-3l-5-3-5 3H4V9l2-2V4Zm2 2v2.2L6 9.5v7.1l4-2.4 2 1.2 2-1.2 4 2.4V9.5l-2-1.3V6H8Z"), + CAPE("M7 5h10v3l2 2v9h-4l-3-2-3 2H5v-9l2-2V5Zm2 2v2l-2 1.5V17h2.5l2.5-1.7 2.5 1.7H17v-6.5L15 9V7H9Z"), // Not Material 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"), CHECK("M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4 9.55 18Z"), CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java index dc50399432f..351b24b1f27 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java @@ -19,11 +19,13 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; +import javafx.animation.PauseTransition; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; +import javafx.util.Duration; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.microsoft.MicrosoftService.MinecraftProfileResponseCape; import org.jackhuang.hmcl.game.CapePreview; @@ -40,6 +42,7 @@ import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -48,19 +51,41 @@ /// activate one of them or remove the active cape. /// /// Cape data is always read from the Minecraft Services profile, never from a -/// locally fabricated list. +/// locally fabricated list. A cape change is debounced and serialized so rapid +/// clicks yield a single `PUT`/`DELETE` instead of hammering the rate-limited +/// Minecraft Services endpoint. @NotNullByDefault public final class MicrosoftAccountCapePane extends StackPane { - private final MicrosoftAccount account; - private final AdvancedListBox listBox = new AdvancedListBox(); - private final SpinnerPane spinnerPane = new SpinnerPane(); + /// Idle delay before a pending cape change is actually sent. Rapid clicks + /// within this window are coalesced into the final selection. + private static final int CAPE_CHANGE_DEBOUNCE_MILLIS = 400; /// Preview display width in the list; the height is derived from the 10:16 /// cape aspect ratio so the front face is never stretched into a square. private static final double CAPE_PREVIEW_WIDTH = 20; private static final double CAPE_PREVIEW_HEIGHT = CAPE_PREVIEW_WIDTH / CapePreview.ASPECT_RATIO; + private final MicrosoftAccount account; + private final AdvancedListBox listBox = new AdvancedListBox(); + private final SpinnerPane spinnerPane = new SpinnerPane(); + + /// Guards against concurrent `GET /minecraft/profile` reloads. + private final AtomicBoolean reloading = new AtomicBoolean(false); + /// Guards against concurrent cape-change requests, so only one + /// `PUT`/`DELETE` runs at a time. + private final AtomicBoolean changingCape = new AtomicBoolean(false); + + /// The cape list currently displayed; reused when a `DELETE` returns no body. + private List currentCapes = List.of(); + /// Whether a debounced cape change is pending. + private boolean hasPendingChange; + /// The pending cape id (`null` means "remove the active cape"). + private @Nullable String pendingCapeId; + + /// Debounce timer coalescing rapid selections. + private final PauseTransition debounce = new PauseTransition(Duration.millis(CAPE_CHANGE_DEBOUNCE_MILLIS)); + public MicrosoftAccountCapePane(MicrosoftAccount account) { this.account = account; @@ -74,6 +99,8 @@ public MicrosoftAccountCapePane(MicrosoftAccount account) { spinnerPane.setPrefHeight(360); spinnerPane.setOnFailedAction(e -> reload()); + debounce.setOnFinished(e -> runPendingChange()); + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); cancelButton.getStyleClass().add("dialog-cancel"); cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); @@ -85,28 +112,41 @@ public MicrosoftAccountCapePane(MicrosoftAccount account) { reload(); } - /// Reloads the cape list from the server and re-renders the item list. + /// Reloads the cape list from the server. At most one reload runs at a time. private void reload() { + if (!reloading.compareAndSet(false, true)) { + return; + } spinnerPane.showSpinner(); Task.supplyAsync(account::getCapes) - .whenComplete(Schedulers.javafx(), this::renderCapes) + .whenComplete(Schedulers.javafx(), (capes, exception) -> { + reloading.set(false); + renderCapes(capes, exception); + }) .start(); } - /// Renders the loading result, or the failure reason when loading failed. + /// Renders the initial-load result, or the failure reason when loading failed. private void renderCapes(@Nullable List capes, @Nullable Exception exception) { spinnerPane.hideSpinner(); if (exception != null) { spinnerPane.setFailedReason(Accounts.localizeErrorMessage(exception)); return; } + renderList(capes == null ? List.of() : capes, false); + } - boolean hasActiveCape = capes.stream().anyMatch(cape -> "ACTIVE".equals(cape.state)); + /// Re-renders the list. When `allInactive` is true, every cape is shown as + /// inactive regardless of its stored state, used for a `DELETE` that returned + /// no body. + private void renderList(List capes, boolean allInactive) { + currentCapes = capes; + boolean hasActive = !allInactive && capes.stream().anyMatch(cape -> "ACTIVE".equals(cape.state)); listBox.clear(); - listBox.add(buildNoCapeItem(!hasActiveCape)); + listBox.add(buildNoCapeItem(!hasActive)); for (MinecraftProfileResponseCape cape : capes) { - listBox.add(buildCapeItem(cape)); + listBox.add(buildCapeItem(cape, !allInactive && "ACTIVE".equals(cape.state))); } } @@ -115,55 +155,86 @@ private AdvancedListItem buildNoCapeItem(boolean active) { AdvancedListItem item = new AdvancedListItem(); item.setTitle(i18n("account.cape.none")); item.setActive(active); - item.setOnAction(e -> confirmCapeChange(i18n("account.cape.remove.confirm"), () -> applyCape(null))); + item.setOnAction(e -> requestCapeChange(null)); return item; } /// Builds the list entry for one cape with a lazily loaded preview. - private AdvancedListItem buildCapeItem(MinecraftProfileResponseCape cape) { + private AdvancedListItem buildCapeItem(MinecraftProfileResponseCape cape, boolean active) { AdvancedListItem item = new AdvancedListItem(); item.setTitle(StringUtils.isBlank(cape.alias) ? cape.id : cape.alias); - boolean active = "ACTIVE".equals(cape.state); item.setSubtitle(i18n(active ? "account.cape.active" : "account.cape.inactive")); item.setActive(active); ImageView preview = createPreview(); item.setLeftGraphic(preview); - item.setOnAction(e -> confirmCapeChange(i18n("account.cape.set.confirm"), () -> applyCape(cape.id))); + item.setOnAction(e -> requestCapeChange(cape.id)); bindPreview(preview, cape.url); return item; } - /// Asks the user to confirm before mutating the active cape, then runs `commit`. - private void confirmCapeChange(String message, Runnable commit) { - Controllers.confirm(message, i18n("account.cape.manage"), commit, null); + /// Records a cape change request, coalescing rapid clicks through the debounce timer. + /// + /// @param capeId the cape to activate, or `null` to remove the active cape + private void requestCapeChange(@Nullable String capeId) { + hasPendingChange = true; + pendingCapeId = capeId; + debounce.playFromStart(); } - /// Applies a cape change in the background and reloads the list afterwards. + /// Runs the pending cape change after the debounce delay expires. /// - /// @param capeId the cape to activate, or `null` to remove the active cape - private void applyCape(@Nullable String capeId) { + /// Factors is ignored while a change is already in flight; the in-flight + /// completion re-arms the debounce and applies the newest pending selection. + private void runPendingChange() { + if (!hasPendingChange || changingCape.get()) { + return; + } + @Nullable String capeId = pendingCapeId; + if (!changingCape.compareAndSet(false, true)) { + return; + } + hasPendingChange = false; + applyChange(capeId); + } + + /// Applies one cape change in the background, then renders the returned state. + private void applyChange(@Nullable String capeId) { spinnerPane.showSpinner(); - Task.runAsync(() -> { + Task.supplyAsync(() -> { if (capeId == null) { - account.hideCape(); + return account.hideCape(); } else { - account.showCape(capeId); + return account.showCape(capeId); } }) - .whenComplete(Schedulers.javafx(), exception -> { - spinnerPane.hideSpinner(); - if (exception != null) { - Controllers.showToast(Accounts.localizeErrorMessage(exception)); + .whenComplete(Schedulers.javafx(), (capes, exception) -> { + changingCape.set(false); + handleChangeResult(capes, exception); + if (hasPendingChange) { + // A newer selection arrived while this request was in flight. + debounce.playFromStart(); } - reload(); }) .start(); } + /// Updates the UI from a completed cape change. It never reloads the profile. + private void handleChangeResult(@Nullable List capes, @Nullable Exception exception) { + spinnerPane.hideSpinner(); + if (exception != null) { + // A rate-limited or otherwise failed change must not trigger a reload. + Controllers.showToast(Accounts.localizeErrorMessage(exception)); + return; + } + if (capes == null) { + // DELETE returned no body: keep owned capes, clear ACTIVE locally. + renderList(currentCapes, true); + } else { + renderList(capes, false); + } + } + /// Creates a sized, aligned preview view for a cape front face. - /// - /// The view keeps the 10:16 cape aspect ratio, so the extracted front face - /// is never stretched into a square. private static ImageView createPreview() { ImageView view = new ImageView(); view.setFitWidth(CAPE_PREVIEW_WIDTH); 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 e023658b034..c8c1c76d614 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 @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; import org.jackhuang.hmcl.util.io.ResponseCodeException; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import org.jetbrains.annotations.Nullable; import java.net.HttpURLConnection; import java.nio.file.Path; @@ -172,41 +173,50 @@ public List getCapes() throws Aut /// Activates an owned cape for this account. /// + /// The caller uses the returned cape list directly, avoiding an additional + /// `GET /minecraft/profile` after the change. + /// /// @param capeId the server-side cape ID to activate + /// @return the updated cape list, as returned by the activation request /// @throws AuthenticationException on failure, or when the server rejects the token - public void showCape(String capeId) throws AuthenticationException { + public List showCape(String capeId) throws AuthenticationException { requireNonNull(capeId); logIn(); + MicrosoftService.MinecraftProfileResponse profile; try { - service.showCape(session.accessToken(), capeId); + profile = service.showCape(session.accessToken(), capeId); } catch (AuthenticationException e) { if (isUnauthorized(e)) { refreshSession(); - service.showCape(session.accessToken(), capeId); + profile = service.showCape(session.accessToken(), capeId); } else { throw e; } } clearProfileCache(); + return capesOf(profile); } /// Removes this account's active cape. /// + /// @return the updated cape list, or `null` when the server returned no profile /// @throws AuthenticationException on failure, or when the server rejects the token - public void hideCape() throws AuthenticationException { + public @Nullable List hideCape() throws AuthenticationException { logIn(); + MicrosoftService.MinecraftProfileResponse profile; try { - service.hideCape(session.accessToken()); + profile = service.hideCape(session.accessToken()); } catch (AuthenticationException e) { if (isUnauthorized(e)) { refreshSession(); - service.hideCape(session.accessToken()); + profile = service.hideCape(session.accessToken()); } else { throw e; } } clearProfileCache(); + return profile == null ? null : capesOf(profile); } /// Reads the current cape list straight from the Minecraft Services profile. @@ -218,6 +228,13 @@ private List readCapes() throws A .orElse(List.of()); } + /// Extracts the cape list from a Minecraft Services profile. + private static List capesOf(MicrosoftService.MinecraftProfileResponse profile) { + return profile.capes == null + ? List.of() + : profile.capes.stream().filter(Objects::nonNull).toList(); + } + /// Invalidates the cached profile so the UI re-fetches the server state after a cape change. private void clearProfileCache() { service.getProfileRepository().invalidate(profileID); 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 4f0138efbf8..ddeb2b6d26a 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 @@ -32,9 +32,11 @@ import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.function.ExceptionalSupplier; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.*; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -56,6 +58,7 @@ public class MicrosoftService { private static final String SCOPE = "XboxLive.signin offline_access"; private static final String CAPES_ACTIVE_ENDPOINT = "https://api.minecraftservices.com/minecraft/profile/capes/active"; + private static final int[] CAPE_RETRY_BACKOFF_MILLIS = {2000, 5000}; private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10, TimeUnit.SECONDS); @@ -316,42 +319,84 @@ public void uploadSkin(String accessToken, boolean isSlim, Path file) throws Aut /// Activates an owned cape for the given Minecraft access token. /// /// Sends `PUT /minecraft/profile/capes/active` with the JSON body - /// `{"capeId":"..."}`. A non-2xx response is reported as a - /// [ServerDisconnectException] whose cause chain carries the HTTP status - /// code, so callers can distinguish `401 Unauthorized` from other failures. + /// `{"capeId":"..."}` and parses the returned profile, so callers can update + /// the UI without issuing an additional `GET /minecraft/profile`. + /// + /// An `HTTP 429` is retried at most twice with exponential-ish backoff + /// (2s, then 5s); other non-2xx responses are reported as a + /// [ServerDisconnectException] whose cause chain carries the status code. /// /// @param accessToken the Minecraft Services access token /// @param capeId the server-side cape ID to activate + /// @return the profile returned by the server /// @throws AuthenticationException on network failure or a non-2xx response - public void showCape(String accessToken, String capeId) throws AuthenticationException { + public MinecraftProfileResponse showCape(String accessToken, String capeId) throws AuthenticationException { requireNonNull(accessToken); requireNonNull(capeId); try { - HttpRequest.PUT(CAPES_ACTIVE_ENDPOINT) + String response = requestCapeWithBackoff(() -> HttpRequest.PUT(CAPES_ACTIVE_ENDPOINT) .json(mapOf(pair("capeId", capeId))) .authorization("Bearer " + accessToken) .accept("application/json") - .getString(); - } catch (IOException e) { - throw new ServerDisconnectException(e); + .getString()); + return JsonUtils.fromNonNullJson(response, MinecraftProfileResponse.class); + } catch (JsonParseException e) { + throw new ServerResponseMalformedException(e); } } /// Removes the active cape for the given Minecraft access token. /// - /// Sends `DELETE /minecraft/profile/capes/active` without a request body. + /// Sends `DELETE /minecraft/profile/capes/active` without a request body and, + /// when the server returns a profile, parses it. /// /// @param accessToken the Minecraft Services access token + /// @return the profile returned by the server, or `null` when the response has no body /// @throws AuthenticationException on network failure or a non-2xx response - public void hideCape(String accessToken) throws AuthenticationException { + public @Nullable MinecraftProfileResponse hideCape(String accessToken) throws AuthenticationException { requireNonNull(accessToken); try { - HttpRequest.DELETE(CAPES_ACTIVE_ENDPOINT) + String response = requestCapeWithBackoff(() -> HttpRequest.DELETE(CAPES_ACTIVE_ENDPOINT) .authorization("Bearer " + accessToken) .accept("application/json") - .getString(); - } catch (IOException e) { - throw new ServerDisconnectException(e); + .getString()); + if (StringUtils.isBlank(response)) { + return null; + } + return JsonUtils.fromNonNullJson(response, MinecraftProfileResponse.class); + } catch (JsonParseException e) { + throw new ServerResponseMalformedException(e); + } + } + + /// Runs a cape endpoint request, retrying `HTTP 429` with bounded backoff. + /// + /// @param request the HTTP request producing the response body + /// @return the raw JSON response body + /// @throws AuthenticationException on network failure or a non-retriable non-2xx response + private static String requestCapeWithBackoff(ExceptionalSupplier request) throws AuthenticationException { + for (int attempt = 0; ; attempt++) { + try { + return request.get(); + } catch (ResponseCodeException e) { + if (e.getResponseCode() == 429 && attempt < CAPE_RETRY_BACKOFF_MILLIS.length) { + sleepBeforeRetry(CAPE_RETRY_BACKOFF_MILLIS[attempt], e); + continue; + } + throw new ServerDisconnectException(e); + } catch (IOException e) { + throw new ServerDisconnectException(e); + } + } + } + + /// Sleeps for the given backoff before retrying a rate-limited request. + private static void sleepBeforeRetry(long millis, IOException cause) throws ServerDisconnectException { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServerDisconnectException(cause); } } From b43686c446afcbc48629110b4dd4c22648108362 Mon Sep 17 00:00:00 2001 From: BaiPiaoCN Date: Sat, 29 Aug 2026 15:54:29 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E9=A2=91=E7=8E=87=E8=BF=87=E5=BF=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/setting/Accounts.java | 2 + .../ui/account/MicrosoftAccountCapePane.java | 27 +-- .../resources/assets/lang/I18N.properties | 1 + .../resources/assets/lang/I18N_zh.properties | 1 + .../assets/lang/I18N_zh_CN.properties | 1 + .../hmcl/auth/microsoft/MicrosoftAccount.java | 154 ++++++++++++--- .../hmcl/auth/microsoft/MicrosoftService.java | 57 ++---- .../MicrosoftAccountProfileCacheTest.java | 177 ++++++++++++++++++ 8 files changed, 338 insertions(+), 82 deletions(-) create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index 30f3f194242..bd9aa0029ee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -569,6 +569,8 @@ public static String localizeErrorMessage(Exception exception) { return i18n("account.methods.microsoft.error.no_character"); } else if (exception instanceof MicrosoftService.NoXuiException) { return i18n("account.methods.microsoft.error.add_family"); + } else if (exception instanceof MicrosoftService.MinecraftServicesRateLimitException) { + return i18n("account.cape.rate_limited"); } else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) { return i18n("account.methods.microsoft.snapshot"); } else if (exception instanceof OAuthAccount.WrongAccountException) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java index 351b24b1f27..c333b56dc3c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java @@ -28,6 +28,7 @@ import javafx.util.Duration; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.microsoft.MicrosoftService.MinecraftProfileResponseCape; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftService.MinecraftServicesRateLimitException; import org.jackhuang.hmcl.game.CapePreview; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; @@ -70,8 +71,6 @@ public final class MicrosoftAccountCapePane extends StackPane { private final AdvancedListBox listBox = new AdvancedListBox(); private final SpinnerPane spinnerPane = new SpinnerPane(); - /// Guards against concurrent `GET /minecraft/profile` reloads. - private final AtomicBoolean reloading = new AtomicBoolean(false); /// Guards against concurrent cape-change requests, so only one /// `PUT`/`DELETE` runs at a time. private final AtomicBoolean changingCape = new AtomicBoolean(false); @@ -112,17 +111,12 @@ public MicrosoftAccountCapePane(MicrosoftAccount account) { reload(); } - /// Reloads the cape list from the server. At most one reload runs at a time. + /// Reloads the cape list. Deduplication and rate-limit handling happen inside + /// [MicrosoftAccount], so no pane-level guard is needed here. private void reload() { - if (!reloading.compareAndSet(false, true)) { - return; - } spinnerPane.showSpinner(); Task.supplyAsync(account::getCapes) - .whenComplete(Schedulers.javafx(), (capes, exception) -> { - reloading.set(false); - renderCapes(capes, exception); - }) + .whenComplete(Schedulers.javafx(), this::renderCapes) .start(); } @@ -222,7 +216,11 @@ private void applyChange(@Nullable String capeId) { private void handleChangeResult(@Nullable List capes, @Nullable Exception exception) { spinnerPane.hideSpinner(); if (exception != null) { - // A rate-limited or otherwise failed change must not trigger a reload. + // A rate-limited change must not trigger a reload nor auto-apply a + // pending selection; drop it so the cooldown is not fought against. + if (exception instanceof MinecraftServicesRateLimitException) { + clearPendingChange(); + } Controllers.showToast(Accounts.localizeErrorMessage(exception)); return; } @@ -234,6 +232,13 @@ private void handleChangeResult(@Nullable List cap } } + /// Cancels any queued cape change and stops the debounce timer. + private void clearPendingChange() { + hasPendingChange = false; + pendingCapeId = null; + debounce.stop(); + } + /// Creates a sized, aligned preview view for a cape front face. private static ImageView createPreview() { ImageView view = new ImageView(); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index a7239783908..1b57b238ce1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -58,6 +58,7 @@ account.cape.active=In Use account.cape.inactive=Not In Use account.cape.manage=Manage Cape account.cape.none=No Cape +account.cape.rate_limited=Minecraft services are rate-limited. Please try again later. account.cape.remove.confirm=Remove the active cape? account.cape.set.confirm=Set this cape as the active cape? account.character=Player diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 6be0c977b25..9f6d8f3af78 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -57,6 +57,7 @@ account.cape.active=使用中 account.cape.inactive=未使用 account.cape.manage=管理披風 account.cape.none=不使用披風 +account.cape.rate_limited=Minecraft 服務請求過於頻繁,請稍後再試。 account.cape.remove.confirm=確定取消當前激活的披風嗎? account.cape.set.confirm=確定將此披風設定為當前披風嗎? account.character=角色 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 0927b0c5b97..03914bb2ed3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -57,6 +57,7 @@ account.cape.active=使用中 account.cape.inactive=未使用 account.cape.manage=管理披风 account.cape.none=不使用披风 +account.cape.rate_limited=Minecraft 服务请求过于频繁,请稍后再试。 account.cape.remove.confirm=确定取消当前激活的披风吗? account.cape.set.confirm=确定将此披风设置为当前披风吗? account.character=角色 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 c8c1c76d614..feffce81047 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 @@ -40,12 +40,29 @@ public final class MicrosoftAccount extends OAuthAccount { + /// How long a fetched Minecraft Services profile stays valid before a new + /// `GET /minecraft/profile` is allowed. + private static final long PROFILE_CACHE_TTL_MILLIS = 30_000L; + + /// Fixed cooldown applied after an HTTP 429 response. During this window no + /// new profile/cape request is issued. + private static final long RATE_LIMIT_COOLDOWN_MILLIS = 10_000L; + protected final MicrosoftService service; protected UUID profileID; private boolean authenticated = false; private MicrosoftSession session; + /// Last Minecraft Services profile fetched for this account, and the time it + /// was fetched. Written before `cachedProfileAt` so a stale timestamp can + /// only trigger an extra refresh, never serve stale data as fresh. + private volatile MicrosoftService.MinecraftProfileResponse cachedProfile; + private volatile long cachedProfileAt; + /// Timestamp until which all Minecraft Services profile/cape requests are + /// blocked after a 429 response. + private volatile long minecraftServicesRateLimitUntil; + protected MicrosoftAccount(AccountID accountID, MicrosoftService service, MicrosoftSession session) { super(accountID); this.service = requireNonNull(service); @@ -150,25 +167,41 @@ public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException service.uploadSkin(session.accessToken(), isSlim, file); } - /// Returns every cape owned by this account, as reported by Minecraft Services. + /// Returns the Minecraft Services profile for this account, using a short-lived + /// cache that also deduplicates concurrent calls and honors a rate-limit cooldown. /// - /// The token is handled internally: the session is made valid first and, - /// when the server reports `401 Unauthorized`, the session is refreshed once - /// and the profile is re-fetched. + /// Consecutive callers within the TTL share the cached profile; concurrent + /// callers whose cache has expired share a single `GET /minecraft/profile` + /// because this method is synchronized on the account instance. + /// + /// @return the profile + /// @throws AuthenticationException when the profile cannot be loaded, or during a + /// 429 cooldown when no cached profile exists + public synchronized MicrosoftService.MinecraftProfileResponse getMinecraftProfile() throws AuthenticationException { + long now = System.currentTimeMillis(); + + if (now < minecraftServicesRateLimitUntil) { + MicrosoftService.MinecraftProfileResponse cached = cachedProfile; + if (cached != null) { + return cached; + } + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + + MicrosoftService.MinecraftProfileResponse cached = cachedProfile; + if (cached != null && now - cachedProfileAt < PROFILE_CACHE_TTL_MILLIS) { + return cached; + } + + return fetchProfile(); + } + + /// Returns every cape owned by this account, as reported by Minecraft Services. /// /// @return the cape list (possibly empty); it is not mutable /// @throws AuthenticationException when the profile cannot be loaded public List getCapes() throws AuthenticationException { - logIn(); - try { - return readCapes(); - } catch (AuthenticationException e) { - if (isUnauthorized(e)) { - refreshSession(); - return readCapes(); - } - throw e; - } + return capesOf(getMinecraftProfile()); } /// Activates an owned cape for this account. @@ -178,10 +211,11 @@ public List getCapes() throws Aut /// /// @param capeId the server-side cape ID to activate /// @return the updated cape list, as returned by the activation request - /// @throws AuthenticationException on failure, or when the server rejects the token + /// @throws AuthenticationException on failure, or when the server is rate-limited public List showCape(String capeId) throws AuthenticationException { requireNonNull(capeId); + checkRateLimit(); logIn(); MicrosoftService.MinecraftProfileResponse profile; try { @@ -191,18 +225,23 @@ public List showCape(String capeI refreshSession(); profile = service.showCape(session.accessToken(), capeId); } else { + if (isRateLimited(e)) { + enterRateLimitCooldown(); + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } throw e; } } - clearProfileCache(); + cacheProfile(profile); return capesOf(profile); } /// Removes this account's active cape. /// /// @return the updated cape list, or `null` when the server returned no profile - /// @throws AuthenticationException on failure, or when the server rejects the token + /// @throws AuthenticationException on failure, or when the server is rate-limited public @Nullable List hideCape() throws AuthenticationException { + checkRateLimit(); logIn(); MicrosoftService.MinecraftProfileResponse profile; try { @@ -212,20 +251,66 @@ public List showCape(String capeI refreshSession(); profile = service.hideCape(session.accessToken()); } else { + if (isRateLimited(e)) { + enterRateLimitCooldown(); + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + throw e; + } + } + if (profile != null) { + cacheProfile(profile); + return capesOf(profile); + } + // DELETE returned no body: keep owned capes locally; never re-GET here. + return null; + } + + /// Fetches the profile from the server, refreshing a stale token once on 401 + /// and applying a cooldown on 429. Callers must hold the account lock. + private MicrosoftService.MinecraftProfileResponse fetchProfile() throws AuthenticationException { + logIn(); + MicrosoftService.MinecraftProfileResponse profile; + try { + profile = readProfileFromServer(); + } catch (AuthenticationException e) { + if (isUnauthorized(e)) { + refreshSession(); + profile = readProfileFromServer(); + } else { + if (isRateLimited(e)) { + enterRateLimitCooldown(); + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } throw e; } } - clearProfileCache(); - return profile == null ? null : capesOf(profile); + cacheProfile(profile); + return profile; } - /// Reads the current cape list straight from the Minecraft Services profile. - private List readCapes() throws AuthenticationException { + /// Performs a single `GET /minecraft/profile` without caching or rate-limit logic. + private MicrosoftService.MinecraftProfileResponse readProfileFromServer() throws AuthenticationException { return service.getCompleteProfile(session.getAuthorization()) - .map(profile -> profile.capes) - .filter(Objects::nonNull) - .map(capes -> capes.stream().filter(Objects::nonNull).toList()) - .orElse(List.of()); + .orElseThrow(() -> new ServerResponseMalformedException("Empty Minecraft profile")); + } + + /// Stores the freshly fetched profile and its timestamp. + private void cacheProfile(MicrosoftService.MinecraftProfileResponse profile) { + cachedProfile = profile; + cachedProfileAt = System.currentTimeMillis(); + } + + /// Throws when the rate-limit cooldown is still active. + private void checkRateLimit() throws AuthenticationException { + if (System.currentTimeMillis() < minecraftServicesRateLimitUntil) { + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + } + + /// Records a fixed cooldown after a 429 response. + private void enterRateLimitCooldown() { + minecraftServicesRateLimitUntil = System.currentTimeMillis() + RATE_LIMIT_COOLDOWN_MILLIS; } /// Extracts the cape list from a Minecraft Services profile. @@ -235,12 +320,6 @@ private static List capesOf(Micro : profile.capes.stream().filter(Objects::nonNull).toList(); } - /// Invalidates the cached profile so the UI re-fetches the server state after a cape change. - private void clearProfileCache() { - service.getProfileRepository().invalidate(profileID); - invalidate(); - } - /// Reports whether the exception chain contains a `401 Unauthorized` HTTP response. /// /// The Minecraft Services cape endpoints are contacted through [MicrosoftService], @@ -258,6 +337,19 @@ private static boolean isUnauthorized(Throwable exception) { return false; } + /// Reports whether the exception chain contains a `429 Too Many Requests` HTTP response. + private static boolean isRateLimited(Throwable exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof ResponseCodeException responseCodeException + && responseCodeException.getResponseCode() == 429) { + return true; + } + cause = cause.getCause(); + } + return false; + } + @Override public void writeMetadata(JsonObject metadata) { super.writeMetadata(metadata); 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 ddeb2b6d26a..07352c79cb5 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 @@ -32,7 +32,6 @@ import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.function.ExceptionalSupplier; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.*; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; @@ -58,7 +57,6 @@ public class MicrosoftService { private static final String SCOPE = "XboxLive.signin offline_access"; private static final String CAPES_ACTIVE_ENDPOINT = "https://api.minecraftservices.com/minecraft/profile/capes/active"; - private static final int[] CAPE_RETRY_BACKOFF_MILLIS = {2000, 5000}; private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10, TimeUnit.SECONDS); @@ -322,9 +320,9 @@ public void uploadSkin(String accessToken, boolean isSlim, Path file) throws Aut /// `{"capeId":"..."}` and parses the returned profile, so callers can update /// the UI without issuing an additional `GET /minecraft/profile`. /// - /// An `HTTP 429` is retried at most twice with exponential-ish backoff - /// (2s, then 5s); other non-2xx responses are reported as a - /// [ServerDisconnectException] whose cause chain carries the status code. + /// A non-2xx response is reported as a [ServerDisconnectException] whose + /// cause chain carries the HTTP status code, so callers can distinguish + /// `401 Unauthorized` from `429 Too Many Requests`. /// /// @param accessToken the Minecraft Services access token /// @param capeId the server-side cape ID to activate @@ -334,14 +332,16 @@ public MinecraftProfileResponse showCape(String accessToken, String capeId) thro requireNonNull(accessToken); requireNonNull(capeId); try { - String response = requestCapeWithBackoff(() -> HttpRequest.PUT(CAPES_ACTIVE_ENDPOINT) + String response = HttpRequest.PUT(CAPES_ACTIVE_ENDPOINT) .json(mapOf(pair("capeId", capeId))) .authorization("Bearer " + accessToken) .accept("application/json") - .getString()); + .getString(); return JsonUtils.fromNonNullJson(response, MinecraftProfileResponse.class); } catch (JsonParseException e) { throw new ServerResponseMalformedException(e); + } catch (IOException e) { + throw new ServerDisconnectException(e); } } @@ -356,47 +356,18 @@ public MinecraftProfileResponse showCape(String accessToken, String capeId) thro public @Nullable MinecraftProfileResponse hideCape(String accessToken) throws AuthenticationException { requireNonNull(accessToken); try { - String response = requestCapeWithBackoff(() -> HttpRequest.DELETE(CAPES_ACTIVE_ENDPOINT) + String response = HttpRequest.DELETE(CAPES_ACTIVE_ENDPOINT) .authorization("Bearer " + accessToken) .accept("application/json") - .getString()); + .getString(); if (StringUtils.isBlank(response)) { return null; } return JsonUtils.fromNonNullJson(response, MinecraftProfileResponse.class); } catch (JsonParseException e) { throw new ServerResponseMalformedException(e); - } - } - - /// Runs a cape endpoint request, retrying `HTTP 429` with bounded backoff. - /// - /// @param request the HTTP request producing the response body - /// @return the raw JSON response body - /// @throws AuthenticationException on network failure or a non-retriable non-2xx response - private static String requestCapeWithBackoff(ExceptionalSupplier request) throws AuthenticationException { - for (int attempt = 0; ; attempt++) { - try { - return request.get(); - } catch (ResponseCodeException e) { - if (e.getResponseCode() == 429 && attempt < CAPE_RETRY_BACKOFF_MILLIS.length) { - sleepBeforeRetry(CAPE_RETRY_BACKOFF_MILLIS[attempt], e); - continue; - } - throw new ServerDisconnectException(e); - } catch (IOException e) { - throw new ServerDisconnectException(e); - } - } - } - - /// Sleeps for the given backoff before retrying a rate-limited request. - private static void sleepBeforeRetry(long millis, IOException cause) throws ServerDisconnectException { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ServerDisconnectException(cause); + } catch (IOException e) { + throw new ServerDisconnectException(e); } } @@ -446,6 +417,12 @@ public final static class MinecraftJavaEditionLicenseNotFoundException extends A public final static class NoXuiException extends AuthenticationException { } + /// Thrown when Minecraft Services rejects a profile/cape request with + /// `HTTP 429` (Too Many Requests). Unlike `401`, this is not an + /// authentication problem and must never trigger a token refresh. + public final static class MinecraftServicesRateLimitException extends AuthenticationException { + } + private final static class XBoxLiveAuthenticationResponseDisplayClaims { List> xui; } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java new file mode 100644 index 00000000000..ad6f59e03c5 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java @@ -0,0 +1,177 @@ +/* + * 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.microsoft; + +import org.jackhuang.hmcl.auth.AccountID; +import org.jackhuang.hmcl.auth.AuthenticationException; +import org.jackhuang.hmcl.auth.OAuth; +import org.jackhuang.hmcl.auth.ServerDisconnectException; +import org.jackhuang.hmcl.util.io.ResponseCodeException; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/// Unit tests for the MicrosoftAccount Minecraft Services profile cache, its +/// in-flight deduplication, and its 429 cooldown. +public final class MicrosoftAccountProfileCacheTest { + + private static MicrosoftSession newSession() { + return new MicrosoftSession( + "Bearer", "access-token", Long.MAX_VALUE, "refresh-token", + new MicrosoftSession.User("user-id"), + new MicrosoftSession.GameProfile(UUID.randomUUID(), "Player")); + } + + private static MicrosoftService.MinecraftProfileResponse newProfile() { + MicrosoftService.MinecraftProfileResponse profile = new MicrosoftService.MinecraftProfileResponse(); + profile.id = UUID.randomUUID(); + profile.name = "Player"; + profile.skins = List.of(); + profile.capes = List.of(); + return profile; + } + + /// Stubs the network layer so cache/cooldown behavior can be exercised without HTTP. + private static final class StubService extends MicrosoftService { + final MicrosoftService.MinecraftProfileResponse profile; + final AtomicInteger attempts = new AtomicInteger(); + volatile boolean rateLimited; + volatile long fetchDelayMillis; + + StubService(MicrosoftService.MinecraftProfileResponse profile) { + super(new OAuth.Callback() { + @Override + public OAuth.Session startServer() { + return null; + } + + @Override + public void grantDeviceCode(String userCode, String verificationURI) { + } + + @Override + public void loginCompletedDeviceCode() { + } + + @Override + public void openBrowser(OAuth.GrantFlow grantFlow, String url) { + } + + @Override + public String getClientId() { + return "test-client-id"; + } + }); + this.profile = profile; + } + + @Override + public boolean validate(long notAfter, String tokenType, String accessToken) { + return true; + } + + @Override + public Optional getCompleteProfile(String authorization) throws AuthenticationException { + attempts.incrementAndGet(); + if (fetchDelayMillis > 0) { + try { + Thread.sleep(fetchDelayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServerDisconnectException(e); + } + } + if (rateLimited) { + throw new ServerDisconnectException(new ResponseCodeException("https://api.minecraftservices.com/minecraft/profile", 429)); + } + return Optional.ofNullable(profile); + } + } + + @Test + public void testProfileIsCachedWithinTtl() throws Exception { + StubService service = new StubService(newProfile()); + MicrosoftAccount account = new MicrosoftAccount(AccountID.generate(), service, newSession()); + + assertEquals(0, account.getCapes().size()); + assertEquals(0, account.getCapes().size()); + assertEquals(1, service.attempts.get()); + } + + @Test + public void testConcurrentLoadsShareOneRequest() throws Exception { + StubService service = new StubService(newProfile()); + service.fetchDelayMillis = 150; + MicrosoftAccount account = new MicrosoftAccount(AccountID.generate(), service, newSession()); + + CyclicBarrier barrier = new CyclicBarrier(2); + AtomicInteger failures = new AtomicInteger(); + Runnable task = () -> { + try { + barrier.await(); + account.getCapes(); + } catch (Exception e) { + failures.incrementAndGet(); + } + }; + + Thread t1 = new Thread(task); + Thread t2 = new Thread(task); + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + assertEquals(0, failures.get()); + assertEquals(1, service.attempts.get()); + } + + @Test + public void testRateLimitWithoutCacheEntersCooldown() throws Exception { + StubService service = new StubService(newProfile()); + service.rateLimited = true; + MicrosoftAccount account = new MicrosoftAccount(AccountID.generate(), service, newSession()); + + assertThrows(MicrosoftService.MinecraftServicesRateLimitException.class, account::getCapes); + assertEquals(1, service.attempts.get()); + + // Still within the cooldown: no further request is attempted. + assertThrows(MicrosoftService.MinecraftServicesRateLimitException.class, account::getCapes); + assertEquals(1, service.attempts.get()); + } + + @Test + public void testFreshCacheIsUsedDuringRateLimit() throws Exception { + StubService service = new StubService(newProfile()); + MicrosoftAccount account = new MicrosoftAccount(AccountID.generate(), service, newSession()); + + account.getCapes(); + assertEquals(1, service.attempts.get()); + + // Even if the server would now rate-limit, the fresh cache short-circuits. + service.rateLimited = true; + assertEquals(0, account.getCapes().size()); + assertEquals(1, service.attempts.get()); + } +} \ No newline at end of file From 099ee3678a22b0791a3af5ca678687eb9a296134 Mon Sep 17 00:00:00 2001 From: BaiPiaoCN Date: Sat, 29 Aug 2026 16:32:52 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=A7=84=E8=8C=83?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/game/CapePreview.java | 2 +- .../ui/account/MicrosoftAccountCapePane.java | 11 +- .../jackhuang/hmcl/game/CapePreviewTest.java | 2 +- .../hmcl/auth/microsoft/MicrosoftAccount.java | 170 ++++++++++++++---- .../jackhuang/hmcl/util/io/HttpRequest.java | 19 +- .../hmcl/util/io/ResponseCodeException.java | 21 ++- .../MicrosoftAccountProfileCacheTest.java | 2 +- .../microsoft/MicrosoftServiceCapeTest.java | 2 +- 8 files changed, 188 insertions(+), 41 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java index 2ae41fc38f5..fa1441e09d2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/CapePreview.java @@ -143,4 +143,4 @@ private static int clamp(int value, int min, int max) { } return value; } -} \ No newline at end of file +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java index c333b56dc3c..386fa04ab09 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java @@ -20,6 +20,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import javafx.animation.PauseTransition; +import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.image.ImageView; @@ -94,7 +95,13 @@ public MicrosoftAccountCapePane(MicrosoftAccount account) { getChildren().setAll(layout); layout.setHeading(new Label(i18n("account.cape.manage"))); - spinnerPane.setContent(listBox); + // Keep the list from touching the dialog's upper/lower edges: let the ScrollPane + // scroll inside a padded container instead of expanding to fit its content. + listBox.setFitToHeight(false); + StackPane listContent = new StackPane(listBox); + listContent.setPadding(new Insets(12, 0, 12, 0)); + + spinnerPane.setContent(listContent); spinnerPane.setPrefHeight(360); spinnerPane.setOnFailedAction(e -> reload()); @@ -266,4 +273,4 @@ private static void bindPreview(ImageView view, @Nullable String url) { }) .start(); } -} \ No newline at end of file +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java index e9b93991581..6a7acecaf15 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/game/CapePreviewTest.java @@ -93,4 +93,4 @@ public void testExtractFrontFaceCopiesPixels() { public void testExtractFrontFaceOnNullReturnsNull() { assertNull(CapePreview.extractFrontFace(null)); } -} \ No newline at end of file +} 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 feffce81047..8cc98724a4c 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 @@ -34,6 +34,8 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -63,6 +65,19 @@ public final class MicrosoftAccount extends OAuthAccount { /// blocked after a 429 response. private volatile long minecraftServicesRateLimitUntil; + /// Coordinates the profile cache, in-flight GET, mutation version, and + /// rate-limit cooldown so a stale GET can never overwrite a newer mutation. + private final Object profileStateLock = new Object(); + + /// Monotonic counter bumped by every successful cape mutation. Guarded by + /// `profileStateLock`. An in-flight GET records it before issuing HTTP and + /// only writes the cache when it is still unchanged afterwards. + private long mutationVersion; + + /// In-flight profile GET, deduplicating concurrent loads. Guarded by + /// `profileStateLock`, and cleared by its creator once it completes. + private CompletableFuture profileLoading; + protected MicrosoftAccount(AccountID accountID, MicrosoftService service, MicrosoftSession session) { super(accountID); this.service = requireNonNull(service); @@ -170,14 +185,10 @@ public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException /// Returns the Minecraft Services profile for this account, using a short-lived /// cache that also deduplicates concurrent calls and honors a rate-limit cooldown. /// - /// Consecutive callers within the TTL share the cached profile; concurrent - /// callers whose cache has expired share a single `GET /minecraft/profile` - /// because this method is synchronized on the account instance. - /// /// @return the profile /// @throws AuthenticationException when the profile cannot be loaded, or during a /// 429 cooldown when no cached profile exists - public synchronized MicrosoftService.MinecraftProfileResponse getMinecraftProfile() throws AuthenticationException { + public MicrosoftService.MinecraftProfileResponse getMinecraftProfile() throws AuthenticationException { long now = System.currentTimeMillis(); if (now < minecraftServicesRateLimitUntil) { @@ -193,7 +204,86 @@ public synchronized MicrosoftService.MinecraftProfileResponse getMinecraftProfil return cached; } - return fetchProfile(); + return loadProfileDeduped(); + } + + /// Loads the profile through a shared in-flight future so concurrent callers + /// issue a single `GET`, while a mutation version prevents a stale GET result + /// from overwriting a newer cape mutation. + private MicrosoftService.MinecraftProfileResponse loadProfileDeduped() throws AuthenticationException { + long versionAtStart; + CompletableFuture future; + boolean creator; + + synchronized (profileStateLock) { + // Re-check under the lock: a concurrent caller may have just completed. + if (System.currentTimeMillis() < minecraftServicesRateLimitUntil) { + MicrosoftService.MinecraftProfileResponse cached = cachedProfile; + if (cached != null) { + return cached; + } + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + + versionAtStart = mutationVersion; + if (profileLoading == null) { + profileLoading = new CompletableFuture<>(); + creator = true; + } else { + creator = false; + } + future = profileLoading; + } + + // Non-creators wait on the shared future outside the lock. + if (!creator) { + return unwrapProfileFuture(future); + } + + try { + MicrosoftService.MinecraftProfileResponse fetched = fetchProfile(); + synchronized (profileStateLock) { + MicrosoftService.MinecraftProfileResponse result; + if (versionAtStart == mutationVersion) { + cachedProfile = fetched; + cachedProfileAt = System.currentTimeMillis(); + result = fetched; + } else { + // A mutation happened during this GET; prefer its newer result. + result = cachedProfile != null ? cachedProfile : fetched; + } + profileLoading.complete(result); + return result; + } + } catch (AuthenticationException e) { + synchronized (profileStateLock) { + profileLoading.completeExceptionally(e); + } + throw e; + } finally { + synchronized (profileStateLock) { + if (profileLoading == future) { + profileLoading = null; + } + } + } + } + + /// Waits for a shared profile future and unwraps its result or failure. + private static MicrosoftService.MinecraftProfileResponse unwrapProfileFuture( + CompletableFuture future) throws AuthenticationException { + try { + return future.join(); + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof AuthenticationException authenticationException) { + throw authenticationException; + } + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new ServerResponseMalformedException(cause); + } } /// Returns every cape owned by this account, as reported by Minecraft Services. @@ -226,13 +316,13 @@ public List showCape(String capeI profile = service.showCape(session.accessToken(), capeId); } else { if (isRateLimited(e)) { - enterRateLimitCooldown(); + enterRateLimitCooldown(e); throw new MicrosoftService.MinecraftServicesRateLimitException(); } throw e; } } - cacheProfile(profile); + commitMutation(profile); return capesOf(profile); } @@ -252,41 +342,37 @@ public List showCape(String capeI profile = service.hideCape(session.accessToken()); } else { if (isRateLimited(e)) { - enterRateLimitCooldown(); + enterRateLimitCooldown(e); throw new MicrosoftService.MinecraftServicesRateLimitException(); } throw e; } } if (profile != null) { - cacheProfile(profile); + commitMutation(profile); return capesOf(profile); } // DELETE returned no body: keep owned capes locally; never re-GET here. return null; } - /// Fetches the profile from the server, refreshing a stale token once on 401 - /// and applying a cooldown on 429. Callers must hold the account lock. + /// Performs the profile GET, refreshing a stale token once on 401 and applying a + /// cooldown on 429. The caller stores the result in the cache. private MicrosoftService.MinecraftProfileResponse fetchProfile() throws AuthenticationException { logIn(); - MicrosoftService.MinecraftProfileResponse profile; try { - profile = readProfileFromServer(); + return readProfileFromServer(); } catch (AuthenticationException e) { if (isUnauthorized(e)) { refreshSession(); - profile = readProfileFromServer(); - } else { - if (isRateLimited(e)) { - enterRateLimitCooldown(); - throw new MicrosoftService.MinecraftServicesRateLimitException(); - } - throw e; + return readProfileFromServer(); + } + if (isRateLimited(e)) { + enterRateLimitCooldown(e); + throw new MicrosoftService.MinecraftServicesRateLimitException(); } + throw e; } - cacheProfile(profile); - return profile; } /// Performs a single `GET /minecraft/profile` without caching or rate-limit logic. @@ -295,10 +381,14 @@ private MicrosoftService.MinecraftProfileResponse readProfileFromServer() throws .orElseThrow(() -> new ServerResponseMalformedException("Empty Minecraft profile")); } - /// Stores the freshly fetched profile and its timestamp. - private void cacheProfile(MicrosoftService.MinecraftProfileResponse profile) { - cachedProfile = profile; - cachedProfileAt = System.currentTimeMillis(); + /// Records a successful mutation, bumping the version so an in-flight stale + /// GET cannot overwrite it afterwards. + private void commitMutation(MicrosoftService.MinecraftProfileResponse profile) { + synchronized (profileStateLock) { + mutationVersion++; + cachedProfile = profile; + cachedProfileAt = System.currentTimeMillis(); + } } /// Throws when the rate-limit cooldown is still active. @@ -308,9 +398,29 @@ private void checkRateLimit() throws AuthenticationException { } } - /// Records a fixed cooldown after a 429 response. - private void enterRateLimitCooldown() { - minecraftServicesRateLimitUntil = System.currentTimeMillis() + RATE_LIMIT_COOLDOWN_MILLIS; + /// Records a cooldown after a 429 response, preferring the server's `Retry-After` + /// with a fixed fallback. + private void enterRateLimitCooldown(Throwable cause) { + minecraftServicesRateLimitUntil = System.currentTimeMillis() + rateLimitCooldownMillis(cause); + } + + /// Computes the cooldown duration from the `Retry-After` header of a 429 + /// response, falling back to the fixed cooldown when absent or unparseable. + private static long rateLimitCooldownMillis(Throwable exception) { + long cooldownMillis = RATE_LIMIT_COOLDOWN_MILLIS; + Throwable cause = exception; + while (cause != null) { + if (cause instanceof ResponseCodeException responseCodeException + && responseCodeException.getResponseCode() == 429) { + Integer retryAfterSeconds = responseCodeException.getRetryAfterSeconds(); + if (retryAfterSeconds != null && retryAfterSeconds > 0) { + cooldownMillis = Math.max(cooldownMillis, retryAfterSeconds * 1000L); + } + break; + } + cause = cause.getCause(); + } + return cooldownMillis; } /// Extracts the cape list from a Minecraft Services profile. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java index a904bad79e1..0ca71454c10 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.function.ExceptionalSupplier; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.OutputStream; @@ -128,8 +129,9 @@ protected void checkResponseCode(HttpURLConnection con) throws IOException { int code = con.getResponseCode(); if (code / 100 != 2) { if (!ignoreHttpCode && !toleratedHttpCodes.contains(code)) { + Integer retryAfter = readRetryAfterSeconds(con); try { - throw new ResponseCodeException(this.url, code, NetworkUtils.readFullyAsString(con)); + throw new ResponseCodeException(this.url, code, NetworkUtils.readFullyAsString(con), retryAfter); } catch (IOException e) { throw new ResponseCodeException(this.url, code, e); } @@ -137,6 +139,21 @@ protected void checkResponseCode(HttpURLConnection con) throws IOException { } } + /// Reads the `Retry-After` header as a non-negative second count, or `null` + /// when it is absent or not a plain integer. + private static @Nullable Integer readRetryAfterSeconds(HttpURLConnection con) { + String value = con.getHeaderField("Retry-After"); + if (value == null) { + return null; + } + try { + int seconds = Integer.parseInt(value.trim()); + return seconds > 0 ? seconds : null; + } catch (NumberFormatException e) { + return null; + } + } + public static abstract class HttpSimpleRequest extends HttpRequest { protected HttpSimpleRequest(String url, String method) { super(url, method); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/ResponseCodeException.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/ResponseCodeException.java index 67ed53dfea9..9af0fcb52c7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/ResponseCodeException.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/ResponseCodeException.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.util.io; +import org.jetbrains.annotations.Nullable; + import java.io.IOException; import java.net.URI; @@ -25,6 +27,7 @@ public final class ResponseCodeException extends IOException { private final String uri; private final int responseCode; private final String data; + private final @Nullable Integer retryAfterSeconds; public ResponseCodeException(URI uri, int responseCode) { this(uri.toString(), responseCode); @@ -39,10 +42,7 @@ public ResponseCodeException(URI uri, int responseCode, String data) { } public ResponseCodeException(String uri, int responseCode) { - super("Unable to request url " + uri + ", response code: " + responseCode); - this.uri = uri; - this.responseCode = responseCode; - this.data = null; + this(uri, responseCode, null, null); } public ResponseCodeException(String uri, int responseCode, Throwable cause) { @@ -50,13 +50,21 @@ public ResponseCodeException(String uri, int responseCode, Throwable cause) { this.uri = uri; this.responseCode = responseCode; this.data = null; + this.retryAfterSeconds = null; } public ResponseCodeException(String uri, int responseCode, String data) { + this(uri, responseCode, data, null); + } + + /// Creates a response-code exception carrying the server-provided `Retry-After` + /// duration, when available. + public ResponseCodeException(String uri, int responseCode, String data, @Nullable Integer retryAfterSeconds) { super("Unable to request url " + uri + ", response code: " + responseCode + ", data: " + data); this.uri = uri; this.responseCode = responseCode; this.data = data; + this.retryAfterSeconds = retryAfterSeconds; } public String getUri() { @@ -70,4 +78,9 @@ public int getResponseCode() { public String getData() { return data; } + + /// Returns the `Retry-After` duration in seconds, or `null` when absent/unparseable. + public @Nullable Integer getRetryAfterSeconds() { + return retryAfterSeconds; + } } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java index ad6f59e03c5..6b179267343 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountProfileCacheTest.java @@ -174,4 +174,4 @@ public void testFreshCacheIsUsedDuringRateLimit() throws Exception { assertEquals(0, account.getCapes().size()); assertEquals(1, service.attempts.get()); } -} \ No newline at end of file +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java index 8d20d24979c..c17f8b8ccbb 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftServiceCapeTest.java @@ -102,4 +102,4 @@ public void testEmptyCapesProducesNoCapeTexture() { assertTrue(textures.isPresent()); assertFalse(textures.get().containsKey(TextureType.CAPE)); } -} \ No newline at end of file +} From 14603380795ac212f5a993b81d4871673805a28f Mon Sep 17 00:00:00 2001 From: BaiPiaoCN Date: Sat, 29 Aug 2026 16:42:40 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/account/MicrosoftAccountCapePane.java | 12 ++++++------ .../hmcl/auth/microsoft/MicrosoftAccount.java | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java index 386fa04ab09..dede593fae5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java @@ -95,8 +95,8 @@ public MicrosoftAccountCapePane(MicrosoftAccount account) { getChildren().setAll(layout); layout.setHeading(new Label(i18n("account.cape.manage"))); - // Keep the list from touching the dialog's upper/lower edges: let the ScrollPane - // scroll inside a padded container instead of expanding to fit its content. + /// Keep the list from touching the dialog's upper/lower edges: let the ScrollPane + /// scroll inside a padded container instead of expanding to fit its content. listBox.setFitToHeight(false); StackPane listContent = new StackPane(listBox); listContent.setPadding(new Insets(12, 0, 12, 0)); @@ -212,7 +212,7 @@ private void applyChange(@Nullable String capeId) { changingCape.set(false); handleChangeResult(capes, exception); if (hasPendingChange) { - // A newer selection arrived while this request was in flight. + /// A newer selection arrived while this request was in flight. debounce.playFromStart(); } }) @@ -223,8 +223,8 @@ private void applyChange(@Nullable String capeId) { private void handleChangeResult(@Nullable List capes, @Nullable Exception exception) { spinnerPane.hideSpinner(); if (exception != null) { - // A rate-limited change must not trigger a reload nor auto-apply a - // pending selection; drop it so the cooldown is not fought against. + /// A rate-limited change must not trigger a reload nor auto-apply a + /// pending selection; drop it so the cooldown is not fought against. if (exception instanceof MinecraftServicesRateLimitException) { clearPendingChange(); } @@ -232,7 +232,7 @@ private void handleChangeResult(@Nullable List cap return; } if (capes == null) { - // DELETE returned no body: keep owned capes, clear ACTIVE locally. + /// DELETE returned no body: keep owned capes, clear ACTIVE locally. renderList(currentCapes, true); } else { renderList(capes, false); 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 8cc98724a4c..cf4e20cfcac 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 @@ -216,7 +216,7 @@ private MicrosoftService.MinecraftProfileResponse loadProfileDeduped() throws Au boolean creator; synchronized (profileStateLock) { - // Re-check under the lock: a concurrent caller may have just completed. + /// Re-check under the lock: a concurrent caller may have just completed. if (System.currentTimeMillis() < minecraftServicesRateLimitUntil) { MicrosoftService.MinecraftProfileResponse cached = cachedProfile; if (cached != null) { @@ -235,7 +235,7 @@ private MicrosoftService.MinecraftProfileResponse loadProfileDeduped() throws Au future = profileLoading; } - // Non-creators wait on the shared future outside the lock. + /// Non-creators wait on the shared future outside the lock. if (!creator) { return unwrapProfileFuture(future); } @@ -249,7 +249,7 @@ private MicrosoftService.MinecraftProfileResponse loadProfileDeduped() throws Au cachedProfileAt = System.currentTimeMillis(); result = fetched; } else { - // A mutation happened during this GET; prefer its newer result. + /// A mutation happened during this GET; prefer its newer result. result = cachedProfile != null ? cachedProfile : fetched; } profileLoading.complete(result); @@ -352,7 +352,7 @@ public List showCape(String capeI commitMutation(profile); return capesOf(profile); } - // DELETE returned no body: keep owned capes locally; never re-GET here. + /// DELETE returned no body: keep owned capes locally; never re-GET here. return null; }