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..fa1441e09d2 --- /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; + } +} 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/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index 9991ac13b85..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,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("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/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..dede593fae5 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountCapePane.java @@ -0,0 +1,276 @@ +/* + * 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.animation.PauseTransition; +import javafx.geometry.Insets; +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.auth.microsoft.MicrosoftService.MinecraftServicesRateLimitException; +import org.jackhuang.hmcl.game.CapePreview; +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 java.util.concurrent.atomic.AtomicBoolean; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +/// 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. 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 { + + /// 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 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; + + setPrefWidth(480); + + JFXDialogLayout layout = new JFXDialogLayout(); + 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. + 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()); + + debounce.setOnFinished(e -> runPendingChange()); + + 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. Deduplication and rate-limit handling happen inside + /// [MicrosoftAccount], so no pane-level guard is needed here. + private void reload() { + spinnerPane.showSpinner(); + Task.supplyAsync(account::getCapes) + .whenComplete(Schedulers.javafx(), this::renderCapes) + .start(); + } + + /// 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); + } + + /// 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(!hasActive)); + for (MinecraftProfileResponseCape cape : capes) { + listBox.add(buildCapeItem(cape, !allInactive && "ACTIVE".equals(cape.state))); + } + } + + /// 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 -> requestCapeChange(null)); + return item; + } + + /// Builds the list entry for one cape with a lazily loaded preview. + private AdvancedListItem buildCapeItem(MinecraftProfileResponseCape cape, boolean active) { + AdvancedListItem item = new AdvancedListItem(); + item.setTitle(StringUtils.isBlank(cape.alias) ? cape.id : cape.alias); + item.setSubtitle(i18n(active ? "account.cape.active" : "account.cape.inactive")); + item.setActive(active); + ImageView preview = createPreview(); + item.setLeftGraphic(preview); + item.setOnAction(e -> requestCapeChange(cape.id)); + bindPreview(preview, cape.url); + return item; + } + + /// 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(); + } + + /// Runs the pending cape change after the debounce delay expires. + /// + /// 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.supplyAsync(() -> { + if (capeId == null) { + return account.hideCape(); + } else { + return account.showCape(capeId); + } + }) + .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(); + } + }) + .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 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; + } + if (capes == null) { + /// DELETE returned no body: keep owned capes, clear ACTIVE locally. + renderList(currentCapes, true); + } else { + renderList(capes, false); + } + } + + /// 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(); + view.setFitWidth(CAPE_PREVIEW_WIDTH); + view.setFitHeight(CAPE_PREVIEW_HEIGHT); + view.setSmooth(true); + view.setPreserveRatio(true); + BorderPane.setMargin(view, AdvancedListItem.LEFT_ICON_MARGIN); + BorderPane.setAlignment(view, Pos.CENTER); + view.setMouseTransparent(true); + return view; + } + + /// 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 b06e2c413ef..1b57b238ce1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -54,6 +54,13 @@ 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.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 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..9f6d8f3af78 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -53,6 +53,13 @@ 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.rate_limited=Minecraft 服務請求過於頻繁,請稍後再試。 +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..03914bb2ed3 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,13 @@ 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.rate_limited=Minecraft 服务请求过于频繁,请稍后再试。 +account.cape.remove.confirm=确定取消当前激活的披风吗? +account.cape.set.confirm=确定将此披风设置为当前披风吗? account.character=角色 account.choose=选择一个角色 account.create=添加账户 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..6a7acecaf15 --- /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)); + } +} 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..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 @@ -23,25 +23,61 @@ 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 org.jetbrains.annotations.Nullable; +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; 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; 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; + + /// 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); @@ -81,24 +117,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 +182,284 @@ public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException service.uploadSkin(session.accessToken(), isSlim, file); } + /// Returns the Minecraft Services profile for this account, using a short-lived + /// cache that also deduplicates concurrent calls and honors a rate-limit cooldown. + /// + /// @return the profile + /// @throws AuthenticationException when the profile cannot be loaded, or during a + /// 429 cooldown when no cached profile exists + public 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 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. + /// + /// @return the cape list (possibly empty); it is not mutable + /// @throws AuthenticationException when the profile cannot be loaded + public List getCapes() throws AuthenticationException { + return capesOf(getMinecraftProfile()); + } + + /// 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 is rate-limited + public List showCape(String capeId) throws AuthenticationException { + requireNonNull(capeId); + + checkRateLimit(); + logIn(); + MicrosoftService.MinecraftProfileResponse profile; + try { + profile = service.showCape(session.accessToken(), capeId); + } catch (AuthenticationException e) { + if (isUnauthorized(e)) { + refreshSession(); + profile = service.showCape(session.accessToken(), capeId); + } else { + if (isRateLimited(e)) { + enterRateLimitCooldown(e); + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + throw e; + } + } + commitMutation(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 is rate-limited + public @Nullable List hideCape() throws AuthenticationException { + checkRateLimit(); + logIn(); + MicrosoftService.MinecraftProfileResponse profile; + try { + profile = service.hideCape(session.accessToken()); + } catch (AuthenticationException e) { + if (isUnauthorized(e)) { + refreshSession(); + profile = service.hideCape(session.accessToken()); + } else { + if (isRateLimited(e)) { + enterRateLimitCooldown(e); + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + throw e; + } + } + if (profile != null) { + commitMutation(profile); + return capesOf(profile); + } + /// DELETE returned no body: keep owned capes locally; never re-GET here. + return null; + } + + /// 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(); + try { + return readProfileFromServer(); + } catch (AuthenticationException e) { + if (isUnauthorized(e)) { + refreshSession(); + return readProfileFromServer(); + } + if (isRateLimited(e)) { + enterRateLimitCooldown(e); + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + throw e; + } + } + + /// Performs a single `GET /minecraft/profile` without caching or rate-limit logic. + private MicrosoftService.MinecraftProfileResponse readProfileFromServer() throws AuthenticationException { + return service.getCompleteProfile(session.getAuthorization()) + .orElseThrow(() -> new ServerResponseMalformedException("Empty Minecraft profile")); + } + + /// 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. + private void checkRateLimit() throws AuthenticationException { + if (System.currentTimeMillis() < minecraftServicesRateLimitUntil) { + throw new MicrosoftService.MinecraftServicesRateLimitException(); + } + } + + /// 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. + private static List capesOf(MicrosoftService.MinecraftProfileResponse profile) { + return profile.capes == null + ? List.of() + : profile.capes.stream().filter(Objects::nonNull).toList(); + } + + /// 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; + } + + /// 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 b65e2587950..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 @@ -35,6 +35,7 @@ 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; @@ -55,6 +56,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 +218,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 +234,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 +314,63 @@ 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":"..."}` and parses the returned profile, so callers can update + /// the UI without issuing an additional `GET /minecraft/profile`. + /// + /// 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 + /// @return the profile returned by the server + /// @throws AuthenticationException on network failure or a non-2xx response + public MinecraftProfileResponse showCape(String accessToken, String capeId) throws AuthenticationException { + requireNonNull(accessToken); + requireNonNull(capeId); + try { + String response = HttpRequest.PUT(CAPES_ACTIVE_ENDPOINT) + .json(mapOf(pair("capeId", capeId))) + .authorization("Bearer " + accessToken) + .accept("application/json") + .getString(); + return JsonUtils.fromNonNullJson(response, MinecraftProfileResponse.class); + } catch (JsonParseException e) { + throw new ServerResponseMalformedException(e); + } 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 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 @Nullable MinecraftProfileResponse hideCape(String accessToken) throws AuthenticationException { + requireNonNull(accessToken); + try { + String response = HttpRequest.DELETE(CAPES_ACTIVE_ENDPOINT) + .authorization("Bearer " + accessToken) + .accept("application/json") + .getString(); + if (StringUtils.isBlank(response)) { + return null; + } + return JsonUtils.fromNonNullJson(response, MinecraftProfileResponse.class); + } catch (JsonParseException e) { + throw new ServerResponseMalformedException(e); + } catch (IOException e) { + throw new ServerDisconnectException(e); + } + } + private static String request(String url, Object payload) throws AuthenticationException { try { if (payload == null) @@ -347,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; } @@ -438,8 +514,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/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 new file mode 100644 index 00000000000..6b179267343 --- /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()); + } +} 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..c17f8b8ccbb --- /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)); + } +}