diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java index 09500345e2..11d416a9c1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java @@ -38,6 +38,7 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.Holder; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.io.IOException; @@ -90,7 +91,7 @@ private static Path getTexturePath(Texture texture) { return TEXTURES_DIR.resolve(prefix).resolve(hash); } - public static LoadedTexture loadTexture(Texture texture) throws Throwable { + public static LoadedTexture loadTexture(Texture texture) throws Exception { if (StringUtils.isBlank(texture.url())) { throw new IOException("Texture url is empty"); } @@ -322,4 +323,16 @@ public static void unbindAvatar(Canvas canvas) { } } // ==== + + public static Optional getMicrosoftAccountSkin(String uuid) throws IOException, ServerResponseMalformedException { + return YggdrasilService.getTextures( + HttpRequest.GET("https://api.minecraftservices.com/minecraft/profile/" + uuid) + .getJson(CompleteGameProfile.class)).map(e -> e.get(TextureType.SKIN)); + } + + public static Optional getYggdrasilAccountSkin(String uuid, String url) throws IOException, ServerResponseMalformedException { + return YggdrasilService.getTextures( + HttpRequest.GET(url + uuid) + .getJson(CompleteGameProfile.class)).map(e -> e.get(TextureType.SKIN)); + } } 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 fb2c3b9985..3574b9ebee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -73,6 +73,7 @@ public enum SVG { GAMEPAD("M12 7.65ZM16.35 12ZM7.65 12ZM12 16.35ZM12 10.5 9 7.5V2H15V7.5L12 10.5ZM16.5 15 13.5 12 16.5 9H22V15H16.5ZM2 15V9H7.5L10.5 12 7.5 15H2ZM9 22V16.5L12 13.5 15 16.5V22H9ZM12 7.65 13 6.65V4H11V6.65L12 7.65ZM4 13H6.65L7.65 12 6.65 11H4V13ZM11 20H13V17.35L12 16.35 11 17.35V20ZM17.35 13H20V11H17.35L16.35 12 17.35 13Z"), GLOBE_BOOK("M3.075 13Q3.05 12.75 3.0375 12.5T3.025 12Q3.025 10.125 3.725 8.4875T5.65 5.6375Q6.875 4.425 8.5 3.7125T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 12.25 20.9875 12.5T20.95 13H18.925Q18.975 12.75 18.9875 12.5T19 12Q19 11.75 18.9875 11.5T18.925 11H15.975Q16 11.25 16 11.5V12.5Q16 12.75 15.975 13H14V12.175Q14 11.875 13.9875 11.575T13.95 11H10.075Q10.05 11.275 10.0375 11.575T10.025 12.175V13H8.05Q8.025 12.75 8.025 12.5V11.5Q8.025 11.25 8.05 11H5.1Q5.05 11.25 5.0375 11.5T5.025 12Q5.025 12.25 5.0375 12.5T5.1 13H3.075ZM5.7 9H8.275Q8.475 7.925 8.775 7.0625T9.425 5.5Q8.225 5.95 7.25 6.8625T5.7 9ZM10.35 9H13.65Q13.4 7.925 13.025 6.9T12 5Q11.35 5.875 10.9625 6.9T10.35 9ZM15.75 9H18.325Q17.75 7.775 16.7625 6.8625T14.575 5.5Q14.925 6.25 15.2375 7.0875T15.75 9ZM11 21V20Q11 18.75 10.125 17.875T8 17H2V15H8Q9.2 15 10.2375 15.525T12 17Q12.725 16.05 13.7625 15.525T16 15H22V17H16Q14.75 17 13.875 17.875T13 20V21H11Z"), GRAPH2("M5 22q-1.25 0-2.125-.875T2 19q0-.975.5625-1.75T4 16.175V14q0-1.25.875-2.125T7 11h4V7.825q-.875-.3-1.4375-1.075T9 5q0-1.25.875-2.125T12 2t2.125.875T15 5q0 .975-.5625 1.75T13 7.825V11h4q1.25 0 2.125.875T20 14v2.175q.875.3 1.4375 1.075T22 19q0 1.25-.875 2.125T19 22t-2.125-.875T16 19q0-.975.5625-1.75T18 16.175V14q0-.425-.2875-.7125T17 13H13v3.175q.875.3 1.4375 1.075T15 19q0 1.25-.875 2.125T12 22t-2.125-.875T9 19q0-.975.5625-1.75T11 16.175V13H7q-.425 0-.7125.2875T6 14v2.175q.875.3 1.4375 1.075T8 19q0 1.25-.875 2.125T5 22Zm0-2q.425 0 .7125-.2875T6 19q0-.425-.2875-.7125T5 18q-.425 0-.7125.2875T4 19q0 .425.2875.7125T5 20Zm7 0q.425 0 .7125-.2875T13 19q0-.425-.2875-.7125T12 18q-.425 0-.7125.2875T11 19q0 .425.2875.7125T12 20Zm7 0q.425 0 .7125-.2875T20 19q0-.425-.2875-.7125T19 18q-.425 0-.7125.2875T18 19q0 .425.2875.7125T19 20ZM12 6q.425 0 .7125-.2875T13 5q0-.425-.2875-.7125T12 4q-.425 0-.7125.2875T11 5q0 .425.2875.7125T12 6Z"), + GROUP("M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825M3 18h12v-.8q0-.275-.137-.5t-.363-.35q-1.35-.675-2.725-1.012T9 15t-2.775.338T3.5 16.35q-.225.125-.363.35T3 17.2zm7.413-8.587Q11 8.825 11 8t-.587-1.412T9 6t-1.412.588T7 8t.588 1.413T9 10t1.413-.587M9 8"), HELP("M11.95 18Q12.475 18 12.8375 17.6375T13.2 16.75Q13.2 16.225 12.8375 15.8625T11.95 15.5Q11.425 15.5 11.0625 15.8625T10.7 16.75Q10.7 17.275 11.0625 17.6375T11.95 18ZM11.05 14.15H12.9Q12.9 13.325 13.0875 12.85T14.15 11.55Q14.8 10.9 15.175 10.3125T15.55 8.9Q15.55 7.5 14.525 6.75T12.1 6Q10.675 6 9.7875 6.75T8.55 8.55L10.2 9.2Q10.325 8.75 10.7625 8.225T12.1 7.7Q12.9 7.7 13.3 8.1375T13.7 9.1Q13.7 9.6 13.4 10.0375T12.65 10.85Q11.55 11.825 11.3 12.325T11.05 14.15ZM12 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"), HELP_FILL("M11.95 18q.525 0 .8875-.3625T13.2 16.75q0-.525-.3625-.8875T11.95 15.5q-.525 0-.8875.3625T10.7 16.75q0 .525.3625.8875T11.95 18Zm-.9-3.85H12.9q0-.825.1875-1.3t1.0625-1.3q.65-.65 1.025-1.2375T15.55 8.9q0-1.4-1.025-2.15T12.1 6q-1.425 0-2.3125.75T8.55 8.55l1.65.65q.125-.45.5625-.975T12.1 7.7q.8 0 1.2.4375t.4.9625q0 .5-.3.9375t-.75.8125q-1.1.975-1.35 1.475t-.25 1.825ZM12 22q-2.075 0-3.9-.7875T4.925 19.075q-1.35-1.35-2.1375-3.175T2 12q0-2.075.7875-3.9T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2q2.075 0 3.9.7875T19.075 4.925q1.35 1.35 2.1375 3.175T22 12q0 2.075-.7875 3.9T19.075 19.075q-1.35 1.35-3.175 2.1375T12 22Z"), HOME("M6 19H9V13H15V19H18V10L12 5.5 6 10V19ZM4 21V9L12 3 20 9V21H13V15H11V21H4ZM12 12.25Z"), @@ -99,6 +100,8 @@ public enum SVG { PACKAGE2("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM11 21.725 4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725ZM16 8.525 17.925 7.425 12 4 10.05 5.125 16 8.525ZM12 10.85 13.95 9.725 8.025 6.3 6.075 7.425 12 10.85Z"), PACKAGE2_FILL("M11 21.725v-9.15L3 7.95v8.025q0 .55.2625 1T4 17.7l7 4.025Zm2 0L20 17.7q.475-.275.7375-.725t.2625-1V7.95l-8 4.625v9.15Zm3.975-13.75 2.95-1.725L13 2.275Q12.525 2 12 2t-1 .275L9.025 3.4l7.95 4.575ZM12 10.85l2.975-1.7L7.05 4.55l-3 1.725L12 10.85Z"), PERSON("M12 12Q10.35 12 9.175 10.825T8 8Q8 6.35 9.175 5.175T12 4Q13.65 4 14.825 5.175T16 8Q16 9.65 14.825 10.825T12 12ZM4 20V17.2Q4 16.35 4.4375 15.6375T5.6 14.55Q7.15 13.775 8.75 13.3875T12 13Q13.65 13 15.25 13.3875T18.4 14.55Q19.125 14.925 19.5625 15.6375T20 17.2V20H4ZM6 18H18V17.2Q18 16.925 17.8625 16.7T17.5 16.35Q16.15 15.675 14.775 15.3375T12 15Q10.6 15 9.225 15.3375T6.5 16.35Q6.275 16.475 6.1375 16.7T6 17.2V18ZM12 10Q12.825 10 13.4125 9.4125T14 8Q14 7.175 13.4125 6.5875T12 6Q11.175 6 10.5875 6.5875T10 8Q10 8.825 10.5875 9.4125T12 10ZM12 8ZM12 18Z"), + PERSON_ADD("M18 14v-3h-3V9h3V6h2v3h3v2h-3v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm2-2h12v-.8q0-.275-.137-.5t-.363-.35q-1.35-.675-2.725-1.012T9 15t-2.775.338T3.5 16.35q-.225.125-.363.35T3 17.2zm7.413-8.587Q11 8.825 11 8t-.587-1.412T9 6t-1.412.588T7 8t.588 1.413T9 10t1.413-.587M9 18"), + PERSON_CANCEL("M17.4 13L16 11.6l2.075-2.1L16 7.425L17.4 6l2.1 2.1L21.575 6L23 7.425L20.9 9.5l2.1 2.1l-1.425 1.4l-2.075-2.075zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm2-2h12v-.8q0-.275-.137-.5t-.363-.35q-1.35-.675-2.725-1.012T9 15t-2.775.338T3.5 16.35q-.225.125-.363.35T3 17.2zm7.413-8.587Q11 8.825 11 8t-.587-1.412T9 6t-1.412.588T7 8t.588 1.413T9 10t1.413-.587M9 18"), PUBLIC("M12 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 22ZM11 19.95V18Q10.175 18 9.5875 17.4125T9 16V15L4.2 10.2Q4.125 10.65 4.0625 11.1T4 12Q4 15.025 5.9875 17.3T11 19.95ZM17.9 17.4Q18.925 16.275 19.4625 14.8875T20 12Q20 9.55 18.6375 7.525T15 4.6V5Q15 5.825 14.4125 6.4125T13 7H11V9Q11 9.425 10.7125 9.7125T10 10H8V12H14Q14.425 12 14.7125 12.2875T15 13V16H16Q16.65 16 17.175 16.3875T17.9 17.4Z"), REFRESH("M12 20Q8.65 20 6.325 17.675T4 12Q4 8.65 6.325 6.325T12 4Q13.725 4 15.3 4.7125T18 6.75V4H20V11H13V9H17.2Q16.4 7.6 15.0125 6.8T12 6Q9.5 6 7.75 7.75T6 12Q6 14.5 7.75 16.25T12 18Q13.925 18 15.475 16.9T17.65 14H19.75Q19.05 16.65 16.9 18.325T12 20Z"), REDO("M9.9 19q-2.425 0-4.163-1.575T4 13.5t1.738-3.925T9.9 8h6.3l-2.6-2.6L15 4l5 5l-5 5l-1.4-1.4l2.6-2.6H9.9q-1.575 0-2.738 1T6 13.5T7.163 16T9.9 17H17v2z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java index fca58b099e..5838296d91 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java @@ -33,9 +33,11 @@ import org.jackhuang.hmcl.auth.CredentialExpiredException; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -135,6 +137,14 @@ public ObservableBooleanValue canUploadSkin() { } } + public ObservableBooleanValue canAddFriend() { + if (account instanceof YggdrasilAccount || account instanceof MicrosoftAccount) { + return createBooleanBinding(() -> true); + } else { + return createBooleanBinding(() -> false); + } + } + /** * @return the skin upload task, null if no file is selected */ 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 b42282760d..79997437cf 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 @@ -20,20 +20,20 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.effects.JFXDepthManager; +import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.geometry.Pos; import javafx.scene.Cursor; import javafx.scene.canvas.Canvas; -import javafx.scene.control.Label; import javafx.scene.control.SkinBase; import javafx.scene.control.Tooltip; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; -import javafx.scene.layout.VBox; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; @@ -42,8 +42,12 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.util.io.ResponseCodeException; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import java.io.IOException; + import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class AccountListItemSkin extends SkinBase { @@ -68,21 +72,18 @@ public AccountListItemSkin(AccountListItem skinnable) { Canvas canvas = new Canvas(32, 32); TexturesLoader.bindAvatar(canvas, skinnable.getAccount()); - Label title = new Label(); - title.getStyleClass().add("title"); - title.textProperty().bind(skinnable.titleProperty()); - Label subtitle = new Label(); - subtitle.getStyleClass().add("subtitle"); - subtitle.textProperty().bind(skinnable.subtitleProperty()); + TwoLineListItem item = new TwoLineListItem(); + item.getFirstLine().getStyleClass().clear(); + BorderPane.setAlignment(item, Pos.CENTER); + + item.titleProperty().bind(skinnable.titleProperty()); + item.subtitleProperty().bind(skinnable.subtitleProperty()); if (skinnable.getAccount() instanceof AuthlibInjectorAccount) { Tooltip tooltip = new Tooltip(); AuthlibInjectorServer server = ((AuthlibInjectorAccount) skinnable.getAccount()).getServer(); tooltip.textProperty().bind(BindingMapping.of(server, AuthlibInjectorServer::toString)); - FXUtils.installSlowTooltip(subtitle, tooltip); + FXUtils.installSlowTooltip(item, tooltip); } - VBox item = new VBox(title, subtitle); - item.getStyleClass().add("two-line-list-item"); - BorderPane.setAlignment(item, Pos.CENTER); center.getChildren().setAll(canvas, item); root.setCenter(center); @@ -156,6 +157,44 @@ public AccountListItemSkin(AccountListItem skinnable) { spinnerUpload.getStyleClass().add("small-spinner-pane"); right.getChildren().add(spinnerUpload); + SpinnerPane spinnerFriend = new SpinnerPane(); + spinnerFriend.getStyleClass().add("small-spinner-pane"); + JFXButton btnFriend = FXUtils.newToggleButton4(SVG.GROUP); + btnFriend.disableProperty().bind(Bindings.not(skinnable.canAddFriend())); + FXUtils.installFastTooltip(btnFriend, i18n("account.friend")); + spinnerFriend.setContent(btnFriend); + btnFriend.setOnAction(actionEvent -> { + spinnerFriend.showSpinner(); + + var account = skinnable.getAccount(); + + Task.runAsync(skinnable::refreshAsync) + .whenComplete(exception -> { + if (exception != null) { + Controllers.showToast(Accounts.localizeErrorMessage(exception)); + return; + } + + if (account instanceof MicrosoftAccount microsoftAccount) { + Platform.runLater(() -> Controllers.navigate(new FriendListPage(account, microsoftAccount))); + } else if (account instanceof YggdrasilAccount yggdrasilAccount) { + try { + yggdrasilAccount.getFriendList(); + Platform.runLater(() -> Controllers.navigate(new FriendListPage(account, yggdrasilAccount))); + } catch (IOException e) { + Platform.runLater(() -> { + if (e instanceof ResponseCodeException responseCodeException && responseCodeException.getResponseCode() == 404) { + Controllers.dialog(i18n("account.friend.unsupported")); + } + + Controllers.showToast(Accounts.localizeErrorMessage(e)); + }); + } + } + }).thenRunAsync(Schedulers.javafx(), spinnerFriend::hideSpinner).start(); + }); + right.getChildren().add(spinnerFriend); + JFXButton btnCopyUUID = FXUtils.newToggleButton4(SVG.CONTENT_COPY); SpinnerPane spinnerCopyUUID = new SpinnerPane(); spinnerCopyUUID.getStyleClass().add("small-spinner-pane"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/FriendListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/FriendListPage.java new file mode 100644 index 0000000000..8d9bf79f16 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/FriendListPage.java @@ -0,0 +1,239 @@ +/* + * 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 javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.canvas.Canvas; +import javafx.scene.control.ListCell; +import javafx.scene.control.Skin; +import javafx.scene.layout.*; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; +import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.TexturesLoader; +import org.jackhuang.hmcl.game.friend.FriendControl; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.ListPageBase; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.ToolbarListPageSkin; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.AdvancedListBox; +import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jackhuang.hmcl.ui.construct.TabHeader; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; + +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class FriendListPage extends DecoratorAnimatedPage implements DecoratorPage { + private final Account account; + private final FriendControl control; + private static final Pattern REGEX = Pattern.compile("([0-9a-fA-F]{8})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{12})"); + + private final TabHeader tab; + private final TabHeader.Tab friendPage = new TabHeader.Tab<>("friendPage"); + private final TransitionPane transitionPane = new TransitionPane(); + + @SuppressWarnings("unused") + private ChangeListener instanceChangeListenerHolder; + + + private ReadOnlyObjectWrapper state = null; + + public FriendListPage(Account account, FriendControl control) { + this.account = account; + this.control = control; + + friendPage.setNodeSupplier(Right::new); + tab = new TabHeader(transitionPane, friendPage); + tab.select(friendPage); + + BorderPane left = new BorderPane(); + FXUtils.setLimitWidth(left, 200); + VBox.setVgrow(left, Priority.ALWAYS); + setLeft(left); + + AdvancedListBox sideBar = new AdvancedListBox().addNavigationDrawerTab(tab, friendPage, i18n("account.friend"), SVG.GROUP); + left.setTop(sideBar); + + AdvancedListBox toolbar = new AdvancedListBox().addNavigationDrawerItem(i18n("account.friend.add"), SVG.PERSON_ADD, this::onAddFriend); + BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); + left.setBottom(toolbar); + + setCenter(transitionPane); + } + + private void onAddFriend() { + + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + if (state == null) + state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("account.friend") + " - " + account.getProfileName())); + return state; + } + + private final class Right extends ListPageBase { + private Right() { + super(); + + refresh(); + } + + private void refresh() { + getItems().clear(); + setLoading(true); + setFailedReason(null); + + Task.supplyAsync(control::getFriendList).whenComplete(Schedulers.javafx(), (result, exception) -> { + setLoading(false); + + if (exception != null) { + LOG.warning("Failed to get friend list" + exception); + setFailedReason(i18n("account.friend.failed")); + } else { + LOG.info("Received friend list" + result); + + getItems().addAll(result.friends().stream().map(it -> new FriendListItem(it.profileId(), it.name(), FriendStatus.FRIEND)).collect(Collectors.toList())); + getItems().addAll(result.outgoingRequests().stream().map(it -> new FriendListItem(it.profileId(), it.name(), FriendStatus.OUTGOING)).collect(Collectors.toList())); + getItems().addAll(result.incomingRequests().stream().map(it -> new FriendListItem(it.profileId(), it.name(), FriendStatus.INCOMING)).collect(Collectors.toList())); + } + + }).start(); + } + + @Override + protected Skin createDefaultSkin() { + return new RightSkin(this); + } + } + + private final class RightSkin extends ToolbarListPageSkin { + public RightSkin(Right skinnable) { + super(skinnable); + + listView.setCellFactory(x -> new FriendListCell()); + } + + @Override + protected List initializeToolbar(Right skinnable) { + return List.of(); + } + } + + private final class FriendListCell extends ListCell { + private final Region graphic; + private final Canvas avatar = new Canvas(32, 32); + private final TwoLineListItem twoLineListItem = new TwoLineListItem(); + + public FriendListCell() { + BorderPane root = new BorderPane(); + root.getStyleClass().add("md-list-cell"); + root.setPadding(new Insets(8, 8, 8, 8)); + + RipplerContainer container = new RipplerContainer(root); + this.graphic = container; + + HBox center = new HBox(); + center.setMouseTransparent(true); + center.setPrefWidth(Region.USE_PREF_SIZE); + center.setSpacing(8); + center.setAlignment(Pos.CENTER_LEFT); + + center.getChildren().setAll(avatar, twoLineListItem); + root.setCenter(center); + + HBox right = new HBox(); + + var deleteButton = FXUtils.newToggleButton4(SVG.PERSON_CANCEL); + + right.getChildren().addAll(deleteButton); + right.setAlignment(Pos.CENTER_RIGHT); + root.setRight(right); + } + + @Override + protected void updateItem(FriendListItem item, boolean empty) { + var currentItem = getItem(); + + super.updateItem(item, empty); + + TexturesLoader.unbindAvatar(avatar); + + if (empty || item == null) { + this.setGraphic(null); + } else { + this.setGraphic(this.graphic); + + if (currentItem == getItem()) return; + + String formatted = REGEX.matcher(item.profileId()).replaceAll("$1-$2-$3-$4-$5"); + var uuid = UUID.fromString(formatted); + + Task.supplyAsync(() -> { + CompleteGameProfile profile = null; + + if (account instanceof YggdrasilAccount yggdrasilAccount) { + profile = yggdrasilAccount.getYggdrasilService().getCompleteGameProfile(uuid).orElseThrow(); + } else if (account instanceof MicrosoftAccount microsoftAccount) { + profile = microsoftAccount.getService().getCompleteGameProfile(uuid).orElseThrow(); + } + + var texture = YggdrasilService.getTextures(profile).map(it -> it.get(TextureType.SKIN)).orElseThrow(); + return TexturesLoader.loadTexture(texture); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + TexturesLoader.drawAvatar(avatar, result.image()); + return; + } else LOG.warning("Failed to load skin", exception); + + var skin = TexturesLoader.getDefaultSkin(uuid); + TexturesLoader.drawAvatar(avatar, skin.image()); + }).start(); + + twoLineListItem.setTitle(item.name()); + twoLineListItem.setSubtitle(toString()); + } + } + } + + private record FriendListItem(String profileId, String name, FriendStatus status) { + } + + private enum FriendStatus { + FRIEND, INCOMING, OUTGOING + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 199d231bf9..96618f4ef8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -79,6 +79,10 @@ account.failed.server_response_malformed=Invalid server response. The authentica account.failed.ssl=An SSL error occurred while connecting to the server. Please try updating your Java. account.failed.dns=An SSL error occurred while connecting to the server. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service. account.failed.wrong_account=You have logged in to the wrong account. +account.friend=Friend List +account.friend.add=Add Friend +account.friend.failed=Failed to load friend list. Please click here to retry. +account.friend.unsupported=Your authentication server does not support friend feature. account.storage.read_only=The account file was saved by a newer version of HMCL. The current version of HMCL cannot modify accounts. account.hmcl.hint=You need to click "Log in" and complete the process in the opened browser window. account.injector.add=New Auth Server diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 9e571a5b40..c17eb8761b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -82,6 +82,10 @@ account.failed.server_response_malformed=無法解析認證伺服器回應,可 account.failed.ssl=連線伺服器時發生了 SSL 錯誤。可能網站證書已過期或你使用的 Java 版本過低。請嘗試更新 Java。 account.failed.dns=連線伺服器時發生了 SSL 錯誤。可能是 DNS 解析有誤。請嘗試更換 DNS 伺服器或使用代理服務。 account.failed.wrong_account=登入了錯誤的帳戶 +account.friend=好友列表 +account.friend.add=添加好友 +account.friend.failed=載入好友列表失敗,按一下此處重試。 +account.friend.unsupported=你的認證伺服器不支援好友功能。 account.storage.read_only=帳戶檔案是由更高版本的 HMCL 儲存的,目前版本的 HMCL 無法修改帳戶。 account.hmcl.hint=你需要點擊「登入」按鈕,並在開啟的網頁中完成登入 account.injector.add=新增認證伺服器 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 3f339510c7..2b08a890f7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -83,6 +83,10 @@ account.failed.server_response_malformed=无法解析认证服务器响应。可 account.failed.ssl=连接服务器时发生了 SSL 错误。可能网站证书已过期或你使用的 Java 版本过低。请尝试更新 Java,或关闭网络代理后再试。\n你可以点击右上角帮助按钮进行求助。 account.failed.dns=连接服务器时发生了 SSL 错误。可能是 DNS 解析有误。请尝试更换 DNS 服务器或使用代理服务。\n你可以点击右上角帮助按钮进行求助。 account.failed.wrong_account=登录了错误的账户 +account.friend=好友列表 +account.friend.add=添加好友 +account.friend.failed=[加载好友列表失败,点击此处重试]\n(你可以点击右上角帮助按钮进行求助) +account.friend.unsupported=你的认证服务器不支持好友功能。 account.storage.read_only=账户文件是由更高版本的 HMCL 保存的,当前版本的 HMCL 无法修改账户。 account.hmcl.hint=你需要点击“登录”按钮,并在弹出的网页中完成登录。 account.injector.add=添加认证服务器 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccountFactory.java index 59d7887990..22fb68f036 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccountFactory.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccountFactory.java @@ -18,11 +18,7 @@ package org.jackhuang.hmcl.auth.authlibinjector; import com.google.gson.JsonObject; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.AccountID; -import org.jackhuang.hmcl.auth.AccountFactory; -import org.jackhuang.hmcl.auth.AuthenticationException; -import org.jackhuang.hmcl.auth.CharacterSelector; +import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilSession; @@ -92,7 +88,7 @@ static AuthlibInjectorAccount fromStorage( Map properties = JsonUtils.GSON.fromJson( profilePropertiesObject, JsonUtils.mapTypeOf(String.class, String.class)); - GameProfile selected = session.getSelectedProfile(); + GameProfile selected = session.selectedProfile(); ObservableOptionalCache profileRepository = server.getYggdrasilService().getProfileRepository(); profileRepository.put(selected.getId(), new CompleteGameProfile(selected, properties)); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java index ee9c05acd8..5fa4fd4d51 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorProvider.java @@ -18,37 +18,30 @@ package org.jackhuang.hmcl.auth.authlibinjector; import org.glavo.uuid.UUIDs; -import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilProvider; import java.net.URI; import java.util.UUID; -public class AuthlibInjectorProvider implements YggdrasilProvider { - - private final String apiRoot; - - public AuthlibInjectorProvider(String apiRoot) { - this.apiRoot = apiRoot; - } +public record AuthlibInjectorProvider(String apiRoot) implements YggdrasilProvider { @Override - public URI getAuthenticationURL() throws AuthenticationException { + public URI getAuthenticationURL() { return URI.create(apiRoot + "authserver/authenticate"); } @Override - public URI getRefreshmentURL() throws AuthenticationException { + public URI getRefreshmentURL() { return URI.create(apiRoot + "authserver/refresh"); } @Override - public URI getValidationURL() throws AuthenticationException { + public URI getValidationURL() { return URI.create(apiRoot + "authserver/validate"); } @Override - public URI getInvalidationURL() throws AuthenticationException { + public URI getInvalidationURL() { return URI.create(apiRoot + "authserver/invalidate"); } @@ -58,12 +51,12 @@ public URI getSkinUploadURL(UUID uuid) throws UnsupportedOperationException { } @Override - public URI getProfilePropertiesURL(UUID uuid) throws AuthenticationException { + public URI getProfilePropertiesURL(UUID uuid) { return URI.create(apiRoot + "sessionserver/session/minecraft/profile/" + UUIDs.toCompactString(uuid)); } @Override - public String toString() { - return apiRoot; + public URI getFriendsURL() { + return URI.create(apiRoot + "minecraftservices/friends"); } } 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 1d7a1de730..64a32e254c 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,8 +23,11 @@ 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.game.friend.FriendControl; +import org.jackhuang.hmcl.game.friend.FriendResponse; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import java.io.IOException; import java.nio.file.Path; import java.util.Map; import java.util.Objects; @@ -34,7 +37,7 @@ import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class MicrosoftAccount extends OAuthAccount { +public final class MicrosoftAccount extends OAuthAccount implements FriendControl { protected final MicrosoftService service; protected UUID profileID; @@ -171,6 +174,11 @@ public void clearCache() { service.getProfileRepository().invalidate(profileID); } + @Override + public FriendResponse getFriendList() throws IOException { + return service.getFriendList(session.accessToken()); + } + @Override public String toString() { return "MicrosoftAccount[accountID=" + getAccountID() + ", profileID=" + 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 b65e258795..ee167e89c2 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 @@ -31,6 +31,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.game.friend.FriendResponse; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.*; @@ -312,6 +313,19 @@ private static String request(String url, Object payload) throws AuthenticationE } } + public FriendResponse getFriendList(String accessToken) throws IOException { + HttpURLConnection request = HttpRequest.GET("https://api.minecraftservices.com/friends") + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").createConnection(); + + if (request.getResponseCode() != 200) { + throw new ResponseCodeException("https://api.minecraftservices.com/friends", request.getResponseCode()); + } + + return JsonUtils.fromNonNullJson(NetworkUtils.readFullyAsString(request), FriendResponse.class); + } + public static class XboxAuthorizationException extends AuthenticationException { private final long errorCode; private final String redirect; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java index 9a292fe8d7..2aa094710d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java @@ -43,10 +43,6 @@ public MicrosoftSession(String tokenType, String accessToken, long notAfter, Str if (accessToken != null) Logger.registerAccessToken(accessToken); } - public String getAuthorization() { - return String.format("%s %s", tokenType(), accessToken()); - } - /// Returns whether the stored session contains a usable Minecraft profile name. public boolean hasProfileName() { return profile != null && StringUtils.isNotBlank(profile.name()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java index d178b6951f..ec4004c239 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java @@ -21,16 +21,21 @@ import javafx.beans.binding.ObjectBinding; import org.glavo.uuid.UUIDs; import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.game.friend.FriendControl; +import org.jackhuang.hmcl.game.friend.FriendResponse; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import java.io.IOException; import java.nio.file.Path; -import java.util.*; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public abstract class YggdrasilAccount extends ClassicAccount { +public abstract class YggdrasilAccount extends ClassicAccount implements FriendControl { protected final YggdrasilService service; protected final UUID profileID; @@ -43,7 +48,7 @@ protected YggdrasilAccount(AccountID accountID, YggdrasilService service, String super(accountID); this.service = requireNonNull(service); this.loginName = requireNonNull(loginName); - this.profileID = requireNonNull(session.getSelectedProfile().getId()); + this.profileID = requireNonNull(session.selectedProfile().getId()); this.session = requireNonNull(session); addProfilePropertiesListener(); @@ -55,23 +60,23 @@ protected YggdrasilAccount(YggdrasilService service, String loginName, String pa this.loginName = requireNonNull(loginName); YggdrasilSession acquiredSession = service.authenticate(loginName, password, randomClientToken()); - if (acquiredSession.getSelectedProfile() == null) { - if (acquiredSession.getAvailableProfiles() == null || acquiredSession.getAvailableProfiles().isEmpty()) { + if (acquiredSession.selectedProfile() == null) { + if (acquiredSession.availableProfiles() == null || acquiredSession.availableProfiles().isEmpty()) { throw new NoCharacterException(); } - GameProfile characterToSelect = selector.select(service, acquiredSession.getAvailableProfiles()); + GameProfile characterToSelect = selector.select(service, acquiredSession.availableProfiles()); session = service.refresh( - acquiredSession.getAccessToken(), - acquiredSession.getClientToken(), + acquiredSession.accessToken(), + acquiredSession.clientToken(), characterToSelect); // response validity has been checked in refresh() } else { session = acquiredSession; } - profileID = session.getSelectedProfile().getId(); + profileID = session.selectedProfile().getId(); authenticated = true; addProfilePropertiesListener(); @@ -93,23 +98,23 @@ public String getLoginName() { @Override public String getProfileName() { - return session.getSelectedProfile().getName(); + return session.selectedProfile().getName(); } @Override public UUID getProfileID() { - return session.getSelectedProfile().getId(); + return session.selectedProfile().getId(); } @Override public synchronized AuthInfo logIn() throws AuthenticationException { if (!authenticated || !session.hasProfileName()) { - if (session.hasProfileName() && service.validate(session.getAccessToken(), session.getClientToken())) { + if (session.hasProfileName() && service.validate(session.accessToken(), session.clientToken())) { authenticated = true; } else { YggdrasilSession acquiredSession; try { - acquiredSession = service.refresh(session.getAccessToken(), session.getClientToken(), null); + acquiredSession = service.refresh(session.accessToken(), session.clientToken(), null); } catch (RemoteAuthenticationException e) { if ("ForbiddenOperationException".equals(e.getRemoteName())) { throw new CredentialExpiredException(e); @@ -117,8 +122,8 @@ public synchronized AuthInfo logIn() throws AuthenticationException { throw e; } } - if (acquiredSession.getSelectedProfile() == null || - !acquiredSession.getSelectedProfile().getId().equals(profileID)) { + if (acquiredSession.selectedProfile() == null || + !acquiredSession.selectedProfile().getId().equals(profileID)) { throw new ServerResponseMalformedException("Selected profile changed"); } if (!acquiredSession.hasProfileName()) { @@ -139,23 +144,23 @@ public synchronized AuthInfo logIn() throws AuthenticationException { public synchronized AuthInfo logInWithPassword(String password) throws AuthenticationException { YggdrasilSession acquiredSession = service.authenticate(loginName, password, randomClientToken()); - if (acquiredSession.getSelectedProfile() == null) { - if (acquiredSession.getAvailableProfiles() == null || acquiredSession.getAvailableProfiles().isEmpty()) { + if (acquiredSession.selectedProfile() == null) { + if (acquiredSession.availableProfiles() == null || acquiredSession.availableProfiles().isEmpty()) { throw new CharacterDeletedException(); } - GameProfile characterToSelect = acquiredSession.getAvailableProfiles().stream() + GameProfile characterToSelect = acquiredSession.availableProfiles().stream() .filter(charatcer -> charatcer.getId().equals(profileID)) .findFirst() .orElseThrow(CharacterDeletedException::new); session = service.refresh( - acquiredSession.getAccessToken(), - acquiredSession.getClientToken(), + acquiredSession.accessToken(), + acquiredSession.clientToken(), characterToSelect); } else { - if (!acquiredSession.getSelectedProfile().getId().equals(profileID)) { + if (!acquiredSession.selectedProfile().getId().equals(profileID)) { throw new CharacterDeletedException(); } session = acquiredSession; @@ -221,13 +226,18 @@ public boolean canUploadSkin() { @Override public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException { - service.uploadSkin(profileID, session.getAccessToken(), isSlim, file); + service.uploadSkin(profileID, session.accessToken(), isSlim, file); } private static String randomClientToken() { return UUIDs.toCompactString(UUID.randomUUID()); } + @Override + public FriendResponse getFriendList() throws IOException { + return service.getFriendList(session.accessToken()); + } + @Override public String toString() { return "YggdrasilAccount[accountID=" + getAccountID() + ", profileID=" + profileID diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java index db90e38f1a..51a18b5db6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilProvider.java @@ -27,32 +27,33 @@ */ public interface YggdrasilProvider { - URI getAuthenticationURL() throws AuthenticationException; + URI getAuthenticationURL(); - URI getRefreshmentURL() throws AuthenticationException; + URI getRefreshmentURL(); - URI getValidationURL() throws AuthenticationException; + URI getValidationURL(); - URI getInvalidationURL() throws AuthenticationException; + URI getInvalidationURL(); /** * URL to upload skin. - * + *

* Headers: - * Authentication: Bearer <access token> - * + * Authentication: Bearer <access token> + *

* Payload: - * The payload for this API consists of multipart form data. There are two parts (order does not matter b/c of boundary): - * model: Empty string for the default model and "slim" for the slim model - * file: Raw image file data + * The payload for this API consists of multipart form data. There are two parts (order does not matter b/c of boundary): + * model: Empty string for the default model and "slim" for the slim model + * file: Raw image file data * - * @see https://wiki.vg/Mojang_API#Upload_Skin * @return url to upload skin - * @throws AuthenticationException if url cannot be generated. e.g. some parameter or query is malformed. + * @throws AuthenticationException if url cannot be generated. e.g. some parameter or query is malformed. * @throws UnsupportedOperationException if the Yggdrasil provider does not support third-party skin uploading. + * @see https://wiki.vg/Mojang_API#Upload_Skin */ URI getSkinUploadURL(UUID uuid) throws AuthenticationException, UnsupportedOperationException; URI getProfilePropertiesURL(UUID uuid) throws AuthenticationException; + URI getFriendsURL(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java index 540ba623d7..1afb1eeaae 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java @@ -24,11 +24,11 @@ import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.ServerDisconnectException; import org.jackhuang.hmcl.auth.ServerResponseMalformedException; +import org.jackhuang.hmcl.game.friend.FriendResponse; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.ValidationTypeAdapterFactory; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.io.HttpMultipartRequest; -import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jackhuang.hmcl.util.io.*; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; import java.io.IOException; @@ -45,8 +45,8 @@ import static java.util.Collections.unmodifiableList; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.threadPool; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class YggdrasilService { @@ -111,8 +111,8 @@ public YggdrasilSession refresh(String accessToken, String clientToken, GameProf YggdrasilSession response = handleAuthenticationResponse(request(provider.getRefreshmentURL(), request), clientToken); if (characterToSelect != null) { - if (response.getSelectedProfile() == null || - !response.getSelectedProfile().getId().equals(characterToSelect.getId())) { + if (response.selectedProfile() == null || + !response.selectedProfile().getId().equals(characterToSelect.getId())) { throw new ServerResponseMalformedException("Failed to select character"); } } @@ -168,7 +168,7 @@ public void uploadSkin(UUID uuid, String accessToken, boolean isSlim, Path file) /** * Get complete game profile. - * + *

* Game profile provided from authentication is not complete (no skin data in properties). * * @param uuid the uuid that the character corresponding to. @@ -227,6 +227,20 @@ private static void handleErrorMessage(ErrorResponse response) throws Authentica } } + public FriendResponse getFriendList(String accessToken) throws IOException { + var url = provider.getFriendsURL().toString(); + HttpURLConnection request = HttpRequest.GET(url) + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").createConnection(); + + if (request.getResponseCode() != 200) { + throw new ResponseCodeException(url, request.getResponseCode()); + } + + return JsonUtils.fromNonNullJson(NetworkUtils.readFullyAsString(request), FriendResponse.class); + } + private static String request(URI uri, Object payload) throws AuthenticationException { try { if (payload == null) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilSession.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilSession.java index 68d46caff9..c32251c96c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilSession.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilSession.java @@ -31,15 +31,8 @@ import java.util.stream.Collectors; @Immutable -public class YggdrasilSession { - - private final String clientToken; - private final String accessToken; - private final GameProfile selectedProfile; - private final List availableProfiles; - - @Nullable - private final Map userProperties; +public record YggdrasilSession(String clientToken, String accessToken, GameProfile selectedProfile, + List availableProfiles, @Nullable Map userProperties) { public YggdrasilSession(String clientToken, String accessToken, GameProfile selectedProfile, List availableProfiles, Map userProperties) { this.clientToken = clientToken; @@ -51,32 +44,6 @@ public YggdrasilSession(String clientToken, String accessToken, GameProfile sele if (accessToken != null) Logger.registerAccessToken(accessToken); } - public String getClientToken() { - return clientToken; - } - - public String getAccessToken() { - return accessToken; - } - - /** - * @return nullable (null if no character is selected) - */ - public GameProfile getSelectedProfile() { - return selectedProfile; - } - - /** - * @return nullable (null if the YggdrasilSession is loaded from storage) - */ - public List getAvailableProfiles() { - return availableProfiles; - } - - public Map getUserProperties() { - return userProperties; - } - public boolean hasProfileName() { return selectedProfile != null && StringUtils.isNotBlank(selectedProfile.getName()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendControl.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendControl.java new file mode 100644 index 0000000000..af1062113a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendControl.java @@ -0,0 +1,24 @@ +/* + * 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.friend; + +import java.io.IOException; + +public interface FriendControl { + FriendResponse getFriendList() throws IOException; +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendItem.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendItem.java new file mode 100644 index 0000000000..7cbcce338a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendItem.java @@ -0,0 +1,27 @@ +/* + * 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.friend; + +import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.util.gson.JsonSerializable; + +@JsonSerializable +public record FriendItem( + @SerializedName("profileId") String profileId, + @SerializedName("name") String name) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendResponse.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendResponse.java new file mode 100644 index 0000000000..2eaa885fde --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendResponse.java @@ -0,0 +1,29 @@ +/* + * 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.friend; + +import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.util.gson.JsonSerializable; + +import java.util.List; + +@JsonSerializable +public record FriendResponse(@SerializedName("friends") List friends, + @SerializedName("incomingRequests") List incomingRequests, + @SerializedName("outgoingRequests") List outgoingRequests) { +}