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/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index 713c5d698a..c6add5049d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -44,14 +44,10 @@ import java.io.IOException; import java.nio.file.Paths; import java.util.*; +import java.util.regex.Pattern; -import static java.util.stream.Collectors.toList; import static javafx.collections.FXCollections.observableArrayList; -import static org.jackhuang.hmcl.setting.SettingsManager.settings; -import static org.jackhuang.hmcl.setting.SettingsManager.getAccountMetadataRecords; -import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; -import static org.jackhuang.hmcl.setting.SettingsManager.getUserAccountMetadataRecords; -import static org.jackhuang.hmcl.setting.SettingsManager.userSettings; +import static org.jackhuang.hmcl.setting.SettingsManager.*; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.util.Lang.immutableListOf; import static org.jackhuang.hmcl.util.Lang.mapOf; @@ -63,6 +59,8 @@ * @author huangyuhui */ public final class Accounts { + public static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$"); + private Accounts() { } @@ -492,7 +490,7 @@ private static void removeDanglingAuthlibInjectorAccounts() { .map(AuthlibInjectorAccount.class::cast) .filter(it -> !getAuthlibInjectorServers().contains(it.getServer())) .filter(Accounts::canRemoveAccount) - .collect(toList()) + .toList() .forEach(accounts::remove); } // ==== 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..2c5bdd3ddc 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,10 @@ 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"), + PERSON_CHECK("M17.55 12L14 8.45l1.425-1.4l2.125 2.125l4.25-4.25l1.4 1.425zM6.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 8"), + PERSON_OFF("M19.775 22.625L17.15 20H4v-2.8q0-.85.438-1.562T5.6 14.55q1.125-.575 2.288-.925t2.362-.525L1.375 4.225L2.8 2.8l18.4 18.4zM6 18h9.15l-3-3H12q-1.4 0-2.775.338T6.5 16.35q-.225.125-.363.35T6 17.2zm12.4-3.45q.725.35 1.15 1.063T20 17.15l-3.35-3.35q.45.175.888.35t.862.4m-4.2-3.2l-1.475-1.475q.575-.225.925-.737T14 8q0-.825-.587-1.412T12 6q-.625 0-1.137.35t-.738.925L8.65 5.8q.575-.85 1.45-1.325T12 4q1.65 0 2.825 1.175T16 8q0 1.025-.475 1.9T14.2 11.35m.95 6.65H6zm-3.725-9.425"), 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 731132489a..034772cc79 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,21 +20,21 @@ 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.Insets; 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,9 +42,14 @@ import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.account.friend.FriendPage; 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 { @@ -69,21 +74,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); @@ -127,15 +129,13 @@ public AccountListItemSkin(AccountListItem skinnable) { } btnRefresh.setOnAction(e -> { spinnerRefresh.showSpinner(); - skinnable.refreshAsync() - .whenComplete(Schedulers.javafx(), ex -> { - spinnerRefresh.hideSpinner(); - - if (ex != null) { - Controllers.showToast(Accounts.localizeErrorMessage(ex)); - } - }) - .start(); + skinnable.refreshAsync().whenComplete(Schedulers.javafx(), ex -> { + spinnerRefresh.hideSpinner(); + + if (ex != null) { + Controllers.showToast(Accounts.localizeErrorMessage(ex)); + } + }).start(); }); FXUtils.installFastTooltip(btnRefresh, i18n("button.refresh")); spinnerRefresh.setContent(btnRefresh); @@ -147,9 +147,7 @@ public AccountListItemSkin(AccountListItem skinnable) { Task uploadTask = skinnable.uploadSkin(); if (uploadTask != null) { spinnerUpload.showSpinner(); - uploadTask - .whenComplete(Schedulers.javafx(), ex -> spinnerUpload.hideSpinner()) - .start(); + uploadTask.whenComplete(Schedulers.javafx(), ex -> spinnerUpload.hideSpinner()).start(); } }); FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload")); @@ -158,6 +156,46 @@ 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(); + + skinnable.refreshAsync().whenComplete(exception -> { + Platform.runLater(spinnerFriend::hideSpinner); + + if (exception != null) { + Platform.runLater(() -> Controllers.showToast(Accounts.localizeErrorMessage(exception))); + return; + } + + if (account instanceof MicrosoftAccount microsoftAccount) { + Platform.runLater(() -> Controllers.navigate(new FriendPage(account, microsoftAccount))); + } else if (account instanceof YggdrasilAccount yggdrasilAccount) { + try { + yggdrasilAccount.getFriendList(); + Platform.runLater(() -> Controllers.navigate(new FriendPage(account, yggdrasilAccount))); + } catch (IOException e) { + Platform.runLater(() -> { + if (e instanceof ResponseCodeException responseCodeException && responseCodeException.getResponseCode() == 404) { + Controllers.dialog(i18n("account.friend.unsupported")); + return; + } + + Controllers.showToast(Accounts.localizeErrorMessage(e)); + }); + } + } + }).start(); + }); + right.getChildren().add(spinnerFriend); + JFXButton btnCopyUUID = FXUtils.newToggleButton4(SVG.CONTENT_COPY); btnCopyUUID.setOnAction(e -> FXUtils.copyText(skinnable.getAccount().getProfileID().toString())); FXUtils.installFastTooltip(btnCopyUUID, i18n("account.copy_uuid")); @@ -183,8 +221,7 @@ private static void moveAccount(AccountListItem skinnable) { Accounts.getAccounts().remove(account); if (account.isPortable()) { account.setPortable(false); - if (!Accounts.getAccounts().contains(account)) - Accounts.getAccounts().add(account); + if (!Accounts.getAccounts().contains(account)) Accounts.getAccounts().add(account); } else { account.setPortable(true); if (!Accounts.getAccounts().contains(account)) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index 7fad875bff..e866571f48 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -61,21 +61,18 @@ import java.util.*; import java.util.concurrent.CountDownLatch; -import java.util.regex.Pattern; import static java.util.Collections.emptyList; import static java.util.Collections.unmodifiableList; import static javafx.beans.binding.Bindings.bindContent; import static javafx.beans.binding.Bindings.createBooleanBinding; -import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.ui.FXUtils.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor; public class CreateAccountPane extends JFXDialogLayout implements DialogAware { - private static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$"); - private boolean showMethodSwitcher; private AccountFactory factory; @@ -247,7 +244,7 @@ private void onAccept() { }).executor(true); }; - if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) { + if (factory instanceof OfflineAccountFactory && username != null && (!Accounts.USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) { Controllers.confirmWithCountdown(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), 10, MessageDialogPane.MessageType.WARNING, doCreate, () -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListCell.java new file mode 100644 index 0000000000..abbc467f6f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListCell.java @@ -0,0 +1,151 @@ +/* + * 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.friend; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXListView; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.canvas.Canvas; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +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.EnumPresenceStatus; +import org.jackhuang.hmcl.game.friend.EnumUpdateType; +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.SVG; +import org.jackhuang.hmcl.ui.construct.MDListCell; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.util.i18n.I18n; + +import java.util.Locale; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class FriendListCell extends MDListCell { + private final Account account; + private final FriendControl control; + private final Canvas avatar = new Canvas(32, 32); + private final TwoLineListItem twoLineListItem = new TwoLineListItem(); + + private final JFXButton copyButton = FXUtils.newToggleButton4(SVG.CONTENT_COPY); + private final JFXButton deleteButton = FXUtils.newToggleButton4(SVG.PERSON_OFF); + private final JFXButton acceptButton = FXUtils.newToggleButton4(SVG.PERSON_CHECK); + private final JFXButton rejectButton = FXUtils.newToggleButton4(SVG.PERSON_CANCEL); + private final HBox actions = new HBox(); + + public FriendListCell(JFXListView listView, Account account, FriendControl control, FriendListPage friendListPage) { + super(listView); + + this.account = account; + this.control = control; + + BorderPane root = new BorderPane(); + root.setPadding(new Insets(8, 8, 8, 8)); + + 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); + + copyButton.setOnAction(event -> FXUtils.copyText(getItem().profileId().toString())); + FXUtils.installFastTooltip(copyButton, i18n("account.copy_uuid")); + + deleteButton.setOnAction(event -> friendListPage.confirmUpdateFriend(getItem(), EnumUpdateType.REMOVE, i18n("account.friend.delete.confirm"), i18n("account.friend.delete.failed"))); + FXUtils.installFastTooltip(deleteButton, i18n("account.friend.delete")); + + acceptButton.setOnAction(event -> friendListPage.confirmUpdateFriend(getItem(), EnumUpdateType.ADD, i18n("account.friend.accept.confirm"), i18n("account.friend.accept.failed"))); + FXUtils.installFastTooltip(acceptButton, i18n("account.friend.accept")); + + rejectButton.setOnAction(event -> friendListPage.confirmUpdateFriend(getItem(), EnumUpdateType.REMOVE, i18n("account.friend.reject.confirm"), i18n("account.friend.reject.failed"))); + FXUtils.installFastTooltip(rejectButton, i18n("account.friend.reject")); + + actions.setAlignment(Pos.CENTER_RIGHT); + + root.setRight(actions); + + getContainer().getChildren().setAll(root); + } + + @Override + protected void updateControl(FriendListItem item, boolean empty) { + TexturesLoader.unbindAvatar(avatar); + + if (item == null || empty) return; + + var uuid = item.profileId(); + + 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()); + + var statusText = i18n("account.friend.presence_status." + (item.presenceStatus() == null ? EnumPresenceStatus.OFFLINE : item.presenceStatus()).name().toLowerCase(Locale.ROOT)); + + twoLineListItem.setSubtitle(i18n("account.friend.presence_status.last_updated", I18n.formatDateTime(item.lastUpdated())) + " / " + statusText); + + twoLineListItem.getTags().clear(); + if (item.status() != FriendStatus.NORMAL) { + var tag = new Label(i18n("account.friend." + item.status().name().toLowerCase(Locale.ROOT))); + tag.getStyleClass().add("tag"); + twoLineListItem.getTags().add(tag); + } + + actions.getChildren().setAll(copyButton); + + if (item.status() == FriendStatus.NORMAL) { + actions.getChildren().addAll(deleteButton); + } else if (item.status() == FriendStatus.INCOMING) { + actions.getChildren().addAll(acceptButton, rejectButton); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListItem.java new file mode 100644 index 0000000000..953bdb5f88 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListItem.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.ui.account.friend; + +import org.jackhuang.hmcl.game.friend.EnumPresenceStatus; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +public record FriendListItem(UUID profileId, String name, FriendStatus status, @Nullable EnumPresenceStatus presenceStatus, @Nullable Instant lastUpdated) { +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListPage.java new file mode 100644 index 0000000000..c786e7ec1c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListPage.java @@ -0,0 +1,172 @@ +/* + * 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.friend; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXSpinner; +import javafx.scene.control.Skin; +import javafx.scene.layout.StackPane; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.game.friend.*; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.ListPageBase; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.util.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class FriendListPage extends ListPageBase { + private final Account account; + private final FriendControl control; + private final Runnable onAddFriend; + + public FriendListPage(Account account, FriendControl control, Runnable onAddFriend) { + super(); + + this.account = account; + this.control = control; + this.onAddFriend = onAddFriend; + + refresh(); + } + + public void refresh() { + setLoading(true); + setFailedReason(null); + setOnFailedAction(null); + + Task.supplyAsync(control::getFriendList) + .thenApplyAsync(result -> pair(result, control.getPresence(EnumPresenceStatus.ONLINE))) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + setLoading(false); + + var friends = result.key(); + var presence = result.value(); + + LOG.info("Received friend list" + friends); + LOG.info("Received presences" + presence); + + if (exception != null) { + LOG.warning("Failed to get friend list" + exception); + setFailedReason(i18n("account.friend.failed")); + } else { + setData(friends, presence); + } + }).start(); + } + + @Override + protected Skin createDefaultSkin() { + return new FriendListPageSkin(this); + } + + public Account getAccount() { + return account; + } + + public FriendControl getControl() { + return control; + } + + public void confirmUpdateFriend(FriendListItem item, EnumUpdateType updateType, String text, String failedText) { + JFXButton btnOk = new JFXButton(i18n("button.ok")); + + StackPane stackPane = new StackPane(); + + var spinner = new JFXSpinner(); + spinner.getStyleClass().add("small-spinner"); + + btnOk.setOnAction(action -> { + stackPane.getChildren().setAll(spinner); + + Task.supplyAsync(() -> control.updateFriend(null, item.profileId(), updateType)).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null) { + LOG.warning("Failed to update friend", exception); + fireEvent(new DialogCloseEvent()); + Controllers.dialog(failedText + "\n" + StringUtils.getStackTrace(exception), null, MessageDialogPane.MessageType.ERROR); + return; + } + + setData(result, null); + fireEvent(new DialogCloseEvent()); + }).start(); + }); + + btnOk.getStyleClass().add("dialog-accept"); + stackPane.getChildren().setAll(btnOk); + + var dialog = new MessageDialogPane.Builder(i18n(text, item.name()), i18n("message.question"), MessageDialogPane.MessageType.QUESTION) + .addAction(stackPane) + .addCancel(null) + .build(); + + Controllers.dialog(dialog); + } + + private PresenceResponse lastPresence; + + public void setData(FriendResponse friends, PresenceResponse nullablePresence) { + if (nullablePresence != null) lastPresence = nullablePresence; + + var presence = lastPresence; + + getItems().clear(); + + if (friends.empty()) { + setFailedReason(i18n("account.friend.empty")); + setOnFailedAction(event -> onAddFriend.run()); + return; + } + + Map presenceMap = presence == null || presence.presence() == null + ? Map.of() + : presence.presence().stream() + .collect(Collectors.toMap(PresenceItem::profileId, Function.identity())); + + List, FriendStatus>> sources = List.of( + Map.entry(friends.friends(), FriendStatus.NORMAL), + Map.entry(friends.outgoingRequests(), FriendStatus.OUTGOING), + Map.entry(friends.incomingRequests(), FriendStatus.INCOMING) + ); + + sources.forEach(entry -> + getItems().addAll(entry.getKey().stream() + .map(friend -> { + PresenceItem p = presenceMap.get(friend.profileId()); + return new FriendListItem( + friend.profileId(), + friend.name(), + entry.getValue(), + p != null ? p.status() : null, + p != null ? p.lastUpdated() : null + ); + }) + .toList()) + ); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListPageSkin.java new file mode 100644 index 0000000000..48927bdc94 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendListPageSkin.java @@ -0,0 +1,41 @@ +/* + * 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.friend; + +import javafx.scene.Node; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.ToolbarListPageSkin; + +import java.util.List; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public final class FriendListPageSkin extends ToolbarListPageSkin { + public FriendListPageSkin(FriendListPage skinnable) { + super(skinnable); + + listView.setCellFactory(x -> new FriendListCell(super.listView, skinnable.getAccount(), skinnable.getControl(), skinnable)); + } + + @Override + protected List initializeToolbar(FriendListPage skinnable) { + return List.of( + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh) + ); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendPage.java new file mode 100644 index 0000000000..039d76b241 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendPage.java @@ -0,0 +1,115 @@ +/* + * 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.friend; + +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; +import org.jackhuang.hmcl.game.friend.EnumUpdateType; +import org.jackhuang.hmcl.game.friend.FriendControl; +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.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.AdvancedListBox; +import org.jackhuang.hmcl.ui.construct.RequiredValidator; +import org.jackhuang.hmcl.ui.construct.TabHeader; +import org.jackhuang.hmcl.ui.construct.UserNameValidator; +import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.ResponseCodeException; + +import java.util.Locale; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class FriendPage extends DecoratorAnimatedPage implements DecoratorPage { + private final Account account; + private final FriendControl control; + + private final TabHeader tab; + private final TabHeader.Tab friendListPage = new TabHeader.Tab<>("friendPage"); + private final TransitionPane transitionPane = new TransitionPane(); + + @SuppressWarnings("unused") + private ChangeListener instanceChangeListenerHolder; + + + private ReadOnlyObjectWrapper state = null; + + public FriendPage(Account account, FriendControl control) { + this.account = account; + this.control = control; + + friendListPage.setNodeSupplier(() -> new FriendListPage(account, control, this::onAddFriend)); + tab = new TabHeader(transitionPane, friendListPage); + tab.select(friendListPage); + + BorderPane left = new BorderPane(); + FXUtils.setLimitWidth(left, 200); + VBox.setVgrow(left, Priority.ALWAYS); + setLeft(left); + + AdvancedListBox sideBar = new AdvancedListBox().addNavigationDrawerTab(tab, friendListPage, 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() { + Controllers.prompt(i18n("account.friend.add"), (name, resultHandler) -> { + Task.supplyAsync(() -> control.updateFriend(name, null, EnumUpdateType.ADD)).whenComplete(Schedulers.javafx(), (result, e) -> { + if (e == null) { + resultHandler.resolve(); + + friendListPage.getNode().setData(result, null); + } else { + LOG.warning("Failed to add friend", e); + if (e.getCause() instanceof ResponseCodeException cause && cause.getData() != null) { + var errResponse = JsonUtils.fromJson(cause.getData(), MicrosoftService.MinecraftErrorResponse.class); + if (errResponse != null && errResponse.details != null && errResponse.details.status != null) { + resultHandler.reject(i18n("account.failed." + errResponse.details.status.toLowerCase(Locale.ROOT))); + } else resultHandler.reject(Accounts.localizeErrorMessage(e)); + } else resultHandler.reject(Accounts.localizeErrorMessage(e)); + } + }).start(); + }, null, new RequiredValidator(), new UserNameValidator()); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + if (state == null) + state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("account.friend") + " - " + account.getProfileName())); + return state; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendStatus.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendStatus.java new file mode 100644 index 0000000000..f53ef53189 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/friend/FriendStatus.java @@ -0,0 +1,22 @@ +/* + * 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.friend; + +public enum FriendStatus { + NORMAL, INCOMING, OUTGOING +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/UserNameValidator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/UserNameValidator.java new file mode 100644 index 0000000000..541e065aa2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/UserNameValidator.java @@ -0,0 +1,51 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 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.construct; + +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.NamedArg; +import javafx.scene.control.TextInputControl; + +import static org.jackhuang.hmcl.setting.Accounts.USERNAME_CHECKER_PATTERN; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class UserNameValidator extends ValidatorBase { + + public UserNameValidator() { + this(i18n("account.invailed_name")); + } + + public UserNameValidator(@NamedArg("message") String message) { + super(message); + } + + @Override + protected void eval() { + if (srcControl.get() instanceof TextInputControl) { + evalTextInputField(); + } + } + + private void evalTextInputField() { + TextInputControl textField = ((TextInputControl) srcControl.get()); + + var username = textField.getText(); + + hasErrors.set(!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16 || username.length() < 3); + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index a2b393f421..786fa5cb9d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -64,6 +64,10 @@ account.create.authlibInjector=Add an authlib-injector Account account.email=Email account.empty=No Accounts account.failed=Failed to refresh account. +account.failed.unknown_profile=Name does not exist +account.failed.cannot_add_self=Cannot add yourself as friend. +account.failed.already_friends=Already friends. +account.failed.invite_rejected=User does not have friends enabled or accept invites. account.failed.character_deleted=The player has already been deleted. account.failed.connect_authentication_server=Failed to connect to the authentication server, your network connection may be down. account.failed.connect_injector_server=Failed to connect to the authentication server. Please check your network and make sure you entered the correct URL. @@ -79,6 +83,29 @@ 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.delete=Delete Friend +account.friend.delete.failed=Failed to delete friend +account.friend.delete.confirm=Are you sure you want to delete friend "%s"? This operation cannot be undone! +account.friend.accept=Accept Friend Request +account.friend.accept.failed=Failed to accept friend request +account.friend.accept.confirm=Are you sure you want to accept friend request "%s"? +account.friend.reject=Reject Friend Request +account.friend.reject.failed=Failed to reject friend request +account.friend.reject.confirm=Are you sure you want to reject friend request "%s"? +account.friend.incoming=Pending your acceptance +account.friend.outgoing=Pending the other party's acceptance +account.friend.presence_status.last_updated=Last updated: %1s +account.friend.presence_status.offline=Offline +account.friend.presence_status.online=Online +account.friend.presence_status.playing_offline=In a world +account.friend.presence_status.playing_hosted_server=In a world +account.friend.presence_status.playing_realms=In a Minecraft Realms server +account.friend.presence_status.playing_server=In a third-party server +account.friend.failed=Failed to load friend list. Please click here to retry. +account.friend.empty=Friend list is empty. Please click here to add a friend. +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 @@ -90,6 +117,7 @@ account.injector.server=Authentication Server account.injector.server.storage.read_only=The authlib-injector server file was saved by a newer version of HMCL. The current version of HMCL cannot modify authentication servers. account.injector.server_url=Server URL account.injector.server_name=Server Name +account.invailed_name=Invalid username account.login=Log in account.login.hint=We never store your password. account.login.skip=Log in offline diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 0574758c5a..7163b76a78 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -63,6 +63,10 @@ account.create.authlibInjector=新增 authlib-injector 帳戶 account.email=電子信箱 account.empty=沒有帳戶 account.failed=帳戶重新整理失敗 +account.failed.unknown_profile=用戶不存在 +account.failed.cannot_add_self=不能添加自己為好友 +account.failed.already_friends=已添加對方為好友 +account.failed.invite_rejected=該用戶未開啟好友功能,或不接受邀請 account.failed.character_deleted=已刪除此角色 account.failed.connect_authentication_server=無法連線至認證伺服器。可能是網路問題,請檢查裝置能否正常上網或使用代理服務。 account.failed.connect_injector_server=無法連線至認證伺服器。可能是網路故障,請檢查裝置能否正常上網、檢查網址是否輸入錯誤或使用代理服務。 @@ -82,6 +86,29 @@ 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.delete=刪除好友 +account.friend.delete.failed=刪除好友失敗 +account.friend.delete.confirm=確認要刪除好友 "%s" 吗?此操作無法恢復! +account.friend.accept=接受好友请求 +account.friend.accept.failed=接受好友请求失敗 +account.friend.accept.confirm=確認要接受好友请求 "%s" 吗? +account.friend.reject=拒绝好友请求 +account.friend.reject.failed=拒绝好友请求失敗 +account.friend.reject.confirm=確認要拒绝好友请求 "%s" 吗? +account.friend.incoming=待你接受 +account.friend.outgoing=待對方接受 +account.friend.presence_status.last_updated=最後更新時間: %1s +account.friend.presence_status.offline=離線 +account.friend.presence_status.online=在線 +account.friend.presence_status.playing_offline=在世界內 +account.friend.presence_status.playing_hosted_server=在世界內 +account.friend.presence_status.playing_realms=在 Minecraft Realms 伺服器內 +account.friend.presence_status.playing_server=在第三方 Minecraft 伺服器內 +account.friend.failed=載入好友列表失敗,按一下此處重試。 +account.friend.empty=好友列表為空,按一下此處添加好友。 +account.friend.unsupported=你的認證伺服器不支援好友功能。 account.storage.read_only=帳戶檔案是由更高版本的 HMCL 儲存的,目前版本的 HMCL 無法修改帳戶。 account.hmcl.hint=你需要點擊「登入」按鈕,並在開啟的網頁中完成登入 account.injector.add=新增認證伺服器 @@ -93,6 +120,7 @@ account.injector.server=認證伺服器 account.injector.server.storage.read_only=認證伺服器檔案是由更高版本的 HMCL 儲存的,目前版本的 HMCL 無法修改認證伺服器。 account.injector.server_url=伺服器位址 account.injector.server_name=伺服器名稱 +account.invailed_name=非法使用者名稱 account.login=登入 account.login.hint=我們不會儲存你的密碼 account.login.skip=跳過重新整理帳戶 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 52cac07405..22280788a1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -63,6 +63,10 @@ account.create.authlibInjector=添加外置登录账户 (authlib-injector) account.email=邮箱 account.empty=没有账户 account.failed=账户刷新失败 +account.failed.unknown_profile=用户不存在 +account.failed.cannot_add_self=不能添加自己为好友 +account.failed.already_friends=已添加对方为好友 +account.failed.invite_rejected=该用户未开启好友功能,或不接受邀请 account.failed.character_deleted=此角色已被删除 account.failed.connect_authentication_server=无法连接认证服务器。可能是网络问题,请检查设备能否正常上网或使用代理服务。\n你可以点击右上角帮助按钮进行求助。 account.failed.connect_injector_server=无法连接认证服务器。可能是网络问题,请检查设备是否能正常上网、URL 是否输入错误,或使用代理服务。\n你可以点击右上角帮助按钮进行求助。 @@ -83,6 +87,29 @@ 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.delete=删除好友 +account.friend.delete.failed=删除好友失败 +account.friend.delete.confirm=确认要删除好友 “%1s” 吗?此操作无法撤销! +account.friend.accept=接受好友请求 +account.friend.accept.failed=接受好友请求失败 +account.friend.accept.confirm=确认要接受好友请求 “%1s” 吗? +account.friend.reject=拒绝好友请求 +account.friend.reject.failed=拒绝好友请求失败 +account.friend.reject.confirm=确认要拒绝好友请求 “%1s” 吗? +account.friend.incoming=待你接受 +account.friend.outgoing=待对方接受 +account.friend.presence_status.last_updated=最后更新时间: %1s +account.friend.presence_status.offline=离线 +account.friend.presence_status.online=在线 +account.friend.presence_status.playing_offline=在世界内 +account.friend.presence_status.playing_hosted_server=在世界内 +account.friend.presence_status.playing_realms=在 Minecraft Realms 服务器内 +account.friend.presence_status.playing_server=在第三方服务器内 +account.friend.failed=[加载好友列表失败,点击此处重试]\n(你可以点击右上角帮助按钮进行求助) +account.friend.empty=好友列表为空,点击此处添加好友 +account.friend.unsupported=你的认证服务器不支持好友功能。 account.storage.read_only=账户文件是由更高版本的 HMCL 保存的,当前版本的 HMCL 无法修改账户。 account.hmcl.hint=你需要点击“登录”按钮,并在弹出的网页中完成登录。 account.injector.add=添加认证服务器 @@ -94,6 +121,7 @@ account.injector.server=认证服务器 account.injector.server.storage.read_only=认证服务器文件是由更高版本的 HMCL 保存的,当前版本的 HMCL 无法修改认证服务器。 account.injector.server_url=服务器地址 account.injector.server_name=服务器名称 +account.invailed_name=非法用户名 account.login=登录 account.login.hint=我们不会保存你的密码 account.login.skip=跳过账户刷新 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java index 403cc60f79..3e1f520967 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java @@ -27,6 +27,7 @@ import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.function.ExceptionalSupplier; + import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -47,12 +48,7 @@ public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtif this.downloader = downloader; } - public AuthlibInjectorAccount( - AccountID accountID, - AuthlibInjectorServer server, - AuthlibInjectorArtifactProvider downloader, - String username, - YggdrasilSession session) { + public AuthlibInjectorAccount(AccountID accountID, AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, YggdrasilSession session) { super(accountID, server.getYggdrasilService(), username, session); this.server = server; this.downloader = downloader; @@ -135,10 +131,7 @@ public AuthlibInjectorAuthInfo(AuthInfo authInfo, AuthlibInjectorArtifactInfo ar @Override public Arguments getLaunchArguments(LaunchOptions options) { - return new Arguments().addJVMArguments( - "-javaagent:" + artifact.location().toString() + "=" + server.getUrl(), - "-Dauthlibinjector.side=client", - "-Dauthlibinjector.yggdrasil.prefetched=" + Base64.getEncoder().encodeToString(prefetchedMeta.getBytes(UTF_8))); + return new Arguments().addJVMArguments("-javaagent:" + artifact.location().toString() + "=" + server.getUrl(), "-Dauthlibinjector.side=client", "-Dauthlibinjector.yggdrasil.prefetched=" + Base64.getEncoder().encodeToString(prefetchedMeta.getBytes(UTF_8))); } } @@ -160,18 +153,12 @@ public AuthlibInjectorServer getServer() { @Override public String toString() { - return new ToStringBuilder(this) - .append("accountID", getAccountID()) - .append("profileID", profileID) - .append("loginName", getLoginName()) - .append("server", getServer().getUrl()) - .toString(); + return new ToStringBuilder(this).append("accountID", getAccountID()).append("profileID", profileID).append("loginName", getLoginName()).append("server", getServer().getUrl()).toString(); } public static Set getUploadableTextures(CompleteGameProfile profile) { String prop = profile.getProperties().get("uploadableTextures"); - if (prop == null) - return emptySet(); + if (prop == null) return emptySet(); Set result = EnumSet.noneOf(TextureType.class); for (String val : prop.split(",")) { val = val.toUpperCase(Locale.ROOT); 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..a26f46fd2b 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,17 @@ 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"); + } + + @Override + public URI getPresenceURL() { + return URI.create(apiRoot + "minecraftservices/presence"); } } 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..e37b2eb807 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,12 @@ import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.game.friend.*; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.nio.file.Path; import java.util.Map; import java.util.Objects; @@ -34,7 +38,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; @@ -153,7 +157,7 @@ public MicrosoftService getService() { } @Override - public ObjectBinding>> getTextures() { + public @NotNull ObjectBinding>> getTextures() { return BindingMapping.of(service.getProfileRepository().binding(getProfileID())) .map(profile -> profile.flatMap(it -> { try { @@ -171,6 +175,21 @@ public void clearCache() { service.getProfileRepository().invalidate(profileID); } + @Override + public FriendResponse getFriendList() throws IOException { + return service.getFriendList(session.accessToken()); + } + + @Override + public FriendResponse updateFriend(@Nullable String name, @Nullable UUID uuid, @NotNull EnumUpdateType updateType) throws IOException { + return service.updateFriend(session.accessToken(), name, uuid, updateType); + } + + @Override + public PresenceResponse getPresence(@NotNull EnumPresenceStatus selfPresence) throws IOException { + return service.getPresence(session.accessToken(), selfPresence); + } + @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..d23ba77058 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,10 +31,13 @@ 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.*; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.*; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -42,6 +45,7 @@ import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.*; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -312,6 +316,35 @@ private static String request(String url, Object payload) throws AuthenticationE } } + public FriendResponse getFriendList(String accessToken) throws IOException { + var url = "https://api.minecraftservices.com/friends"; + + return HttpRequest.GET(url) + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").getJson(FriendResponse.class); + } + + public FriendResponse updateFriend(String accessToken, @Nullable String name, @Nullable UUID uuid, EnumUpdateType updateType) throws IOException { + var url = "https://api.minecraftservices.com/friends"; + + return HttpRequest.PUT(url) + .json(new FriendUpdateRequst(name, uuid != null ? uuid.toString() : null, updateType), GSON) + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").getJson(FriendResponse.class); + } + + public PresenceResponse getPresence(String accessToken, @NotNull EnumPresenceStatus status) throws IOException { + var url = "https://api.minecraftservices.com/presence"; + + return HttpRequest.POST(url) + .json(new PresenceRequst(status), GSON) + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").getJson(PresenceResponse.class); + } + public static class XboxAuthorizationException extends AuthenticationException { private final long errorCode; private final String redirect; @@ -477,16 +510,22 @@ public void validate() throws JsonParseException, TolerableValidationException { } } - private static class MinecraftErrorResponse { + public static class MinecraftErrorResponse { public String path; public String errorType; public String error; public String errorMessage; public String developerMessage; + public Details details; + } + + public static class Details { + public String status; } private static final Gson GSON = new GsonBuilder() .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) + .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) .create(); } 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..c4638219c8 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,22 @@ import javafx.beans.binding.ObjectBinding; import org.glavo.uuid.UUIDs; import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.game.friend.*; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +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 +49,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,29 +61,27 @@ 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(), - characterToSelect); + session = service.refresh(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(); } private ObjectBinding> profilePropertiesBinding; + private void addProfilePropertiesListener() { // binding() is thread-safe // hold the binding so that it won't be garbage-collected @@ -93,23 +97,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 +121,7 @@ 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 +142,17 @@ 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() - .filter(charatcer -> charatcer.getId().equals(profileID)) - .findFirst() - .orElseThrow(CharacterDeletedException::new); + GameProfile characterToSelect = acquiredSession.availableProfiles().stream().filter(charatcer -> charatcer.getId().equals(profileID)).findFirst().orElseThrow(CharacterDeletedException::new); - session = service.refresh( - acquiredSession.getAccessToken(), - acquiredSession.getClientToken(), - characterToSelect); + session = service.refresh(acquiredSession.accessToken(), acquiredSession.clientToken(), characterToSelect); } else { - if (!acquiredSession.getSelectedProfile().getId().equals(profileID)) { + if (!acquiredSession.selectedProfile().getId().equals(profileID)) { throw new CharacterDeletedException(); } session = acquiredSession; @@ -186,8 +183,7 @@ public void writeMetadata(JsonObject metadata) { public void writePrivateData(JsonObject privateData) { super.writePrivateData(privateData); session.writePrivateData(privateData); - service.getProfileRepository().getImmediately(profileID).ifPresent(profile -> - privateData.add("profileProperties", JsonUtils.GSON.toJsonTree(profile.getProperties()))); + service.getProfileRepository().getImmediately(profileID).ifPresent(profile -> privateData.add("profileProperties", JsonUtils.GSON.toJsonTree(profile.getProperties()))); } public YggdrasilService getYggdrasilService() { @@ -202,15 +198,14 @@ public void clearCache() { @Override public ObjectBinding>> getTextures() { - return BindingMapping.of(service.getProfileRepository().binding(getProfileID())) - .map(profile -> profile.flatMap(it -> { - try { - return YggdrasilService.getTextures(it); - } catch (ServerResponseMalformedException e) { - LOG.warning("Failed to parse texture payload", e); - return Optional.empty(); - } - })); + return BindingMapping.of(service.getProfileRepository().binding(getProfileID())).map(profile -> profile.flatMap(it -> { + try { + return YggdrasilService.getTextures(it); + } catch (ServerResponseMalformedException e) { + LOG.warning("Failed to parse texture payload", e); + return Optional.empty(); + } + })); } @@ -221,16 +216,30 @@ 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 FriendResponse updateFriend(@Nullable String name, @Nullable UUID uuid, @NotNull EnumUpdateType updateType) throws IOException { + return service.updateFriend(session.accessToken(), name, uuid, updateType); + } + + @Override + public PresenceResponse getPresence(@NotNull EnumPresenceStatus status) throws IOException { + return service.getPresence(session.accessToken(), status); + } + @Override public String toString() { - return "YggdrasilAccount[accountID=" + getAccountID() + ", profileID=" + profileID - + ", loginName=" + loginName + "]"; + return "YggdrasilAccount[accountID=" + getAccountID() + ", profileID=" + profileID + ", loginName=" + loginName + "]"; } } 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..0d85ef12be 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,35 @@ */ 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(); + + URI getPresenceURL(); } 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..9d17541242 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 @@ -19,17 +19,22 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonParseException; import org.glavo.uuid.UUIDs; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.ServerDisconnectException; import org.jackhuang.hmcl.auth.ServerResponseMalformedException; +import org.jackhuang.hmcl.game.friend.*; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.InstantTypeAdapter; +import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.gson.ValidationTypeAdapterFactory; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.HttpMultipartRequest; +import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -37,6 +42,7 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.*; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -45,8 +51,9 @@ 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.gson.JsonUtils.fromJson; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class YggdrasilService { @@ -111,8 +118,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 +175,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. @@ -193,7 +200,9 @@ public static Optional> getTextures(CompleteGameProfil throw new ServerResponseMalformedException(e); } TextureResponse texturePayload = fromJson(new String(decodedBinary, UTF_8), TextureResponse.class); - return Optional.ofNullable(texturePayload.textures); + if (texturePayload != null) { + return Optional.ofNullable(texturePayload.textures); + } else return Optional.empty(); } else { return Optional.empty(); } @@ -227,6 +236,35 @@ private static void handleErrorMessage(ErrorResponse response) throws Authentica } } + public FriendResponse getFriendList(String accessToken) throws IOException { + var url = provider.getFriendsURL().toString(); + + return HttpRequest.GET(url) + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").getJson(FriendResponse.class); + } + + public FriendResponse updateFriend(String accessToken, @Nullable String name, @Nullable UUID uuid, EnumUpdateType updateType) throws IOException { + var url = provider.getFriendsURL().toString(); + + return HttpRequest.PUT(url) + .json(new FriendUpdateRequst(name, uuid != null ? uuid.toString() : null, updateType), GSON) + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").getJson(FriendResponse.class); + } + + public PresenceResponse getPresence(String accessToken, @NotNull EnumPresenceStatus status) throws IOException { + var url = provider.getPresenceURL().toString(); + + return HttpRequest.POST(url) + .json(new PresenceRequst(status), GSON) + .authorization("Bearer " + accessToken) + .retry(5) + .accept("application/json").getJson(PresenceResponse.class); + } + private static String request(URI uri, Object payload) throws AuthenticationException { try { if (payload == null) @@ -238,14 +276,6 @@ private static String request(URI uri, Object payload) throws AuthenticationExce } } - private static T fromJson(String text, Class typeOfT) throws ServerResponseMalformedException { - try { - return GSON.fromJson(text, typeOfT); - } catch (JsonParseException e) { - throw new ServerResponseMalformedException(text, e); - } - } - private final static class TextureResponse { public Map textures; } @@ -266,6 +296,8 @@ private static class ErrorResponse { private static final Gson GSON = new GsonBuilder() .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) + .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) + .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) .create(); public static final String PURCHASE_URL = "https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj"; 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/EnumPresenceStatus.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/EnumPresenceStatus.java new file mode 100644 index 0000000000..d59af0c06a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/EnumPresenceStatus.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; + +public enum EnumPresenceStatus { + OFFLINE, + ONLINE, + PLAYING_OFFLINE, + PLAYING_HOSTED_SERVER, + PLAYING_REALMS, + PLAYING_SERVER +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/EnumUpdateType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/EnumUpdateType.java new file mode 100644 index 0000000000..244e6c0d81 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/EnumUpdateType.java @@ -0,0 +1,23 @@ +/* + * 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; + +public enum EnumUpdateType { + ADD, + REMOVE, +} 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..f60458c12a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendControl.java @@ -0,0 +1,32 @@ +/* + * 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 org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.UUID; + +public interface FriendControl { + FriendResponse getFriendList() throws IOException; + + FriendResponse updateFriend(@Nullable String name, @Nullable UUID uuid, @NotNull EnumUpdateType updateType) throws IOException; + + PresenceResponse getPresence(@NotNull EnumPresenceStatus selfPresence) 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..988022b88f --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendItem.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.UUID; + +@JsonSerializable +public record FriendItem( + @SerializedName("profileId") UUID 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..72d0ce026c --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendResponse.java @@ -0,0 +1,30 @@ +/* + * 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, + @SerializedName("empty") boolean empty) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendUpdateRequst.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendUpdateRequst.java new file mode 100644 index 0000000000..f281fad708 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/FriendUpdateRequst.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; + +@JsonSerializable +public record FriendUpdateRequst( + @SerializedName("name") String name, + @SerializedName("profileId") String profileId, + @SerializedName("updateType") EnumUpdateType updateType +) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceItem.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceItem.java new file mode 100644 index 0000000000..646fe95fab --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceItem.java @@ -0,0 +1,25 @@ +/* + * 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 java.time.Instant; + +public record PresenceItem(@SerializedName("profileId") String profileId, @SerializedName("status") EnumPresenceStatus status , @SerializedName("lastUpdated") Instant lastUpdated) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceRequst.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceRequst.java new file mode 100644 index 0000000000..be4df940e3 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceRequst.java @@ -0,0 +1,25 @@ +/* + * 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 PresenceRequst(@SerializedName("status") EnumPresenceStatus status) { +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceResponse.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceResponse.java new file mode 100644 index 0000000000..bba1ed39ed --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/friend/PresenceResponse.java @@ -0,0 +1,25 @@ +/* + * 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 java.util.List; + +public record PresenceResponse(@SerializedName("presence") List presence) { +} 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 8431d08ac8..066076704e 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 @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.util.io; +import com.google.gson.Gson; import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.task.Schedulers; @@ -188,6 +189,10 @@ public T json(Object payload) throws JsonParseException { return string(payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json"); } + public T json(Object payload, Gson gson) throws JsonParseException { + return string(payload instanceof String ? (String) payload : gson.toJson(payload), "application/json"); + } + public T form(Map params) { return string(NetworkUtils.withQuery("", params), "application/x-www-form-urlencoded"); }