diff --git a/1.16_combat-6/build.gradle.kts b/1.16_combat-6/build.gradle.kts index 3dac0cf7d..acdabf07b 100644 --- a/1.16_combat-6/build.gradle.kts +++ b/1.16_combat-6/build.gradle.kts @@ -73,6 +73,7 @@ tasks.processResources { tasks.runClient { classpath(sourceSets.getByName("test").runtimeClasspath) + jvmArgs("-XX:+AllowEnhancedClassRedefinition", "-XX:+IgnoreUnrecognizedVMOptions") } tasks.withType(JavaCompile::class).configureEach { diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/mixin/ConfigVanillaButtonWidgetMixin.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/mixin/ConfigVanillaButtonWidgetMixin.java new file mode 100644 index 000000000..1832b6a56 --- /dev/null +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/mixin/ConfigVanillaButtonWidgetMixin.java @@ -0,0 +1,61 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.mixin; + +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.vanilla.widgets.VanillaButtonWidget; +import io.github.axolotlclient.modules.hud.util.DrawUtil; +import io.github.axolotlclient.util.ButtonWidgetTextures; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(VanillaButtonWidget.class) +public abstract class ConfigVanillaButtonWidgetMixin extends ButtonWidget { + @Shadow(remap = false) + public abstract int getX(); + + @Shadow(remap = false) + public abstract int getY(); + + private ConfigVanillaButtonWidgetMixin(int x, int y, int width, int height, Text message, PressAction action) { + super(x, y, width, height, message, action); + } + + @Redirect(method = "renderButton", at = @At(value = "INVOKE", target = "Lio/github/axolotlclient/AxolotlClientConfig/impl/ui/vanilla/widgets/VanillaButtonWidget;drawTexture(Lnet/minecraft/client/util/math/MatrixStack;IIIIII)V", ordinal = 0)) + private void drawTexture$1(VanillaButtonWidget instance, MatrixStack stack, int x, int y, int u, int v, int width, int height) { + + } + + @Redirect(method = "renderButton", at = @At(value = "INVOKE", target = "Lio/github/axolotlclient/AxolotlClientConfig/impl/ui/vanilla/widgets/VanillaButtonWidget;drawTexture(Lnet/minecraft/client/util/math/MatrixStack;IIIIII)V", ordinal = 1)) + private void drawTexture$2$replaceWithNineSlice(VanillaButtonWidget instance, MatrixStack stack, int x, int y, int u, int v, int width, int height) { + Identifier tex = ButtonWidgetTextures.get(getYImage(hovered)); + DrawUtil.blitSprite(tex, getX(), getY(), getWidth(), getHeight(), new DrawUtil.NineSlice(200, 20, 3)); + MinecraftClient.getInstance().getTextureManager().bindTexture(WIDGETS_LOCATION); + } +} diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/mixin/skins/PlayerSkinTextureAccessor.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/mixin/skins/PlayerSkinTextureAccessor.java new file mode 100644 index 000000000..c14dc1a3d --- /dev/null +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/mixin/skins/PlayerSkinTextureAccessor.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.mixin.skins; + +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.PlayerSkinTexture; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(PlayerSkinTexture.class) +public interface PlayerSkinTextureAccessor { + + @Invoker("remapTexture") + static NativeImage invokeRemapTexture(NativeImage img) { + throw new UnsupportedOperationException(); + } +} diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java index 3adb33a3d..081ae3749 100644 --- a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java @@ -22,6 +22,7 @@ package io.github.axolotlclient.modules.auth; +import io.github.axolotlclient.modules.auth.skin.SkinManagementScreen; import net.minecraft.client.gui.screen.ConfirmScreen; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.screen.ScreenTexts; @@ -36,6 +37,7 @@ public class AccountsScreen extends Screen { private ButtonWidget loginButton; private ButtonWidget deleteButton; private ButtonWidget refreshButton; + private ButtonWidget skinsButton; public AccountsScreen(Screen currentScreen) { super(new TranslatableText("accounts")); @@ -77,10 +79,14 @@ public void init() { accountsListWidget.setAccounts(Auth.getInstance().getAccounts()); - addButton(loginButton = new ButtonWidget(this.width / 2 - 154, this.height - 52, 150, 20, new TranslatableText("auth.login"), + addButton(loginButton = new ButtonWidget(this.width / 2 - 154, this.height - 52, 100, 20, new TranslatableText("auth.login"), buttonWidget -> login())); - this.addButton(new ButtonWidget(this.width / 2 + 4, this.height - 52, 150, 20, new TranslatableText("auth.add"), + addButton(skinsButton = new ButtonWidget(this.width / 2 - 50, this.height - 52, 100, 20, new TranslatableText("skins.manage"), + btn -> client.openScreen(new SkinManagementScreen( + this, accountsListWidget.getSelected().getAccount())))); + + this.addButton(new ButtonWidget(this.width / 2 + 4 + 50, this.height - 52, 100, 20, new TranslatableText("auth.add"), button -> { if (!Auth.getInstance().allowOfflineAccounts()) { initMSAuth(); @@ -120,17 +126,14 @@ public void removed() { } private void initMSAuth() { - Auth.getInstance().getAuth().startDeviceAuth().thenRun(() -> client.execute(this::refresh)); + Auth.getInstance().getMsApi().startDeviceAuth().thenRun(() -> client.execute(this::refresh)); } private void refreshAccount() { refreshButton.active = false; AccountsListWidget.Entry entry = accountsListWidget.getSelected(); if (entry != null) { - entry.getAccount().refresh(Auth.getInstance().getAuth()).thenRun(() -> client.execute(() -> { - Auth.getInstance().save(); - refresh(); - })); + entry.getAccount().refresh(Auth.getInstance().getMsApi()); } } @@ -138,9 +141,10 @@ private void updateButtonActivationStates() { AccountsListWidget.Entry entry = accountsListWidget.getSelected(); if (client.world == null && entry != null) { loginButton.active = entry.getAccount().isExpired() || !entry.getAccount().equals(Auth.getInstance().getCurrent()); - deleteButton.active = refreshButton.active = true; + refreshButton.active = skinsButton.active = !entry.getAccount().isOffline(); + deleteButton.active = true; } else { - loginButton.active = deleteButton.active = refreshButton.active = false; + loginButton.active = deleteButton.active = refreshButton.active = skinsButton.active = false; } } diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/Auth.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/Auth.java index 766b121a3..0298c9de5 100644 --- a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/Auth.java +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/Auth.java @@ -23,24 +23,24 @@ package io.github.axolotlclient.modules.auth; import java.util.*; +import java.util.concurrent.CompletableFuture; import com.mojang.authlib.GameProfile; import com.mojang.authlib.minecraft.MinecraftProfileTexture; import io.github.axolotlclient.AxolotlClient; -import io.github.axolotlclient.AxolotlClientConfig.api.options.OptionCategory; import io.github.axolotlclient.AxolotlClientConfig.impl.options.BooleanOption; import io.github.axolotlclient.api.API; import io.github.axolotlclient.api.types.User; import io.github.axolotlclient.api.util.UUIDHelper; import io.github.axolotlclient.mixin.MinecraftClientAccessor; import io.github.axolotlclient.modules.Module; +import io.github.axolotlclient.modules.auth.skin.SkinManager; import io.github.axolotlclient.util.ThreadExecuter; import io.github.axolotlclient.util.notifications.Notifications; import io.github.axolotlclient.util.options.GenericOption; import lombok.Getter; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.ConfirmScreen; -import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.util.DefaultSkinHelper; import net.minecraft.client.util.Session; import net.minecraft.text.TranslatableText; @@ -51,17 +51,20 @@ public class Auth extends Accounts implements Module { @Getter private final static Auth Instance = new Auth(); public final BooleanOption showButton = new BooleanOption("auth.showButton", false); + public final BooleanOption skinManagerAnimations = new BooleanOption("skins.manage.animations", true); private final MinecraftClient client = MinecraftClient.getInstance(); private final GenericOption viewAccounts = new GenericOption("viewAccounts", "clickToOpen", () -> client.openScreen(new AccountsScreen(client.currentScreen))); private final Map textures = new HashMap<>(); private final Set loadingTexture = new HashSet<>(); private final Map profileCache = new WeakHashMap<>(); + @Getter + private final SkinManager skinManager = new SkinManager(); @Override public void init() { load(); - this.auth = new MSAuth(AxolotlClient.LOGGER, this, () -> client.options.language); + this.msApi = new MSApi(this, () -> client.options.language); if (isContained(client.getSession().getUuid())) { current = getAccounts().stream().filter(account -> account.getUuid().equals(client.getSession().getUuid())).toList().get(0); current.setAuthToken(client.getSession().getAccessToken()); @@ -73,7 +76,6 @@ public void init() { current = new Account(client.getSession().getUsername(), client.getSession().getUuid(), client.getSession().getAccessToken()); } - OptionCategory category = OptionCategory.create("auth"); category.add(showButton, viewAccounts); AxolotlClient.config().general.add(category); } @@ -88,12 +90,10 @@ protected void login(Account account) { if (account.isExpired()) { Notifications.getInstance().addStatus(new TranslatableText("auth.notif.title"), new TranslatableText("auth.notif.refreshing", account.getName())); } - account.refresh(auth).thenAccept(res -> { - res.ifPresent(a -> { - if (!a.isExpired()) { - login(a); - } - }); + account.refresh(msApi).thenAccept(a -> { + if (!a.isExpired()) { + login(a); + } }).thenRun(this::save); } else { try { @@ -138,14 +138,18 @@ public void loadTextures(String uuid, String name) { } @Override - void showAccountsExpiredScreen(Account account) { - Screen current = client.currentScreen; + CompletableFuture showAccountsExpiredScreen(Account account) { + var screen = client.currentScreen; + var fut = new CompletableFuture(); client.execute(() -> client.openScreen(new ConfirmScreen((bl) -> { - client.openScreen(current); if (bl) { - auth.startDeviceAuth(); + msApi.startDeviceAuth().thenRun(() -> fut.complete(account)); + } else { + fut.cancel(true); } + client.openScreen(screen); }, new TranslatableText("auth"), new TranslatableText("auth.accountExpiredNotice", account.getName())))); + return fut; } @Override diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java new file mode 100644 index 000000000..045ba579d --- /dev/null +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java @@ -0,0 +1,863 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.mojang.blaze3d.systems.RenderSystem; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.AxolotlClientConfig.api.util.Colors; +import io.github.axolotlclient.api.SimpleTextInputScreen; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import io.github.axolotlclient.modules.hud.util.DrawUtil; +import io.github.axolotlclient.util.ButtonWidgetTextures; +import io.github.axolotlclient.util.ClientColors; +import io.github.axolotlclient.util.Watcher; +import io.github.axolotlclient.util.notifications.Notifications; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.Drawable; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.ParentElement; +import net.minecraft.client.gui.screen.ConfirmScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.ScreenTexts; +import net.minecraft.client.gui.widget.AbstractButtonWidget; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.client.render.Tessellator; +import net.minecraft.client.render.VertexFormats; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.LiteralText; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.text.TranslatableText; +import net.minecraft.util.Identifier; +import net.minecraft.util.Util; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SkinManagementScreen extends Screen { + private static final Path SKINS_DIR = FabricLoader.getInstance().getGameDir().resolve("skins"); + private static final int LIST_SKIN_WIDTH = 75; + private static final int LIST_SKIN_HEIGHT = 110; + private static final Text TEXT_EQUIPPING = new TranslatableText("skins.manage.equipping"); + private final Screen parent; + private final Account account; + private MSApi.MCProfile cachedProfile; + private SkinListWidget skinList; + private SkinListWidget capesList; + private boolean capesTab; + private SkinWidget current; + private final Watcher skinDirWatcher; + private final List drawables = new ArrayList<>(); + private final CompletableFuture refreshFuture; + private Text tooltip; + + public SkinManagementScreen(Screen parent, Account account) { + super(new TranslatableText("skins.manage")); + this.parent = parent; + this.account = account; + skinDirWatcher = Watcher.createSelfTicking(SKINS_DIR, () -> { + AxolotlClientCommon.getInstance().getLogger().info("Reloading screen as local files changed!"); + loadSkinsList(); + }); + if (account.needsRefresh()) { + refreshFuture = account.refresh(Auth.getInstance().getMsApi()); + } else { + refreshFuture = CompletableFuture.completedFuture(null); + } + } + + @Override + public void init() { + int headerHeight = 33; + int contentHeight = height - headerHeight * 2; + + var back = addDrawableChild(new ButtonWidget(width / 2 - 75, height - headerHeight / 2 - 10, 150, 20, ScreenTexts.BACK, btn -> onClose())); + + var loadingPlaceholder = new AbstractButtonWidget(0, headerHeight, width, contentHeight, new TranslatableText("skins.loading")) { + @Override + public void renderButton(MatrixStack graphics, int mouseX, int mouseY, float delta) { + int centerX = this.x + this.getWidth() / 2; + int centerY = this.y + this.getHeight() / 2; + Text text = this.getMessage(); + textRenderer.draw(graphics, text, centerX - textRenderer.getWidth(text) / 2f, centerY - 9, -1); + String string = switch ((int) (Util.getMeasuringTimeMs() / 300L % 4L)) { + case 1, 3 -> "o O o"; + case 2 -> "o o O"; + default -> "O o o"; + }; + textRenderer.draw(graphics, string, centerX - textRenderer.getWidth(string) / 2f, centerY + 9, 0xFF808080); + } + + @Override + protected MutableText getNarrationMessage() { + return LiteralText.EMPTY.copy(); + } + }; + loadingPlaceholder.active = false; + addDrawableChild(loadingPlaceholder); + addDrawableChild(back); + + skinList = new SkinListWidget(client, width / 2, contentHeight - 24, headerHeight + 24, LIST_SKIN_HEIGHT + 34); + capesList = new SkinListWidget(client, width / 2, contentHeight - 24, headerHeight + 24, skinList.getEntryContentsHeight() + 24); + skinList.setLeftPos(width / 2); + capesList.setLeftPos(width / 2); + var currentHeight = Math.min((width / 2f) * 120 / 85, contentHeight); + var currentWidth = currentHeight * 85 / 120; + current = new SkinWidget((int) currentWidth, (int) currentHeight, null, account); + current.setPosition((int) (width / 4f - currentWidth / 2), (int) (height / 2f - currentHeight / 2)); + + if (!capesTab) { + capesList.visible = capesList.active = false; + } else { + skinList.visible = skinList.active = false; + } + List navBar = new ArrayList<>(); + var skinsTab = new ButtonWidget(Math.max(width * 3 / 4 - 102, width / 2 + 2), headerHeight, Math.min(100, width / 4 - 2), 20, new TranslatableText("skins.nav.skins"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = true; + capesList.visible = capesList.active = false; + capesTab = false; + }); + navBar.add(skinsTab); + var capesTab = new ButtonWidget(width * 3 / 4 + 2, headerHeight, Math.min(100, width / 4 - 2), 20, new TranslatableText("skins.nav.capes"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = false; + capesList.visible = capesList.active = true; + this.capesTab = true; + }); + navBar.add(capesTab); + var importButton = new SpriteButton(new TranslatableText("skins.manage.import.local"), btn -> { + btn.active = false; + SkinImportUtil.openImportSkinDialog().thenAccept(this::filesDragged).thenRun(() -> btn.active = true); + }, new Identifier("axolotlclient", "textures/gui/sprites/folder.png")); + var downloadButton = new SpriteButton(new TranslatableText("skins.manage.import.online"), btn -> { + btn.active = false; + promptForSkinDownload(); + }, new Identifier("axolotlclient", "textures/gui/sprites/download.png")); + downloadButton.x = importButton.x - 2 - 11; + downloadButton.y = capesTab.y - 13; + if (width - (capesTab.x + capesTab.getWidth()) > 28) { + importButton.x = width - importButton.getWidth() - 2; + downloadButton.x = importButton.x - downloadButton.getWidth() - 2; + importButton.y = capesTab.y + capesTab.getHeight() - 11; + downloadButton.y = importButton.y; + } else { + importButton.x = capesTab.x + capesTab.getWidth() - 11; + importButton.y = capesTab.y - 13; + downloadButton.x = importButton.x - 2 - 11; + downloadButton.y = importButton.y; + } + skinsTab.active = this.capesTab; + capesTab.active = !this.capesTab; + Runnable addWidgets = () -> { + clear(); + addDrawableChild(current); + addDrawableChild(skinsTab); + addDrawableChild(capesTab); + addDrawableChild(downloadButton); + addDrawableChild(importButton); + addDrawableChild(skinList); + addDrawableChild(capesList); + addDrawableChild(back); + }; + if (cachedProfile != null) { + initDisplay(); + addWidgets.run(); + return; + } + refreshFuture.thenComposeAsync(unused -> Auth.getInstance().getMsApi().getProfile(account)) + .thenAcceptAsync(profile -> { + cachedProfile = profile; + initDisplay(); + addWidgets.run(); + }).exceptionally(t -> { + if (t.getCause() instanceof CancellationException) { + client.openScreen(parent); + return null; + } + AxolotlClientCommon.getInstance().getLogger().error("Failed to load skins!", t); + var error = new TranslatableText("skins.error.failed_to_load"); + var errorDesc = new TranslatableText("skins.error.failed_to_load_desc"); + clear(); + addDrawableChild(back); + class TextWidget extends AbstractButtonWidget { + + public TextWidget(int x, int y, int width, int height, Text message, TextRenderer textRenderer) { + super(x, y, width, height, message); + active = false; + } + + @Override + public void renderButton(MatrixStack matrices, int mouseX, int mouseY, float delta) { + drawCenteredText(matrices, textRenderer, getMessage(), x + getWidth() / 2, y + getHeight() / 2 - textRenderer.fontHeight / 2, -1); + } + } + addDrawableChild(new TextWidget(width / 2 - textRenderer.getWidth(error) / 2, height / 2 - textRenderer.fontHeight - 2, textRenderer.getWidth(error), textRenderer.fontHeight, error, textRenderer)); + addDrawableChild(new TextWidget(width / 2 - textRenderer.getWidth(errorDesc) / 2, height / 2 + 1, textRenderer.getWidth(errorDesc), textRenderer.fontHeight, errorDesc, textRenderer)); + return null; + }); + } + + private void promptForSkinDownload() { + client.openScreen(new SimpleTextInputScreen(this, new TranslatableText("skins.manage.import.online"), new TranslatableText("skins.manage.import.online.input"), s -> + UUIDHelper.ensureUuidOpt(s).thenAccept(o -> { + if (o.isPresent()) { + AxolotlClientCommon.getInstance().getLogger().info("Downloading skin of {} ({})", s, o.get()); + Auth.getInstance().getMsApi().getTextures(o.get()) + .exceptionally(th -> { + AxolotlClientCommon.getInstance().getLogger().info("Failed to download skin of {} ({})", s, o.get(), th); + return null; + }).thenAccept(t -> { + if (t == null) { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_download", s); + return; + } + try { + var bytes = t.skin().join(); + var out = ensureNonexistent(SKINS_DIR.resolve(t.skinKey())); + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, t.classicModel(), "name", t.name(), "uuid", t.id(), "download_time", Instant.now())); + Files.write(out, bytes); + client.execute(this::loadSkinsList); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.downloaded", t.name()); + AxolotlClientCommon.getInstance().getLogger().info("Downloaded skin of {} ({})", t.name(), o.get()); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to write skin file", e); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_save", t.name()); + } + }); + } else { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.not_found", s); + } + }))); + } + + private T addDrawableChild(T child) { + drawables.add(child); + return addChild(child); + } + + private void clear() { + children.clear(); + buttons.clear(); + drawables.clear(); + } + + @Override + public void render(MatrixStack graphics, int mouseX, int mouseY, float delta) { + tooltip = null; + renderBackground(graphics); + drawables.forEach(d -> d.render(graphics, mouseX, mouseY, delta)); + drawCenteredText(graphics, textRenderer, getTitle(), width / 2, 33 / 2 - textRenderer.fontHeight / 2, -1); + if (tooltip != null) { + renderTooltip(graphics, tooltip, mouseX, mouseY + 20); + } + } + + private void initDisplay() { + loadSkinsList(); + loadCapesList(); + } + + private void refreshCurrentList() { + if (capesTab) { + var scroll = capesList.getScrollAmount(); + loadCapesList(); + capesList.setScrollAmount(scroll); + } else { + var scroll = skinList.getScrollAmount(); + loadSkinsList(); + skinList.setScrollAmount(scroll); + } + } + + private void loadCapesList() { + capesList.clearEntries0(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + var capes = profile.capes(); + var deselectCape = createWidgetForCape(current.getSkin(), null); + var activeCape = capes.stream().filter(Cape::active).findFirst(); + current.setCape(activeCape.orElse(null)); + deselectCape.noCape(activeCape.isEmpty()); + for (int i = 0; i < capes.size() + 1; i += columns) { + Entry widget; + if (i == 0) { + widget = createEntry(capesList.getEntryContentsHeight(), deselectCape, new TranslatableText("skins.capes.no_cape")); + } else { + var cape = capes.get(i - 1); + widget = createEntryForCape(current.getSkin(), cape, capesList.getEntryContentsHeight()); + } + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < capes.size() + 1 - c)) continue; + var cape2 = capes.get(i + c - 1); + Entry widget2 = createEntryForCape(current.getSkin(), cape2, capesList.getEntryContentsHeight()); + + widgets.add(widget2); + } + capesList.addEntry(new Row(widgets)); + } + } + + private void loadSkinsList() { + skinList.clearEntries0(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + List skins = new ArrayList<>(profile.skins()); + var hashes = skins.stream().map(Asset::textureKey).collect(Collectors.toSet()); + var defaultSkinHash = Auth.getInstance().getSkinManager().getDefaultSkinHash(account); + var local = new ArrayList<>(loadLocalSkins()); + var localHashes = local.stream().collect(Collectors.toMap(Asset::textureKey, Function.identity(), (skin, skin2) -> skin)); + local.removeIf(s -> !localHashes.containsValue(s)); + skins.replaceAll(s -> { + if (s instanceof MSApi.MCProfile.OnlineSkin online) { + if (localHashes.containsKey(s.textureKey()) && localHashes.get(s.textureKey()) instanceof Skin.Local file) { + local.remove(localHashes.remove(s.textureKey())); + return new Skin.Shared(file, online); + } + } + return s; + }); + skins.addAll(local); + if (!hashes.contains(defaultSkinHash)) { + skins.add(null); + } + populateSkinList(skins, columns); + } + + private List loadLocalSkins() { + try { + Files.createDirectories(SKINS_DIR); + try (Stream skins = Files.list(SKINS_DIR)) { + return skins.filter(Files::isRegularFile).sorted(Comparator.comparingLong(p -> { + try { + return Files.getLastModifiedTime(p).toMillis(); + } catch (IOException e) { + return 0L; + } + }).reversed()).map(Auth.getInstance().getSkinManager()::read).filter(Objects::nonNull).toList(); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to read skins dir!", e); + } + return Collections.emptyList(); + } + + private void populateSkinList(List skins, int columns) { + int entryHeight = skinList.getEntryContentsHeight(); + for (int i = 0; i < skins.size(); i += columns) { + var s = skins.get(i); + if (s != null && s.active()) { + current.setSkin(s); + } + var widget = createEntryForSkin(s, entryHeight); + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < skins.size() - c)) continue; + var s2 = skins.get(i + c); + if (s2 != null && s2.active()) { + current.setSkin(s2); + } + var widget2 = createEntryForSkin(s2, entryHeight); + widgets.add(widget2); + } + skinList.addEntry(new Row(widgets)); + } + } + + private Path ensureNonexistent(Path p) { + if (Files.exists(p)) { + int counter = 0; + do { + counter++; + p = p.resolveSibling(p.getFileName().toString() + "_" + counter); + } while (Files.exists(p)); + } + return p; + } + + @Override + public void filesDragged(List packs) { + if (packs.isEmpty()) return; + + CompletableFuture[] futs = new CompletableFuture[packs.size()]; + for (int i = 0; i < packs.size(); i++) { + Path p = packs.get(i); + futs[i] = CompletableFuture.runAsync(() -> { + try { + var target = ensureNonexistent(SKINS_DIR.resolve(p.getFileName())); + var skin = Auth.getInstance().getSkinManager().read(p, false); + if (skin != null) { + Files.write(target, skin.image().join()); + } else { + AxolotlClientCommon.getInstance().getLogger().info("Skipping dragged file {} because it does not seem to be a valid skin!", p); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.not_copied", p.getFileName()); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to copy skin file: ", e); + } + }, client); + } + CompletableFuture.allOf(futs).thenRun(this::loadSkinsList); + } + + private @NotNull Entry createEntryForSkin(Skin skin, int entryHeight) { + return createEntry(entryHeight, new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, skin, account)); + } + + private @NotNull Entry createEntryForCape(Skin currentSkin, Cape cape, int entryHeight) { + return createEntry(entryHeight, createWidgetForCape(currentSkin, cape), new LiteralText(cape.alias())); + } + + private SkinWidget createWidgetForCape(Skin currentSkin, Cape cape) { + SkinWidget widget2 = new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, currentSkin, cape, account); + widget2.setRotationY(210); + return widget2; + } + + @Override + public void resize(MinecraftClient client, int width, int height) { + Auth.getInstance().getSkinManager().releaseAll(); + super.resize(client, width, height); + } + + @Override + public void removed() { + Auth.getInstance().getSkinManager().releaseAll(); + Watcher.close(skinDirWatcher); + } + + @Override + public void onClose() { + client.openScreen(parent); + } + + private SkinListWidget getCurrentList() { + return capesTab ? capesList : skinList; + } + + private class SkinListWidget extends ElementListWidget { + public boolean active = true, visible = true; + + public SkinListWidget(MinecraftClient minecraft, int width, int height, int y, int entryHeight) { + super(minecraft, width, SkinManagementScreen.this.height, y, y + height, entryHeight); + setRenderHeader(false, 0); + //setRenderBackground(false); + } + + @Override + public int addEntry(Row entry) { + return super.addEntry(entry); + } + + @Override + protected int getScrollbarPositionX() { + return right - 8; + } + + @Override + public int getRowLeft() { + return left + 3; + } + + @Override + public int getRowWidth() { + if (!(getMaxScroll() > 0)) { + return width - 4; + } + return width - 14; + } + + private int getMaxScroll() { + return Math.max(0, this.getMaxPosition() - (this.bottom - this.top - 4)); + } + + public int getEntryContentsHeight() { + return itemHeight - 4; + } + + public void clearEntries0() { + super.clearEntries(); + } + + @Override + public void centerScrollOn(Row entry) { + super.centerScrollOn(entry); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double amountY) { + if (!visible) return false; + return super.mouseScrolled(mouseX, mouseY, amountY); + } + + @Override + public boolean isMouseOver(double mouseX, double mouseY) { + return active && visible && super.isMouseOver(mouseX, mouseY); + } + + @Override + public void render(MatrixStack graphics, int mouseX, int mouseY, float delta) { + if (!visible) return; + super.render(graphics, mouseX, mouseY, delta); + } + } + + private class Row extends ElementListWidget.Entry { + private final List widgets; + + public Row(List entries) { + this.widgets = entries; + } + + @Override + public void render(MatrixStack guiGraphics, int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovering, float partialTick) { + int x = left; + if (widgets.isEmpty()) return; + int count = widgets.size(); + int padding = ((width - 5 * (count - 1)) / count); + for (var w : widgets) { + w.x = x; + w.y = top; + w.setWidth(padding); + w.render(guiGraphics, mouseX, mouseY, partialTick); + x += w.getWidth() + 5; + } + } + + @Override + public @NotNull List children() { + return widgets; + } + + @Override + public void setFocused(@Nullable Element focused) { + super.setFocused(focused); + if (focused != null) { + getCurrentList().centerScrollOn(this); + } + } + } + + Entry createEntry(int height, SkinWidget widget) { + return createEntry(height, widget, null); + } + + Entry createEntry(int height, SkinWidget widget, Text label) { + return new Entry(height, widget, label); + } + + private class Entry extends AbstractButtonWidget implements ParentElement { + private final SkinWidget skinWidget; + private final @Nullable AbstractButtonWidget label; + private final List actionButtons = new ArrayList<>(); + private final AbstractButtonWidget equipButton; + private boolean equipping; + private long equippingStart; + @Nullable + private Element focused; + private boolean dragging; + + public Entry(int height, SkinWidget widget, @Nullable Text label) { + super(0, 0, widget.getWidth(), height, LiteralText.EMPTY); + widget.setWidth(getWidth() - 4); + var asset = widget.getFocusedAsset(); + if (asset instanceof Skin skin) { + var wideSprite = new Identifier("axolotlclient", "textures/gui/sprites/wide.png"); + var slimSprite = new Identifier("axolotlclient", "textures/gui/sprites/slim.png"); + var slimText = new TranslatableText("skins.manage.variant.classic"); + var wideText = new TranslatableText("skins.manage.variant.slim"); + actionButtons.add(new SpriteButton(skin.classicVariant() ? wideText : slimText, btn -> { + var self = (SpriteButton) btn; + skin.classicVariant(!skin.classicVariant()); + self.sprite = skin.classicVariant() ? slimSprite : wideSprite; + self.setMessage(skin.classicVariant() ? wideText : slimText); + }, skin.classicVariant() ? slimSprite : wideSprite)); + } + if (asset != null) { + if (asset.isLocal()) { + this.actionButtons.add(new SpriteButton(new TranslatableText("skins.manage.delete"), btn -> { + btn.active = false; + client.openScreen(new ConfirmScreen(confirmed -> { + client.openScreen(SkinManagementScreen.this); + if (confirmed) { + try { + Files.delete(asset.file()); + Skin.Local.deleteMetadata(asset.file()); + refreshCurrentList(); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to delete: ", e); + } + } + btn.active = true; + }, new TranslatableText("skins.manage.delete.confirm"), (Text) (asset.active() ? + new TranslatableText("skins.manage.delete.confirm.desc_active") : + new TranslatableText("skins.manage.delete.confirm.desc") + ).br$color(Colors.RED.toInt()))); + }, new Identifier("axolotlclient", "textures/gui/sprites/delete.png"))); + } + if (asset.supportsDownload() && !asset.isLocal()) { + this.actionButtons.add(new SpriteButton(new TranslatableText("skins.manage.download"), btn -> { + btn.active = false; + asset.image().thenAcceptAsync(b -> { + try { + var out = SKINS_DIR.resolve(asset.textureKey()); + Files.createDirectories(out.getParent()); + Files.write(out, b); + if (asset instanceof Skin skin) { + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, skin.classicVariant())); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to download: ", e); + } + refreshCurrentList(); + btn.active = true; + }); + }, new Identifier("axolotlclient", "textures/gui/sprites/download.png"))); + } + } + if (label != null) { + this.label = new AbstractButtonWidget(0, 0, widget.getWidth(), 16, label) { + @Override + public void renderButton(MatrixStack guiGraphics, int mouseX, int mouseY, float partialTick) { + DrawUtil.drawScrollableText(guiGraphics, textRenderer, getMessage(), x + 2, y, x + width - 2, y + height, -1); + } + }; + this.label.active = false; + } else { + this.label = null; + } + this.equipButton = new ButtonWidget(0, 0, widget.getWidth(), 20, new TranslatableText( + widget.isEquipped() ? "skins.manage.equipped" : "skins.manage.equip"), + btn -> { + equippingStart = Util.getMeasuringTimeMs(); + equipping = true; + btn.setMessage(TEXT_EQUIPPING); + btn.active = false; + widget.equip().thenAcceptAsync(p -> { + cachedProfile = p; + refreshCurrentList(); + }).exceptionally(t -> { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to equip asset!", t); + equipping = false; + return null; + }); + }); + this.equipButton.active = !widget.isEquipped(); + this.skinWidget = widget; + } + + @Override + public final boolean isDragging() { + return this.dragging; + } + + @Override + public final void setDragging(boolean dragging) { + this.dragging = dragging; + } + + @Nullable + @Override + public Element getFocused() { + return this.focused; + } + + @Override + public void setFocused(@Nullable Element child) { + this.focused = child; + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return ParentElement.super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return ParentElement.super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return ParentElement.super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + @Override + public boolean isFocused() { + return getFocused() != null; + } + + @Override + public void setFocused(boolean focused) { + + } + + @Override + public @NotNull List children() { + return Stream.concat(actionButtons.stream(), Stream.of(skinWidget, label, equipButton)).filter(Objects::nonNull).toList(); + } + + private float applyEasing(float x) { + return x * x * x; + } + + @Override + public void renderButton(MatrixStack guiGraphics, int mouseX, int mouseY, float partialTick) { + int y = this.y + 4; + int x = this.x + 2; + skinWidget.setPosition(x, y); + skinWidget.setWidth(getWidth() - 4); + if (skinWidget.isEquipped() || equipping) { + long prog; + if (Auth.getInstance().skinManagerAnimations.get()) { + if (equipping) prog = (Util.getMeasuringTimeMs() - equippingStart) / 20 % 100; + else prog = Math.abs((Util.getMeasuringTimeMs() / 30 % 200) - 100); + } else prog = 100; + var percent = (prog / 100f); + float gradientWidth; + if (equipping) { + gradientWidth = percent * Math.min(getWidth() / 3f, getHeight() / 3f); + } else { + gradientWidth = Math.min(getWidth() / 15f, getHeight() / 6f) + applyEasing(percent) * Math.min(getWidth() * 2 / 15f, getHeight() / 6f); + } + GradientHoleRectangleRenderState.render(guiGraphics, this.x + 2, this.y + 2, this.x + getWidth() - 2, + skinWidget.getY() + skinWidget.getHeight() + 2, + gradientWidth, + equipping ? 0xFFFF0088 : ClientColors.SELECTOR_GREEN.toInt(), 0); + } + skinWidget.render(guiGraphics, mouseX, mouseY, partialTick); + int actionButtonY = this.y + 2; + for (var button : actionButtons) { + button.x = skinWidget.getX() + skinWidget.getWidth() - button.getWidth(); + button.y = actionButtonY; + if (isHovered() || button.isHovered()) { + button.render(guiGraphics, mouseX, mouseY, partialTick); + } + actionButtonY += button.getHeight() + 2; + } + if (label != null) { + label.x = x; + label.y = skinWidget.getY() + skinWidget.getHeight() + 6; + label.render(guiGraphics, mouseX, mouseY, partialTick); + label.setWidth(getWidth() - 4); + equipButton.x = x; + equipButton.y = label.y + label.getHeight() + 2; + } else { + equipButton.x = x; + equipButton.y = skinWidget.getY() + skinWidget.getHeight() + 4; + } + equipButton.setWidth(getWidth() - 4); + equipButton.render(guiGraphics, mouseX, mouseY, partialTick); + + if (isHovered()) { + guiGraphics.br$outlineRect(this.x, this.y, getWidth(), getHeight(), -1); + } + } + + private static class GradientHoleRectangleRenderState { + + public static void render(MatrixStack graphics, int x0, int y0, int x1, int y1, float gradientWidth, int col1, int col2) { + RenderSystem.disableTexture(); + RenderSystem.enableBlend(); + RenderSystem.disableAlphaTest(); + RenderSystem.defaultBlendFunc(); + RenderSystem.shadeModel(7425); + var tessellator = Tessellator.getInstance(); + var vertexConsumer = tessellator.getBuffer(); + float z = 0; + //top + var pose = graphics.peek().getModel(); + vertexConsumer.begin(7, VertexFormats.POSITION_COLOR); + vertexConsumer.vertex(pose, x0, y0, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y0 + gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y0 + gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x1, y0, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + //left + vertexConsumer.vertex(pose, x0, y1, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y1 - gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y0 + gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x0, y0, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + //bottom + vertexConsumer.vertex(pose, x1, y1, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y1 - gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y1 - gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x0, y1, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + //right + vertexConsumer.vertex(pose, x1, y0, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y0 + gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y1 - gradientWidth, z).color(col2 >> 16 & 255, col2 >> 8 & 255, col2 & 255, col2 >> 24 & 255).next(); + vertexConsumer.vertex(pose, x1, y1, z).color(col1 >> 16 & 255, col1 >> 8 & 255, col1 & 255, col1 >> 24 & 255).next(); + tessellator.draw(); + RenderSystem.shadeModel(7424); + RenderSystem.disableBlend(); + RenderSystem.enableAlphaTest(); + RenderSystem.enableTexture(); + } + } + } + + private class SpriteButton extends ButtonWidget { + private Identifier sprite; + + public SpriteButton(Text message, PressAction onPress, Identifier sprite) { + super(0, 0, 11, 11, message, onPress); + this.sprite = sprite; + } + + @Override + public void renderButton(MatrixStack graphics, int mouseX, int mouseY, float delta) { + Identifier tex = ButtonWidgetTextures.get(getYImage(hovered)); + DrawUtil.blitSprite(tex, x, y, width, height, new DrawUtil.NineSlice(200, 20, 3)); + client.getTextureManager().bindTexture(sprite); + drawTexture(graphics, x + 2, y + 2, 0, 0, 7, 7, 7, 7); + if (this.isHovered()) { + tooltip = getMessage(); + } + } + } +} diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java new file mode 100644 index 000000000..24b2871e8 --- /dev/null +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java @@ -0,0 +1,162 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListSet; + +import com.google.common.hash.Hashing; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.AxoMinecraftClient; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.mixin.skins.PlayerSkinTextureAccessor; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.util.ClientColors; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.client.util.DefaultSkinHelper; +import net.minecraft.util.Identifier; + +public class SkinManager { + + private final Set loadedTextures = new ConcurrentSkipListSet<>(Comparator.comparing(Object::toString)); + + public Skin read(Path p) { + return read(p, true); + } + + @SuppressWarnings("UnstableApiUsage") + public Skin read(Path p, boolean fix) { + boolean slim; + String sha256; + try { + var in = Files.readAllBytes(p); + sha256 = Hashing.sha256().hashBytes(in).toString(); + try (var stream = new ByteArrayInputStream(in)) { + try (var img = NativeImage.read(stream)) { + int width = img.getWidth(); + int height = img.getHeight(); + if (width != 64) return null; + if (height == 32) { + if (fix) { + try (var img2 = PlayerSkinTextureAccessor.invokeRemapTexture(img)) { + img2.writeFile(p); + } + } + slim = false; + } else if (height != 64) { + return null; + } else { + slim = ClientColors.ARGB.alpha(img.getPixelColor(50, 16)) == 0; + } + var metadata = Skin.Local.readMetadata(p); + if (metadata != null && metadata.containsKey(Skin.Local.CLASSIC_METADATA_KEY)) { + slim = !(boolean) metadata.get(Skin.Local.CLASSIC_METADATA_KEY); + } + } + } + return new Skin.Local(!slim, p, sha256); + } catch (Exception e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to probe skin: ", e); + } + return null; + } + + public CompletableFuture loadSkin(Skin skin) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "skins/" + skin.textureKey()); + if (loadedTextures.contains(rl)) { + return CompletableFuture.completedFuture(rl); + } + + return skin.image().thenApplyAsync(bytes -> { + try (var stream = new ByteArrayInputStream(bytes)) { + var tex = new NativeImageBackedTexture(NativeImage.read(stream)); + tex.upload(); + MinecraftClient.getInstance().getTextureManager().registerTexture((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((v, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load skin!", t); + } + return v; + }); + } + + public AxoIdentifier loadCape(Cape cape) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "capes/" + cape.textureKey()); + if (loadedTextures.contains(rl)) { + return rl; + } + + return cape.image().thenApplyAsync(bytes -> { + try (var stream = new ByteArrayInputStream(bytes)) { + var tex = new NativeImageBackedTexture(NativeImage.read(stream)); + MinecraftClient.getInstance().getTextureManager().registerTexture((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((id, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load cape!", t); + } + return id; + }).getNow(null); + + } + + public void releaseAll() { + loadedTextures.forEach(id -> MinecraftClient.getInstance().getTextureManager().destroyTexture((Identifier) id)); + loadedTextures.clear(); + } + + @SuppressWarnings("UnstableApiUsage") + public String getDefaultSkinHash(Account account) { + var skin = DefaultSkinHelper.getTexture(UUIDHelper.fromUndashed(account.getUuid())); + var mc = MinecraftClient.getInstance(); + var resourceManager = mc.getResourceManager(); + try { + var res = resourceManager.getResource(skin); + try ( + var in = res.br$asStream()) { + return Hashing.sha256().hashBytes(in.readAllBytes()).toString(); + } + } catch (IOException ignored) { + } + return null; + } +} diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java new file mode 100644 index 000000000..aa18c8668 --- /dev/null +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java @@ -0,0 +1,116 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.util.function.Consumer; + +import com.mojang.blaze3d.systems.RenderSystem; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.model.ModelPart; +import net.minecraft.client.render.*; +import net.minecraft.client.render.entity.model.PlayerEntityModel; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.client.util.math.Vector3f; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +public class SkinRenderer { + private static PlayerEntityModel classicModel, slimModel; + + private SkinRenderer() { + } + + public static void render(MatrixStack graphics, boolean classicVariant, + Identifier skinTexture, + @Nullable Identifier cape, + float rotationX, + float rotationY, + float pivotY, + int x0, + int y0, + int x1, + int y1, + float scale) { + if (classicModel == null && classicVariant) { + classicModel = new PlayerEntityModel<>(0, false); + classicModel.child = false; + } + if (slimModel == null && !classicVariant) { + slimModel = new PlayerEntityModel<>(0, true); + slimModel.child = false; + } + + int width = x1 - x0; + DiffuseLighting.disable(); + graphics.push(); + graphics.translate(x0 + width / 2.0F, (float) (y1), 100.0F); + graphics.scale(scale, scale, scale); + graphics.translate(0.0F, -0.0625F, 0.0F); + graphics.translate(0, pivotY, 0); + graphics.multiply(Vector3f.POSITIVE_X.getDegreesQuaternion(rotationX)); + graphics.translate(0, -pivotY, 0); + graphics.multiply(Vector3f.POSITIVE_Y.getDegreesQuaternion(rotationY)); + graphics.push(); + graphics.scale(1.0F, 1.0F, -1.0F); + graphics.translate(0.0F, -1.5F, 0.0F); + var model = classicVariant ? classicModel : slimModel; + var tessellator = Tessellator.getInstance(); + RenderSystem.enableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.enableTexture(); + MinecraftClient.getInstance().getTextureManager().bindTexture(skinTexture); + var consumer = VertexConsumerProvider.immediate(tessellator.getBuffer()).getBuffer(model.getLayer(skinTexture)); + Consumer renderModelPart = m -> m.render(graphics, consumer, 15728880, OverlayTexture.DEFAULT_UV, 1, 1, 1, 1); + renderModelPart.accept(model.head); + renderModelPart.accept(model.torso); + renderModelPart.accept(model.rightArm); + renderModelPart.accept(model.leftArm); + renderModelPart.accept(model.rightLeg); + renderModelPart.accept(model.leftLeg); + renderModelPart.accept(model.helmet); + renderModelPart.accept(model.leftPantLeg); + renderModelPart.accept(model.rightPantLeg); + renderModelPart.accept(model.leftSleeve); + graphics.translate(0, 0, -0.62f); + renderModelPart.accept(model.rightSleeve); + graphics.translate(0, 0, 0.62f); + renderModelPart.accept(model.jacket); + tessellator.draw(); + if (cape != null) { + graphics.push(); + MinecraftClient.getInstance().getTextureManager().bindTexture(cape); + graphics.translate(0.0F, 0.0F, 0.125F); + graphics.multiply(Vector3f.POSITIVE_X.getDegreesQuaternion(6.0F)); + graphics.multiply(Vector3f.POSITIVE_Y.getDegreesQuaternion(180.0F)); + model.renderCape(graphics, VertexConsumerProvider.immediate(tessellator.getBuffer()).getBuffer(RenderLayer.getEntitySolid(cape)), 15728880, OverlayTexture.DEFAULT_UV); + tessellator.draw(); + graphics.pop(); + } + graphics.pop(); + + graphics.pop(); + RenderSystem.disableBlend(); + RenderSystem.disableDepthTest(); + DiffuseLighting.enableGuiDepthLighting(); + } +} diff --git a/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java new file mode 100644 index 000000000..ee19b52cd --- /dev/null +++ b/1.16_combat-6/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java @@ -0,0 +1,155 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.util.concurrent.CompletableFuture; + +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import lombok.Getter; +import lombok.Setter; +import net.minecraft.client.gui.widget.AbstractButtonWidget; +import net.minecraft.client.sound.SoundManager; +import net.minecraft.client.util.DefaultSkinHelper; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.LiteralText; +import net.minecraft.text.MutableText; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import org.jetbrains.annotations.Nullable; + +public class SkinWidget extends AbstractButtonWidget { + private static final float MODEL_HEIGHT = 2.125F; + private static final float FIT_SCALE = 0.97F; + private static final float ROTATION_SENSITIVITY = 2.5F; + private static final float DEFAULT_ROTATION_X = -5.0F; + private static final float DEFAULT_ROTATION_Y = 30.0F; + private static final float ROTATION_X_LIMIT = 50.0F; + private float rotationX = DEFAULT_ROTATION_X; + @Setter + private float rotationY = DEFAULT_ROTATION_Y; + @Getter + @Setter + private Skin skin; + @Getter + @Setter + private Cape cape; + private final Account owner; + private boolean noCape, noCapeActive; + + public SkinWidget(int width, int height, Skin skin, @Nullable Cape cape, Account owner) { + super(0, 0, width, height, LiteralText.EMPTY); + this.skin = skin; + this.cape = cape; + this.owner = owner; + } + + public SkinWidget(int width, int height, Skin skin, Account owner) { + this(width, height, skin, null, owner); + } + + public void noCape(boolean noCapeActive) { + noCape = true; + this.noCapeActive = noCapeActive; + } + + @Override + public void renderButton(MatrixStack guiGraphics, int mouseX, int mouseY, float partialTick) { + float scale = FIT_SCALE * this.getHeight() / MODEL_HEIGHT; + float pivotY = -1.0625F; + + AxoIdentifier skinRl; + boolean classic; + SkinManager skinManager = Auth.getInstance().getSkinManager(); + CompletableFuture loader = skin == null ? null : skinManager.loadSkin(skin); + if (loader != null && loader.isDone()) { + skinRl = loader.join(); + classic = skin.classicVariant(); + } else { + var uuid = UUIDHelper.fromUndashed(owner.getUuid()); + classic = DefaultSkinHelper.getModel(uuid).equals("default"); + skinRl = DefaultSkinHelper.getTexture(uuid); + } + var capeRl = cape == null ? null : skinManager.loadCape(cape); + + SkinRenderer.render(guiGraphics, classic, (Identifier) skinRl, (Identifier) capeRl, this.rotationX, this.rotationY, pivotY, this.getX(), this.getY(), this.getX() + getWidth(), this.getY() + getHeight(), scale); + } + + public int getY() { + return y; + } + + public int getX() { + return x; + } + + @Override + protected void onDrag(double mouseX, double mouseY, double dragX, double dragY) { + this.rotationX = MathHelper.clamp(this.rotationX - (float) dragY * ROTATION_SENSITIVITY, -ROTATION_X_LIMIT, ROTATION_X_LIMIT); + this.rotationY += (float) dragX * ROTATION_SENSITIVITY; + } + + @Override + public void playDownSound(SoundManager handler) { + } + + @Override + protected MutableText getNarrationMessage() { + return LiteralText.EMPTY.copy(); + } + + @Override + public boolean changeFocus(boolean lookForwards) { + return false; + } + + public boolean isEquipped() { + return noCape ? noCapeActive : (cape != null ? cape.active() : skin != null && skin.active()); + } + + public CompletableFuture equip() { + var msApi = Auth.getInstance().getMsApi(); + if (noCape) { + return msApi.hideCape(owner); + } + if (cape != null) { + return cape.equip(msApi, owner); + } + if (skin != null) { + return skin.equip(msApi, owner); + } + return msApi.resetSkin(owner); + } + + public Asset getFocusedAsset() { + return noCape ? null : cape != null ? cape : skin; + } + + public void setPosition(int x, int y) { + this.x = x; + this.y = y; + } +} diff --git a/1.16_combat-6/src/main/resources/axolotlclient.mixins.json b/1.16_combat-6/src/main/resources/axolotlclient.mixins.json index f9da4cbef..bb9947a9d 100644 --- a/1.16_combat-6/src/main/resources/axolotlclient.mixins.json +++ b/1.16_combat-6/src/main/resources/axolotlclient.mixins.json @@ -17,6 +17,7 @@ "ClientPlayerEntityMixin", "ClientPlayNetworkHandlerMixin", "ClientWorldMixin", + "ConfigVanillaButtonWidgetMixin", "DebugHudMixin", "DownloadingTerrainScreenMixin", "EmitterParticleMixin", @@ -62,6 +63,7 @@ "WorldRendererAccessor", "WorldRendererMixin", "api.JoinMulitplayerScreenMixin", + "skins.PlayerSkinTextureAccessor", "translation.LanguageMixin" ], "injectors": { diff --git a/1.20/build.gradle.kts b/1.20/build.gradle.kts index 852d41d34..a2dc7012f 100644 --- a/1.20/build.gradle.kts +++ b/1.20/build.gradle.kts @@ -87,6 +87,7 @@ java { tasks.runClient { classpath(sourceSets.getByName("test").runtimeClasspath) + jvmArgs("-XX:+AllowEnhancedClassRedefinition", "-XX:+IgnoreUnrecognizedVMOptions") } // Configure the maven publication diff --git a/1.20/src/main/java/io/github/axolotlclient/api/SimpleTextInputScreen.java b/1.20/src/main/java/io/github/axolotlclient/api/SimpleTextInputScreen.java index ebf2034f6..dccffcfb3 100644 --- a/1.20/src/main/java/io/github/axolotlclient/api/SimpleTextInputScreen.java +++ b/1.20/src/main/java/io/github/axolotlclient/api/SimpleTextInputScreen.java @@ -59,6 +59,11 @@ public void init() { }).positionAndSize(width / 2 + 5, height - 50, 150, 20).build()); } + @Override + public void tick() { + input.tick(); + } + @Override public void render(GuiGraphics graphics, int i, int j, float f) { renderBackground(graphics); diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java b/1.20/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java index 35b5ee9d9..6730ce829 100644 --- a/1.20/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java +++ b/1.20/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java @@ -22,6 +22,7 @@ package io.github.axolotlclient.modules.auth; +import io.github.axolotlclient.modules.auth.skin.SkinManagementScreen; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screen.ConfirmScreen; @@ -36,6 +37,7 @@ public class AccountsScreen extends Screen { private ButtonWidget loginButton; private ButtonWidget deleteButton; private ButtonWidget refreshButton; + private ButtonWidget skinsButton; public AccountsScreen(Screen currentScreen) { super(Text.translatable("accounts")); @@ -82,7 +84,12 @@ public void init() { accountsListWidget.setAccounts(Auth.getInstance().getAccounts()); addDrawableChild(loginButton = new ButtonWidget.Builder(Text.translatable("auth.login"), - buttonWidget -> login()).positionAndSize(this.width / 2 - 154, this.height - 52, 150, 20).build()); + buttonWidget -> login()).positionAndSize(this.width / 2 - 154, this.height - 52, 100, 20).build()); + + addDrawableChild(skinsButton = ButtonWidget.builder(Text.translatable("skins.manage"), + btn -> client.setScreen(new SkinManagementScreen( + this, accountsListWidget.getSelectedOrNull().getAccount()))) + .positionAndSize(this.width / 2 - 50, this.height - 52, 100, 20).build()); this.addDrawableChild(ButtonWidget.builder(Text.translatable("auth.add"), button -> { @@ -99,7 +106,7 @@ public void init() { }, Text.translatable("auth.add.choose"), Text.empty(), Text.translatable("auth.add.offline"), Text.translatable("auth.add.ms"))); } }) - .positionAndSize(this.width / 2 + 4, this.height - 52, 150, 20).build()); + .positionAndSize(this.width / 2 + 4 + 50, this.height - 52, 100, 20).build()); this.deleteButton = this.addDrawableChild(ButtonWidget.builder(Text.translatable("selectServer.delete"), button -> { AccountsListWidget.Entry entry = this.accountsListWidget.getSelectedOrNull(); @@ -125,17 +132,14 @@ public void init() { } private void initMSAuth() { - Auth.getInstance().getAuth().startDeviceAuth().thenRun(() -> client.execute(this::refresh)); + Auth.getInstance().getMsApi().startDeviceAuth().thenRun(() -> client.execute(this::refresh)); } private void refreshAccount() { refreshButton.active = false; AccountsListWidget.Entry entry = accountsListWidget.getSelectedOrNull(); if (entry != null) { - entry.getAccount().refresh(Auth.getInstance().getAuth()).thenRun(() -> client.execute(() -> { - Auth.getInstance().save(); - refresh(); - })); + entry.getAccount().refresh(Auth.getInstance().getMsApi()); } } @@ -143,9 +147,10 @@ private void updateButtonActivationStates() { AccountsListWidget.Entry entry = accountsListWidget.getSelectedOrNull(); if (client.world == null && entry != null) { loginButton.active = entry.getAccount().isExpired() || !entry.getAccount().equals(Auth.getInstance().getCurrent()); - deleteButton.active = refreshButton.active = true; + refreshButton.active = skinsButton.active = !entry.getAccount().isOffline(); + deleteButton.active = true; } else { - loginButton.active = deleteButton.active = refreshButton.active = false; + loginButton.active = deleteButton.active = refreshButton.active = skinsButton.active = false; } } diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/auth/Auth.java b/1.20/src/main/java/io/github/axolotlclient/modules/auth/Auth.java index cb5343e47..002cd25a3 100644 --- a/1.20/src/main/java/io/github/axolotlclient/modules/auth/Auth.java +++ b/1.20/src/main/java/io/github/axolotlclient/modules/auth/Auth.java @@ -23,18 +23,19 @@ package io.github.axolotlclient.modules.auth; import java.util.*; +import java.util.concurrent.CompletableFuture; import com.mojang.authlib.GameProfile; import com.mojang.authlib.minecraft.MinecraftProfileTexture; import com.mojang.authlib.minecraft.UserApiService; import io.github.axolotlclient.AxolotlClient; -import io.github.axolotlclient.AxolotlClientConfig.api.options.OptionCategory; import io.github.axolotlclient.AxolotlClientConfig.impl.options.BooleanOption; import io.github.axolotlclient.api.API; import io.github.axolotlclient.api.types.User; import io.github.axolotlclient.api.util.UUIDHelper; import io.github.axolotlclient.mixin.MinecraftClientAccessor; import io.github.axolotlclient.modules.Module; +import io.github.axolotlclient.modules.auth.skin.SkinManager; import io.github.axolotlclient.util.ThreadExecuter; import io.github.axolotlclient.util.notifications.Notifications; import io.github.axolotlclient.util.options.GenericOption; @@ -56,15 +57,18 @@ public class Auth extends Accounts implements Module { @Getter private final static Auth Instance = new Auth(); public final BooleanOption showButton = new BooleanOption("auth.showButton", false); + public final BooleanOption skinManagerAnimations = new BooleanOption("skins.manage.animations", true); private final MinecraftClient client = MinecraftClient.getInstance(); private final GenericOption viewAccounts = new GenericOption("viewAccounts", "clickToOpen", () -> client.setScreen(new AccountsScreen(client.currentScreen))); private final Set loadingTexture = new HashSet<>(); private final Map textures = new HashMap<>(); + @Getter + private final SkinManager skinManager = new SkinManager(); @Override public void init() { load(); - this.auth = new MSAuth(AxolotlClient.LOGGER, this, () -> client.options.language); + this.msApi = new MSApi(this, () -> client.options.language); if (isContained(client.getSession().getSessionId())) { current = getAccounts().stream().filter(account -> account.getUuid() .equals(UUIDHelper.toUndashed(client.getSession().getPlayerUuid()))).toList().get(0); @@ -77,8 +81,7 @@ public void init() { current = new Account(client.getSession().getUsername(), UUIDHelper.toUndashed(client.getSession().getPlayerUuid()), client.getSession().getAccessToken()); } - OptionCategory category = OptionCategory.create("auth"); - category.add(showButton, viewAccounts); + category.add(showButton, viewAccounts, skinManagerAnimations); AxolotlClient.config().general.add(category); } @@ -92,11 +95,11 @@ protected void login(Account account) { if (account.isExpired()) { Notifications.getInstance().addStatus(Text.translatable("auth.notif.title"), Text.translatable("auth.notif.refreshing", account.getName())); } - account.refresh(auth).thenAccept(res -> res.ifPresent(a -> { + account.refresh(msApi).thenAccept(a -> { if (!a.isExpired()) { login(a); } - })).thenRun(this::save); + }).thenRun(this::save); } else { try { API.getInstance().shutdown(); @@ -124,14 +127,18 @@ protected void login(Account account) { } @Override - void showAccountsExpiredScreen(Account account) { + CompletableFuture showAccountsExpiredScreen(Account account) { Screen current = client.currentScreen; + var fut = new CompletableFuture(); client.execute(() -> client.setScreen(new ConfirmScreen((bl) -> { - client.setScreen(current); if (bl) { - auth.startDeviceAuth(); + msApi.startDeviceAuth().thenRun(() -> fut.complete(account)); + } else { + fut.cancel(true); } + client.setScreen(current); }, Text.translatable("auth"), Text.translatable("auth.accountExpiredNotice", account.getName())))); + return fut; } @Override diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java new file mode 100644 index 000000000..e820771f0 --- /dev/null +++ b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java @@ -0,0 +1,847 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.AxolotlClientConfig.api.util.Colors; +import io.github.axolotlclient.api.SimpleTextInputScreen; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import io.github.axolotlclient.util.ClientColors; +import io.github.axolotlclient.util.Watcher; +import io.github.axolotlclient.util.notifications.Notifications; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.*; +import net.minecraft.client.gui.navigation.GuiNavigationEvent; +import net.minecraft.client.gui.screen.ConfirmScreen; +import net.minecraft.client.gui.screen.LoadingDisplay; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.*; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.text.CommonTexts; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.Util; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SkinManagementScreen extends Screen { + private static final Path SKINS_DIR = FabricLoader.getInstance().getGameDir().resolve("skins"); + private static final int LIST_SKIN_WIDTH = 75; + private static final int LIST_SKIN_HEIGHT = 110; + private static final Text TEXT_EQUIPPING = Text.translatable("skins.manage.equipping"); + private final Screen parent; + private final Account account; + private MSApi.MCProfile cachedProfile; + private SkinListWidget skinList; + private SkinListWidget capesList; + private boolean capesTab; + private SkinWidget current; + private final Watcher skinDirWatcher; + private final CompletableFuture refreshFuture; + + public SkinManagementScreen(Screen parent, Account account) { + super(Text.translatable("skins.manage")); + this.parent = parent; + this.account = account; + skinDirWatcher = Watcher.createSelfTicking(SKINS_DIR, () -> { + AxolotlClientCommon.getInstance().getLogger().info("Reloading screen as local files changed!"); + loadSkinsList(); + }); + if (account.needsRefresh()) { + refreshFuture = account.refresh(Auth.getInstance().getMsApi()); + } else { + refreshFuture = CompletableFuture.completedFuture(null); + } + } + + @Override + protected void init() { + int headerHeight = 33; + int contentHeight = height - headerHeight * 2; + + var titleWidget = new TextWidget(0, headerHeight / 2 - textRenderer.fontHeight / 2, width, textRenderer.fontHeight, getTitle(), textRenderer); + addDrawableChild(titleWidget); + + var back = addDrawableChild(ButtonWidget.builder(CommonTexts.BACK, btn -> closeScreen()) + .positionAndSize(width / 2 - 75, height - headerHeight / 2 - 10, 150, 20).build()); + + var loadingPlaceholder = new ClickableWidget(0, headerHeight, width, contentHeight, Text.translatable("skins.loading")) { + @Override + protected void drawWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + int centerX = this.getX() + this.getWidth() / 2; + int centerY = this.getY() + this.getHeight() / 2; + Text text = this.getMessage(); + graphics.drawText(textRenderer, text, centerX - textRenderer.getWidth(text) / 2, centerY - 9, -1, false); + String string = LoadingDisplay.get(Util.getMeasuringTimeMs()); + graphics.drawText(textRenderer, string, centerX - textRenderer.getWidth(string) / 2, centerY + 9, 0xFF808080, false); + } + + @Override + protected void updateNarration(NarrationMessageBuilder builder) { + + } + }; + loadingPlaceholder.active = false; + addDrawableChild(loadingPlaceholder); + addDrawableChild(back); + + skinList = new SkinListWidget(client, width / 2, contentHeight - 24, headerHeight + 24, LIST_SKIN_HEIGHT + 34); + capesList = new SkinListWidget(client, width / 2, contentHeight - 24, headerHeight + 24, skinList.getEntryContentsHeight() + 24); + skinList.setLeftPos(width / 2); + capesList.setLeftPos(width / 2); + var currentHeight = Math.min((width / 2f) * 120 / 85, contentHeight); + var currentWidth = currentHeight * 85 / 120; + current = new SkinWidget((int) currentWidth, (int) currentHeight, null, account); + current.setPosition((int) (width / 4f - currentWidth / 2), (int) (height / 2f - currentHeight / 2)); + + if (!capesTab) { + capesList.visible = capesList.active = false; + } else { + skinList.visible = skinList.active = false; + } + List navBar = new ArrayList<>(); + var skinsTab = ButtonWidget.builder(Text.translatable("skins.nav.skins"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = true; + capesList.visible = capesList.active = false; + capesTab = false; + }).position(Math.max(width * 3 / 4 - 102, width / 2 + 2), headerHeight).width(Math.min(100, width / 4 - 2)).build(); + navBar.add(skinsTab); + var capesTab = ButtonWidget.builder(Text.translatable("skins.nav.capes"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = false; + capesList.visible = capesList.active = true; + this.capesTab = true; + }).position(width * 3 / 4 + 2, headerHeight).width(Math.min(100, width / 4 - 2)).build(); + navBar.add(capesTab); + var importButton = new SpriteButton(Text.translatable("skins.manage.import.local"), btn -> { + btn.active = false; + SkinImportUtil.openImportSkinDialog().thenAccept(this::filesDragged).thenRun(() -> btn.active = true); + }, new Identifier("axolotlclient", "textures/gui/sprites/folder.png")); + var downloadButton = new SpriteButton(Text.translatable("skins.manage.import.online"), btn -> { + btn.active = false; + promptForSkinDownload(); + }, new Identifier("axolotlclient", "textures/gui/sprites/download.png")); + if (width - (capesTab.getX() + capesTab.getWidth()) > 28) { + importButton.setX(width - importButton.getWidth() - 2); + downloadButton.setX(importButton.getX() - downloadButton.getWidth() - 2); + importButton.setY(capesTab.getY() + capesTab.getHeight() - 11); + downloadButton.setY(importButton.getY()); + } else { + importButton.setX(capesTab.getX() + capesTab.getWidth() - 11); + importButton.setY(capesTab.getY() - 13); + downloadButton.setX(importButton.getX() - 2 - 11); + downloadButton.setY(importButton.getY()); + } + skinsTab.active = this.capesTab; + capesTab.active = !this.capesTab; + Runnable addWidgets = () -> { + clearChildren(); + addDrawableChild(titleWidget); + addDrawableChild(current); + addDrawableChild(skinsTab); + addDrawableChild(capesTab); + addDrawableChild(downloadButton); + addDrawableChild(importButton); + addDrawableChild(skinList); + addDrawableChild(capesList); + addDrawableChild(back); + }; + if (cachedProfile != null) { + initDisplay(); + addWidgets.run(); + return; + } + refreshFuture.thenComposeAsync(unused -> Auth.getInstance().getMsApi().getProfile(account)) + .thenAcceptAsync(profile -> { + cachedProfile = profile; + initDisplay(); + addWidgets.run(); + }).exceptionally(t -> { + if (t.getCause() instanceof CancellationException) { + client.setScreen(parent); + return null; + } + AxolotlClientCommon.getInstance().getLogger().error("Failed to load skins!", t); + var error = Text.translatable("skins.error.failed_to_load"); + var errorDesc = Text.translatable("skins.error.failed_to_load_desc"); + clearChildren(); + addDrawableChild(titleWidget); + addDrawableChild(new TextWidget(width / 2 - textRenderer.getWidth(error) / 2, height / 2 - textRenderer.fontHeight - 2, textRenderer.getWidth(error), textRenderer.fontHeight, error, textRenderer)); + addDrawableChild(new TextWidget(width / 2 - textRenderer.getWidth(errorDesc) / 2, height / 2 + 1, textRenderer.getWidth(errorDesc), textRenderer.fontHeight, errorDesc, textRenderer)); + addDrawableChild(back); + return null; + }); + } + + private void promptForSkinDownload() { + client.setScreen(new SimpleTextInputScreen(this, Text.translatable("skins.manage.import.online"), Text.translatable("skins.manage.import.online.input"), s -> + UUIDHelper.ensureUuidOpt(s).thenAccept(o -> { + if (o.isPresent()) { + AxolotlClientCommon.getInstance().getLogger().info("Downloading skin of {} ({})", s, o.get()); + Auth.getInstance().getMsApi().getTextures(o.get()) + .exceptionally(th -> { + AxolotlClientCommon.getInstance().getLogger().info("Failed to download skin of {} ({})", s, o.get(), th); + return null; + }).thenAccept(t -> { + if (t == null) { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_download", s); + return; + } + try { + var bytes = t.skin().join(); + var out = ensureNonexistent(SKINS_DIR.resolve(t.skinKey())); + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, t.classicModel(), "name", t.name(), "uuid", t.id(), "download_time", Instant.now())); + Files.write(out, bytes); + client.execute(this::loadSkinsList); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.downloaded", t.name()); + AxolotlClientCommon.getInstance().getLogger().info("Downloaded skin of {} ({})", t.name(), o.get()); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to write skin file", e); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_save", t.name()); + } + }); + } else { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.not_found", s); + } + }))); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + renderBackground(graphics); + super.render(graphics, mouseX, mouseY, delta); + } + + private void initDisplay() { + loadSkinsList(); + loadCapesList(); + } + + private void refreshCurrentList() { + if (capesTab) { + var scroll = capesList.getScrollAmount(); + loadCapesList(); + capesList.setScrollAmount(scroll); + } else { + var scroll = skinList.getScrollAmount(); + loadSkinsList(); + skinList.setScrollAmount(scroll); + } + } + + private void loadCapesList() { + capesList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + var capes = profile.capes(); + var deselectCape = createWidgetForCape(current.getSkin(), null); + var activeCape = capes.stream().filter(Cape::active).findFirst(); + current.setCape(activeCape.orElse(null)); + deselectCape.noCape(activeCape.isEmpty()); + for (int i = 0; i < capes.size() + 1; i += columns) { + Entry widget; + if (i == 0) { + widget = createEntry(capesList.getEntryContentsHeight(), deselectCape, Text.translatable("skins.capes.no_cape")); + } else { + var cape = capes.get(i - 1); + widget = createEntryForCape(current.getSkin(), cape, capesList.getEntryContentsHeight()); + } + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < capes.size() + 1 - c)) continue; + var cape2 = capes.get(i + c - 1); + Entry widget2 = createEntryForCape(current.getSkin(), cape2, capesList.getEntryContentsHeight()); + + widgets.add(widget2); + } + capesList.addEntry(new Row(widgets)); + } + } + + private void loadSkinsList() { + skinList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + List skins = new ArrayList<>(profile.skins()); + var hashes = skins.stream().map(Asset::textureKey).collect(Collectors.toSet()); + var defaultSkinHash = Auth.getInstance().getSkinManager().getDefaultSkinHash(account); + var local = new ArrayList<>(loadLocalSkins()); + var localHashes = local.stream().collect(Collectors.toMap(Asset::textureKey, Function.identity(), (skin, skin2) -> skin)); + local.removeIf(s -> !localHashes.containsValue(s)); + skins.replaceAll(s -> { + if (s instanceof MSApi.MCProfile.OnlineSkin online) { + if (localHashes.containsKey(s.textureKey()) && localHashes.get(s.textureKey()) instanceof Skin.Local file) { + local.remove(localHashes.remove(s.textureKey())); + return new Skin.Shared(file, online); + } + } + return s; + }); + skins.addAll(local); + if (!hashes.contains(defaultSkinHash)) { + skins.add(null); + } + populateSkinList(skins, columns); + } + + private List loadLocalSkins() { + try { + Files.createDirectories(SKINS_DIR); + try (Stream skins = Files.list(SKINS_DIR)) { + return skins.filter(Files::isRegularFile).sorted(Comparator.comparingLong(p -> { + try { + return Files.getLastModifiedTime(p).toMillis(); + } catch (IOException e) { + return 0L; + } + }).reversed()).map(Auth.getInstance().getSkinManager()::read).filter(Objects::nonNull).toList(); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to read skins dir!", e); + } + return Collections.emptyList(); + } + + private void populateSkinList(List skins, int columns) { + int entryHeight = skinList.getEntryContentsHeight(); + for (int i = 0; i < skins.size(); i += columns) { + var s = skins.get(i); + if (s != null && s.active()) { + current.setSkin(s); + } + var widget = createEntryForSkin(s, entryHeight); + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < skins.size() - c)) continue; + var s2 = skins.get(i + c); + if (s2 != null && s2.active()) { + current.setSkin(s2); + } + var widget2 = createEntryForSkin(s2, entryHeight); + widgets.add(widget2); + } + skinList.addEntry(new Row(widgets)); + } + } + + private Path ensureNonexistent(Path p) { + if (Files.exists(p)) { + int counter = 0; + do { + counter++; + p = p.resolveSibling(p.getFileName().toString() + "_" + counter); + } while (Files.exists(p)); + } + return p; + } + + @Override + public void filesDragged(List packs) { + if (packs.isEmpty()) return; + + CompletableFuture[] futs = new CompletableFuture[packs.size()]; + for (int i = 0; i < packs.size(); i++) { + Path p = packs.get(i); + futs[i] = CompletableFuture.runAsync(() -> { + try { + var target = ensureNonexistent(SKINS_DIR.resolve(p.getFileName())); + var skin = Auth.getInstance().getSkinManager().read(p, false); + if (skin != null) { + Files.write(target, skin.image().join()); + } else { + AxolotlClientCommon.getInstance().getLogger().info("Skipping dragged file {} because it does not seem to be a valid skin!", p); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.not_copied", p.getFileName()); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to copy skin file: ", e); + } + }, client); + } + CompletableFuture.allOf(futs).thenRun(this::loadSkinsList); + } + + private @NotNull Entry createEntryForSkin(Skin skin, int entryHeight) { + return createEntry(entryHeight, new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, skin, account)); + } + + private @NotNull Entry createEntryForCape(Skin currentSkin, Cape cape, int entryHeight) { + return createEntry(entryHeight, createWidgetForCape(currentSkin, cape), Text.literal(cape.alias())); + } + + private SkinWidget createWidgetForCape(Skin currentSkin, Cape cape) { + SkinWidget widget2 = new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, currentSkin, cape, account); + widget2.setRotationY(210); + return widget2; + } + + @Override + protected void clearAndInit() { + Auth.getInstance().getSkinManager().releaseAll(); + super.clearAndInit(); + } + + @Override + public void removed() { + Auth.getInstance().getSkinManager().releaseAll(); + Watcher.close(skinDirWatcher); + } + + @Override + public void closeScreen() { + client.setScreen(parent); + } + + private SkinListWidget getCurrentList() { + return capesTab ? capesList : skinList; + } + + private class SkinListWidget extends ElementListWidget { + public boolean active = true, visible = true; + + public SkinListWidget(MinecraftClient minecraft, int width, int height, int y, int entryHeight) { + super(minecraft, width, SkinManagementScreen.this.height, y, y + height, entryHeight); + setRenderHeader(false, 0); + } + + @Override + public int addEntry(Row entry) { + return super.addEntry(entry); + } + + @Override + protected int getScrollbarPositionX() { + return right - 8; + } + + @Override + public int getRowLeft() { + return left + 3; + } + + @Override + public int getRowWidth() { + if (!(getMaxScroll() > 0)) { + return width - 4; + } + return width - 14; + } + + public int getEntryContentsHeight() { + return itemHeight - 4; + } + + @Override + public @Nullable ElementPath nextFocusPath(GuiNavigationEvent event) { + if (!active || !visible) return null; + return super.nextFocusPath(event); + } + + @Override + public void clearEntries() { + super.clearEntries(); + } + + @Override + public void centerScrollOn(Row entry) { + super.centerScrollOn(entry); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double amountY) { + if (!visible) return false; + return super.mouseScrolled(mouseX, mouseY, amountY); + } + + @Override + public boolean isMouseOver(double mouseX, double mouseY) { + return active && visible && super.isMouseOver(mouseX, mouseY); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + if (!visible) return; + super.render(graphics, mouseX, mouseY, delta); + } + } + + private class Row extends ElementListWidget.Entry { + private final List widgets; + + public Row(List entries) { + this.widgets = entries; + } + + @Override + public @NotNull List selectableChildren() { + return widgets; + } + + @Override + public void render(GuiGraphics guiGraphics, int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovering, float partialTick) { + int x = left; + if (widgets.isEmpty()) return; + int count = widgets.size(); + int padding = ((width - 5 * (count - 1)) / count); + for (var w : widgets) { + w.setPosition(x, top); + w.setWidth(padding); + w.render(guiGraphics, mouseX, mouseY, partialTick); + x += w.getWidth() + 5; + } + } + + @Override + public @NotNull List children() { + return widgets; + } + + @Override + public void setFocusedChild(@Nullable Element focused) { + super.setFocusedChild(focused); + if (focused != null) { + getCurrentList().centerScrollOn(this); + } + } + } + + Entry createEntry(int height, SkinWidget widget) { + return createEntry(height, widget, null); + } + + Entry createEntry(int height, SkinWidget widget, Text label) { + return new Entry(height, widget, label); + } + + private class Entry extends ClickableWidget implements ParentElement { + private final SkinWidget skinWidget; + private final @Nullable ClickableWidget label; + private final List actionButtons = new ArrayList<>(); + private final ClickableWidget equipButton; + private boolean equipping; + private long equippingStart; + @Nullable + private Element focused; + private boolean dragging; + + public Entry(int height, SkinWidget widget, @Nullable Text label) { + super(0, 0, widget.getWidth(), height, Text.empty()); + widget.setWidth(getWidth() - 4); + var asset = widget.getFocusedAsset(); + if (asset != null) { + if (asset instanceof Skin skin) { + var wideSprite = new Identifier("axolotlclient", "textures/gui/sprites/wide.png"); + var slimSprite = new Identifier("axolotlclient", "textures/gui/sprites/slim.png"); + var slimText = Text.translatable("skins.manage.variant.classic"); + var wideText = Text.translatable("skins.manage.variant.slim"); + actionButtons.add(new SpriteButton(skin.classicVariant() ? wideText : slimText, btn -> { + var self = (SpriteButton) btn; + skin.classicVariant(!skin.classicVariant()); + self.sprite = skin.classicVariant() ? slimSprite : wideSprite; + self.setMessage(skin.classicVariant() ? wideText : slimText); + }, skin.classicVariant() ? slimSprite : wideSprite)); + } + if (asset.isLocal()) { + this.actionButtons.add(new SpriteButton(Text.translatable("skins.manage.delete"), btn -> { + btn.active = false; + client.setScreen(new ConfirmScreen(confirmed -> { + client.setScreen(SkinManagementScreen.this); + if (confirmed) { + try { + Files.delete(asset.file()); + Skin.Local.deleteMetadata(asset.file()); + refreshCurrentList(); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to delete: ", e); + } + } + btn.active = true; + }, Text.translatable("skins.manage.delete.confirm"), (Text) (asset.active() ? + Text.translatable("skins.manage.delete.confirm.desc_active") : + Text.translatable("skins.manage.delete.confirm.desc") + ).br$color(Colors.RED.toInt()))); + }, new Identifier("axolotlclient", "textures/gui/sprites/delete.png"))); + } + if (asset.supportsDownload() && !asset.isLocal()) { + this.actionButtons.add(new SpriteButton(Text.translatable("skins.manage.download"), btn -> { + btn.active = false; + asset.image().thenAcceptAsync(b -> { + try { + var out = SKINS_DIR.resolve(asset.textureKey()); + Files.createDirectories(out.getParent()); + Files.write(out, b); + if (asset instanceof Skin skin) { + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, skin.classicVariant())); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to download: ", e); + } + refreshCurrentList(); + btn.active = true; + }); + }, new Identifier("axolotlclient", "textures/gui/sprites/download.png"))); + } + } + if (label != null) { + this.label = new AbstractTextWidget(0, 0, widget.getWidth(), 16, label, textRenderer) { + @Override + protected void drawWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + drawScrollingText(guiGraphics, textRenderer, 2, -1); + } + }; + this.label.active = false; + } else { + this.label = null; + } + this.equipButton = ButtonWidget.builder(Text.translatable( + widget.isEquipped() ? "skins.manage.equipped" : "skins.manage.equip"), + btn -> { + equippingStart = Util.getMeasuringTimeMs(); + equipping = true; + btn.setMessage(TEXT_EQUIPPING); + btn.active = false; + widget.equip().thenAcceptAsync(p -> { + cachedProfile = p; + refreshCurrentList(); + }).exceptionally(t -> { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to equip asset!", t); + equipping = false; + return null; + }); + }).width(widget.getWidth()).build(); + this.equipButton.active = !widget.isEquipped(); + this.skinWidget = widget; + } + + @Override + public final boolean isDragging() { + return this.dragging; + } + + @Override + public final void setDragging(boolean dragging) { + this.dragging = dragging; + } + + @Nullable + @Override + public Element getFocused() { + return this.focused; + } + + @Override + public void setFocusedChild(@Nullable Element child) { + if (this.focused != null) { + this.focused.setFocused(false); + } + + if (child != null) { + child.setFocused(true); + } + + this.focused = child; + } + + @Nullable + @Override + public ElementPath nextFocusPath(GuiNavigationEvent event) { + return ParentElement.super.nextFocusPath(event); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return ParentElement.super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return ParentElement.super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return ParentElement.super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + @Override + public boolean isFocused() { + return ParentElement.super.isFocused(); + } + + @Override + public void setFocused(boolean focused) { + ParentElement.super.setFocused(focused); + } + + @Override + public @NotNull List children() { + return Stream.concat(actionButtons.stream(), Stream.of(skinWidget, label, equipButton)).filter(Objects::nonNull).toList(); + } + + private float applyEasing(float x) { + return x * x * x; + } + + @Override + protected void drawWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + int y = getY() + 4; + int x = getX() + 2; + skinWidget.setPosition(x, y); + skinWidget.setWidth(getWidth() - 4); + if (skinWidget.isEquipped() || equipping) { + long prog; + if (Auth.getInstance().skinManagerAnimations.get()) { + if (equipping) prog = (Util.getMeasuringTimeMs() - equippingStart) / 20 % 100; + else prog = Math.abs((Util.getMeasuringTimeMs() / 30 % 200) - 100); + } else prog = 100; + var percent = (prog / 100f); + float gradientWidth; + if (equipping) { + gradientWidth = percent * Math.min(getWidth() / 3f, getHeight() / 3f); + } else { + gradientWidth = Math.min(getWidth() / 15f, getHeight() / 6f) + applyEasing(percent) * Math.min(getWidth() * 2 / 15f, getHeight() / 6f); + } + GradientHoleRectangleRenderState.render(guiGraphics, getX() + 2, getY() + 2, getX() + getWidth() - 2, + skinWidget.getY() + skinWidget.getHeight() + 2, + gradientWidth, + equipping ? 0xFFFF0088 : ClientColors.SELECTOR_GREEN.toInt(), 0); + } + skinWidget.render(guiGraphics, mouseX, mouseY, partialTick); + int actionButtonY = getY() + 2; + for (var button : actionButtons) { + button.setPosition(skinWidget.getX() + skinWidget.getWidth() - button.getWidth(), actionButtonY); + if (isHovered() || button.isHoveredOrFocused()) { + button.render(guiGraphics, mouseX, mouseY, partialTick); + } + actionButtonY += button.getHeight() + 2; + } + if (label != null) { + label.setPosition(x, skinWidget.getY() + skinWidget.getHeight() + 6); + label.render(guiGraphics, mouseX, mouseY, partialTick); + label.setWidth(getWidth() - 4); + equipButton.setPosition(x, label.getY() + label.getHeight() + 2); + } else { + equipButton.setPosition(x, skinWidget.getY() + skinWidget.getHeight() + 4); + } + equipButton.setWidth(getWidth() - 4); + equipButton.render(guiGraphics, mouseX, mouseY, partialTick); + + if (isHovered()) { + guiGraphics.br$outlineRect(getX(), getY(), getWidth(), getHeight(), -1); + } + } + + @Override + protected void updateNarration(NarrationMessageBuilder narrationElementOutput) { + skinWidget.appendNarrations(narrationElementOutput); + actionButtons.forEach(w -> w.appendNarrations(narrationElementOutput)); + if (label != null) { + label.appendNarrations(narrationElementOutput); + } + equipButton.appendNarrations(narrationElementOutput); + } + + private static class GradientHoleRectangleRenderState { + + public static void render(GuiGraphics graphics, int x0, int y0, int x1, int y1, float gradientWidth, int col1, int col2) { + var vertexConsumer = graphics.getVertexConsumers().getBuffer(RenderLayer.getGui()); + float z = 0; + //top + var pose = graphics.getMatrices().peek().getModel(); + vertexConsumer.vertex(pose, x0, y0, z).color(col1).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y0 + gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y0 + gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x1, y0, z).color(col1).next(); + //left + vertexConsumer.vertex(pose, x0, y1, z).color(col1).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y1 - gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y0 + gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x0, y0, z).color(col1).next(); + //bottom + vertexConsumer.vertex(pose, x1, y1, z).color(col1).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y1 - gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x0 + gradientWidth, y1 - gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x0, y1, z).color(col1).next(); + //right + vertexConsumer.vertex(pose, x1, y0, z).color(col1).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y0 + gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x1 - gradientWidth, y1 - gradientWidth, z).color(col2).next(); + vertexConsumer.vertex(pose, x1, y1, z).color(col1).next(); + } + } + + } + + private static class SpriteButton extends ButtonWidget { + private Identifier sprite; + + public SpriteButton(Text message, PressAction onPress, Identifier sprite) { + super(0, 0, 11, 11, message, onPress, DEFAULT_NARRATION); + this.sprite = sprite; + setTooltip(Tooltip.create(message, Text.empty())); + } + + @Override + public void setMessage(Text message) { + super.setMessage(message); + setTooltip(Tooltip.create(message, Text.empty())); + } + + @Override + protected void drawWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + super.drawWidget(graphics, mouseX, mouseY, delta); + graphics.drawTexture(sprite, getX() + 2, getY() + 2, 0, 0, 7, 7, 7, 7); + } + + @Override + public void drawScrollableText(GuiGraphics graphics, TextRenderer renderer, int color) { + + } + } +} diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java new file mode 100644 index 000000000..e7e48943c --- /dev/null +++ b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java @@ -0,0 +1,213 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListSet; + +import com.google.common.hash.Hashing; +import com.mojang.blaze3d.texture.NativeImage; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.AxoMinecraftClient; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.util.ClientColors; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.client.util.DefaultSkinHelper; +import net.minecraft.util.Identifier; + +public class SkinManager { + + private final Set loadedTextures = new ConcurrentSkipListSet<>(Comparator.comparing(Object::toString)); + + public Skin read(Path p) { + return read(p, true); + } + + public Skin read(Path p, boolean fix) { + boolean slim; + String sha256; + try { + var in = Files.readAllBytes(p); + sha256 = Hashing.sha256().hashBytes(in).toString(); + try (var img = NativeImage.read(in)) { + int width = img.getWidth(); + int height = img.getHeight(); + if (width != 64) return null; + if (height == 32) { + if (fix) { + try (var img2 = remapTexture(img)) { + img2.writeFile(p); + } + } + slim = false; + } else if (height != 64) { + return null; + } else { + slim = ClientColors.ARGB.alpha(img.getPixelColor(50, 16)) == 0; + } + var metadata = Skin.Local.readMetadata(p); + if (metadata != null && metadata.containsKey(Skin.Local.CLASSIC_METADATA_KEY)) { + slim = !(boolean) metadata.get(Skin.Local.CLASSIC_METADATA_KEY); + } + } + return new Skin.Local(!slim, p, sha256); + } catch (Exception e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to probe skin: {}", p, e); + } + return null; + } + + private static NativeImage remapTexture(NativeImage skinImage) { + boolean legacySkin = skinImage.getHeight() == 32; + if (legacySkin) { + NativeImage nativeImage = new NativeImage(64, 64, true); + nativeImage.copyFrom(skinImage); + skinImage.close(); + skinImage = nativeImage; + nativeImage.fillRect(0, 32, 64, 32, 0); + nativeImage.copyRectangle(4, 16, 16, 32, 4, 4, true, false); + nativeImage.copyRectangle(8, 16, 16, 32, 4, 4, true, false); + nativeImage.copyRectangle(0, 20, 24, 32, 4, 12, true, false); + nativeImage.copyRectangle(4, 20, 16, 32, 4, 12, true, false); + nativeImage.copyRectangle(8, 20, 8, 32, 4, 12, true, false); + nativeImage.copyRectangle(12, 20, 16, 32, 4, 12, true, false); + nativeImage.copyRectangle(44, 16, -8, 32, 4, 4, true, false); + nativeImage.copyRectangle(48, 16, -8, 32, 4, 4, true, false); + nativeImage.copyRectangle(40, 20, 0, 32, 4, 12, true, false); + nativeImage.copyRectangle(44, 20, -8, 32, 4, 12, true, false); + nativeImage.copyRectangle(48, 20, -16, 32, 4, 12, true, false); + nativeImage.copyRectangle(52, 20, -8, 32, 4, 12, true, false); + } + + stripAlpha(skinImage, 0, 0, 32, 16); + if (legacySkin) { + stripColor(skinImage, 32, 0, 64, 32); + } + + stripAlpha(skinImage, 0, 16, 64, 32); + stripAlpha(skinImage, 16, 48, 48, 64); + return skinImage; + } + + @SuppressWarnings("SameParameterValue") + private static void stripColor(NativeImage image, int x1, int y1, int x2, int y2) { + for (int x = x1; x < x2; x++) { + for (int y = y1; y < y2; y++) { + int k = image.getPixelColor(x, y); + if ((k >> 24 & 0xFF) < 128) { + return; + } + } + } + + for (int x = x1; x < x2; x++) { + for (int y = y1; y < y2; y++) { + image.setPixelColor(x, y, image.getPixelColor(x, y) & 16777215); + } + } + } + + private static void stripAlpha(NativeImage image, int x1, int y1, int x2, int y2) { + for (int x = x1; x < x2; x++) { + for (int y = y1; y < y2; y++) { + image.setPixelColor(x, y, image.getPixelColor(x, y) | 0xFF000000); + } + } + } + + public CompletableFuture loadSkin(Skin skin) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "skins/" + skin.textureKey()); + if (loadedTextures.contains(rl)) { + return CompletableFuture.completedFuture(rl); + } + + return skin.image().thenApplyAsync(bytes -> { + try { + var tex = new NativeImageBackedTexture(NativeImage.read(bytes)); + MinecraftClient.getInstance().getTextureManager().registerTexture((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((v, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load skin!", t); + } + return v; + }); + } + + public AxoIdentifier loadCape(Cape cape) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "capes/" + cape.textureKey()); + if (loadedTextures.contains(rl)) { + return rl; + } + + return cape.image().thenApplyAsync(bytes -> { + try { + var tex = new NativeImageBackedTexture(NativeImage.read(bytes)); + MinecraftClient.getInstance().getTextureManager().registerTexture((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((id, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load cape!", t); + } + return id; + }).getNow(null); + + } + + public void releaseAll() { + loadedTextures.forEach(id -> MinecraftClient.getInstance().getTextureManager().destroyTexture((Identifier) id)); + loadedTextures.clear(); + } + + public String getDefaultSkinHash(Account account) { + var skin = DefaultSkinHelper.getTexture(UUIDHelper.fromUndashed(account.getUuid())); + var mc = MinecraftClient.getInstance(); + var resourceManager = mc.getResourceManager(); + try { + var res = resourceManager.getResourceOrThrow(skin); + try ( + var in = res.open()) { + return Hashing.sha256().hashBytes(in.readAllBytes()).toString(); + } + } catch (IOException ignored) { + } + return null; + } +} diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java new file mode 100644 index 000000000..4ffe9fd88 --- /dev/null +++ b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java @@ -0,0 +1,90 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import com.mojang.blaze3d.lighting.DiffuseLighting; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.render.LightmapTextureManager; +import net.minecraft.client.render.OverlayTexture; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.entity.model.EntityModelLayers; +import net.minecraft.client.render.entity.model.PlayerEntityModel; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Axis; +import org.jetbrains.annotations.Nullable; + +public class SkinRenderer { + private static PlayerEntityModel classicModel, slimModel; + private static final MinecraftClient minecraft = MinecraftClient.getInstance(); + + private SkinRenderer() { + } + + public static void render(GuiGraphics graphics, boolean classicVariant, + Identifier skinTexture, + @Nullable Identifier cape, + float rotationX, + float rotationY, + float pivotY, + int x0, + int y0, + int x1, + int y1, + float scale) { + if (classicModel == null && classicVariant) { + classicModel = new PlayerEntityModel<>(minecraft.getEntityModelLoader().getModelPart(EntityModelLayers.PLAYER), false); + classicModel.child = false; + } + if (slimModel == null && !classicVariant) { + slimModel = new PlayerEntityModel<>(minecraft.getEntityModelLoader().getModelPart(EntityModelLayers.PLAYER_SLIM), true); + slimModel.child = false; + } + + int width = x1 - x0; + DiffuseLighting.setupInventoryEntityLighting(); + graphics.getMatrices().push(); + graphics.getMatrices().translate(x0 + width / 2.0F, (float) (y1), 100.0F); + graphics.getMatrices().scale(scale, scale, scale); + graphics.getMatrices().translate(0.0F, -0.0625F, 0.0F); + graphics.getMatrices().rotateAround(Axis.X_POSITIVE.rotationDegrees(rotationX), 0.0F, pivotY, 0.0F); + graphics.getMatrices().multiply(Axis.Y_POSITIVE.rotationDegrees(rotationY)); + graphics.draw(); + graphics.getMatrices().push(); + graphics.getMatrices().scale(1.0F, 1.0F, -1.0F); + graphics.getMatrices().translate(0.0F, -1.5F, 0.0F); + var model = classicVariant ? classicModel : slimModel; + RenderLayer renderLayer = RenderLayer.getEntityAlpha(skinTexture); + model.render(graphics.getMatrices(), graphics.getVertexConsumers().getBuffer(renderLayer), LightmapTextureManager.MAX_LIGHT_COORDINATE, OverlayTexture.DEFAULT_UV, 1, 1, 1, 1); + if (cape != null) { + graphics.getMatrices().translate(0.0F, 0.0F, 0.125F); + graphics.getMatrices().multiply(Axis.X_POSITIVE.rotationDegrees(6.0F)); + graphics.getMatrices().multiply(Axis.Y_POSITIVE.rotationDegrees(180.0F)); + model.renderCape(graphics.getMatrices(), graphics.getVertexConsumers().getBuffer(RenderLayer.getEntityAlpha(cape)), LightmapTextureManager.MAX_LIGHT_COORDINATE, OverlayTexture.DEFAULT_UV); + } + graphics.getMatrices().pop(); + graphics.draw(); + graphics.getMatrices().pop(); + DiffuseLighting.setup3DGuiLighting(); + } +} diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java new file mode 100644 index 000000000..17f8982a7 --- /dev/null +++ b/1.20/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java @@ -0,0 +1,147 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.util.concurrent.CompletableFuture; + +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import lombok.Getter; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.ElementPath; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.navigation.GuiNavigationEvent; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.sound.SoundManager; +import net.minecraft.client.util.DefaultSkinHelper; +import net.minecraft.text.CommonTexts; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import org.jetbrains.annotations.Nullable; + +public class SkinWidget extends ClickableWidget { + private static final float MODEL_HEIGHT = 2.125F; + private static final float FIT_SCALE = 0.97F; + private static final float ROTATION_SENSITIVITY = 2.5F; + private static final float DEFAULT_ROTATION_X = -5.0F; + private static final float DEFAULT_ROTATION_Y = 30.0F; + private static final float ROTATION_X_LIMIT = 50.0F; + private float rotationX = DEFAULT_ROTATION_X; + @Setter + private float rotationY = DEFAULT_ROTATION_Y; + @Getter + @Setter + private Skin skin; + @Getter + @Setter + private Cape cape; + private final Account owner; + private boolean noCape, noCapeActive; + + public SkinWidget(int width, int height, Skin skin, @Nullable Cape cape, Account owner) { + super(0, 0, width, height, CommonTexts.EMPTY); + this.skin = skin; + this.cape = cape; + this.owner = owner; + } + + public SkinWidget(int width, int height, Skin skin, Account owner) { + this(width, height, skin, null, owner); + } + + public void noCape(boolean noCapeActive) { + noCape = true; + this.noCapeActive = noCapeActive; + } + + @Override + protected void drawWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + var minecraft = MinecraftClient.getInstance(); + + float scale = FIT_SCALE * this.getHeight() / MODEL_HEIGHT; + float pivotY = -1.0625F; + + AxoIdentifier skinRl; + boolean classic; + SkinManager skinManager = Auth.getInstance().getSkinManager(); + CompletableFuture loader = skin == null ? null : skinManager.loadSkin(skin); + if (loader != null && loader.isDone()) { + skinRl = loader.join(); + classic = skin.classicVariant(); + } else { + var uuid = UUIDHelper.fromUndashed(owner.getUuid()); + classic = DefaultSkinHelper.getModel(uuid).equals("default"); + skinRl = DefaultSkinHelper.getTexture(uuid); + } + var capeRl = cape == null ? null : skinManager.loadCape(cape); + + SkinRenderer.render(guiGraphics, classic, (Identifier) skinRl, (Identifier) capeRl, this.rotationX, this.rotationY, pivotY, this.getX(), this.getY(), this.getX() + getWidth(), this.getY() + getHeight(), scale); + } + + @Override + protected void onDrag(double mouseX, double mouseY, double dragX, double dragY) { + this.rotationX = MathHelper.clamp(this.rotationX - (float) dragY * ROTATION_SENSITIVITY, -ROTATION_X_LIMIT, ROTATION_X_LIMIT); + this.rotationY += (float) dragX * ROTATION_SENSITIVITY; + } + + @Override + public void playDownSound(SoundManager handler) { + } + + @Override + protected void updateNarration(NarrationMessageBuilder builder) { + + } + + @Override + public @Nullable ElementPath nextFocusPath(GuiNavigationEvent event) { + return null; + } + + public boolean isEquipped() { + return noCape ? noCapeActive : (cape != null ? cape.active() : skin != null && skin.active()); + } + + public CompletableFuture equip() { + var msApi = Auth.getInstance().getMsApi(); + if (noCape) { + return msApi.hideCape(owner); + } + if (cape != null) { + return cape.equip(msApi, owner); + } + if (skin != null) { + return skin.equip(msApi, owner); + } + return msApi.resetSkin(owner); + } + + public Asset getFocusedAsset() { + return noCape ? null : cape != null ? cape : skin; + } +} diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java b/1.20/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java index 0959e1411..6b5a38ccd 100644 --- a/1.20/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java +++ b/1.20/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java @@ -54,9 +54,9 @@ public class HudEditScreen extends Screen { private static final BooleanOption snapping = new BooleanOption("snapping", true); private static final OptionCategory hudEditScreenCategory = OptionCategory.create("hudEditScreen"); private static final int GRAB_TOLERANCE = 5; - private static final long MOVE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_ALL_CURSOR); - private static final long DEFAULT_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_ARROW_CURSOR); - private static final long NWSE_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NWSE_CURSOR), + private final long MOVE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_ALL_CURSOR); + private final long DEFAULT_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_ARROW_CURSOR); + private final long NWSE_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NWSE_CURSOR), NESW_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NESW_CURSOR); public static boolean isSnappingEnabled() { @@ -188,6 +188,10 @@ public void removed() { setCursor(DEFAULT_CURSOR); mode = ModificationMode.NONE; super.removed(); + GLFW.glfwDestroyCursor(MOVE_CURSOR); + GLFW.glfwDestroyCursor(DEFAULT_CURSOR); + GLFW.glfwDestroyCursor(NESW_RESIZE_CURSOR); + GLFW.glfwDestroyCursor(NWSE_RESIZE_CURSOR); } @Override diff --git a/1.20/src/main/java/io/github/axolotlclient/modules/screenshotUtils/LoadingImageScreen.java b/1.20/src/main/java/io/github/axolotlclient/modules/screenshotUtils/LoadingImageScreen.java index 9b88f618b..cbf337a26 100644 --- a/1.20/src/main/java/io/github/axolotlclient/modules/screenshotUtils/LoadingImageScreen.java +++ b/1.20/src/main/java/io/github/axolotlclient/modules/screenshotUtils/LoadingImageScreen.java @@ -87,10 +87,10 @@ public void closeScreen() { private void drawHorizontalGradient(GuiGraphics guiGraphics, int x1, int y1, int y2, int x2) { VertexConsumer consumer = client.getBufferBuilders().getEntityVertexConsumers().getBuffer(RenderLayer.getGui()); Matrix4f matrix4f = guiGraphics.getMatrices().peek().getModel(); - consumer.vertex(matrix4f, x1, y1, 0).color(LoadingImageScreen.bgColor); - consumer.vertex(matrix4f, x1, y2, 0).color(LoadingImageScreen.bgColor); - consumer.vertex(matrix4f, x2, y2, 0).color(LoadingImageScreen.accent); - consumer.vertex(matrix4f, x2, y1, 0).color(LoadingImageScreen.accent); + consumer.vertex(matrix4f, x1, y1, 0).color(LoadingImageScreen.bgColor).next(); + consumer.vertex(matrix4f, x1, y2, 0).color(LoadingImageScreen.bgColor).next(); + consumer.vertex(matrix4f, x2, y2, 0).color(LoadingImageScreen.accent).next(); + consumer.vertex(matrix4f, x2, y1, 0).color(LoadingImageScreen.accent).next(); } private double easeInOutCubic(double x) { diff --git a/1.21.7/build.gradle.kts b/1.21.7/build.gradle.kts index 202d4c3c7..e3545f526 100644 --- a/1.21.7/build.gradle.kts +++ b/1.21.7/build.gradle.kts @@ -104,6 +104,7 @@ java { tasks.runClient { classpath(sourceSets.getByName("test").runtimeClasspath) + jvmArgs("-XX:+AllowEnhancedClassRedefinition", "-XX:+IgnoreUnrecognizedVMOptions") } // Configure the maven publication diff --git a/1.21.7/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java b/1.21.7/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java index 556d92737..9e817568b 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java @@ -22,9 +22,6 @@ package io.github.axolotlclient.mixin; -import java.util.ArrayList; -import java.util.List; - import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; @@ -35,14 +32,11 @@ import com.mojang.math.Axis; import io.github.axolotlclient.AxolotlClient; import io.github.axolotlclient.modules.blur.MotionBlur; -import io.github.axolotlclient.modules.hud.util.PlayerHudEntityRenderer; import io.github.axolotlclient.modules.zoom.Zoom; import net.minecraft.client.Camera; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.render.pip.PictureInPictureRenderer; import net.minecraft.client.renderer.GameRenderer; -import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.util.Mth; import net.minecraft.util.profiling.Profiler; import org.joml.Matrix4f; @@ -51,7 +45,6 @@ import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.ModifyArg; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(GameRenderer.class) @@ -64,13 +57,6 @@ public abstract class GameRendererMixin { @Shadow private boolean panoramicMode; - @ModifyArg(method = "", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/render/GuiRenderer;(Lnet/minecraft/client/gui/render/state/GuiRenderState;Lnet/minecraft/client/renderer/MultiBufferSource$BufferSource;Ljava/util/List;)V"), index = 2) - private List> addPlayerHudEntityRenderer(List> list, @Local MultiBufferSource.BufferSource source, @Local(argsOnly = true) Minecraft minecraft) { - List> mutable = new ArrayList<>(list); - mutable.add(new PlayerHudEntityRenderer(source, minecraft.getEntityRenderDispatcher())); - return mutable; - } - @WrapOperation(method = "getFov", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Mth;lerp(FFF)F")) private float disableDynamicFov(float delta, float start, float end, Operation original) { if (!AxolotlClient.config().dynamicFOV.get()) { diff --git a/1.21.7/src/main/java/io/github/axolotlclient/mixin/GuiRendererMixin.java b/1.21.7/src/main/java/io/github/axolotlclient/mixin/GuiRendererMixin.java new file mode 100644 index 000000000..2ee466417 --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/mixin/GuiRendererMixin.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.mixin; + +import java.util.Map; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import io.github.axolotlclient.util.IdentifiablePiPRenderState; +import net.minecraft.client.gui.render.GuiRenderer; +import net.minecraft.client.gui.render.pip.PictureInPictureRenderer; +import net.minecraft.client.gui.render.state.pip.PictureInPictureRenderState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(GuiRenderer.class) +public abstract class GuiRendererMixin { + + @WrapOperation(method = "preparePictureInPictureState", at = @At(value = "INVOKE", target = "Ljava/util/Map;get(Ljava/lang/Object;)Ljava/lang/Object;")) + private Object improvePiPRenderers(Map, PictureInPictureRenderer> instance, Object o, Operation> original, PictureInPictureRenderState state) { + if (state instanceof IdentifiablePiPRenderState idState) { + return idState.renderer(); + } + return original.call(instance, o); + } +} diff --git a/1.21.7/src/main/java/io/github/axolotlclient/mixin/skins/SkinTextureDownloaderAccessor.java b/1.21.7/src/main/java/io/github/axolotlclient/mixin/skins/SkinTextureDownloaderAccessor.java new file mode 100644 index 000000000..b561887b4 --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/mixin/skins/SkinTextureDownloaderAccessor.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.mixin.skins; + +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.client.renderer.texture.SkinTextureDownloader; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(SkinTextureDownloader.class) +public interface SkinTextureDownloaderAccessor { + @Invoker("processLegacySkin") + static NativeImage invokeProcessLegacySkin(NativeImage img, String url) { + throw new UnsupportedOperationException(); + } +} diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java index ecd92b156..38ca87b9a 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java @@ -22,6 +22,7 @@ package io.github.axolotlclient.modules.auth; +import io.github.axolotlclient.modules.auth.skin.SkinManagementScreen; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.ConfirmScreen; @@ -35,6 +36,7 @@ public class AccountsScreen extends Screen { private Button loginButton; private Button deleteButton; private Button refreshButton; + private Button skinsButton; public AccountsScreen(Screen currentScreen) { super(Component.translatable("accounts")); @@ -75,7 +77,12 @@ public void init() { accountsListWidget.setAccounts(Auth.getInstance().getAccounts()); addRenderableWidget(loginButton = Button.builder(Component.translatable("auth.login"), buttonWidget -> login()) - .bounds(this.width / 2 - 154, this.height - 52, 150, 20).build()); + .bounds(this.width / 2 - 154, this.height - 52, 100, 20).build()); + + addRenderableWidget(skinsButton = Button.builder(Component.translatable("skins.manage"), + btn -> minecraft.setScreen(new SkinManagementScreen( + this, accountsListWidget.getSelected().getAccount()))) + .bounds(this.width / 2 - 50, this.height - 52, 100, 20).build()); this.addRenderableWidget(Button.builder(Component.translatable("auth.add"), button -> { if (!Auth.getInstance().allowOfflineAccounts()) { @@ -93,7 +100,7 @@ public void init() { Component.translatable("auth.add.ms") )); } - }).bounds(this.width / 2 + 4, this.height - 52, 150, 20).build()); + }).bounds(this.width / 2 + 4 + 50, this.height - 52, 100, 20).build()); this.deleteButton = this.addRenderableWidget(Button.builder(Component.translatable("selectServer.delete"), button -> { @@ -116,17 +123,14 @@ public void init() { } private void initMSAuth() { - Auth.getInstance().getAuth().startDeviceAuth().thenRun(() -> minecraft.execute(this::refresh)); + Auth.getInstance().getMsApi().startDeviceAuth().thenRun(() -> minecraft.execute(this::refresh)); } private void refreshAccount() { refreshButton.active = false; AccountsListWidget.Entry entry = accountsListWidget.getSelected(); if (entry != null) { - entry.getAccount().refresh(Auth.getInstance().getAuth()).thenRun(() -> minecraft.execute(() -> { - Auth.getInstance().save(); - refresh(); - })); + entry.getAccount().refresh(Auth.getInstance().getMsApi()); } } @@ -134,9 +138,10 @@ private void updateButtonActivationStates() { AccountsListWidget.Entry entry = accountsListWidget.getSelected(); if (minecraft.level == null && entry != null) { loginButton.active = entry.getAccount().isExpired() || !entry.getAccount().equals(Auth.getInstance().getCurrent()); - deleteButton.active = refreshButton.active = true; + refreshButton.active = skinsButton.active = !entry.getAccount().isOffline(); + deleteButton.active = true; } else { - loginButton.active = deleteButton.active = refreshButton.active = false; + loginButton.active = deleteButton.active = refreshButton.active = skinsButton.active = false; } } diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java index fb42fbe32..60f9c5434 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java @@ -43,12 +43,11 @@ public AddOfflineScreen(Screen parent) { @Override public void render(GuiGraphics graphics, int i, int j, float f) { - renderBackground(graphics, i, j, f); super.render(graphics, i, j, f); graphics.drawString(font, Component.translatable("auth.add.offline.name"), (int) (width / 2F - 100), (int) (height / 2f - 20), -1 ); - graphics.drawString(this.font, this.title, this.width / 2, 20, -1); + graphics.drawCenteredString(this.font, this.title, this.width / 2, 20, -1); } @Override diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/Auth.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/Auth.java index 4a041f634..530cc569b 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/Auth.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/Auth.java @@ -28,7 +28,6 @@ import com.mojang.authlib.minecraft.UserApiService; import com.mojang.authlib.yggdrasil.ProfileResult; import io.github.axolotlclient.AxolotlClient; -import io.github.axolotlclient.AxolotlClientConfig.api.options.OptionCategory; import io.github.axolotlclient.AxolotlClientConfig.impl.options.BooleanOption; import io.github.axolotlclient.api.API; import io.github.axolotlclient.api.util.UUIDHelper; @@ -37,6 +36,7 @@ import io.github.axolotlclient.mixin.ServerPackManagerAccessor; import io.github.axolotlclient.mixin.SplashManagerAccessor; import io.github.axolotlclient.modules.Module; +import io.github.axolotlclient.modules.auth.skin.SkinManager; import io.github.axolotlclient.util.ThreadExecuter; import io.github.axolotlclient.util.notifications.Notifications; import io.github.axolotlclient.util.options.GenericOption; @@ -59,26 +59,28 @@ public class Auth extends Accounts implements Module { @Getter private final static Auth Instance = new Auth(); public final BooleanOption showButton = new BooleanOption("auth.showButton", false); + public final BooleanOption skinManagerAnimations = new BooleanOption("skins.manage.animations", true); private final Minecraft mc = Minecraft.getInstance(); private final GenericOption viewAccounts = new GenericOption("viewAccounts", "clickToOpen", () -> mc.setScreen(new AccountsScreen(mc.screen))); private final Set loadingTexture = new HashSet<>(); private final Map textures = new WeakHashMap<>(); + @Getter + private final SkinManager skinManager = new SkinManager(); @Override public void init() { load(); - this.auth = new MSAuth(AxolotlClient.LOGGER, this, () -> mc.options.languageCode); + this.msApi = new MSApi(this, () -> mc.options.languageCode); if (isContained(mc.getUser().getSessionId())) { current = getAccounts().stream().filter(account -> account.getUuid().equals(UUIDHelper.toUndashed(mc.getUser().getProfileId()))).toList().getFirst(); if (current.needsRefresh()) { - current.refresh(auth).thenRun(this::save); + current.refresh(msApi).thenRun(this::save); } } else { current = new Account(mc.getUser().getName(), UUIDHelper.toUndashed(mc.getUser().getProfileId()), mc.getUser().getAccessToken()); } - OptionCategory category = OptionCategory.create("auth"); - category.add(showButton, viewAccounts); + category.add(showButton, viewAccounts, skinManagerAnimations); AxolotlClient.config().general.add(category); } @@ -92,11 +94,11 @@ protected void login(Account account) { if (account.isExpired()) { Notifications.getInstance().addStatus(Component.translatable("auth.notif.title"), Component.translatable("auth.notif.refreshing", account.getName())); } - account.refresh(auth).thenAccept(res -> res.ifPresent(a -> { + account.refresh(msApi).thenAccept(a -> { if (!a.isExpired()) { login(a); } - })).thenRun(this::save); + }).thenRun(this::save); } else { try { API.getInstance().shutdown(); @@ -129,14 +131,18 @@ protected void login(Account account) { } @Override - void showAccountsExpiredScreen(Account account) { + CompletableFuture showAccountsExpiredScreen(Account account) { Screen current = mc.screen; + var fut = new CompletableFuture(); mc.execute(() -> mc.setScreen(new ConfirmScreen((bl) -> { - mc.setScreen(current); if (bl) { - auth.startDeviceAuth(); + msApi.startDeviceAuth().thenRun(() -> fut.complete(account)); + } else { + fut.cancel(true); } + mc.setScreen(current); }, Component.translatable("auth"), Component.translatable("auth.accountExpiredNotice", account.getName())))); + return fut; } @Override diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java new file mode 100644 index 000000000..b9c3c6848 --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java @@ -0,0 +1,785 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.vertex.VertexConsumer; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.AxolotlClientConfig.api.util.Colors; +import io.github.axolotlclient.api.SimpleTextInputScreen; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.mixin.GameRendererAccessor; +import io.github.axolotlclient.mixin.GuiGraphicsAccessor; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import io.github.axolotlclient.util.ClientColors; +import io.github.axolotlclient.util.Watcher; +import io.github.axolotlclient.util.notifications.Notifications; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ComponentPath; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.*; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.navigation.FocusNavigationEvent; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import net.minecraft.client.gui.render.TextureSetup; +import net.minecraft.client.gui.render.state.GuiElementRenderState; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix3x2f; + +public class SkinManagementScreen extends Screen { + private static final Path SKINS_DIR = FabricLoader.getInstance().getGameDir().resolve("skins"); + private static final int LIST_SKIN_WIDTH = 75; + private static final int LIST_SKIN_HEIGHT = 110; + private static final Component TEXT_EQUIPPING = Component.translatable("skins.manage.equipping"); + private final Screen parent; + private final Account account; + private MSApi.MCProfile cachedProfile; + private SkinListWidget skinList; + private SkinListWidget capesList; + private boolean capesTab; + private SkinWidget current; + private final Watcher skinDirWatcher; + private final CompletableFuture refreshFuture; + + public SkinManagementScreen(Screen parent, Account account) { + super(Component.translatable("skins.manage")); + this.parent = parent; + this.account = account; + skinDirWatcher = Watcher.createSelfTicking(SKINS_DIR, () -> { + AxolotlClientCommon.getInstance().getLogger().info("Reloading screen as local files changed!"); + loadSkinsList(); + }); + if (account.needsRefresh()) { + refreshFuture = account.refresh(Auth.getInstance().getMsApi()); + } else { + refreshFuture = CompletableFuture.completedFuture(null); + } + } + + @Override + protected void init() { + int headerHeight = 33; + int contentHeight = height - headerHeight * 2; + + StringWidget titleWidget = new StringWidget(0, headerHeight / 2 - font.lineHeight / 2, width, font.lineHeight, getTitle(), getFont()); + addRenderableWidget(titleWidget); + + var back = Button.builder(CommonComponents.GUI_BACK, btn -> onClose()) + .bounds(width / 2 - 75, height - headerHeight / 2 - 10, 150, 20).build(); + + var loadingPlaceholder = new LoadingDotsWidget(getFont(), Component.translatable("skins.loading")); + loadingPlaceholder.setRectangle(width, contentHeight, 0, + headerHeight); + addRenderableWidget(loadingPlaceholder); + addRenderableWidget(back); + + skinList = new SkinListWidget(minecraft, width / 2, contentHeight - 24, headerHeight + 24, LIST_SKIN_HEIGHT + 34); + capesList = new SkinListWidget(minecraft, width / 2, contentHeight - 24, headerHeight + 24, skinList.getEntryContentsHeight() + 24); + skinList.setX(width / 2); + capesList.setX(width / 2); + var currentHeight = Math.min((width / 2f) * 120 / 85, contentHeight); + var currentWidth = currentHeight * 85 / 120; + current = new SkinWidget((int) currentWidth, (int) currentHeight, null, account); + current.setPosition((int) (width / 4f - currentWidth / 2), (int) (height / 2f - currentHeight / 2)); + + if (!capesTab) { + capesList.visible = capesList.active = false; + } else { + skinList.visible = skinList.active = false; + } + List navBar = new ArrayList<>(); + var skinsTab = Button.builder(Component.translatable("skins.nav.skins"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = true; + capesList.visible = capesList.active = false; + capesTab = false; + }).pos(Math.max(width * 3 / 4 - 102, width / 2 + 2), headerHeight).width(Math.min(100, width / 4 - 2)).build(); + navBar.add(skinsTab); + var capesTab = Button.builder(Component.translatable("skins.nav.capes"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = false; + capesList.visible = capesList.active = true; + this.capesTab = true; + }).pos(width * 3 / 4 + 2, headerHeight).width(Math.min(100, width / 4 - 2)).build(); + navBar.add(capesTab); + var importButton = SpriteIconButton.builder(Component.translatable("skins.manage.import.local"), btn -> { + btn.active = false; + SkinImportUtil.openImportSkinDialog().thenAccept(this::onFilesDrop).thenRun(() -> btn.active = true); + }, true).sprite(ResourceLocation.fromNamespaceAndPath("axolotlclient", "folder"), 7, 7).size(11, 11).build(); + importButton.setTooltip(Tooltip.create(importButton.getMessage())); + var downloadButton = SpriteIconButton.builder(Component.translatable("skins.manage.import.online"), btn -> { + btn.active = false; + promptForSkinDownload(); + }, true).sprite(ResourceLocation.fromNamespaceAndPath("axolotlclient", "download"), 7, 7).size(11, 11).build(); + downloadButton.setTooltip(Tooltip.create(downloadButton.getMessage())); + if (width - (capesTab.getX() + capesTab.getWidth()) > 28) { + importButton.setX(width - importButton.getWidth() - 2); + downloadButton.setX(importButton.getX() - downloadButton.getWidth() - 2); + importButton.setY(capesTab.getY() + capesTab.getHeight() - 11); + downloadButton.setY(importButton.getY()); + } else { + importButton.setX(capesTab.getX() + capesTab.getWidth() - 11); + importButton.setY(capesTab.getY() - 13); + downloadButton.setX(importButton.getX() - 2 - 11); + downloadButton.setY(importButton.getY()); + } + skinsTab.active = this.capesTab; + capesTab.active = !this.capesTab; + Runnable addWidgets = () -> { + clearWidgets(); + addRenderableWidget(titleWidget); + addRenderableWidget(current); + addRenderableWidget(skinsTab); + addRenderableWidget(capesTab); + addRenderableWidget(downloadButton); + addRenderableWidget(importButton); + addRenderableWidget(skinList); + addRenderableWidget(capesList); + addRenderableWidget(back); + }; + if (cachedProfile != null) { + initDisplay(); + addWidgets.run(); + return; + } + + refreshFuture.thenComposeAsync(unused -> Auth.getInstance().getMsApi().getProfile(account)) + .thenAcceptAsync(profile -> { + cachedProfile = profile; + initDisplay(); + addWidgets.run(); + }).exceptionally(t -> { + if (t.getCause() instanceof CancellationException) { + minecraft.setScreen(parent); + return null; + } + AxolotlClientCommon.getInstance().getLogger().error("Failed to load skins!", t); + var error = Component.translatable("skins.error.failed_to_load"); + var errorDesc = Component.translatable("skins.error.failed_to_load_desc"); + clearWidgets(); + addRenderableWidget(titleWidget); + addRenderableWidget(new StringWidget(width / 2 - getFont().width(error) / 2, height / 2 - getFont().lineHeight - 2, getFont().width(error), getFont().lineHeight, error, getFont())); + addRenderableWidget(new StringWidget(width / 2 - getFont().width(errorDesc) / 2, height / 2 + 1, getFont().width(errorDesc), getFont().lineHeight, errorDesc, getFont())); + addRenderableWidget(back); + return null; + }); + } + + private void promptForSkinDownload() { + minecraft.setScreen(new SimpleTextInputScreen(this, Component.translatable("skins.manage.import.online"), Component.translatable("skins.manage.import.online.input"), s -> + UUIDHelper.ensureUuidOpt(s).thenAcceptAsync(o -> { + if (o.isPresent()) { + AxolotlClientCommon.getInstance().getLogger().info("Downloading skin of {} ({})", s, o.get()); + Auth.getInstance().getMsApi().getTextures(o.get()) + .exceptionally(th -> { + AxolotlClientCommon.getInstance().getLogger().info("Failed to download skin of {} ({})", s, o.get(), th); + return null; + }).thenAcceptAsync(t -> { + if (t == null) { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_download", s); + return; + } + try { + var bytes = t.skin().join(); + var out = ensureNonexistent(SKINS_DIR.resolve(t.skinKey())); + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, t.classicModel(), "name", t.name(), "uuid", t.id(), "download_time", Instant.now())); + Files.write(out, bytes); + minecraft.execute(this::loadSkinsList); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.downloaded", t.name()); + AxolotlClientCommon.getInstance().getLogger().info("Downloaded skin of {} ({})", t.name(), o.get()); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to write skin file", e); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_save", t.name()); + } + }); + } else { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.not_found", s); + } + }))); + } + + private void initDisplay() { + loadSkinsList(); + loadCapesList(); + } + + private void refreshCurrentList() { + if (capesTab) { + var scroll = capesList.scrollAmount(); + loadCapesList(); + capesList.setScrollAmount(scroll); + } else { + var scroll = skinList.scrollAmount(); + loadSkinsList(); + skinList.setScrollAmount(scroll); + } + } + + private void loadCapesList() { + capesList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + var capes = profile.capes(); + var deselectCape = createWidgetForCape(current.getSkin(), null); + var activeCape = capes.stream().filter(Cape::active).findFirst(); + current.setCape(activeCape.orElse(null)); + deselectCape.noCape(activeCape.isEmpty()); + for (int i = 0; i < capes.size() + 1; i += columns) { + Entry widget; + if (i == 0) { + widget = createEntry(capesList.getEntryContentsHeight(), deselectCape, Component.translatable("skins.capes.no_cape")); + } else { + var cape = capes.get(i - 1); + widget = createEntryForCape(current.getSkin(), cape, capesList.getEntryContentsHeight()); + } + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < capes.size() + 1 - c)) continue; + var cape2 = capes.get(i + c - 1); + Entry widget2 = createEntryForCape(current.getSkin(), cape2, capesList.getEntryContentsHeight()); + + widgets.add(widget2); + } + capesList.addEntry(new Row(widgets)); + } + } + + private void loadSkinsList() { + skinList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + List skins = new ArrayList<>(profile.skins()); + var hashes = skins.stream().map(Asset::textureKey).collect(Collectors.toSet()); + var defaultSkinHash = Auth.getInstance().getSkinManager().getDefaultSkinHash(account); + var local = new ArrayList<>(loadLocalSkins()); + var localHashes = local.stream().collect(Collectors.toMap(Asset::textureKey, Function.identity(), (skin, skin2) -> skin)); + local.removeIf(s -> !localHashes.containsValue(s)); + skins.replaceAll(s -> { + if (s instanceof MSApi.MCProfile.OnlineSkin online) { + if (localHashes.containsKey(s.textureKey()) && localHashes.get(s.textureKey()) instanceof Skin.Local file) { + local.remove(localHashes.remove(s.textureKey())); + return new Skin.Shared(file, online); + } + } + return s; + }); + + skins.addAll(local); + if (!hashes.contains(defaultSkinHash)) { + skins.add(null); + } + populateSkinList(skins, columns); + } + + private List loadLocalSkins() { + try { + Files.createDirectories(SKINS_DIR); + try (Stream skins = Files.list(SKINS_DIR)) { + return skins.filter(Files::isRegularFile).sorted(Comparator.comparingLong(p -> { + try { + return Files.getLastModifiedTime(p).toMillis(); + } catch (IOException e) { + return 0L; + } + }).reversed()).map(Auth.getInstance().getSkinManager()::read).filter(Objects::nonNull).toList(); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to read skins dir!", e); + } + return Collections.emptyList(); + } + + private void populateSkinList(List skins, int columns) { + int entryHeight = skinList.getEntryContentsHeight(); + for (int i = 0; i < skins.size(); i += columns) { + var s = skins.get(i); + if (s != null && s.active()) { + current.setSkin(s); + } + var widget = createEntryForSkin(s, entryHeight); + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < skins.size() - c)) continue; + var s2 = skins.get(i + c); + if (s2 != null && s2.active()) { + current.setSkin(s2); + } + var widget2 = createEntryForSkin(s2, entryHeight); + widgets.add(widget2); + } + skinList.addEntry(new Row(widgets)); + } + } + + private Path ensureNonexistent(Path p) { + if (Files.exists(p)) { + int counter = 0; + do { + counter++; + p = p.resolveSibling(p.getFileName().toString() + "_" + counter); + } while (Files.exists(p)); + } + return p; + } + + @Override + public void onFilesDrop(List packs) { + if (packs.isEmpty()) return; + + CompletableFuture[] futs = new CompletableFuture[packs.size()]; + for (int i = 0; i < packs.size(); i++) { + Path p = packs.get(i); + futs[i] = CompletableFuture.runAsync(() -> { + try { + var target = ensureNonexistent(SKINS_DIR.resolve(p.getFileName())); + var skin = Auth.getInstance().getSkinManager().read(p, false); + if (skin != null) { + Files.write(target, skin.image().join()); + } else { + AxolotlClientCommon.getInstance().getLogger().info("Skipping dragged file {} because it does not seem to be a valid skin!", p); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.not_copied", p.getFileName()); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to copy skin file: ", e); + } + }, minecraft); + } + CompletableFuture.allOf(futs).thenRun(this::loadSkinsList); + } + + private @NotNull Entry createEntryForSkin(Skin skin, int entryHeight) { + return createEntry(entryHeight, new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, skin, account)); + } + + private @NotNull Entry createEntryForCape(Skin currentSkin, Cape cape, int entryHeight) { + return createEntry(entryHeight, createWidgetForCape(currentSkin, cape), Component.literal(cape.alias())); + } + + private SkinWidget createWidgetForCape(Skin currentSkin, Cape cape) { + SkinWidget widget2 = new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, currentSkin, cape, account); + widget2.setRotationY(210); + return widget2; + } + + @Override + protected void clearWidgets() { + super.clearWidgets(); + SkinRenderer.closeRenderers(); + Auth.getInstance().getSkinManager().releaseAll(); + } + + @Override + public void removed() { + Auth.getInstance().getSkinManager().releaseAll(); + Watcher.close(skinDirWatcher); + SkinRenderer.closeRenderers(); + } + + @Override + public void onClose() { + minecraft.setScreen(parent); + } + + private SkinListWidget getCurrentList() { + return capesTab ? capesList : skinList; + } + + private static class SkinListWidget extends ContainerObjectSelectionList { + public SkinListWidget(Minecraft minecraft, int width, int height, int y, int entryHeight) { + super(minecraft, width, height, y, entryHeight); + } + + @Override + public int addEntry(Row entry) { + return super.addEntry(entry); + } + + @Override + protected int scrollBarX() { + return getRight() - 8; + } + + @Override + public int getRowLeft() { + return getX() + 3; + } + + @Override + public int getRowWidth() { + if (!scrollbarVisible()) { + return getWidth() - 4; + } + return getWidth() - 14; + } + + public int getEntryContentsHeight() { + return itemHeight - 4; + } + + @Override + public @Nullable ComponentPath nextFocusPath(FocusNavigationEvent event) { + if (!active || !visible) return null; + return super.nextFocusPath(event); + } + + @Override + public void clearEntries() { + super.clearEntries(); + } + + @Override + public void centerScrollOn(Row entry) { + super.centerScrollOn(entry); + } + } + + private class Row extends ContainerObjectSelectionList.Entry { + private final List widgets; + + public Row(List entries) { + this.widgets = entries; + } + + @Override + public @NotNull List narratables() { + return widgets; + } + + @Override + public void render(GuiGraphics guiGraphics, int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovering, float partialTick) { + int x = left; + if (widgets.isEmpty()) return; + int count = widgets.size(); + int padding = ((width - 5 * (count - 1)) / count); + for (var w : widgets) { + w.setPosition(x, top); + w.setWidth(padding); + w.render(guiGraphics, mouseX, mouseY, partialTick); + x += w.getWidth() + 5; + } + } + + @Override + public @NotNull List children() { + return widgets; + } + + @Override + public void setFocused(@Nullable GuiEventListener focused) { + super.setFocused(focused); + if (focused != null) { + getCurrentList().centerScrollOn(this); + } + } + } + + Entry createEntry(int height, SkinWidget widget) { + return createEntry(height, widget, null); + } + + Entry createEntry(int height, SkinWidget widget, Component label) { + return new Entry(height, widget, label); + } + + private class Entry extends AbstractContainerWidget { + private final SkinWidget skinWidget; + private final @Nullable AbstractWidget label; + private final List actionButtons = new ArrayList<>(); + private final AbstractWidget equipButton; + private boolean equipping; + private long equippingStart; + + public Entry(int height, SkinWidget widget, @Nullable Component label) { + super(0, 0, widget.getWidth(), height, Component.empty()); + widget.setWidth(getWidth() - 4); + var asset = widget.getFocusedAsset(); + if (asset != null) { + class SpriteButton extends Button { + private ResourceLocation sprite; + + public SpriteButton(Component message, OnPress onPress, ResourceLocation sprite) { + super(0, 0, 11, 11, message, onPress, DEFAULT_NARRATION); + this.sprite = sprite; + setTooltip(Tooltip.create(message, Component.empty())); + } + + @Override + public void setMessage(Component message) { + super.setMessage(message); + setTooltip(Tooltip.create(message, Component.empty())); + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + super.renderWidget(graphics, mouseX, mouseY, delta); + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, sprite, this.getX() + 2, this.getY() + 2, 7, 7); + } + + @Override + public void renderString(GuiGraphics guiGraphics, Font font, int color) { + + } + } + if (asset instanceof Skin skin) { + var wideSprite = ResourceLocation.fromNamespaceAndPath("axolotlclient", "wide"); + var slimSprite = ResourceLocation.fromNamespaceAndPath("axolotlclient", "slim"); + var slimText = Component.translatable("skins.manage.variant.classic"); + var wideText = Component.translatable("skins.manage.variant.slim"); + actionButtons.add(new SpriteButton(skin.classicVariant() ? wideText : slimText, btn -> { + var self = (SpriteButton) btn; + skin.classicVariant(!skin.classicVariant()); + self.sprite = skin.classicVariant() ? slimSprite : wideSprite; + self.setMessage(skin.classicVariant() ? wideText : slimText); + }, skin.classicVariant() ? slimSprite : wideSprite)); + } + if (asset.isLocal()) { + this.actionButtons.add(new SpriteButton(Component.translatable("skins.manage.delete"), btn -> { + btn.active = false; + minecraft.setScreen(new ConfirmScreen(confirmed -> { + minecraft.setScreen(SkinManagementScreen.this); + if (confirmed) { + try { + Files.delete(asset.file()); + Skin.Local.deleteMetadata(asset.file()); + refreshCurrentList(); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to delete: ", e); + } + } + btn.active = true; + }, Component.translatable("skins.manage.delete.confirm"), (asset.active() ? + Component.translatable("skins.manage.delete.confirm.desc_active") : + Component.translatable("skins.manage.delete.confirm.desc") + ).withColor(Colors.RED.toInt()))); + }, ResourceLocation.fromNamespaceAndPath("axolotlclient", "delete"))); + } + if (asset.supportsDownload() && !asset.isLocal()) { + this.actionButtons.add(new SpriteButton(Component.translatable("skins.manage.download"), btn -> { + btn.active = false; + asset.image().thenAcceptAsync(b -> { + try { + var out = SKINS_DIR.resolve(asset.textureKey()); + Files.createDirectories(out.getParent()); + Files.write(out, b); + if (asset instanceof Skin skin) { + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, skin.classicVariant())); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to download: ", e); + } + refreshCurrentList(); + btn.active = true; + }); + }, ResourceLocation.fromNamespaceAndPath("axolotlclient", "download"))); + } + } + if (label != null) { + this.label = new AbstractStringWidget(0, 0, widget.getWidth(), 16, label, Minecraft.getInstance().font) { + @Override + protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + renderScrollingString(guiGraphics, getFont(), 2, -1); + } + }; + this.label.active = false; + } else { + this.label = null; + } + this.equipButton = Button.builder(Component.translatable( + widget.isEquipped() ? "skins.manage.equipped" : "skins.manage.equip"), + btn -> { + equippingStart = Util.getMillis(); + equipping = true; + btn.setMessage(TEXT_EQUIPPING); + btn.active = false; + widget.equip().thenAcceptAsync(p -> { + cachedProfile = p; + refreshCurrentList(); + }).exceptionally(t -> { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to equip asset!", t); + equipping = false; + return null; + }); + }).width(widget.getWidth()).build(); + this.equipButton.active = !widget.isEquipped(); + this.skinWidget = widget; + } + + @Override + public @NotNull List children() { + return Stream.concat(actionButtons.stream(), Stream.of(skinWidget, label, equipButton)).filter(Objects::nonNull).toList(); + } + + @Override + protected int contentHeight() { + return getHeight(); + } + + @Override + protected double scrollRate() { + return 0; + } + + private float applyEasing(float x) { + return x * x * x; + } + + @Override + protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + int y = getY() + 4; + int x = getX() + 2; + skinWidget.setPosition(x, y); + skinWidget.setWidth(getWidth() - 4); + if (skinWidget.isEquipped() || equipping) { + long prog; + if (Auth.getInstance().skinManagerAnimations.get()) { + if (equipping) prog = (Util.getMillis() - equippingStart) / 20 % 100; + else prog = Math.abs((Util.getMillis() / 30 % 200) - 100); + } else prog = 100; + var percent = (prog / 100f); + float gradientWidth; + if (equipping) { + gradientWidth = percent * Math.min(getWidth() / 3f, getHeight() / 3f); + } else { + gradientWidth = Math.min(getWidth() / 15f, getHeight() / 6f) + applyEasing(percent) * Math.min(getWidth() * 2 / 15f, getHeight() / 6f); + } + GradientHoleRectangleRenderState.create(guiGraphics, getX() + 2, getY() + 2, getRight() - 2, + skinWidget.getBottom() + 2, + gradientWidth, + equipping ? 0xFFFF0088 : ClientColors.SELECTOR_GREEN.toInt(), 0).submit(); + } + skinWidget.render(guiGraphics, mouseX, mouseY, partialTick); + int actionButtonY = getY() + 2; + for (var button : actionButtons) { + button.setPosition(skinWidget.getRight() - button.getWidth(), actionButtonY); + if (isHovered() || button.isHoveredOrFocused()) { + button.render(guiGraphics, mouseX, mouseY, partialTick); + } + actionButtonY += button.getHeight() + 2; + } + if (label != null) { + label.setPosition(x, skinWidget.getBottom() + 6); + label.render(guiGraphics, mouseX, mouseY, partialTick); + label.setWidth(getWidth() - 4); + equipButton.setPosition(x, label.getBottom() + 2); + } else { + equipButton.setPosition(x, skinWidget.getBottom() + 4); + } + equipButton.setWidth(getWidth() - 4); + equipButton.render(guiGraphics, mouseX, mouseY, partialTick); + + if (isHovered()) { + guiGraphics.renderOutline(getX(), getY(), getWidth(), getHeight(), -1); + } + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + skinWidget.updateNarration(narrationElementOutput); + actionButtons.forEach(w -> w.updateNarration(narrationElementOutput)); + if (label != null) { + label.updateNarration(narrationElementOutput); + } + equipButton.updateNarration(narrationElementOutput); + } + + private record GradientHoleRectangleRenderState(RenderPipeline pipeline, TextureSetup textureSetup, + Matrix3x2f pose, + int x0, int y0, int x1, int y1, float gradientWidth, int col1, + int col2, @Nullable ScreenRectangle scissorArea, + @Nullable ScreenRectangle bounds) implements GuiElementRenderState { + + public static GradientHoleRectangleRenderState create(GuiGraphics graphics, int x0, int y0, int x1, int y1, float gradientWidth, int col1, int col2) { + var matrix = new Matrix3x2f(graphics.pose()); + var area = ((GuiGraphicsAccessor) graphics).getScissorStack().peek(); + return new GradientHoleRectangleRenderState(RenderPipelines.GUI, TextureSetup.noTexture(), matrix, x0, y0, x1, y1, gradientWidth, col1, col2, area, getBounds(x0, y0, x1, y1, matrix, area)); + } + + public void submit() { + ((GameRendererAccessor) Minecraft.getInstance().gameRenderer).getGuiRenderState().submitGuiElement(this); + } + + @Override + public void buildVertices(VertexConsumer vertexConsumer, float z) { + //top + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y0(), z).setColor(this.col1()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0() + gradientWidth(), this.y0() + gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1() - gradientWidth(), this.y0() + gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y0(), z).setColor(this.col1()); + //left + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y1(), z).setColor(this.col1()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0() + gradientWidth(), this.y1() - gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0() + gradientWidth(), this.y0() + gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y0(), z).setColor(this.col1()); + //bottom + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y1(), z).setColor(this.col1()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1() - gradientWidth(), this.y1() - gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0() + gradientWidth(), this.y1() - gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y1(), z).setColor(this.col1()); + //right + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y0(), z).setColor(this.col1()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1() - gradientWidth(), this.y0() + gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1() - gradientWidth(), this.y1() - gradientWidth(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y1(), z).setColor(this.col1()); + } + + @Nullable + private static ScreenRectangle getBounds(int i, int j, int k, int l, Matrix3x2f matrix3x2f, @Nullable ScreenRectangle screenRectangle) { + ScreenRectangle screenRectangle2 = new ScreenRectangle(i, j, k - i, l - j).transformMaxBounds(matrix3x2f); + return screenRectangle != null ? screenRectangle.intersection(screenRectangle2) : screenRectangle2; + } + } + } +} diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java new file mode 100644 index 000000000..4fdaa5d13 --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java @@ -0,0 +1,157 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListSet; + +import com.google.common.hash.Hashing; +import com.mojang.blaze3d.platform.NativeImage; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.AxoMinecraftClient; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.mixin.skins.SkinTextureDownloaderAccessor; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.util.ClientColors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.client.resources.DefaultPlayerSkin; +import net.minecraft.resources.ResourceLocation; + +public class SkinManager { + + private final Set loadedTextures = new ConcurrentSkipListSet<>(Comparator.comparing(Object::toString)); + + public Skin read(Path p) { + return read(p, true); + } + + public Skin read(Path p, boolean fix) { + boolean slim; + String sha256; + try { + var in = Files.readAllBytes(p); + sha256 = Hashing.sha256().hashBytes(in).toString(); + try (var img = NativeImage.read(in)) { + int width = img.getWidth(); + int height = img.getHeight(); + if (width != 64) return null; + if (height == 32) { + if (fix) { + try (var img2 = SkinTextureDownloaderAccessor.invokeProcessLegacySkin(img, "local")) { + img2.writeToFile(p); + } + } + slim = false; + } else if (height != 64) { + return null; + } else { + slim = ClientColors.ARGB.alpha(img.getPixel(50, 16)) == 0; + } + var metadata = Skin.Local.readMetadata(p); + if (metadata != null && metadata.containsKey(Skin.Local.CLASSIC_METADATA_KEY)) { + slim = !(boolean) metadata.get(Skin.Local.CLASSIC_METADATA_KEY); + } + } + return new Skin.Local(!slim, p, sha256); + } catch (Exception e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to probe skin: ", e); + } + return null; + } + + + public CompletableFuture loadSkin(Skin skin) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "skins/" + skin.textureKey()); + if (loadedTextures.contains(rl)) { + return CompletableFuture.completedFuture(rl); + } + + return skin.image().thenApplyAsync(bytes -> { + try { + var tex = new DynamicTexture(rl::toString, NativeImage.read(bytes)); + Minecraft.getInstance().getTextureManager().register((ResourceLocation) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((v, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load skin!", t); + } + return v; + }); + } + + public AxoIdentifier loadCape(Cape cape) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "capes/" + cape.textureKey()); + if (loadedTextures.contains(rl)) { + return rl; + } + + return cape.image().thenApplyAsync(bytes -> { + try { + var tex = new DynamicTexture(rl::toString, NativeImage.read(bytes)); + Minecraft.getInstance().getTextureManager().register((ResourceLocation) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((id, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load cape!", t); + } + return id; + }).getNow(null); + + } + + public void releaseAll() { + loadedTextures.forEach(id -> Minecraft.getInstance().getTextureManager().release((ResourceLocation) id)); + loadedTextures.clear(); + } + + public String getDefaultSkinHash(Account account) { + var skin = DefaultPlayerSkin.get(UUIDHelper.fromUndashed(account.getUuid())); + var mc = Minecraft.getInstance(); + var resourceManager = mc.getResourceManager(); + try { + var res = resourceManager.getResourceOrThrow(skin.texture()); + try ( + var in = res.open()) { + return Hashing.sha256().hashBytes(in.readAllBytes()).toString(); + } + } catch (IOException ignored) { + } + return null; + } +} diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderState.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderState.java new file mode 100644 index 000000000..cd5652e6b --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderState.java @@ -0,0 +1,61 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import io.github.axolotlclient.util.IdentifiablePiPRenderState; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import net.minecraft.client.gui.render.state.pip.PictureInPictureRenderState; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +public record SkinRenderState(boolean classicVariant, + ResourceLocation skinTexture, + @Nullable ResourceLocation cape, + float rotationX, + float rotationY, + float pivotY, + int x0, + int y0, + int x1, + int y1, + float scale, + @Nullable ScreenRectangle scissorArea, + @Nullable ScreenRectangle bounds, + SkinRenderer renderer) implements PictureInPictureRenderState, IdentifiablePiPRenderState { + + public SkinRenderState(boolean classicVariant, + ResourceLocation skinTexture, + @Nullable ResourceLocation cape, + float rotationX, + float rotationY, + float pivotY, + int x0, + int y0, + int x1, + int y1, + float scale, + @Nullable ScreenRectangle scissorArea, + SkinRenderer renderer) { + this(classicVariant, skinTexture, cape, rotationX, rotationY, pivotY, x0, y0, x1, y1, scale, scissorArea, PictureInPictureRenderState.getBounds(x0, y0, x1, y1, scissorArea), renderer); + } +} diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java new file mode 100644 index 000000000..1a500b697 --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java @@ -0,0 +1,107 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.mojang.blaze3d.platform.Lighting; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.render.pip.PictureInPictureRenderer; +import net.minecraft.client.model.PlayerCapeModel; +import net.minecraft.client.model.PlayerModel; +import net.minecraft.client.model.geom.ModelLayers; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.entity.state.PlayerRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import org.jetbrains.annotations.NotNull; +import org.joml.Matrix4fStack; + +public class SkinRenderer extends PictureInPictureRenderer { + private static final Map renderers = new ConcurrentHashMap<>(); + + public static void closeRenderers() { + renderers.values().forEach(PictureInPictureRenderer::close); + renderers.clear(); + } + + public static SkinRenderer getOrCreate(MultiBufferSource.BufferSource bufferSource, Minecraft minecraft, String id) { + return renderers.computeIfAbsent(id, _id -> new SkinRenderer(bufferSource, minecraft, id)); + } + + private PlayerModel classicModel, slimModel; + private PlayerCapeModel capeModel; + private final Minecraft minecraft; + private final String id; + + private SkinRenderer(MultiBufferSource.BufferSource bufferSource, Minecraft minecraft, String id) { + super(bufferSource); + this.minecraft = minecraft; + this.id = id; + } + + @Override + public @NotNull Class getRenderStateClass() { + return SkinRenderState.class; + } + + @Override + protected void renderToTexture(SkinRenderState renderState, PoseStack poseStack) { + if (classicModel == null && renderState.classicVariant()) { + classicModel = new PlayerModel(minecraft.getEntityModels().bakeLayer(ModelLayers.PLAYER), false); + } + if (slimModel == null && !renderState.classicVariant()) { + slimModel = new PlayerModel(minecraft.getEntityModels().bakeLayer(ModelLayers.PLAYER_SLIM), true); + } + Minecraft.getInstance().gameRenderer.getLighting().setupFor(Lighting.Entry.PLAYER_SKIN); + int i = Minecraft.getInstance().getWindow().getGuiScale(); + Matrix4fStack matrix4fStack = RenderSystem.getModelViewStack(); + matrix4fStack.pushMatrix(); + float f = renderState.scale() * i; + matrix4fStack.rotateAround(Axis.XP.rotationDegrees(renderState.rotationX()), 0.0F, f * -renderState.pivotY(), 0.0F); + poseStack.mulPose(Axis.YP.rotationDegrees(-renderState.rotationY())); + poseStack.translate(0.0F, -1.6010001F, 0.0F); + var model = renderState.classicVariant() ? classicModel : slimModel; + RenderType renderType = model.renderType(renderState.skinTexture()); + model.renderToBuffer(poseStack, this.bufferSource.getBuffer(renderType), 15728880, OverlayTexture.NO_OVERLAY); + if (renderState.cape() != null) { + if (capeModel == null) { + capeModel = new PlayerCapeModel<>(minecraft.getEntityModels().bakeLayer(ModelLayers.PLAYER_CAPE)); + } + var type = capeModel.renderType(renderState.cape()); + poseStack.mulPose(Axis.XP.rotationDegrees(6.0F)); + capeModel.renderToBuffer(poseStack, bufferSource.getBuffer(type), 15728880, OverlayTexture.NO_OVERLAY); + } + this.bufferSource.endBatch(); + matrix4fStack.popMatrix(); + } + + @Override + protected @NotNull String getTextureLabel() { + return "axolotlclient/skin_render/" + id; + } +} diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java new file mode 100644 index 000000000..6cece9f9d --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java @@ -0,0 +1,154 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.util.concurrent.CompletableFuture; + +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.mixin.GuiGraphicsAccessor; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import lombok.Getter; +import lombok.Setter; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ComponentPath; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.navigation.FocusNavigationEvent; +import net.minecraft.client.resources.DefaultPlayerSkin; +import net.minecraft.client.resources.PlayerSkin; +import net.minecraft.client.sounds.SoundManager; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; +import org.jetbrains.annotations.Nullable; + +public class SkinWidget extends AbstractWidget { + private static final float MODEL_HEIGHT = 2.125F; + private static final float FIT_SCALE = 0.97F; + private static final float ROTATION_SENSITIVITY = 2.5F; + private static final float DEFAULT_ROTATION_X = -5.0F; + private static final float DEFAULT_ROTATION_Y = 30.0F; + private static final float ROTATION_X_LIMIT = 50.0F; + private float rotationX = DEFAULT_ROTATION_X; + @Setter + private float rotationY = DEFAULT_ROTATION_Y; + @Getter + @Setter + private Skin skin; + @Getter + @Setter + private Cape cape; + private final Account owner; + private boolean noCape, noCapeActive; + + public SkinWidget(int width, int height, Skin skin, @Nullable Cape cape, Account owner) { + super(0, 0, width, height, CommonComponents.EMPTY); + this.skin = skin; + this.cape = cape; + this.owner = owner; + } + + public SkinWidget(int width, int height, Skin skin, Account owner) { + this(width, height, skin, null, owner); + } + + public void noCape(boolean noCapeActive) { + noCape = true; + this.noCapeActive = noCapeActive; + } + + @Override + protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + var minecraft = Minecraft.getInstance(); + + float scale = FIT_SCALE * this.getHeight() / MODEL_HEIGHT; + float pivotY = -1.0625F; + + AxoIdentifier skinRl; + boolean classic; + SkinManager skinManager = Auth.getInstance().getSkinManager(); + CompletableFuture loader = skin == null ? null : skinManager.loadSkin(skin); + if (loader != null && loader.isDone()) { + skinRl = loader.join(); + classic = skin.classicVariant(); + } else { + var skin = DefaultPlayerSkin.get(UUIDHelper.fromUndashed(owner.getUuid())); + classic = skin.model() == PlayerSkin.Model.WIDE; + skinRl = skin.texture(); + } + var capeRl = cape == null ? null : skinManager.loadCape(cape); + + // You might say that using `hashCode()` like this isn't ideal, but in reality it doesn't matter. These objects get freed + // correctly by the screen so we mostly only need unique identifiers per widget which `hashCode()` provides. + var renderer = SkinRenderer.getOrCreate(minecraft.renderBuffers().bufferSource(), minecraft, "" + hashCode()); + ((GuiGraphicsAccessor) guiGraphics).getGuiRenderState() + .submitPicturesInPictureState( + new SkinRenderState(classic, (ResourceLocation) skinRl, (ResourceLocation) capeRl, this.rotationX, this.rotationY, pivotY, this.getX(), this.getY(), this.getRight(), this.getBottom(), scale, guiGraphics.scissorStack.peek(), renderer)); + } + + @Override + protected void onDrag(double mouseX, double mouseY, double dragX, double dragY) { + this.rotationX = Mth.clamp(this.rotationX - (float) dragY * ROTATION_SENSITIVITY, -ROTATION_X_LIMIT, ROTATION_X_LIMIT); + this.rotationY += (float) dragX * ROTATION_SENSITIVITY; + } + + @Override + public void playDownSound(SoundManager handler) { + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + } + + @Nullable + @Override + public ComponentPath nextFocusPath(FocusNavigationEvent event) { + return null; + } + + public boolean isEquipped() { + return noCape ? noCapeActive : (cape != null ? cape.active() : skin != null && skin.active()); + } + + public CompletableFuture equip() { + var msApi = Auth.getInstance().getMsApi(); + if (noCape) { + return msApi.hideCape(owner); + } + if (cape != null) { + return cape.equip(msApi, owner); + } + if (skin != null) { + return skin.equip(msApi, owner); + } + return msApi.resetSkin(owner); + } + + public Asset getFocusedAsset() { + return noCape ? null : cape != null ? cape : skin; + } +} diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java index 3e3b95182..e8fb14a24 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java @@ -54,9 +54,9 @@ public class HudEditScreen extends Screen { private static final BooleanOption snapping = new BooleanOption("snapping", true); private static final OptionCategory hudEditScreenCategory = OptionCategory.create("hudEditScreen"); private static final int GRAB_TOLERANCE = 5; - private static final long MOVE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_ALL_CURSOR); - private static final long DEFAULT_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_ARROW_CURSOR); - private static final long NWSE_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NWSE_CURSOR), + private final long MOVE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_ALL_CURSOR); + private final long DEFAULT_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_ARROW_CURSOR); + private final long NWSE_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NWSE_CURSOR), NESW_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NESW_CURSOR); public static boolean isSnappingEnabled() { @@ -157,6 +157,10 @@ public void removed() { setCursor(DEFAULT_CURSOR); mode = ModificationMode.NONE; super.removed(); + GLFW.glfwDestroyCursor(MOVE_CURSOR); + GLFW.glfwDestroyCursor(DEFAULT_CURSOR); + GLFW.glfwDestroyCursor(NESW_RESIZE_CURSOR); + GLFW.glfwDestroyCursor(NWSE_RESIZE_CURSOR); } @Override diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/gui/hud/PlayerHud.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/gui/hud/PlayerHud.java index 004bff5bf..a6a616748 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/gui/hud/PlayerHud.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/gui/hud/PlayerHud.java @@ -27,6 +27,7 @@ import io.github.axolotlclient.bridge.render.AxoRenderContext; import io.github.axolotlclient.mixin.GuiGraphicsAccessor; import io.github.axolotlclient.modules.hud.util.PlayerHudEntityRenderState; +import io.github.axolotlclient.modules.hud.util.PlayerHudEntityRenderer; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.player.LocalPlayer; @@ -49,6 +50,7 @@ public class PlayerHud extends PlayerHudCommon { private LivingEntityRenderState reusedPlayerRendererState = null; + private PlayerHudEntityRenderer renderer; public PlayerHud() { super(); @@ -148,7 +150,9 @@ private void renderEntityInInventory( @Nullable Quaternionf quaternionf2, LivingEntity livingEntity ) { - EntityRenderDispatcher entityRenderDispatcher = Minecraft.getInstance().getEntityRenderDispatcher(); + Minecraft mc = Minecraft.getInstance(); + EntityRenderDispatcher entityRenderDispatcher = mc.getEntityRenderDispatcher(); + if (renderer == null) renderer = new PlayerHudEntityRenderer(mc.renderBuffers().bufferSource(), entityRenderDispatcher); EntityRenderer entityRenderer = (EntityRenderer) entityRenderDispatcher.getRenderer(livingEntity); if (reusedPlayerRendererState == null) { reusedPlayerRendererState = entityRenderer.createRenderState(); @@ -156,7 +160,7 @@ private void renderEntityInInventory( entityRenderer.extractRenderState(livingEntity, reusedPlayerRendererState, 1.0f); reusedPlayerRendererState.nameTag = null; reusedPlayerRendererState.hitboxesRenderState = null; - ((GuiGraphicsAccessor) guiGraphics).getGuiRenderState().submitPicturesInPictureState(new PlayerHudEntityRenderState(reusedPlayerRendererState, vector3f, quaternionf, quaternionf2, i, j, k, l, f, ((GuiGraphicsAccessor) guiGraphics).getScissorStack().peek())); + ((GuiGraphicsAccessor) guiGraphics).getGuiRenderState().submitPicturesInPictureState(new PlayerHudEntityRenderState(reusedPlayerRendererState, vector3f, quaternionf, quaternionf2, i, j, k, l, f, ((GuiGraphicsAccessor) guiGraphics).getScissorStack().peek(), renderer)); } private boolean isPerformingAction() { diff --git a/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/util/PlayerHudEntityRenderState.java b/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/util/PlayerHudEntityRenderState.java index 2038087f9..3257cb423 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/util/PlayerHudEntityRenderState.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/modules/hud/util/PlayerHudEntityRenderState.java @@ -22,6 +22,7 @@ package io.github.axolotlclient.modules.hud.util; +import io.github.axolotlclient.util.IdentifiablePiPRenderState; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.render.state.pip.PictureInPictureRenderState; import net.minecraft.client.renderer.entity.state.EntityRenderState; @@ -39,7 +40,8 @@ public record PlayerHudEntityRenderState(EntityRenderState renderState, int y1, float scale, @Nullable ScreenRectangle scissorArea, - @Nullable ScreenRectangle bounds) implements PictureInPictureRenderState { + @Nullable ScreenRectangle bounds, + PlayerHudEntityRenderer renderer) implements PictureInPictureRenderState, IdentifiablePiPRenderState { public PlayerHudEntityRenderState( EntityRenderState entityRenderState, @@ -51,11 +53,10 @@ public PlayerHudEntityRenderState( int k, int l, float f, - @Nullable ScreenRectangle screenRectangle + @Nullable ScreenRectangle screenRectangle, + PlayerHudEntityRenderer renderer ) { - this( - entityRenderState, vector3f, quaternionf, quaternionf2, i, j, k, l, f, screenRectangle, PictureInPictureRenderState.getBounds(i, j, k, l, screenRectangle) - ); + this(entityRenderState, vector3f, quaternionf, quaternionf2, i, j, k, l, f, screenRectangle, PictureInPictureRenderState.getBounds(i, j, k, l, screenRectangle), renderer); } } diff --git a/1.21.7/src/main/java/io/github/axolotlclient/util/HorizontalGradientRectangleRenderState.java b/1.21.7/src/main/java/io/github/axolotlclient/util/HorizontalGradientRectangleRenderState.java index f10f151d0..0d010d68e 100644 --- a/1.21.7/src/main/java/io/github/axolotlclient/util/HorizontalGradientRectangleRenderState.java +++ b/1.21.7/src/main/java/io/github/axolotlclient/util/HorizontalGradientRectangleRenderState.java @@ -58,11 +58,11 @@ public void submit() { } @Override - public void buildVertices(VertexConsumer vertexConsumer, float f) { - vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y0(), f).setColor(this.col1()); - vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y1(), f).setColor(this.col1()); - vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y1(), f).setColor(this.col2()); - vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y0(), f).setColor(this.col2()); + public void buildVertices(VertexConsumer vertexConsumer, float z) { + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y0(), z).setColor(this.col1()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x0(), this.y1(), z).setColor(this.col1()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y1(), z).setColor(this.col2()); + vertexConsumer.addVertexWith2DPose(this.pose(), this.x1(), this.y0(), z).setColor(this.col2()); } @Nullable diff --git a/1.21.7/src/main/java/io/github/axolotlclient/util/IdentifiablePiPRenderState.java b/1.21.7/src/main/java/io/github/axolotlclient/util/IdentifiablePiPRenderState.java new file mode 100644 index 000000000..574e776b6 --- /dev/null +++ b/1.21.7/src/main/java/io/github/axolotlclient/util/IdentifiablePiPRenderState.java @@ -0,0 +1,29 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.util; + +import net.minecraft.client.gui.render.pip.PictureInPictureRenderer; + +public interface IdentifiablePiPRenderState> { + T renderer(); +} diff --git a/1.21.7/src/main/resources/axolotlclient.mixins.json b/1.21.7/src/main/resources/axolotlclient.mixins.json index 1c53a17ec..64f9e1196 100644 --- a/1.21.7/src/main/resources/axolotlclient.mixins.json +++ b/1.21.7/src/main/resources/axolotlclient.mixins.json @@ -26,6 +26,7 @@ "GameRendererMixin", "GuiGraphicsAccessor", "GuiGraphicsMixin", + "GuiRendererMixin", "HandledScreenMixin", "InGameHudMixin", "InGameOverlayRendererMixin", @@ -64,6 +65,7 @@ "WorldListWidgetEntryMixin", "WorldRendererMixin", "api.JoinMulitplayerScreenMixin", + "skins.SkinTextureDownloaderAccessor", "translation.TranslationStorageMixin" ], "injectors": { diff --git a/1.21/build.gradle.kts b/1.21/build.gradle.kts index 271d5ac54..3ba61ec8e 100644 --- a/1.21/build.gradle.kts +++ b/1.21/build.gradle.kts @@ -89,6 +89,7 @@ java { tasks.runClient { classpath(sourceSets.getByName("test").runtimeClasspath) + jvmArgs("-XX:+AllowEnhancedClassRedefinition", "-XX:+IgnoreUnrecognizedVMOptions") } // Configure the maven publication diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java b/1.21/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java index ef81f4214..4faaef659 100644 --- a/1.21/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java +++ b/1.21/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java @@ -22,6 +22,7 @@ package io.github.axolotlclient.modules.auth; +import io.github.axolotlclient.modules.auth.skin.SkinManagementScreen; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screen.ConfirmScreen; @@ -36,6 +37,7 @@ public class AccountsScreen extends Screen { private ButtonWidget loginButton; private ButtonWidget deleteButton; private ButtonWidget refreshButton; + private ButtonWidget skinsButton; public AccountsScreen(Screen currentScreen) { super(Text.translatable("accounts")); @@ -76,8 +78,13 @@ public void init() { accountsListWidget.setAccounts(Auth.getInstance().getAccounts()); - addDrawableSelectableElement(loginButton = new ButtonWidget.Builder(Text.translatable("auth.login"), - buttonWidget -> login()).positionAndSize(this.width / 2 - 154, this.height - 52, 150, 20).build()); + addDrawableSelectableElement(loginButton = ButtonWidget.builder(Text.translatable("auth.login"), + buttonWidget -> login()).positionAndSize(this.width / 2 - 154, this.height - 52, 100, 20).build()); + + addDrawableSelectableElement(skinsButton = ButtonWidget.builder(Text.translatable("skins.manage"), + btn -> client.setScreen(new SkinManagementScreen( + this, accountsListWidget.getSelectedOrNull().getAccount()))) + .positionAndSize(this.width / 2 - 50, this.height - 52, 100, 20).build()); this.addDrawableSelectableElement(ButtonWidget.builder(Text.translatable("auth.add"), button -> { @@ -94,7 +101,7 @@ public void init() { }, Text.translatable("auth.add.choose"), Text.empty(), Text.translatable("auth.add.offline"), Text.translatable("auth.add.ms"))); } }) - .positionAndSize(this.width / 2 + 4, this.height - 52, 150, 20).build()); + .positionAndSize(this.width / 2 + 4 + 50, this.height - 52, 100, 20).build()); this.deleteButton = this.addDrawableSelectableElement(ButtonWidget.builder(Text.translatable("selectServer.delete"), button -> { AccountsListWidget.Entry entry = this.accountsListWidget.getSelectedOrNull(); @@ -120,17 +127,14 @@ public void init() { } private void initMSAuth() { - Auth.getInstance().getAuth().startDeviceAuth().thenRun(() -> client.execute(this::refresh)); + Auth.getInstance().getMsApi().startDeviceAuth().thenRun(() -> client.execute(this::refresh)); } private void refreshAccount() { refreshButton.active = false; AccountsListWidget.Entry entry = accountsListWidget.getSelectedOrNull(); if (entry != null) { - entry.getAccount().refresh(Auth.getInstance().getAuth()).thenRun(() -> client.execute(() -> { - Auth.getInstance().save(); - refresh(); - })); + entry.getAccount().refresh(Auth.getInstance().getMsApi()); } } @@ -138,9 +142,10 @@ private void updateButtonActivationStates() { AccountsListWidget.Entry entry = accountsListWidget.getSelectedOrNull(); if (client.world == null && entry != null) { loginButton.active = entry.getAccount().isExpired() || !entry.getAccount().equals(Auth.getInstance().getCurrent()); - deleteButton.active = refreshButton.active = true; + refreshButton.active = skinsButton.active = !entry.getAccount().isOffline(); + deleteButton.active = true; } else { - loginButton.active = deleteButton.active = refreshButton.active = false; + loginButton.active = deleteButton.active = refreshButton.active = skinsButton.active = false; } } diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java b/1.21/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java index 9da89256e..528f8e8df 100644 --- a/1.21/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java +++ b/1.21/src/main/java/io/github/axolotlclient/modules/auth/AddOfflineScreen.java @@ -43,7 +43,6 @@ public AddOfflineScreen(Screen parent) { @Override public void render(GuiGraphics graphics, int i, int j, float f) { - renderBackground(graphics, i, j, f); super.render(graphics, i, j, f); graphics.drawShadowedText(textRenderer, Text.translatable("auth.add.offline.name"), (int) (width / 2F - 100), (int) (height / 2f - 20), -1); graphics.drawCenteredShadowedText(this.textRenderer, this.title, this.width / 2, 20, 16777215); diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/auth/Auth.java b/1.21/src/main/java/io/github/axolotlclient/modules/auth/Auth.java index 521e99c3c..7922c7dd0 100644 --- a/1.21/src/main/java/io/github/axolotlclient/modules/auth/Auth.java +++ b/1.21/src/main/java/io/github/axolotlclient/modules/auth/Auth.java @@ -23,17 +23,18 @@ package io.github.axolotlclient.modules.auth; import java.util.*; +import java.util.concurrent.CompletableFuture; import com.mojang.authlib.minecraft.UserApiService; import com.mojang.authlib.yggdrasil.ProfileResult; import io.github.axolotlclient.AxolotlClient; -import io.github.axolotlclient.AxolotlClientConfig.api.options.OptionCategory; import io.github.axolotlclient.AxolotlClientConfig.impl.options.BooleanOption; import io.github.axolotlclient.api.API; import io.github.axolotlclient.api.types.User; import io.github.axolotlclient.api.util.UUIDHelper; import io.github.axolotlclient.mixin.MinecraftClientAccessor; import io.github.axolotlclient.modules.Module; +import io.github.axolotlclient.modules.auth.skin.SkinManager; import io.github.axolotlclient.util.ThreadExecuter; import io.github.axolotlclient.util.notifications.Notifications; import io.github.axolotlclient.util.options.GenericOption; @@ -55,15 +56,18 @@ public class Auth extends Accounts implements Module { @Getter private final static Auth Instance = new Auth(); public final BooleanOption showButton = new BooleanOption("auth.showButton", false); + public final BooleanOption skinManagerAnimations = new BooleanOption("skins.manage.animations", true); private final MinecraftClient client = MinecraftClient.getInstance(); private final GenericOption viewAccounts = new GenericOption("viewAccounts", "clickToOpen", () -> client.setScreen(new AccountsScreen(client.currentScreen))); private final Set loadingTexture = new HashSet<>(); private final Map textures = new WeakHashMap<>(); + @Getter + private final SkinManager skinManager = new SkinManager(); @Override public void init() { load(); - this.auth = new MSAuth(AxolotlClient.LOGGER, this, () -> client.options.language); + this.msApi = new MSApi(this, () -> client.options.language); if (isContained(client.getSession().getSessionId())) { current = getAccounts().stream().filter(account -> account.getUuid() .equals(UUIDHelper.toUndashed(client.getSession().getPlayerUuid()))).toList().getFirst(); @@ -76,7 +80,6 @@ public void init() { current = new Account(client.getSession().getUsername(), UUIDHelper.toUndashed(client.getSession().getPlayerUuid()), client.getSession().getAccessToken()); } - OptionCategory category = OptionCategory.create("auth"); category.add(showButton, viewAccounts); AxolotlClient.config().general.add(category); } @@ -91,11 +94,11 @@ protected void login(Account account) { if (account.isExpired()) { Notifications.getInstance().addStatus(Text.translatable("auth.notif.title"), Text.translatable("auth.notif.refreshing", account.getName())); } - account.refresh(auth).thenAccept(res -> res.ifPresent(a -> { + account.refresh(msApi).thenAccept(a -> { if (!a.isExpired()) { login(a); } - })).thenRun(this::save); + }).thenRun(this::save); } else { try { API.getInstance().shutdown(); @@ -124,14 +127,18 @@ protected void login(Account account) { } @Override - void showAccountsExpiredScreen(Account account) { + CompletableFuture showAccountsExpiredScreen(Account account) { Screen current = client.currentScreen; + var fut = new CompletableFuture(); client.execute(() -> client.setScreen(new ConfirmScreen((bl) -> { - client.setScreen(current); if (bl) { - auth.startDeviceAuth(); + msApi.startDeviceAuth().thenRun(() -> fut.complete(account)); + } else { + fut.cancel(true); } + client.setScreen(current); }, Text.translatable("auth"), Text.translatable("auth.accountExpiredNotice", account.getName())))); + return fut; } @Override diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java new file mode 100644 index 000000000..ef10579b9 --- /dev/null +++ b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java @@ -0,0 +1,764 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.AxolotlClientConfig.api.util.Colors; +import io.github.axolotlclient.api.SimpleTextInputScreen; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import io.github.axolotlclient.util.ClientColors; +import io.github.axolotlclient.util.Watcher; +import io.github.axolotlclient.util.notifications.Notifications; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.ElementPath; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.Selectable; +import net.minecraft.client.gui.navigation.GuiNavigationEvent; +import net.minecraft.client.gui.screen.ConfirmScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.tooltip.Tooltip; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.DraggingWidget; +import net.minecraft.client.gui.widget.LoadingTextWidget; +import net.minecraft.client.gui.widget.button.ButtonWidget; +import net.minecraft.client.gui.widget.button.SpriteButtonWidget; +import net.minecraft.client.gui.widget.list.ElementListWidget; +import net.minecraft.client.gui.widget.text.AbstractTextWidget; +import net.minecraft.client.gui.widget.text.TextWidget; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.text.CommonTexts; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.Util; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SkinManagementScreen extends Screen { + private static final Path SKINS_DIR = FabricLoader.getInstance().getGameDir().resolve("skins"); + private static final int LIST_SKIN_WIDTH = 75; + private static final int LIST_SKIN_HEIGHT = 110; + private static final Text TEXT_EQUIPPING = Text.translatable("skins.manage.equipping"); + private final Screen parent; + private final Account account; + private MSApi.MCProfile cachedProfile; + private SkinListWidget skinList; + private SkinListWidget capesList; + private boolean capesTab; + private SkinWidget current; + private final Watcher skinDirWatcher; + private final CompletableFuture refreshFuture; + + public SkinManagementScreen(Screen parent, Account account) { + super(Text.translatable("skins.manage")); + this.parent = parent; + this.account = account; + skinDirWatcher = Watcher.createSelfTicking(SKINS_DIR, () -> { + AxolotlClientCommon.getInstance().getLogger().info("Reloading screen as local files changed!"); + loadSkinsList(); + }); + if (account.needsRefresh()) { + refreshFuture = account.refresh(Auth.getInstance().getMsApi()); + } else { + refreshFuture = CompletableFuture.completedFuture(null); + } + } + + @Override + protected void init() { + int headerHeight = 33; + int contentHeight = height - headerHeight * 2; + + var titleWidget = new TextWidget(0, headerHeight / 2 - textRenderer.fontHeight / 2, width, textRenderer.fontHeight, getTitle(), textRenderer); + addDrawableSelectableElement(titleWidget); + + var back = ButtonWidget.builder(CommonTexts.BACK, btn -> closeScreen()) + .positionAndSize(width / 2 - 75, height - headerHeight / 2 - 10, 150, 20).build(); + + var loadingPlaceholder = new LoadingTextWidget(textRenderer, Text.translatable("skins.loading")); + loadingPlaceholder.setDimensionsAndPosition(width, contentHeight, 0, headerHeight); + addDrawableSelectableElement(loadingPlaceholder); + addDrawableSelectableElement(back); + + skinList = new SkinListWidget(client, width / 2, contentHeight - 24, headerHeight + 24, LIST_SKIN_HEIGHT + 34); + capesList = new SkinListWidget(client, width / 2, contentHeight - 24, headerHeight + 24, skinList.getEntryContentsHeight() + 24); + skinList.setX(width / 2); + capesList.setX(width / 2); + var currentHeight = Math.min((width / 2f) * 120 / 85, contentHeight); + var currentWidth = currentHeight * 85 / 120; + current = new SkinWidget((int) currentWidth, (int) currentHeight, null, account); + current.setPosition((int) (width / 4f - currentWidth / 2), (int) (height / 2f - currentHeight / 2)); + + if (!capesTab) { + capesList.visible = capesList.active = false; + } else { + skinList.visible = skinList.active = false; + } + List navBar = new ArrayList<>(); + var skinsTab = ButtonWidget.builder(Text.translatable("skins.nav.skins"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = true; + capesList.visible = capesList.active = false; + capesTab = false; + }).position(Math.max(width * 3 / 4 - 102, width / 2 + 2), headerHeight).width(Math.min(100, width / 4 - 2)).build(); + navBar.add(skinsTab); + var capesTab = ButtonWidget.builder(Text.translatable("skins.nav.capes"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = false; + capesList.visible = capesList.active = true; + this.capesTab = true; + }).position(width * 3 / 4 + 2, headerHeight).width(Math.min(100, width / 4 - 2)).build(); + navBar.add(capesTab); + var importButton = SpriteButtonWidget.builder(Text.translatable("skins.manage.import.local"), btn -> { + btn.active = false; + SkinImportUtil.openImportSkinDialog().thenAccept(this::filesDragged).thenRun(() -> btn.active = true); + }, true).sprite(Identifier.of("axolotlclient", "folder"), 7, 7).dimensions(11, 11).build(); + importButton.setTooltip(Tooltip.create(importButton.getMessage())); + var downloadButton = SpriteButtonWidget.builder(Text.translatable("skins.manage.import.online"), btn -> { + btn.active = false; + promptForSkinDownload(); + }, true).sprite(Identifier.of("axolotlclient", "download"), 7, 7).dimensions(11, 11).build(); + downloadButton.setTooltip(Tooltip.create(downloadButton.getMessage())); + if (width - (capesTab.getX() + capesTab.getWidth()) > 28) { + importButton.setX(width - importButton.getWidth() - 2); + downloadButton.setX(importButton.getX() - downloadButton.getWidth() - 2); + importButton.setY(capesTab.getY() + capesTab.getHeight() - 11); + downloadButton.setY(importButton.getY()); + } else { + importButton.setX(capesTab.getX() + capesTab.getWidth() - 11); + importButton.setY(capesTab.getY() - 13); + downloadButton.setX(importButton.getX() - 2 - 11); + downloadButton.setY(importButton.getY()); + } + skinsTab.active = this.capesTab; + capesTab.active = !this.capesTab; + Runnable addWidgets = () -> { + clearChildren(); + addDrawableSelectableElement(titleWidget); + addDrawableSelectableElement(current); + addDrawableSelectableElement(skinsTab); + addDrawableSelectableElement(capesTab); + addDrawableSelectableElement(downloadButton); + addDrawableSelectableElement(importButton); + addDrawableSelectableElement(skinList); + addDrawableSelectableElement(capesList); + addDrawableSelectableElement(back); + }; + if (cachedProfile != null) { + initDisplay(); + addWidgets.run(); + return; + } + refreshFuture.thenComposeAsync(unused -> Auth.getInstance().getMsApi().getProfile(account)) + .thenAcceptAsync(profile -> { + cachedProfile = profile; + initDisplay(); + addWidgets.run(); + }).exceptionally(t -> { + if (t.getCause() instanceof CancellationException) { + client.setScreen(parent); + return null; + } + AxolotlClientCommon.getInstance().getLogger().error("Failed to load skins!", t); + var error = Text.translatable("skins.error.failed_to_load"); + var errorDesc = Text.translatable("skins.error.failed_to_load_desc"); + clearChildren(); + addDrawableSelectableElement(titleWidget); + addDrawableSelectableElement(new TextWidget(width / 2 - textRenderer.getWidth(error) / 2, height / 2 - textRenderer.fontHeight - 2, textRenderer.getWidth(error), textRenderer.fontHeight, error, textRenderer)); + addDrawableSelectableElement(new TextWidget(width / 2 - textRenderer.getWidth(errorDesc) / 2, height / 2 + 1, textRenderer.getWidth(errorDesc), textRenderer.fontHeight, errorDesc, textRenderer)); + addDrawableSelectableElement(back); + return null; + }); + } + + private void promptForSkinDownload() { + client.setScreen(new SimpleTextInputScreen(this, Text.translatable("skins.manage.import.online"), Text.translatable("skins.manage.import.online.input"), s -> + UUIDHelper.ensureUuidOpt(s).thenAccept(o -> { + if (o.isPresent()) { + AxolotlClientCommon.getInstance().getLogger().info("Downloading skin of {} ({})", s, o.get()); + Auth.getInstance().getMsApi().getTextures(o.get()) + .exceptionally(th -> { + AxolotlClientCommon.getInstance().getLogger().info("Failed to download skin of {} ({})", s, o.get(), th); + return null; + }).thenAccept(t -> { + if (t == null) { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_download", s); + return; + } + try { + var bytes = t.skin().join(); + var out = ensureNonexistent(SKINS_DIR.resolve(t.skinKey())); + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, t.classicModel(), "name", t.name(), "uuid", t.id(), "download_time", Instant.now())); + Files.write(out, bytes); + client.execute(this::loadSkinsList); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.downloaded", t.name()); + AxolotlClientCommon.getInstance().getLogger().info("Downloaded skin of {} ({})", t.name(), o.get()); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to write skin file", e); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_save", t.name()); + } + }); + } else { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.not_found", s); + } + }))); + } + + private void initDisplay() { + loadSkinsList(); + loadCapesList(); + } + + private void refreshCurrentList() { + if (capesTab) { + var scroll = capesList.getScrollAmount(); + loadCapesList(); + capesList.setScrollAmount(scroll); + } else { + var scroll = skinList.getScrollAmount(); + loadSkinsList(); + skinList.setScrollAmount(scroll); + } + } + + private void loadCapesList() { + capesList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + var capes = profile.capes(); + var deselectCape = createWidgetForCape(current.getSkin(), null); + var activeCape = capes.stream().filter(Cape::active).findFirst(); + current.setCape(activeCape.orElse(null)); + deselectCape.noCape(activeCape.isEmpty()); + for (int i = 0; i < capes.size() + 1; i += columns) { + Entry widget; + if (i == 0) { + widget = createEntry(capesList.getEntryContentsHeight(), deselectCape, Text.translatable("skins.capes.no_cape")); + } else { + var cape = capes.get(i - 1); + widget = createEntryForCape(current.getSkin(), cape, capesList.getEntryContentsHeight()); + } + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < capes.size() + 1 - c)) continue; + var cape2 = capes.get(i + c - 1); + Entry widget2 = createEntryForCape(current.getSkin(), cape2, capesList.getEntryContentsHeight()); + + widgets.add(widget2); + } + capesList.addEntry(new Row(widgets)); + } + } + + private void loadSkinsList() { + skinList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + List skins = new ArrayList<>(profile.skins()); + var hashes = skins.stream().map(Asset::textureKey).collect(Collectors.toSet()); + var defaultSkinHash = Auth.getInstance().getSkinManager().getDefaultSkinHash(account); + var local = new ArrayList<>(loadLocalSkins()); + var localHashes = local.stream().collect(Collectors.toMap(Asset::textureKey, Function.identity(), (skin, skin2) -> skin)); + local.removeIf(s -> !localHashes.containsValue(s)); + skins.replaceAll(s -> { + if (s instanceof MSApi.MCProfile.OnlineSkin online) { + if (localHashes.containsKey(s.textureKey()) && localHashes.get(s.textureKey()) instanceof Skin.Local file) { + local.remove(localHashes.remove(s.textureKey())); + return new Skin.Shared(file, online); + } + } + return s; + }); + + skins.addAll(local); + if (!hashes.contains(defaultSkinHash)) { + skins.add(null); + } + populateSkinList(skins, columns); + } + + private List loadLocalSkins() { + try { + Files.createDirectories(SKINS_DIR); + try (Stream skins = Files.list(SKINS_DIR)) { + return skins.filter(Files::isRegularFile).sorted(Comparator.comparingLong(p -> { + try { + return Files.getLastModifiedTime(p).toMillis(); + } catch (IOException e) { + return 0L; + } + }).reversed()).map(Auth.getInstance().getSkinManager()::read).filter(Objects::nonNull).toList(); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to read skins dir!", e); + } + return Collections.emptyList(); + } + + private void populateSkinList(List skins, int columns) { + int entryHeight = skinList.getEntryContentsHeight(); + for (int i = 0; i < skins.size(); i += columns) { + var s = skins.get(i); + if (s != null && s.active()) { + current.setSkin(s); + } + var widget = createEntryForSkin(s, entryHeight); + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < skins.size() - c)) continue; + var s2 = skins.get(i + c); + if (s2 != null && s2.active()) { + current.setSkin(s2); + } + var widget2 = createEntryForSkin(s2, entryHeight); + widgets.add(widget2); + } + skinList.addEntry(new Row(widgets)); + } + } + + private Path ensureNonexistent(Path p) { + if (Files.exists(p)) { + int counter = 0; + do { + counter++; + p = p.resolveSibling(p.getFileName().toString() + "_" + counter); + } while (Files.exists(p)); + } + return p; + } + + @Override + public void filesDragged(List packs) { + if (packs.isEmpty()) return; + + CompletableFuture[] futs = new CompletableFuture[packs.size()]; + for (int i = 0; i < packs.size(); i++) { + Path p = packs.get(i); + futs[i] = CompletableFuture.runAsync(() -> { + try { + var target = ensureNonexistent(SKINS_DIR.resolve(p.getFileName())); + var skin = Auth.getInstance().getSkinManager().read(p, false); + if (skin != null) { + Files.write(target, skin.image().join()); + } else { + AxolotlClientCommon.getInstance().getLogger().info("Skipping dragged file {} because it does not seem to be a valid skin!", p); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.not_copied", p.getFileName()); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to copy skin file: ", e); + } + }, client); + } + CompletableFuture.allOf(futs).thenRun(this::loadSkinsList); + } + + private @NotNull Entry createEntryForSkin(Skin skin, int entryHeight) { + return createEntry(entryHeight, new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, skin, account)); + } + + private @NotNull Entry createEntryForCape(Skin currentSkin, Cape cape, int entryHeight) { + return createEntry(entryHeight, createWidgetForCape(currentSkin, cape), Text.literal(cape.alias())); + } + + private SkinWidget createWidgetForCape(Skin currentSkin, Cape cape) { + SkinWidget widget2 = new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, currentSkin, cape, account); + widget2.setRotationY(210); + return widget2; + } + + @Override + protected void clearChildren() { + super.clearChildren(); + Auth.getInstance().getSkinManager().releaseAll(); + } + + @Override + public void removed() { + Auth.getInstance().getSkinManager().releaseAll(); + Watcher.close(skinDirWatcher); + } + + @Override + public void closeScreen() { + client.setScreen(parent); + } + + private SkinListWidget getCurrentList() { + return capesTab ? capesList : skinList; + } + + private class SkinListWidget extends ElementListWidget { + public SkinListWidget(MinecraftClient minecraft, int width, int height, int y, int entryHeight) { + super(minecraft, width, height, y, entryHeight); + } + + @Override + public int addEntry(Row entry) { + return super.addEntry(entry); + } + + @Override + protected int getScrollbarPositionX() { + return getXEnd() - 8; + } + + @Override + public int getRowLeft() { + return getX() + 3; + } + + @Override + public int getRowWidth() { + if (!canScroll()) { + return getWidth() - 4; + } + return getWidth() - 14; + } + + public int getEntryContentsHeight() { + return itemHeight - 4; + } + + @Override + public @Nullable ElementPath nextFocusPath(GuiNavigationEvent event) { + if (!active || !visible) return null; + return super.nextFocusPath(event); + } + + @Override + public void clearEntries() { + super.clearEntries(); + } + + @Override + public void centerScrollOn(Row entry) { + super.centerScrollOn(entry); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double amountX, double amountY) { + if (!visible) return false; + return super.mouseScrolled(mouseX, mouseY, amountX, amountY); + } + + @Override + public boolean isMouseOver(double mouseX, double mouseY) { + return active && visible && super.isMouseOver(mouseX, mouseY); + } + } + + private class Row extends ElementListWidget.Entry { + private final List widgets; + + public Row(List entries) { + this.widgets = entries; + } + + @Override + public @NotNull List selectableChildren() { + return widgets; + } + + @Override + public void render(GuiGraphics guiGraphics, int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovering, float partialTick) { + int x = left; + if (widgets.isEmpty()) return; + int count = widgets.size(); + int padding = ((width - 5 * (count - 1)) / count); + for (var w : widgets) { + w.setPosition(x, top); + w.setWidth(padding); + w.render(guiGraphics, mouseX, mouseY, partialTick); + x += w.getWidth() + 5; + } + } + + @Override + public @NotNull List children() { + return widgets; + } + + @Override + public void setFocusedChild(@Nullable Element focused) { + super.setFocusedChild(focused); + if (focused != null) { + getCurrentList().centerScrollOn(this); + } + } + } + + Entry createEntry(int height, SkinWidget widget) { + return createEntry(height, widget, null); + } + + Entry createEntry(int height, SkinWidget widget, Text label) { + return new Entry(height, widget, label); + } + + private class Entry extends DraggingWidget { + private final SkinWidget skinWidget; + private final @Nullable ClickableWidget label; + private final List actionButtons = new ArrayList<>(); + private final ClickableWidget equipButton; + private boolean equipping; + private long equippingStart; + + public Entry(int height, SkinWidget widget, @Nullable Text label) { + super(0, 0, widget.getWidth(), height, Text.empty()); + widget.setWidth(getWidth() - 4); + var asset = widget.getFocusedAsset(); + if (asset != null) { + class SpriteButton extends ButtonWidget { + private Identifier sprite; + + public SpriteButton(Text message, PressAction onPress, Identifier sprite) { + super(0, 0, 11, 11, message, onPress, DEFAULT_NARRATION); + this.sprite = sprite; + setTooltip(Tooltip.create(message, Text.empty())); + } + + @Override + public void setMessage(Text message) { + super.setMessage(message); + setTooltip(Tooltip.create(message, Text.empty())); + } + + @Override + protected void drawWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + super.drawWidget(graphics, mouseX, mouseY, delta); + graphics.drawGuiTexture(sprite, this.getX() + 2, this.getY() + 2, 7, 7); + } + + @Override + public void drawScrollableText(GuiGraphics graphics, TextRenderer renderer, int color) { + + } + } + if (asset instanceof Skin skin) { + var wideSprite = Identifier.of("axolotlclient", "wide"); + var slimSprite = Identifier.of("axolotlclient", "slim"); + var slimText = Text.translatable("skins.manage.variant.classic"); + var wideText = Text.translatable("skins.manage.variant.slim"); + actionButtons.add(new SpriteButton(skin.classicVariant() ? wideText : slimText, btn -> { + var self = (SpriteButton) btn; + skin.classicVariant(!skin.classicVariant()); + self.sprite = skin.classicVariant() ? slimSprite : wideSprite; + self.setMessage(skin.classicVariant() ? wideText : slimText); + }, skin.classicVariant() ? slimSprite : wideSprite)); + } + if (asset.isLocal()) { + this.actionButtons.add(new SpriteButton(Text.translatable("skins.manage.delete"), btn -> { + btn.active = false; + client.setScreen(new ConfirmScreen(confirmed -> { + client.setScreen(SkinManagementScreen.this); + if (confirmed) { + try { + Files.delete(asset.file()); + Skin.Local.deleteMetadata(asset.file()); + refreshCurrentList(); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to delete: ", e); + } + } + btn.active = true; + }, Text.translatable("skins.manage.delete.confirm"), (Text) (asset.active() ? + Text.translatable("skins.manage.delete.confirm.desc_active") : + Text.translatable("skins.manage.delete.confirm.desc") + ).br$color(Colors.RED.toInt()))); + }, Identifier.of("axolotlclient", "delete"))); + } + if (asset.supportsDownload() && !asset.isLocal()) { + this.actionButtons.add(new SpriteButton(Text.translatable("skins.manage.download"), btn -> { + btn.active = false; + asset.image().thenAcceptAsync(b -> { + try { + var out = SKINS_DIR.resolve(asset.textureKey()); + Files.createDirectories(out.getParent()); + Files.write(out, b); + if (asset instanceof Skin skin) { + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, skin.classicVariant())); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to download: ", e); + } + refreshCurrentList(); + btn.active = true; + }); + }, Identifier.of("axolotlclient", "download"))); + } + } + if (label != null) { + this.label = new AbstractTextWidget(0, 0, widget.getWidth(), 16, label, textRenderer) { + @Override + protected void drawWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + drawScrollingText(guiGraphics, textRenderer, 2, -1); + } + }; + this.label.active = false; + } else { + this.label = null; + } + this.equipButton = ButtonWidget.builder(Text.translatable( + widget.isEquipped() ? "skins.manage.equipped" : "skins.manage.equip"), + btn -> { + equippingStart = Util.getMeasuringTimeMs(); + equipping = true; + btn.setMessage(TEXT_EQUIPPING); + btn.active = false; + widget.equip().thenAcceptAsync(p -> { + cachedProfile = p; + refreshCurrentList(); + }).exceptionally(t -> { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to equip asset!", t); + equipping = false; + return null; + }); + }).width(widget.getWidth()).build(); + this.equipButton.active = !widget.isEquipped(); + this.skinWidget = widget; + } + + @Override + public @NotNull List children() { + return Stream.concat(actionButtons.stream(), Stream.of(skinWidget, label, equipButton)).filter(Objects::nonNull).toList(); + } + + private float applyEasing(float x) { + return x * x * x; + } + + @Override + protected void drawWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + int y = getY() + 4; + int x = getX() + 2; + skinWidget.setPosition(x, y); + skinWidget.setWidth(getWidth() - 4); + if (skinWidget.isEquipped() || equipping) { + long prog; + if (Auth.getInstance().skinManagerAnimations.get()) { + if (equipping) prog = (Util.getMeasuringTimeMs() - equippingStart) / 20 % 100; + else prog = Math.abs((Util.getMeasuringTimeMs() / 30 % 200) - 100); + } else prog = 100; + var percent = (prog / 100f); + float gradientWidth; + if (equipping) { + gradientWidth = percent * Math.min(getWidth() / 3f, getHeight() / 3f); + } else { + gradientWidth = Math.min(getWidth() / 15f, getHeight() / 6f) + applyEasing(percent) * Math.min(getWidth() * 2 / 15f, getHeight() / 6f); + } + GradientHoleRectangleRenderState.render(guiGraphics, getX() + 2, getY() + 2, getXEnd() - 2, + skinWidget.getYEnd() + 2, + gradientWidth, + equipping ? 0xFFFF0088 : ClientColors.SELECTOR_GREEN.toInt(), 0); + } + skinWidget.render(guiGraphics, mouseX, mouseY, partialTick); + int actionButtonY = getY() + 2; + for (var button : actionButtons) { + button.setPosition(skinWidget.getXEnd() - button.getWidth(), actionButtonY); + if (isHovered() || button.isHoveredOrFocused()) { + button.render(guiGraphics, mouseX, mouseY, partialTick); + } + actionButtonY += button.getHeight() + 2; + } + if (label != null) { + label.setPosition(x, skinWidget.getYEnd() + 6); + label.render(guiGraphics, mouseX, mouseY, partialTick); + label.setWidth(getWidth() - 4); + equipButton.setPosition(x, label.getYEnd() + 2); + } else { + equipButton.setPosition(x, skinWidget.getYEnd() + 4); + } + equipButton.setWidth(getWidth() - 4); + equipButton.render(guiGraphics, mouseX, mouseY, partialTick); + + if (isHovered()) { + guiGraphics.br$outlineRect(getX(), getY(), getWidth(), getHeight(), -1); + } + } + + @Override + protected void updateNarration(NarrationMessageBuilder narrationElementOutput) { + skinWidget.appendNarrations(narrationElementOutput); + actionButtons.forEach(w -> w.appendNarrations(narrationElementOutput)); + if (label != null) { + label.appendNarrations(narrationElementOutput); + } + equipButton.appendNarrations(narrationElementOutput); + } + + private static class GradientHoleRectangleRenderState { + + public static void render(GuiGraphics graphics, int x0, int y0, int x1, int y1, float gradientWidth, int col1, int col2) { + var vertexConsumer = graphics.getVertexConsumers().getBuffer(RenderLayer.getGui()); + float z = 0; + //top + var pose = graphics.getMatrices().peek().getModel(); + vertexConsumer.xyz(pose, x0, y0, z).color(col1); + vertexConsumer.xyz(pose, x0 + gradientWidth, y0 + gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x1 - gradientWidth, y0 + gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x1, y0, z).color(col1); + //left + vertexConsumer.xyz(pose, x0, y1, z).color(col1); + vertexConsumer.xyz(pose, x0 + gradientWidth, y1 - gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x0 + gradientWidth, y0 + gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x0, y0, z).color(col1); + //bottom + vertexConsumer.xyz(pose, x1, y1, z).color(col1); + vertexConsumer.xyz(pose, x1 - gradientWidth, y1 - gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x0 + gradientWidth, y1 - gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x0, y1, z).color(col1); + //right + vertexConsumer.xyz(pose, x1, y0, z).color(col1); + vertexConsumer.xyz(pose, x1 - gradientWidth, y0 + gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x1 - gradientWidth, y1 - gradientWidth, z).color(col2); + vertexConsumer.xyz(pose, x1, y1, z).color(col1); + } + } + } +} diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java new file mode 100644 index 000000000..aaa7d678b --- /dev/null +++ b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java @@ -0,0 +1,213 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListSet; + +import com.google.common.hash.Hashing; +import com.mojang.blaze3d.texture.NativeImage; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.AxoMinecraftClient; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.util.ClientColors; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.client.util.DefaultSkinHelper; +import net.minecraft.util.Identifier; + +public class SkinManager { + + private final Set loadedTextures = new ConcurrentSkipListSet<>(Comparator.comparing(Object::toString)); + + public Skin read(Path p) { + return read(p, true); + } + + public Skin read(Path p, boolean fix) { + boolean slim; + String sha256; + try { + var in = Files.readAllBytes(p); + sha256 = Hashing.sha256().hashBytes(in).toString(); + try (var img = NativeImage.read(in)) { + int width = img.getWidth(); + int height = img.getHeight(); + if (width != 64) return null; + if (height == 32) { + if (fix) { + try (var img2 = remapTexture(img)) { + img2.writeFile(p); + } + } + slim = false; + } else if (height != 64) { + return null; + } else { + slim = ClientColors.ARGB.alpha(img.getPixelColor(50, 16)) == 0; + } + var metadata = Skin.Local.readMetadata(p); + if (metadata != null && metadata.containsKey(Skin.Local.CLASSIC_METADATA_KEY)) { + slim = !(boolean) metadata.get(Skin.Local.CLASSIC_METADATA_KEY); + } + } + return new Skin.Local(!slim, p, sha256); + } catch (Exception e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to probe skin: ", e); + } + return null; + } + + private static NativeImage remapTexture(NativeImage skinImage) { + boolean legacySkin = skinImage.getHeight() == 32; + if (legacySkin) { + NativeImage nativeImage = new NativeImage(64, 64, true); + nativeImage.copyFrom(skinImage); + skinImage.close(); + skinImage = nativeImage; + nativeImage.fillRect(0, 32, 64, 32, 0); + nativeImage.copyRectangle(4, 16, 16, 32, 4, 4, true, false); + nativeImage.copyRectangle(8, 16, 16, 32, 4, 4, true, false); + nativeImage.copyRectangle(0, 20, 24, 32, 4, 12, true, false); + nativeImage.copyRectangle(4, 20, 16, 32, 4, 12, true, false); + nativeImage.copyRectangle(8, 20, 8, 32, 4, 12, true, false); + nativeImage.copyRectangle(12, 20, 16, 32, 4, 12, true, false); + nativeImage.copyRectangle(44, 16, -8, 32, 4, 4, true, false); + nativeImage.copyRectangle(48, 16, -8, 32, 4, 4, true, false); + nativeImage.copyRectangle(40, 20, 0, 32, 4, 12, true, false); + nativeImage.copyRectangle(44, 20, -8, 32, 4, 12, true, false); + nativeImage.copyRectangle(48, 20, -16, 32, 4, 12, true, false); + nativeImage.copyRectangle(52, 20, -8, 32, 4, 12, true, false); + } + + stripAlpha(skinImage, 0, 0, 32, 16); + if (legacySkin) { + stripColor(skinImage, 32, 0, 64, 32); + } + + stripAlpha(skinImage, 0, 16, 64, 32); + stripAlpha(skinImage, 16, 48, 48, 64); + return skinImage; + } + + @SuppressWarnings("SameParameterValue") + private static void stripColor(NativeImage image, int x1, int y1, int x2, int y2) { + for (int x = x1; x < x2; x++) { + for (int y = y1; y < y2; y++) { + int k = image.getPixelColor(x, y); + if ((k >> 24 & 0xFF) < 128) { + return; + } + } + } + + for (int x = x1; x < x2; x++) { + for (int y = y1; y < y2; y++) { + image.setPixelColor(x, y, image.getPixelColor(x, y) & 16777215); + } + } + } + + private static void stripAlpha(NativeImage image, int x1, int y1, int x2, int y2) { + for (int x = x1; x < x2; x++) { + for (int y = y1; y < y2; y++) { + image.setPixelColor(x, y, image.getPixelColor(x, y) | 0xFF000000); + } + } + } + + public CompletableFuture loadSkin(Skin skin) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "skins/" + skin.textureKey()); + if (loadedTextures.contains(rl)) { + return CompletableFuture.completedFuture(rl); + } + + return skin.image().thenApplyAsync(bytes -> { + try { + var tex = new NativeImageBackedTexture(NativeImage.read(bytes)); + MinecraftClient.getInstance().getTextureManager().registerTexture((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((v, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load skin!", t); + } + return v; + }); + } + + public AxoIdentifier loadCape(Cape cape) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "capes/" + cape.textureKey()); + if (loadedTextures.contains(rl)) { + return rl; + } + + return cape.image().thenApplyAsync(bytes -> { + try { + var tex = new NativeImageBackedTexture(NativeImage.read(bytes)); + MinecraftClient.getInstance().getTextureManager().registerTexture((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((id, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load cape!", t); + } + return id; + }).getNow(null); + + } + + public void releaseAll() { + loadedTextures.forEach(id -> MinecraftClient.getInstance().getTextureManager().destroyTexture((Identifier) id)); + loadedTextures.clear(); + } + + public String getDefaultSkinHash(Account account) { + var skin = DefaultSkinHelper.getSkin(UUIDHelper.fromUndashed(account.getUuid())); + var mc = MinecraftClient.getInstance(); + var resourceManager = mc.getResourceManager(); + try { + var res = resourceManager.getResourceOrThrow(skin.texture()); + try ( + var in = res.open()) { + return Hashing.sha256().hashBytes(in.readAllBytes()).toString(); + } + } catch (IOException ignored) { + } + return null; + } +} diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java new file mode 100644 index 000000000..2f12705c8 --- /dev/null +++ b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java @@ -0,0 +1,89 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import com.mojang.blaze3d.lighting.DiffuseLighting; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.render.OverlayTexture; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.entity.model.EntityModelLayers; +import net.minecraft.client.render.entity.model.PlayerEntityModel; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Axis; +import org.jetbrains.annotations.Nullable; + +public class SkinRenderer { + private static PlayerEntityModel classicModel, slimModel; + private static final MinecraftClient minecraft = MinecraftClient.getInstance(); + + private SkinRenderer() { + } + + public static void render(GuiGraphics graphics, boolean classicVariant, + Identifier skinTexture, + @Nullable Identifier cape, + float rotationX, + float rotationY, + float pivotY, + int x0, + int y0, + int x1, + int y1, + float scale) { + if (classicModel == null && classicVariant) { + classicModel = new PlayerEntityModel<>(minecraft.getEntityModelLoader().getModelPart(EntityModelLayers.PLAYER), false); + classicModel.child = false; + } + if (slimModel == null && !classicVariant) { + slimModel = new PlayerEntityModel<>(minecraft.getEntityModelLoader().getModelPart(EntityModelLayers.PLAYER_SLIM), true); + slimModel.child = false; + } + + int width = x1 - x0; + graphics.getMatrices().push(); + graphics.getMatrices().translate(x0 + width / 2.0F, (float) (y1), 100.0F); + graphics.getMatrices().scale(scale, scale, scale); + graphics.getMatrices().translate(0.0F, -0.0625F, 0.0F); + graphics.getMatrices().rotateAround(Axis.X_POSITIVE.rotationDegrees(rotationX), 0.0F, pivotY, 0.0F); + graphics.getMatrices().rotate(Axis.Y_POSITIVE.rotationDegrees(rotationY)); + graphics.draw(); + DiffuseLighting.setupInventoryShaderLighting(Axis.X_POSITIVE.rotationDegrees(rotationX)); + graphics.getMatrices().push(); + graphics.getMatrices().scale(1.0F, 1.0F, -1.0F); + graphics.getMatrices().translate(0.0F, -1.5F, 0.0F); + var model = classicVariant ? classicModel : slimModel; + RenderLayer renderLayer = model.getLayer(skinTexture); + model.method_60879(graphics.getMatrices(), graphics.getVertexConsumers().getBuffer(renderLayer), 15728880, OverlayTexture.DEFAULT_UV); + if (cape != null) { + graphics.getMatrices().translate(0.0F, 0.0F, 0.125F); + graphics.getMatrices().rotate(Axis.X_POSITIVE.rotationDegrees(6.0F)); + graphics.getMatrices().rotate(Axis.Y_POSITIVE.rotationDegrees(180.0F)); + model.renderCape(graphics.getMatrices(), graphics.getVertexConsumers().getBuffer(RenderLayer.getEntitySolid(cape)), 15728880, OverlayTexture.DEFAULT_UV); + } + graphics.getMatrices().pop(); + graphics.draw(); + DiffuseLighting.setup3DGuiLighting(); + graphics.getMatrices().pop(); + } +} diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java new file mode 100644 index 000000000..d924bf4b2 --- /dev/null +++ b/1.21/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java @@ -0,0 +1,148 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.util.concurrent.CompletableFuture; + +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import lombok.Getter; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.ElementPath; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.navigation.GuiNavigationEvent; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.sound.SoundManager; +import net.minecraft.client.texture.PlayerSkin; +import net.minecraft.client.util.DefaultSkinHelper; +import net.minecraft.text.CommonTexts; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import org.jetbrains.annotations.Nullable; + +public class SkinWidget extends ClickableWidget { + private static final float MODEL_HEIGHT = 2.125F; + private static final float FIT_SCALE = 0.97F; + private static final float ROTATION_SENSITIVITY = 2.5F; + private static final float DEFAULT_ROTATION_X = -5.0F; + private static final float DEFAULT_ROTATION_Y = 30.0F; + private static final float ROTATION_X_LIMIT = 50.0F; + private float rotationX = DEFAULT_ROTATION_X; + @Setter + private float rotationY = DEFAULT_ROTATION_Y; + @Getter + @Setter + private Skin skin; + @Getter + @Setter + private Cape cape; + private final Account owner; + private boolean noCape, noCapeActive; + + public SkinWidget(int width, int height, Skin skin, @Nullable Cape cape, Account owner) { + super(0, 0, width, height, CommonTexts.EMPTY); + this.skin = skin; + this.cape = cape; + this.owner = owner; + } + + public SkinWidget(int width, int height, Skin skin, Account owner) { + this(width, height, skin, null, owner); + } + + public void noCape(boolean noCapeActive) { + noCape = true; + this.noCapeActive = noCapeActive; + } + + @Override + protected void drawWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + var minecraft = MinecraftClient.getInstance(); + + float scale = FIT_SCALE * this.getHeight() / MODEL_HEIGHT; + float pivotY = -1.0625F; + + AxoIdentifier skinRl; + boolean classic; + SkinManager skinManager = Auth.getInstance().getSkinManager(); + CompletableFuture loader = skin == null ? null : skinManager.loadSkin(skin); + if (loader != null && loader.isDone()) { + skinRl = loader.join(); + classic = skin.classicVariant(); + } else { + var skin = DefaultSkinHelper.getSkin(UUIDHelper.fromUndashed(owner.getUuid())); + classic = skin.model() == PlayerSkin.Model.WIDE; + skinRl = skin.texture(); + } + var capeRl = cape == null ? null : skinManager.loadCape(cape); + + SkinRenderer.render(guiGraphics, classic, (Identifier) skinRl, (Identifier) capeRl, this.rotationX, this.rotationY, pivotY, this.getX(), this.getY(), this.getXEnd(), this.getYEnd(), scale); + } + + @Override + protected void onDrag(double mouseX, double mouseY, double dragX, double dragY) { + this.rotationX = MathHelper.clamp(this.rotationX - (float) dragY * ROTATION_SENSITIVITY, -ROTATION_X_LIMIT, ROTATION_X_LIMIT); + this.rotationY += (float) dragX * ROTATION_SENSITIVITY; + } + + @Override + public void playDownSound(SoundManager handler) { + } + + @Override + protected void updateNarration(NarrationMessageBuilder builder) { + + } + + @Override + public @Nullable ElementPath nextFocusPath(GuiNavigationEvent event) { + return null; + } + + public boolean isEquipped() { + return noCape ? noCapeActive : (cape != null ? cape.active() : skin != null && skin.active()); + } + + public CompletableFuture equip() { + var msApi = Auth.getInstance().getMsApi(); + if (noCape) { + return msApi.hideCape(owner); + } + if (cape != null) { + return cape.equip(msApi, owner); + } + if (skin != null) { + return skin.equip(msApi, owner); + } + return msApi.resetSkin(owner); + } + + public Asset getFocusedAsset() { + return noCape ? null : cape != null ? cape : skin; + } +} diff --git a/1.21/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java b/1.21/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java index 0ea711b2d..33d3b7b21 100644 --- a/1.21/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java +++ b/1.21/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java @@ -54,9 +54,9 @@ public class HudEditScreen extends Screen { private static final BooleanOption snapping = new BooleanOption("snapping", true); private static final OptionCategory hudEditScreenCategory = OptionCategory.create("hudEditScreen"); private static final int GRAB_TOLERANCE = 5; - private static final long MOVE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_ALL_CURSOR); - private static final long DEFAULT_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_ARROW_CURSOR); - private static final long NWSE_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NWSE_CURSOR), + private final long MOVE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_ALL_CURSOR); + private final long DEFAULT_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_ARROW_CURSOR); + private final long NWSE_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NWSE_CURSOR), NESW_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NESW_CURSOR); public static boolean isSnappingEnabled() { @@ -157,6 +157,10 @@ public void removed() { setCursor(DEFAULT_CURSOR); mode = ModificationMode.NONE; super.removed(); + GLFW.glfwDestroyCursor(MOVE_CURSOR); + GLFW.glfwDestroyCursor(DEFAULT_CURSOR); + GLFW.glfwDestroyCursor(NESW_RESIZE_CURSOR); + GLFW.glfwDestroyCursor(NWSE_RESIZE_CURSOR); } @Override diff --git a/1.8.9/build.gradle.kts b/1.8.9/build.gradle.kts index e3045c94e..7a82c8912 100644 --- a/1.8.9/build.gradle.kts +++ b/1.8.9/build.gradle.kts @@ -59,9 +59,10 @@ dependencies { localRuntime("org.slf4j:slf4j-jdk14:1.7.36") compileOnly("org.lwjgl:lwjgl-glfw:${lwjglVersion}") + compileOnly("org.lwjgl:lwjgl-sdl:3.4.0-SNAPSHOT") - modCompileOnly("io.github.moehreag:legacy-lwjgl3:${project.property("legacy_lwgjl3")}") - modLocalRuntime("io.github.moehreag:legacy-lwjgl3:${project.property("legacy_lwgjl3")}:all-remapped") + modImplementation("io.github.moehreag:legacy-lwjgl3:${project.property("legacy_lwgjl3")}") + //modLocalRuntime("io.github.moehreag:legacy-lwjgl3:${project.property("legacy_lwgjl3")}:all-remapped") include(implementation("org.lwjgl", "lwjgl-tinyfd", lwjglVersion)) include(runtimeOnly("org.lwjgl", "lwjgl-tinyfd", lwjglVersion, classifier = "natives-linux")) @@ -79,6 +80,7 @@ dependencies { } configurations.configureEach { + exclude("org.lwjgl.lwjgl") resolutionStrategy { dependencySubstitution { substitute(module("io.netty:netty-all:4.0.23.Final")).using(module("io.netty:netty-all:4.0.56.Final")) @@ -102,6 +104,7 @@ tasks.runClient { jvmArgs("-Dorg.lwjgl.glfw.libname=$glfwPath") } classpath(sourceSets.getByName("test").runtimeClasspath) + jvmArgs("-XX:+AllowEnhancedClassRedefinition", "-XX:+IgnoreUnrecognizedVMOptions") } tasks.withType(JavaCompile::class).configureEach { diff --git a/1.8.9/src/main/java/io/github/axolotlclient/bridge/mixin/util/StyleMixin.java b/1.8.9/src/main/java/io/github/axolotlclient/bridge/mixin/util/StyleMixin.java index 61359dc51..78bc9451b 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/bridge/mixin/util/StyleMixin.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/bridge/mixin/util/StyleMixin.java @@ -23,6 +23,8 @@ package io.github.axolotlclient.bridge.mixin.util; import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.sugar.Local; import io.github.axolotlclient.bridge.util.AxoText; import net.minecraft.text.Formatting; @@ -31,6 +33,7 @@ import net.minecraft.text.Text; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; @@ -56,6 +59,13 @@ public abstract class StyleMixin implements AxoText.Style { @Shadow private Style parent; + @Shadow + protected abstract Style getParent(); + + @Shadow + @Final + private static Style ROOT; + @Override public AxoText.Style br$color(AxoText.Color color) { return copy().setColor(switch (color) { @@ -85,9 +95,18 @@ public abstract class StyleMixin implements AxoText.Style { return copy; } + @Unique + private Integer axolotlclient$getColor() { + if (((Object)this) == ROOT) return null; + if (axolotlclient$color == null) { + return ((StyleMixin) (Object)getParent()).axolotlclient$getColor(); + } + return axolotlclient$color; + } + @ModifyReturnValue(method = "copy", at = @At("RETURN")) public Style copyColor(Style original) { - ((StyleMixin) (Object) original).axolotlclient$color = axolotlclient$color; + ((StyleMixin) (Object) original).axolotlclient$color = axolotlclient$getColor(); return original; } @@ -104,16 +123,15 @@ public Style deepCopyColor(Style original) { @Inject(method = "asString", at = @At(value = "INVOKE", target = "Lnet/minecraft/text/Style;isBold()Z")) private void formatColorCode(CallbackInfoReturnable cir, @Local StringBuilder sb) { - Integer color = null; - StyleMixin s = this; - - while (s != null && color == null) { - color = s.axolotlclient$color; - s = (StyleMixin) (Object) s.parent; - } + Integer color = axolotlclient$getColor(); if (color != null) { sb.append("§#").append(StringUtils.leftPad(Integer.toUnsignedString(color & 0xffffff, 16), 6, "0")); } } + + @WrapMethod(method = "isEmpty") + private boolean isEmptyColor(Operation original) { + return original.call() && axolotlclient$color == null; + } } diff --git a/1.8.9/src/main/java/io/github/axolotlclient/config/AxolotlClientConfig.java b/1.8.9/src/main/java/io/github/axolotlclient/config/AxolotlClientConfig.java index d52662c5e..263ea8094 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/config/AxolotlClientConfig.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/config/AxolotlClientConfig.java @@ -36,7 +36,7 @@ import io.github.axolotlclient.config.screen.CreditsScreen; import io.github.axolotlclient.config.screen.ProfilesScreen; import io.github.axolotlclient.modules.Module; -import io.github.axolotlclient.util.GLFWUtil; +import io.github.axolotlclient.util.WindowAccess; import io.github.axolotlclient.util.options.ForceableBooleanOption; import io.github.axolotlclient.util.options.GenericOption; import lombok.Getter; @@ -44,7 +44,6 @@ import net.minecraft.client.options.KeyBinding; import net.ornithemc.osl.keybinds.api.KeyBindingEvents; import net.ornithemc.osl.lifecycle.api.client.MinecraftClientEvents; -import org.lwjgl.glfw.GLFW; public class AxolotlClientConfig extends AxolotlClientConfigCommon { @@ -73,7 +72,7 @@ public class AxolotlClientConfig extends AxolotlClientConfigCommon { public final ColorOption loadingScreenColor = new ColorOption("loadingBgColor", new Color(-1)); public final BooleanOption nightMode = new BooleanOption("nightMode", false); public final BooleanOption rawMouseInput = new BooleanOption("rawMouseInput", false, v -> - GLFWUtil.runUsingGlfwHandle(h -> GLFW.glfwSetInputMode(h, GLFW.GLFW_RAW_MOUSE_MOTION, v ? 1 : 0))); + WindowAccess.getInstance().setRawMouseMotion(v)); public final BooleanOption enableCustomOutlines = new BooleanOption("enabled", false); public final ColorOption outlineColor = new ColorOption("color", Color.parse("#DD000000")); @@ -175,10 +174,15 @@ public AxolotlClientConfig() { AxolotlClient.getInstance().modules.add(new Module() { @Override public void lateInit() { - if (System.getProperty("org.lwjgl.input.Mouse.disableRawInput") == null) { - System.setProperty("org.lwjgl.input.Mouse.disableRawInput", "true"); + if (WindowAccess.getInstance().rawMouseMotionAvailable()) { + + if (System.getProperty("org.lwjgl.input.Mouse.disableRawInput") == null) { + System.setProperty("org.lwjgl.input.Mouse.disableRawInput", "true"); + } + WindowAccess.getInstance().setRawMouseMotion(rawMouseInput.get()); + } else { + AxolotlClient.getInstance().getConfigManager().suppressName(rawMouseInput.getName()); } - GLFWUtil.runUsingGlfwHandle(h -> GLFW.glfwSetInputMode(h, GLFW.GLFW_RAW_MOUSE_MOTION, rawMouseInput.get() ? 1 : 0)); } }); diff --git a/1.8.9/src/main/java/io/github/axolotlclient/mixin/ConfigVanillaButtonWidgetMixin.java b/1.8.9/src/main/java/io/github/axolotlclient/mixin/ConfigVanillaButtonWidgetMixin.java new file mode 100644 index 000000000..ad1ace962 --- /dev/null +++ b/1.8.9/src/main/java/io/github/axolotlclient/mixin/ConfigVanillaButtonWidgetMixin.java @@ -0,0 +1,52 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.mixin; + +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.ButtonWidget; +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.vanilla.widgets.VanillaButtonWidget; +import io.github.axolotlclient.modules.hud.util.DrawUtil; +import io.github.axolotlclient.util.ButtonWidgetTextures; +import net.minecraft.client.Minecraft; +import net.minecraft.resource.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(VanillaButtonWidget.class) +public abstract class ConfigVanillaButtonWidgetMixin extends ButtonWidget { + private ConfigVanillaButtonWidgetMixin(int x, int y, int width, int height, String message, PressAction action) { + super(x, y, width, height, message, action); + } + + @Redirect(method = "drawWidget", at = @At(value = "INVOKE", target = "Lio/github/axolotlclient/AxolotlClientConfig/impl/ui/vanilla/widgets/VanillaButtonWidget;drawTexture(IIIIII)V", ordinal = 0)) + private void drawTexture$1(VanillaButtonWidget instance, int x, int y, int u, int v, int width, int height) { + + } + + @Redirect(method = "drawWidget", at = @At(value = "INVOKE", target = "Lio/github/axolotlclient/AxolotlClientConfig/impl/ui/vanilla/widgets/VanillaButtonWidget;drawTexture(IIIIII)V", ordinal = 1)) + private void drawTexture$2$replaceWithNineSlice(VanillaButtonWidget instance, int x, int y, int u, int v, int width, int height) { + Identifier tex = ButtonWidgetTextures.get(active ? (this.hovered ? 2 : 1) : 0); + DrawUtil.blitSprite(tex, getX(), getY(), getWidth(), getHeight(), new DrawUtil.NineSlice(200, 20, 3)); + Minecraft.getInstance().getTextureManager().bind(WIDGETS_LOCATION); + } +} diff --git a/1.8.9/src/main/java/io/github/axolotlclient/mixin/ConfirmScreenMixin.java b/1.8.9/src/main/java/io/github/axolotlclient/mixin/ConfirmScreenMixin.java new file mode 100644 index 000000000..6972079b0 --- /dev/null +++ b/1.8.9/src/main/java/io/github/axolotlclient/mixin/ConfirmScreenMixin.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.mixin; + +import java.util.List; + +import net.minecraft.client.gui.screen.ConfirmScreen; +import net.minecraft.client.render.TextRenderUtils; +import net.minecraft.client.render.TextRenderer; +import net.minecraft.text.LiteralText; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(ConfirmScreen.class) +public class ConfirmScreenMixin { + + @Redirect(method = "init", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/TextRenderer;split(Ljava/lang/String;I)Ljava/util/List;")) + private List fixTextWrapFormatting(TextRenderer instance, String string, int i) { + return TextRenderUtils.wrapText(new LiteralText(string), i, instance, true, true).stream().map(Text::getFormattedString).toList(); + } +} diff --git a/1.8.9/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java b/1.8.9/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java index 81991cfb8..d9ad28e83 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/mixin/GameRendererMixin.java @@ -58,7 +58,6 @@ import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.At.Shift; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @@ -94,7 +93,7 @@ public abstract class GameRendererMixin { this.viewDistance = (float) (this.viewDistance * 2 + Minecraft.getInstance().player.getSourcePos().y); Entity entity = this.minecraft.getCamera(); - GL11.glFog(2918, this.setFogColor(this.fogRed, this.fogGreen, this.fogBlue, 1.0F)); + GL11.glFogfv(2918, this.setFogColor(this.fogRed, this.fogGreen, this.fogBlue, 1.0F)); GL11.glNormal3f(0.0F, -1.0F, 0.0F); GlStateManager.color4f(1.0F, 1.0F, 1.0F, 1.0F); Block block = Camera.getBlockInside(this.minecraft.world, entity, tickDelta); @@ -197,7 +196,7 @@ public abstract class GameRendererMixin { } } - @Inject(method = "render(FJ)V", at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/pipeline/RenderTarget;bindWrite(Z)V", shift = Shift.BEFORE)) + @Inject(method = "render(FJ)V", at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/pipeline/RenderTarget;bindWrite(Z)V")) public void axolotlclient$worldMotionBlur(float tickDelta, long nanoTime, CallbackInfo ci) { MenuBlur.getInstance().updateBlur(); axolotlclient$postRender(tickDelta, nanoTime, null); diff --git a/1.8.9/src/main/java/io/github/axolotlclient/mixin/TextRenderUtilsMixin.java b/1.8.9/src/main/java/io/github/axolotlclient/mixin/TextRenderUtilsMixin.java index c5c151009..7a79e753f 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/mixin/TextRenderUtilsMixin.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/mixin/TextRenderUtilsMixin.java @@ -31,6 +31,7 @@ import com.llamalad7.mixinextras.sugar.Local; import com.llamalad7.mixinextras.sugar.ref.LocalRef; import io.github.axolotlclient.AxolotlClient; +import io.github.axolotlclient.AxolotlClientConfig.api.util.Color; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import net.minecraft.client.render.TextRenderUtils; import net.minecraft.client.render.TextRenderer; @@ -44,7 +45,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @Mixin(TextRenderUtils.class) -public class TextRenderUtilsMixin { +public abstract class TextRenderUtilsMixin { @Unique private static final Map formattingCodes; @@ -132,6 +133,7 @@ private static Text formatFromCodes(String formattedString) { List modifiers = new ArrayList<>(); Formatting color = null; + Integer br$color = null; for (int i = 0, length = arr.length; i < length; i++) { String s = arr[i]; if (s.isEmpty()) { @@ -141,28 +143,37 @@ private static Text formatFromCodes(String formattedString) { continue; } Formatting formatting = byCodeOfFirstChar(s); - if (formatting == null) { - text.append(s); - continue; - } - Text part; int pL = s.length(); - if (formatting.equals(Formatting.RESET)) { - modifiers.clear(); - color = null; - } else if (formatting.isModifier()) { - modifiers.add(formatting); + int formatLength = 1; + if (formatting == null) { + if (s.toLowerCase(Locale.ROOT).charAt(0) == '#') { + br$color = Color.parse(s.substring(0, 7)).toInt(); + formatLength = 7; + } else { + text.append(s); + continue; + } } else { - color = formatting; + if (formatting.equals(Formatting.RESET)) { + modifiers.clear(); + color = null; + } else if (formatting.isModifier()) { + modifiers.add(formatting); + } else { + color = formatting; + } } if (pL == 1) { continue; } - part = new LiteralText(s.substring(1)); + part = new LiteralText(s.substring(formatLength)); if (color != null) { - part.getStyle().setColor(color); + part.setStyle(part.getStyle().setColor(color)); + } + if (br$color != null) { + part.br$setStyle(part.getStyle().br$color(br$color)); } if (!modifiers.isEmpty()) { diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java index 84b0896a3..a3d64b909 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/AccountsScreen.java @@ -22,6 +22,7 @@ package io.github.axolotlclient.modules.auth; +import io.github.axolotlclient.modules.auth.skin.SkinManagementScreen; import net.minecraft.client.gui.screen.ConfirmScreen; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.widget.ButtonWidget; @@ -35,6 +36,7 @@ public class AccountsScreen extends Screen { private ButtonWidget loginButton; private ButtonWidget deleteButton; private ButtonWidget refreshButton; + private ButtonWidget skinsButton; public AccountsScreen(Screen currentScreen) { title = I18n.translate("accounts"); @@ -112,13 +114,17 @@ protected void buttonClicked(ButtonWidget buttonWidget) { login(); break; case 2: + minecraft.openScreen(new SkinManagementScreen( + this, accountsListWidget.getSelectedEntry().getAccount())); + break; + case 3: if (Auth.getInstance().allowOfflineAccounts()) { minecraft.openScreen(new ConfirmScreen(this, I18n.translate("auth.add.choose"), "", I18n.translate("auth.add.offline"), I18n.translate("auth.add.ms"), 234)); } else { initMSAuth(); } break; - case 3: + case 4: AccountsListWidget.Entry entry = this.accountsListWidget.getSelectedEntry(); if (entry != null) { buttonWidget.active = false; @@ -126,7 +132,7 @@ protected void buttonClicked(ButtonWidget buttonWidget) { refresh(); } break; - case 4: + case 5: refreshAccount(); break; } @@ -139,14 +145,16 @@ public void init() { accountsListWidget.setAccounts(Auth.getInstance().getAccounts()); - buttons.add(loginButton = new ButtonWidget(1, this.width / 2 - 154, this.height - 52, 150, 20, I18n.translate("auth.login"))); + buttons.add(loginButton = new ButtonWidget(1, this.width / 2 - 154, this.height - 52, 100, 20, I18n.translate("auth.login"))); + + buttons.add(skinsButton = new ButtonWidget(2, this.width / 2 - 50, this.height - 52, 100, 20, I18n.translate("skins.manage"))); - this.buttons.add(new ButtonWidget(2, this.width / 2 + 4, this.height - 52, 150, 20, I18n.translate("auth.add"))); + this.buttons.add(new ButtonWidget(3, this.width / 2 + 4 + 50, this.height - 52, 100, 20, I18n.translate("auth.add"))); - this.buttons.add(this.deleteButton = new ButtonWidget(3, this.width / 2 - 50, this.height - 28, 100, 20, I18n.translate("selectServer.delete"))); + this.buttons.add(this.deleteButton = new ButtonWidget(4, this.width / 2 - 50, this.height - 28, 100, 20, I18n.translate("selectServer.delete"))); - this.buttons.add(refreshButton = new ButtonWidget(4, this.width / 2 - 154, this.height - 28, 100, 20, + this.buttons.add(refreshButton = new ButtonWidget(5, this.width / 2 - 154, this.height - 28, 100, 20, I18n.translate("auth.refresh"))); this.buttons.add(new ButtonWidget(0, this.width / 2 + 4 + 50, this.height - 28, 100, 20, @@ -190,9 +198,10 @@ private void updateButtonActivationStates() { AccountsListWidget.Entry entry = accountsListWidget.getSelectedEntry(); if (minecraft.world == null && entry != null) { loginButton.active = entry.getAccount().isExpired() || !entry.getAccount().equals(Auth.getInstance().getCurrent()); - deleteButton.active = refreshButton.active = true; + refreshButton.active = skinsButton.active = !entry.getAccount().isOffline(); + deleteButton.active = true; } else { - loginButton.active = deleteButton.active = refreshButton.active = false; + loginButton.active = deleteButton.active = refreshButton.active = skinsButton.active = false; } } @@ -205,17 +214,14 @@ private void login() { } private void initMSAuth() { - Auth.getInstance().getAuth().startDeviceAuth().thenRun(() -> minecraft.submit(this::refresh)); + Auth.getInstance().getMsApi().startDeviceAuth().thenRun(() -> minecraft.submit(this::refresh)); } private void refreshAccount() { refreshButton.active = false; AccountsListWidget.Entry entry = accountsListWidget.getSelectedEntry(); if (entry != null) { - entry.getAccount().refresh(Auth.getInstance().getAuth()).thenRun(() -> minecraft.submit(() -> { - Auth.getInstance().save(); - refresh(); - })); + entry.getAccount().refresh(Auth.getInstance().getMsApi()); } } } diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/Auth.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/Auth.java index 15af0131a..976e33ddb 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/Auth.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/Auth.java @@ -23,17 +23,18 @@ package io.github.axolotlclient.modules.auth; import java.util.*; +import java.util.concurrent.CompletableFuture; import com.mojang.authlib.GameProfile; import com.mojang.authlib.minecraft.MinecraftProfileTexture; import io.github.axolotlclient.AxolotlClient; -import io.github.axolotlclient.AxolotlClientConfig.api.options.OptionCategory; import io.github.axolotlclient.AxolotlClientConfig.impl.options.BooleanOption; import io.github.axolotlclient.api.API; import io.github.axolotlclient.api.types.User; import io.github.axolotlclient.api.util.UUIDHelper; import io.github.axolotlclient.mixin.MinecraftClientAccessor; import io.github.axolotlclient.modules.Module; +import io.github.axolotlclient.modules.auth.skin.SkinManager; import io.github.axolotlclient.util.ThreadExecuter; import io.github.axolotlclient.util.notifications.Notifications; import io.github.axolotlclient.util.options.GenericOption; @@ -50,19 +51,20 @@ public class Auth extends Accounts implements Module { @Getter private static final Auth Instance = new Auth(); - public final BooleanOption showButton = new BooleanOption("auth.showButton", false); + public final BooleanOption skinManagerAnimations = new BooleanOption("skins.manage.animations", true); private final Minecraft client = Minecraft.getInstance(); private final GenericOption viewAccounts = new GenericOption("viewAccounts", "clickToOpen", () -> client.openScreen(new AccountsScreen(client.screen))); - private final Map textures = new HashMap<>(); private final Set loadingTexture = new HashSet<>(); private final Map profileCache = new WeakHashMap<>(); + @Getter + private final SkinManager skinManager = new SkinManager(); @Override public void init() { load(); - this.auth = new MSAuth(AxolotlClient.LOGGER, this, () -> client.options.language); + this.msApi = new MSApi(this, () -> client.options.language); if (isContained(client.getSession().getUuid())) { current = getAccounts().stream().filter(account -> account.getUuid().equals(client.getSession().getUuid())).toList().get(0); current.setAuthToken(client.getSession().getAccessToken()); @@ -74,7 +76,6 @@ public void init() { current = new Account(client.getSession().getUsername(), client.getSession().getUuid(), client.getSession().getAccessToken()); } - OptionCategory category = OptionCategory.create("auth"); category.add(showButton, viewAccounts); AxolotlClient.config().general.add(category); } @@ -89,11 +90,11 @@ protected void login(Account account) { if (account.isExpired()) { Notifications.getInstance().addStatus("auth.notif.title", "auth.notif.refreshing", account.getName()); } - account.refresh(auth).thenAccept(res -> res.ifPresent(a -> { + account.refresh(msApi).thenAccept(a -> { if (!a.isExpired()) { login(a); } - })).thenRun(this::save); + }).thenRun(this::save); } else { try { API.getInstance().shutdown(); @@ -140,14 +141,18 @@ public void loadTextures(String uuid, String name) { } @Override - void showAccountsExpiredScreen(Account account) { + CompletableFuture showAccountsExpiredScreen(Account account) { Screen current = client.screen; + var fut = new CompletableFuture(); client.submit(() -> client.openScreen(new ConfirmScreen((bl, i) -> { - client.openScreen(current); if (bl) { - auth.startDeviceAuth(); + msApi.startDeviceAuth().thenRun(() -> fut.complete(account)); + } else { + fut.cancel(true); } + client.openScreen(current); }, I18n.translate("auth"), I18n.translate("auth.accountExpiredNotice", account.getName()), 1))); + return fut; } @Override diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java new file mode 100644 index 000000000..3e7c829f6 --- /dev/null +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManagementScreen.java @@ -0,0 +1,890 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.mojang.blaze3d.platform.GlStateManager; +import com.mojang.blaze3d.platform.Lighting; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.Tessellator; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.AxolotlClientConfig.api.util.Color; +import io.github.axolotlclient.AxolotlClientConfig.api.util.Colors; +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.ClickableWidget; +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.Element; +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.ParentElement; +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.vanilla.ElementListWidget; +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.vanilla.widgets.VanillaButtonWidget; +import io.github.axolotlclient.api.SimpleTextInputScreen; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.util.AxoText; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import io.github.axolotlclient.modules.hud.util.DrawUtil; +import io.github.axolotlclient.util.ButtonWidgetTextures; +import io.github.axolotlclient.util.ClientColors; +import io.github.axolotlclient.util.Watcher; +import io.github.axolotlclient.util.notifications.Notifications; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screen.ConfirmScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.render.TextRenderer; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.resource.Identifier; +import net.minecraft.text.Text; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SkinManagementScreen extends io.github.axolotlclient.AxolotlClientConfig.impl.ui.Screen { + private static final Path SKINS_DIR = FabricLoader.getInstance().getGameDir().resolve("skins"); + private static final int LIST_SKIN_WIDTH = 75; + private static final int LIST_SKIN_HEIGHT = 110; + private static final String TEXT_EQUIPPING = I18n.translate("skins.manage.equipping"); + private final Screen parent; + private final Account account; + private MSApi.MCProfile cachedProfile; + private SkinListWidget skinList; + private SkinListWidget capesList; + private boolean capesTab; + private SkinWidget current; + private final Watcher skinDirWatcher; + private final CompletableFuture refreshFuture; + private String tooltip = null; + + public SkinManagementScreen(Screen parent, Account account) { + super(I18n.translate("skins.manage")); + this.parent = parent; + this.account = account; + skinDirWatcher = Watcher.createSelfTicking(SKINS_DIR, () -> { + AxolotlClientCommon.getInstance().getLogger().info("Reloading screen as local files changed!"); + loadSkinsList(); + }); + if (account.needsRefresh()) { + refreshFuture = account.refresh(Auth.getInstance().getMsApi()); + } else { + refreshFuture = CompletableFuture.completedFuture(null); + } + } + + @Override + public void render(int mouseX, int mouseY, float delta) { + tooltip = null; + super.render(mouseX, mouseY, delta); + if (tooltip != null) { + renderTooltip(tooltip, mouseX, mouseY + 20); + Lighting.turnOff(); + } + } + + @Override + public void init() { + int headerHeight = 33; + int contentHeight = height - headerHeight * 2; + class TextWidget extends ClickableWidget { + + public TextWidget(int x, int y, int width, int height, String message) { + super(x, y, width, height, message); + active = false; + } + + @Override + public void drawWidget(int mouseX, int mouseY, float delta) { + drawCenteredString(textRenderer, getMessage(), getX() + getWidth() / 2, getY() + getHeight() / 2 - textRenderer.fontHeight / 2, -1); + } + } + + var titleWidget = new TextWidget(0, headerHeight / 2 - textRenderer.fontHeight / 2, width, textRenderer.fontHeight, getTitle()); + addDrawableChild(titleWidget); + + var back = addDrawableChild(new VanillaButtonWidget(width / 2 - 75, height - headerHeight / 2 - 10, 150, 20, I18n.translate("gui.back"), btn -> closeScreen())); + + var loadingPlaceholder = new ClickableWidget(0, headerHeight, width, contentHeight, I18n.translate("skins.loading")) { + @Override + protected void drawWidget(int mouseX, int mouseY, float delta) { + int centerX = this.getX() + this.getWidth() / 2; + int centerY = this.getY() + this.getHeight() / 2; + var text = this.getMessage(); + textRenderer.draw(text, centerX - textRenderer.getWidth(text) / 2f, centerY - 9, -1, false); + String string = switch ((int) (System.currentTimeMillis() / 300L % 4L)) { + case 1, 3 -> "o O o"; + case 2 -> "o o O"; + default -> "O o o"; + }; + textRenderer.draw(string, centerX - textRenderer.getWidth(string) / 2f, centerY + 9, 0xFF808080, false); + } + }; + loadingPlaceholder.active = false; + addDrawableChild(loadingPlaceholder); + addDrawableChild(back); + + skinList = new SkinListWidget(minecraft, width / 2, contentHeight - 24, headerHeight + 24, LIST_SKIN_HEIGHT + 34); + capesList = new SkinListWidget(minecraft, width / 2, contentHeight - 24, headerHeight + 24, skinList.getEntryContentsHeight() + 24); + skinList.setLeftPos(width / 2); + capesList.setLeftPos(width / 2); + var currentHeight = Math.min((width / 2f) * 120 / 85, contentHeight); + var currentWidth = currentHeight * 85 / 120; + current = new SkinWidget((int) currentWidth, (int) currentHeight, null, account); + current.setPosition((int) (width / 4f - currentWidth / 2), (int) (height / 2f - currentHeight / 2)); + + if (!capesTab) { + capesList.visible = capesList.active = false; + } else { + skinList.visible = skinList.active = false; + } + List navBar = new ArrayList<>(); + var skinsTab = new VanillaButtonWidget(Math.max(width * 3 / 4 - 102, width / 2 + 2), headerHeight, Math.min(100, width / 4 - 2), 20, I18n.translate("skins.nav.skins"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = true; + capesList.visible = capesList.active = false; + capesTab = false; + }); + navBar.add(skinsTab); + var capesTab = new VanillaButtonWidget(width * 3 / 4 + 2, headerHeight, Math.min(100, width / 4 - 2), 20, I18n.translate("skins.nav.capes"), btn -> { + navBar.forEach(w -> { + if (w != btn) w.active = true; + }); + btn.active = false; + skinList.visible = skinList.active = false; + capesList.visible = capesList.active = true; + this.capesTab = true; + }); + navBar.add(capesTab); + var importButton = new SpriteButton(I18n.translate("skins.manage.import.local"), btn -> { + btn.active = false; + SkinImportUtil.openImportSkinDialog().thenAccept(this::onFileDrop).thenRun(() -> btn.active = true); + }, new Identifier("axolotlclient", "textures/gui/sprites/folder.png")); + var downloadButton = new SpriteButton(I18n.translate("skins.manage.import.online"), btn -> { + btn.active = false; + promptForSkinDownload(); + }, new Identifier("axolotlclient", "textures/gui/sprites/download.png")); + if (width - (capesTab.getX() + capesTab.getWidth()) > 28) { + importButton.setX(width - importButton.getWidth() - 2); + downloadButton.setX(importButton.getX() - downloadButton.getWidth() - 2); + importButton.setY(capesTab.getY() + capesTab.getHeight() - 11); + downloadButton.setY(importButton.getY()); + } else { + importButton.setX(capesTab.getX() + capesTab.getWidth() - 11); + importButton.setY(capesTab.getY() - 13); + downloadButton.setX(importButton.getX() - 2 - 11); + downloadButton.setY(importButton.getY()); + } + skinsTab.active = this.capesTab; + capesTab.active = !this.capesTab; + Runnable addWidgets = () -> { + clearChildren(); + addDrawableChild(titleWidget); + addDrawableChild(current); + addDrawableChild(skinList); + addDrawableChild(capesList); + addDrawableChild(skinsTab); + addDrawableChild(capesTab); + addDrawableChild(downloadButton); + addDrawableChild(importButton); + addDrawableChild(back); + }; + if (cachedProfile != null) { + initDisplay(); + addWidgets.run(); + return; + } + refreshFuture.thenComposeAsync(unused -> Auth.getInstance().getMsApi().getProfile(account)) + .thenAcceptAsync(profile -> { + cachedProfile = profile; + initDisplay(); + addWidgets.run(); + }).exceptionally(t -> { + if (t.getCause() instanceof CancellationException) { + minecraft.openScreen(parent); + return null; + } + AxolotlClientCommon.getInstance().getLogger().error("Failed to load skins!", t); + var error = I18n.translate("skins.error.failed_to_load"); + var errorDesc = I18n.translate("skins.error.failed_to_load_desc"); + clearChildren(); + addDrawableChild(titleWidget); + + addDrawableChild(new TextWidget(width / 2 - textRenderer.getWidth(error) / 2, height / 2 - textRenderer.fontHeight - 2, textRenderer.getWidth(error), textRenderer.fontHeight, error)); + addDrawableChild(new TextWidget(width / 2 - textRenderer.getWidth(errorDesc) / 2, height / 2 + 1, textRenderer.getWidth(errorDesc), textRenderer.fontHeight, errorDesc)); + addDrawableChild(back); + return null; + }); + } + + private void promptForSkinDownload() { + minecraft.openScreen(new SimpleTextInputScreen(this, I18n.translate("skins.manage.import.online"), I18n.translate("skins.manage.import.online.input"), s -> + UUIDHelper.ensureUuidOpt(s).thenAccept(o -> { + if (o.isPresent()) { + AxolotlClientCommon.getInstance().getLogger().info("Downloading skin of {} ({})", s, o.get()); + Auth.getInstance().getMsApi().getTextures(o.get()) + .exceptionally(th -> { + AxolotlClientCommon.getInstance().getLogger().info("Failed to download skin of {} ({})", s, o.get(), th); + return null; + }).thenAccept(t -> { + if (t == null) { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_download", s); + return; + } + try { + var bytes = t.skin().join(); + var out = ensureNonexistent(SKINS_DIR.resolve(t.skinKey())); + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, t.classicModel(), "name", t.name(), "uuid", t.id(), "download_time", Instant.now())); + Files.write(out, bytes); + minecraft.submit(this::loadSkinsList); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.downloaded", t.name()); + AxolotlClientCommon.getInstance().getLogger().info("Downloaded skin of {} ({})", t.name(), o.get()); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to write skin file", e); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.failed_to_save", t.name()); + } + }); + } else { + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.import.online.not_found", s); + } + }))); + } + + private void initDisplay() { + loadSkinsList(); + loadCapesList(); + } + + private void refreshCurrentList() { + if (capesTab) { + var scroll = capesList.getScrollAmount(); + loadCapesList(); + capesList.setScrollAmount(scroll); + } else { + var scroll = skinList.getScrollAmount(); + loadSkinsList(); + skinList.setScrollAmount(scroll); + } + } + + private void loadCapesList() { + capesList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + var capes = profile.capes(); + var deselectCape = createWidgetForCape(current.getSkin(), null); + var activeCape = capes.stream().filter(Cape::active).findFirst(); + current.setCape(activeCape.orElse(null)); + deselectCape.noCape(activeCape.isEmpty()); + for (int i = 0; i < capes.size() + 1; i += columns) { + Entry widget; + if (i == 0) { + widget = createEntry(capesList.getEntryContentsHeight(), deselectCape, I18n.translate("skins.capes.no_cape")); + } else { + var cape = capes.get(i - 1); + widget = createEntryForCape(current.getSkin(), cape, capesList.getEntryContentsHeight()); + } + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < capes.size() + 1 - c)) continue; + var cape2 = capes.get(i + c - 1); + Entry widget2 = createEntryForCape(current.getSkin(), cape2, capesList.getEntryContentsHeight()); + + widgets.add(widget2); + } + capesList.addEntry(new Row(widgets)); + } + } + + private void loadSkinsList() { + skinList.clearEntries(); + var profile = cachedProfile; + int columns = Math.max(2, (width / 2 - 25) / LIST_SKIN_WIDTH); + List skins = new ArrayList<>(profile.skins()); + var hashes = skins.stream().map(Asset::textureKey).collect(Collectors.toSet()); + var defaultSkinHash = Auth.getInstance().getSkinManager().getDefaultSkinHash(account); + var local = new ArrayList<>(loadLocalSkins()); + var localHashes = local.stream().collect(Collectors.toMap(Asset::textureKey, Function.identity(), (skin, skin2) -> skin)); + local.removeIf(s -> !localHashes.containsValue(s)); + skins.replaceAll(s -> { + if (s instanceof MSApi.MCProfile.OnlineSkin online) { + if (localHashes.containsKey(s.textureKey()) && localHashes.get(s.textureKey()) instanceof Skin.Local file) { + local.remove(localHashes.remove(s.textureKey())); + return new Skin.Shared(file, online); + } + } + return s; + }); + skins.addAll(local); + if (!hashes.contains(defaultSkinHash)) { + skins.add(null); + } + populateSkinList(skins, columns); + } + + private List loadLocalSkins() { + try { + Files.createDirectories(SKINS_DIR); + try (Stream skins = Files.list(SKINS_DIR)) { + return skins.filter(Files::isRegularFile).sorted(Comparator.comparingLong(p -> { + try { + return Files.getLastModifiedTime(p).toMillis(); + } catch (IOException e) { + return 0L; + } + }).reversed()).map(Auth.getInstance().getSkinManager()::read).filter(Objects::nonNull).toList(); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to read skins dir!", e); + } + return Collections.emptyList(); + } + + private void populateSkinList(List skins, int columns) { + int entryHeight = skinList.getEntryContentsHeight(); + for (int i = 0; i < skins.size(); i += columns) { + var s = skins.get(i); + if (s != null && s.active()) { + current.setSkin(s); + } + var widget = createEntryForSkin(s, entryHeight); + List widgets = new ArrayList<>(); + widgets.add(widget); + for (int c = 1; c < columns; c++) { + if (!(i < skins.size() - c)) continue; + var s2 = skins.get(i + c); + if (s2 != null && s2.active()) { + current.setSkin(s2); + } + var widget2 = createEntryForSkin(s2, entryHeight); + widgets.add(widget2); + } + skinList.addEntry(new Row(widgets)); + } + } + + private Path ensureNonexistent(Path p) { + if (Files.exists(p)) { + int counter = 0; + do { + counter++; + p = p.resolveSibling(p.getFileName().toString() + "_" + counter); + } while (Files.exists(p)); + } + return p; + } + + @Override + public void onFileDrop(List packs) { + if (packs.isEmpty()) return; + + CompletableFuture[] futs = new CompletableFuture[packs.size()]; + for (int i = 0; i < packs.size(); i++) { + Path p = packs.get(i); + futs[i] = CompletableFuture.runAsync(() -> { + try { + var target = ensureNonexistent(SKINS_DIR.resolve(p.getFileName())); + var skin = Auth.getInstance().getSkinManager().read(p, false); + if (skin != null) { + Files.write(target, skin.image().join()); + } else { + AxolotlClientCommon.getInstance().getLogger().info("Skipping dragged file {} because it does not seem to be a valid skin!", p); + Notifications.getInstance().addStatus("skins.notification.title", "skins.notification.not_copied", p.getFileName()); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to copy skin file: ", e); + } + }, minecraft); + } + CompletableFuture.allOf(futs).thenRun(this::loadSkinsList); + } + + private @NotNull Entry createEntryForSkin(Skin skin, int entryHeight) { + return createEntry(entryHeight, new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, skin, account)); + } + + private @NotNull Entry createEntryForCape(Skin currentSkin, Cape cape, int entryHeight) { + return createEntry(entryHeight, createWidgetForCape(currentSkin, cape), I18n.translate(cape.alias())); + } + + private SkinWidget createWidgetForCape(Skin currentSkin, Cape cape) { + SkinWidget widget2 = new SkinWidget(LIST_SKIN_WIDTH, LIST_SKIN_HEIGHT, currentSkin, cape, account); + widget2.setRotationY(210); + return widget2; + } + + @Override + public void clearAndInit() { + Auth.getInstance().getSkinManager().releaseAll(); + super.clearAndInit(); + } + + @Override + public void removed() { + Auth.getInstance().getSkinManager().releaseAll(); + Watcher.close(skinDirWatcher); + } + + public void closeScreen() { + minecraft.openScreen(parent); + } + + private SkinListWidget getCurrentList() { + return capesTab ? capesList : skinList; + } + + private class SkinListWidget extends ElementListWidget { + public boolean active = true, visible = true; + + public SkinListWidget(Minecraft minecraft, int width, int height, int y, int entryHeight) { + super(minecraft, width, SkinManagementScreen.this.height, y, y + height, entryHeight); + setRenderHeader(false, 0); + setRenderBackground(false); + setRenderHorizontalShadows(false); + } + + @Override + public int addEntry(Row entry) { + return super.addEntry(entry); + } + + @Override + protected int getScrollbarPositionX() { + return right - 8; + } + + @Override + public int getRowLeft() { + return left + 3; + } + + @Override + public int getRowWidth() { + if (!(getMaxScroll() > 0)) { + return width - 4; + } + return width - 14; + } + + public int getEntryContentsHeight() { + return itemHeight - 4; + } + + public void clearEntries() { + super.clearEntries(); + } + + @Override + public void centerScrollOn(Row entry) { + super.centerScrollOn(entry); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double amountX, double amountY) { + if (!visible) return false; + return super.mouseScrolled(mouseX, mouseY, amountX, amountY); + } + + @Override + public boolean isMouseOver(double mouseX, double mouseY) { + return active && visible && super.isMouseOver(mouseX, mouseY); + } + + @Override + public void render(int mouseX, int mouseY, float delta) { + if (!visible) return; + super.render(mouseX, mouseY, delta); + renderGradient(); + } + + private void renderGradient() { + GlStateManager.depthFunc(515); + GlStateManager.disableDepthTest(); + GlStateManager.enableBlend(); + GlStateManager.blendFuncSeparate(770, 771, 0, 1); + GlStateManager.disableAlphaTest(); + GlStateManager.shadeModel(7425); + GlStateManager.disableTexture(); + GlStateManager.enableBlend(); + GlStateManager.disableTexture(); + var tessellator = Tessellator.getInstance(); + var bufferBuilder = tessellator.getBuilder(); + bufferBuilder.begin(7, DefaultVertexFormat.POSITION_TEX_COLOR); + bufferBuilder.vertex(left, top + 4, 0.0F).texture(0.0F, 1.0F).color(0, 0, 0, 0).nextVertex(); + bufferBuilder.vertex(right, top + 4, 0.0F).texture(1.0F, 1.0F).color(0, 0, 0, 0).nextVertex(); + bufferBuilder.vertex(right, top, 0.0F).texture(1.0F, 0.0F).color(0, 0, 0, 255).nextVertex(); + bufferBuilder.vertex(left, top, 0.0F).texture(0.0F, 0.0F).color(0, 0, 0, 255).nextVertex(); + tessellator.end(); + bufferBuilder.begin(7, DefaultVertexFormat.POSITION_TEX_COLOR); + bufferBuilder.vertex(this.left, this.bottom, 0.0F).texture(0.0F, 1.0F).color(0, 0, 0, 255).nextVertex(); + bufferBuilder.vertex(this.right, this.bottom, 0.0F).texture(1.0F, 1.0F).color(0, 0, 0, 255).nextVertex(); + bufferBuilder.vertex(this.right, this.bottom - 4, 0.0F).texture(1.0F, 0.0F).color(0, 0, 0, 0).nextVertex(); + bufferBuilder.vertex(this.left, this.bottom - 4, 0.0F).texture(0.0F, 0.0F).color(0, 0, 0, 0).nextVertex(); + tessellator.end(); + GlStateManager.enableTexture(); + } + } + + private class Row extends ElementListWidget.Entry { + private final List widgets; + + public Row(List entries) { + this.widgets = entries; + } + + @Override + public void render(int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovering, float partialTick) { + int x = left; + if (widgets.isEmpty()) return; + int count = widgets.size(); + int padding = ((width - 5 * (count - 1)) / count); + for (var w : widgets) { + w.setPosition(x, top); + w.setWidth(padding); + w.render(mouseX, mouseY, partialTick); + x += w.getWidth() + 5; + } + } + + @Override + public @NotNull List children() { + return widgets; + } + + @Override + public void setFocusedChild(@Nullable Element focused) { + super.setFocusedChild(focused); + if (focused != null) { + getCurrentList().centerScrollOn(this); + } + } + } + + Entry createEntry(int height, SkinWidget widget) { + return createEntry(height, widget, null); + } + + Entry createEntry(int height, SkinWidget widget, String label) { + return new Entry(height, widget, label); + } + + private class Entry extends ClickableWidget implements ParentElement { + private final SkinWidget skinWidget; + private final @Nullable ClickableWidget label; + private final List actionButtons = new ArrayList<>(); + private final ClickableWidget equipButton; + private boolean equipping; + private long equippingStart; + @Nullable + private Element focused; + private boolean dragging; + + public Entry(int height, SkinWidget widget, @Nullable String label) { + super(0, 0, widget.getWidth(), height, ""); + widget.setWidth(getWidth() - 4); + var asset = widget.getFocusedAsset(); + if (asset != null) { + if (asset instanceof Skin skin) { + var wideSprite = new Identifier("axolotlclient", "textures/gui/sprites/wide.png"); + var slimSprite = new Identifier("axolotlclient", "textures/gui/sprites/slim.png"); + var slimText = I18n.translate("skins.manage.variant.classic"); + var wideText = I18n.translate("skins.manage.variant.slim"); + actionButtons.add(new SpriteButton(skin.classicVariant() ? wideText : slimText, btn -> { + var self = (SpriteButton) btn; + skin.classicVariant(!skin.classicVariant()); + self.sprite = skin.classicVariant() ? slimSprite : wideSprite; + self.setMessage(skin.classicVariant() ? wideText : slimText); + }, skin.classicVariant() ? slimSprite : wideSprite)); + } + if (asset.isLocal()) { + this.actionButtons.add(new SpriteButton(I18n.translate("skins.manage.delete"), btn -> { + btn.active = false; + client.openScreen(new ConfirmScreen((confirmed, i) -> { + client.openScreen(SkinManagementScreen.this); + if (confirmed) { + try { + Files.delete(asset.file()); + Skin.Local.deleteMetadata(asset.file()); + refreshCurrentList(); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to delete: ", e); + } + } + btn.active = true; + }, I18n.translate("skins.manage.delete.confirm"), ((Text) (asset.active() ? + AxoText.translatable("skins.manage.delete.confirm.desc_active") : + AxoText.translatable("skins.manage.delete.confirm.desc") + ).br$color(Colors.RED.toInt())).getFormattedString(), 0)); + }, new Identifier("axolotlclient", "textures/gui/sprites/delete.png"))); + } + if (asset.supportsDownload() && !asset.isLocal()) { + this.actionButtons.add(new SpriteButton(I18n.translate("skins.manage.download"), btn -> { + btn.active = false; + asset.image().thenAcceptAsync(b -> { + try { + var out = SKINS_DIR.resolve(asset.textureKey()); + Files.createDirectories(out.getParent()); + Files.write(out, b); + if (asset instanceof Skin skin) { + Skin.Local.writeMetadata(out, Map.of(Skin.Local.CLASSIC_METADATA_KEY, skin.classicVariant())); + } + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to download: ", e); + } + refreshCurrentList(); + btn.active = true; + }); + }, new Identifier("axolotlclient", "textures/gui/sprites/download.png"))); + } + } + if (label != null) { + this.label = new ClickableWidget(0, 0, widget.getWidth(), 16, label) { + @Override + protected void drawWidget(int mouseX, int mouseY, float partialTick) { + DrawUtil.drawScrollableText(textRenderer, getMessage(), getX() + 2, getY(), getX() + getWidth() - 2, getY() + getHeight(), -1); + } + }; + this.label.active = false; + } else { + this.label = null; + } + this.equipButton = new VanillaButtonWidget(0, 0, widget.getWidth(), 20, I18n.translate( + widget.isEquipped() ? "skins.manage.equipped" : "skins.manage.equip"), + btn -> { + equippingStart = System.currentTimeMillis(); + equipping = true; + btn.setMessage(TEXT_EQUIPPING); + btn.active = false; + widget.equip().thenAcceptAsync(p -> { + cachedProfile = p; + refreshCurrentList(); + }).exceptionally(t -> { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to equip asset!", t); + equipping = false; + return null; + }); + }); + this.equipButton.active = !widget.isEquipped(); + this.skinWidget = widget; + } + + @Override + public final boolean isDragging() { + return this.dragging; + } + + @Override + public final void setDragging(boolean dragging) { + this.dragging = dragging; + } + + @Nullable + @Override + public Element getFocused() { + return this.focused; + } + + @Override + public void setFocusedChild(@Nullable Element child) { + if (this.focused != null) { + this.focused.setFocused(false); + } + + if (child != null) { + child.setFocused(true); + } + + this.focused = child; + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return ParentElement.super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return ParentElement.super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return ParentElement.super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + @Override + public boolean isFocused() { + return ParentElement.super.isFocused(); + } + + @Override + public void setFocused(boolean focused) { + ParentElement.super.setFocused(focused); + } + + @Override + public @NotNull List children() { + return Stream.concat(actionButtons.stream(), Stream.of(skinWidget, label, equipButton)).filter(Objects::nonNull).toList(); + } + + private float applyEasing(float x) { + return x * x * x; + } + + @Override + protected void drawWidget(int mouseX, int mouseY, float partialTick) { + int y = getY() + 4; + int x = getX() + 2; + skinWidget.setPosition(x, y); + skinWidget.setWidth(getWidth() - 4); + if (skinWidget.isEquipped() || equipping) { + long prog; + if (Auth.getInstance().skinManagerAnimations.get()) { + if (equipping) prog = (System.currentTimeMillis() - equippingStart) / 20 % 100; + else prog = Math.abs((System.currentTimeMillis() / 30 % 200) - 100); + } else prog = 100; + var percent = (prog / 100f); + float gradientWidth; + if (equipping) { + gradientWidth = percent * Math.min(getWidth() / 3f, getHeight() / 3f); + } else { + gradientWidth = Math.min(getWidth() / 15f, getHeight() / 6f) + applyEasing(percent) * Math.min(getWidth() * 2 / 15f, getHeight() / 6f); + } + GradientHoleRectangleRenderState.render(getX() + 2, getY() + 2, getX() + getWidth() - 2, + skinWidget.getY() + skinWidget.getHeight() + 2, + gradientWidth, + equipping ? 0xFFFF0088 : ClientColors.SELECTOR_GREEN.toInt(), 0); + } + skinWidget.render(mouseX, mouseY, partialTick); + int actionButtonY = getY() + 2; + for (var button : actionButtons) { + button.setPosition(skinWidget.getX() + skinWidget.getWidth() - button.getWidth(), actionButtonY); + if (isHovered() || button.isHovered()) { + button.render(mouseX, mouseY, partialTick); + } + actionButtonY += button.getHeight() + 2; + } + if (label != null) { + label.setPosition(x, skinWidget.getY() + skinWidget.getHeight() + 6); + label.render(mouseX, mouseY, partialTick); + label.setWidth(getWidth() - 4); + equipButton.setPosition(x, label.getY() + label.getHeight() + 2); + } else { + equipButton.setPosition(x, skinWidget.getY() + skinWidget.getHeight() + 4); + } + equipButton.setWidth(getWidth() - 4); + equipButton.render(mouseX, mouseY, partialTick); + + if (isHovered()) { + DrawUtil.outlineRect(getX(), getY(), getWidth(), getHeight(), -1); + } + } + + private static class GradientHoleRectangleRenderState { + + public static void render(int x0, int y0, int x1, int y1, float gradientWidth, int col1, int col2) { + var tess = Tessellator.getInstance(); + var vertexConsumer = tess.getBuilder(); + float z = 0; + int a1 = ClientColors.ARGB.alpha(col1); + int r1 = ClientColors.ARGB.red(col1); + int g1 = ClientColors.ARGB.green(col1); + int b1 = ClientColors.ARGB.blue(col1); + int a2 = ClientColors.ARGB.alpha(col2); + int r2 = ClientColors.ARGB.red(col2); + int g2 = ClientColors.ARGB.green(col2); + int b2 = ClientColors.ARGB.blue(col2); + GlStateManager.disableTexture(); + GlStateManager.enableBlend(); + GlStateManager.disableAlphaTest(); + GlStateManager.blendFuncSeparate(770, 771, 1, 0); + GlStateManager.shadeModel(7425); + //top + vertexConsumer.begin(7, DefaultVertexFormat.POSITION_COLOR); + vertexConsumer.vertex(x0, y0, z).color(r1, g1, b1, a1).nextVertex(); + vertexConsumer.vertex(x0 + gradientWidth, y0 + gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x1 - gradientWidth, y0 + gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x1, y0, z).color(r1, g1, b1, a1).nextVertex(); + //left + vertexConsumer.vertex(x0, y1, z).color(r1, g1, b1, a1).nextVertex(); + vertexConsumer.vertex(x0 + gradientWidth, y1 - gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x0 + gradientWidth, y0 + gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x0, y0, z).color(r1, g1, b1, a1).nextVertex(); + //bottom + vertexConsumer.vertex(x1, y1, z).color(r1, g1, b1, a1).nextVertex(); + vertexConsumer.vertex(x1 - gradientWidth, y1 - gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x0 + gradientWidth, y1 - gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x0, y1, z).color(r1, g1, b1, a1).nextVertex(); + //right + vertexConsumer.vertex(x1, y0, z).color(r1, g1, b1, a1).nextVertex(); + vertexConsumer.vertex(x1 - gradientWidth, y0 + gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x1 - gradientWidth, y1 - gradientWidth, z).color(r2, g2, b2, a2).nextVertex(); + vertexConsumer.vertex(x1, y1, z).color(r1, g1, b1, a1).nextVertex(); + tess.end(); + GlStateManager.shadeModel(7424); + GlStateManager.disableBlend(); + GlStateManager.enableAlphaTest(); + GlStateManager.enableTexture(); + } + } + } + + private class SpriteButton extends VanillaButtonWidget { + private Identifier sprite; + + SpriteButton(String message, PressAction action, Identifier sprite) { + super(0, 0, 11, 11, message, action); + this.sprite = sprite; + } + + @Override + protected void drawWidget(int mouseX, int mouseY, float delta) { + int i = 1; + if (!this.active) { + i = 0; + } else if (hovered) { + i = 2; + tooltip = getMessage(); + } + + Identifier tex = ButtonWidgetTextures.get(i); + DrawUtil.blitSprite(tex, getX(), getY(), getWidth(), getHeight(), new DrawUtil.NineSlice(200, 20, 3)); + minecraft.getTextureManager().bind(sprite); + DrawUtil.drawTexture(getX() + 2, getY() + 2, 0, 0, 7, 7, 7, 7); + } + + @Override + protected void drawScrollingText(TextRenderer renderer, int offset, Color color) { + + } + } +} diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java new file mode 100644 index 000000000..c328bee1c --- /dev/null +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinManager.java @@ -0,0 +1,166 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListSet; + +import com.google.common.hash.Hashing; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.AxoMinecraftClient; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.util.ClientColors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.render.texture.DynamicTexture; +import net.minecraft.client.render.texture.SkinImageProcessor; +import net.minecraft.client.resource.skin.DefaultSkinUtils; +import net.minecraft.resource.Identifier; + +public class SkinManager { + + private final Set loadedTextures = new ConcurrentSkipListSet<>(Comparator.comparing(Object::toString)); + + public Skin read(Path p) { + return read(p, true); + } + + @SuppressWarnings("UnstableApiUsage") + public Skin read(Path p, boolean fix) { + boolean slim; + String sha256; + try { + var in = Files.readAllBytes(p); + sha256 = Hashing.sha256().hashBytes(in).toString(); + try (var bs = new ByteArrayInputStream(in)) { + var img = ImageIO.read(bs); + int height = img.getHeight(); + int width = img.getWidth(); + if (width != 64) return null; + if (height == 32) { + if (fix) { + try (var out = Files.newOutputStream(p)) { + ImageIO.write(new SkinImageProcessor().process(img), "png", out); + } + } + slim = false; + } else if (height != 64) { + return null; + } else { + slim = ClientColors.ARGB.alpha(img.getRGB(50, 16)) == 0; + } + var metadata = Skin.Local.readMetadata(p); + if (metadata != null && metadata.containsKey(Skin.Local.CLASSIC_METADATA_KEY)) { + slim = !(boolean) metadata.get(Skin.Local.CLASSIC_METADATA_KEY); + } + } + return new Skin.Local(!slim, p, sha256); + } catch (Exception e) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to probe skin: ", e); + } + return null; + } + + public CompletableFuture loadSkin(Skin skin) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "skins/" + skin.textureKey()); + if (loadedTextures.contains(rl)) { + return CompletableFuture.completedFuture(rl); + } + + return skin.image().thenApplyAsync(bytes -> { + try (var bs = new ByteArrayInputStream(bytes)) { + var img = ImageIO.read(bs); + var tex = new DynamicTexture(img.getWidth(), img.getHeight()); + img.getRGB(0, 0, img.getWidth(), img.getHeight(), tex.getPixels(), 0, img.getWidth()); + tex.upload(); + Minecraft.getInstance().getTextureManager().register((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((v, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load skin!", t); + } + return v; + }); + } + + public AxoIdentifier loadCape(Cape cape) { + var rl = AxoIdentifier.of(AxolotlClientCommon.MODID, "capes/" + cape.textureKey()); + if (loadedTextures.contains(rl)) { + return rl; + } + + return cape.image().thenApplyAsync(bytes -> { + try (var bs = new ByteArrayInputStream(bytes)) { + var img = ImageIO.read(bs); + var tex = new DynamicTexture(img.getWidth(), img.getHeight()); + img.getRGB(0, 0, img.getWidth(), img.getHeight(), tex.getPixels(), 0, img.getWidth()); + tex.upload(); + Minecraft.getInstance().getTextureManager().register((Identifier) rl, tex); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + loadedTextures.add(rl); + return rl; + }, AxoMinecraftClient.getInstance()).handle((id, t) -> { + if (t != null) { + AxolotlClientCommon.getInstance().getLogger().warn("Failed to load cape!", t); + } + return id; + }).getNow(null); + + } + + public void releaseAll() { + loadedTextures.forEach(id -> Minecraft.getInstance().getTextureManager().close((Identifier) id)); + loadedTextures.clear(); + } + + @SuppressWarnings("UnstableApiUsage") + public String getDefaultSkinHash(Account account) { + var skin = DefaultSkinUtils.getDefaultSkin(UUIDHelper.fromUndashed(account.getUuid())); + var mc = Minecraft.getInstance(); + var resourceManager = mc.getResourceManager(); + try { + var res = resourceManager.getResource(skin); + try ( + var in = res.asStream()) { + return Hashing.sha256().hashBytes(in.readAllBytes()).toString(); + } + } catch (IOException ignored) { + } + return null; + } +} diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java new file mode 100644 index 000000000..f353e61d9 --- /dev/null +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinRenderer.java @@ -0,0 +1,110 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import com.mojang.blaze3d.platform.GLX; +import com.mojang.blaze3d.platform.GlStateManager; +import net.minecraft.client.Minecraft; +import net.minecraft.client.render.model.entity.PlayerModel; +import net.minecraft.resource.Identifier; +import org.jetbrains.annotations.Nullable; + +public class SkinRenderer { + private static PlayerModel classicModel, slimModel; + private static final Minecraft minecraft = Minecraft.getInstance(); + + private SkinRenderer() { + } + + public static void render(boolean classicVariant, + Identifier skinTexture, + @Nullable Identifier cape, + float rotationX, + float rotationY, + float pivotY, + int x0, + int y0, + int x1, + int y1, + float scale) { + + if (classicModel == null && classicVariant) { + classicModel = new PlayerModel(0, false); + classicModel.isBaby = false; + classicModel.setVisible(true); + } + if (slimModel == null && !classicVariant) { + slimModel = new PlayerModel(0, true); + slimModel.isBaby = false; + slimModel.setVisible(true); + } + + int width = x1 - x0; + int light = 15728880; + GLX.multiTexCoord2f(GLX.GL_TEXTURE1, light % 65536, light / 65536f); + GlStateManager.color4f(1.0F, 1.0F, 1.0F, 1.0F); + GlStateManager.pushMatrix(); + GlStateManager.translatef(x0 + width / 2.0F, (float) (y1), 00.0F); + GlStateManager.scalef(scale, scale, 1); + GlStateManager.translatef(0.0F, -0.0625F, 0.0F); + GlStateManager.translatef(0, pivotY, 0); + GlStateManager.rotatef(rotationX, 1, 0, 0); + GlStateManager.translatef(0, -pivotY, 0); + GlStateManager.rotatef(rotationY, 0, 1, 0); + GlStateManager.pushMatrix(); + GlStateManager.scalef(1.0F, 1.0F, -1.0F); + GlStateManager.translatef(0.0F, -1.5F, 0.0F); + var model = classicVariant ? classicModel : slimModel; + minecraft.getTextureManager().bind(skinTexture); + GlStateManager.enableBlend(); + GlStateManager.enableDepthTest(); + GlStateManager.pushMatrix(); + float k = 0.0625F; + model.head.render(k); + model.body.render(k); + model.rightLeg.render(k); + model.leftLeg.render(k); + model.hat.render(k); + model.leftPants.render(k); + model.rightPants.render(k); + model.jacket.render(k); + model.renderLeftArm(); + model.rightArm.render(0.0625F); + GlStateManager.translatef(0, 0, -0.62F); // why? + model.rightSleeve.render(0.0625F); + GlStateManager.popMatrix(); + if (cape != null) { + GlStateManager.pushMatrix(); + minecraft.getTextureManager().bind(cape); + GlStateManager.translatef(0.0F, 0.0F, 0.125F); + GlStateManager.rotatef(6.0F, 1, 0, 0); + GlStateManager.rotatef(180.0F, 0, 1, 0); + model.renderCape(0.0625F); + GlStateManager.popMatrix(); + } + GlStateManager.popMatrix(); + GlStateManager.popMatrix(); + GlStateManager.disableBlend(); + GlStateManager.disableDepthTest(); + } +} diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java new file mode 100644 index 000000000..85c78753d --- /dev/null +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinWidget.java @@ -0,0 +1,130 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.util.concurrent.CompletableFuture; + +import io.github.axolotlclient.AxolotlClientConfig.impl.ui.ClickableWidget; +import io.github.axolotlclient.api.util.UUIDHelper; +import io.github.axolotlclient.bridge.util.AxoIdentifier; +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.Auth; +import io.github.axolotlclient.modules.auth.MSApi; +import lombok.Getter; +import lombok.Setter; +import net.minecraft.client.resource.skin.DefaultSkinUtils; +import net.minecraft.client.sound.system.SoundManager; +import net.minecraft.resource.Identifier; +import net.minecraft.util.math.MathHelper; +import org.jetbrains.annotations.Nullable; + +public class SkinWidget extends ClickableWidget { + private static final float MODEL_HEIGHT = 2.125F; + private static final float FIT_SCALE = 0.97F; + private static final float ROTATION_SENSITIVITY = 2.5F; + private static final float DEFAULT_ROTATION_X = -5.0F; + private static final float DEFAULT_ROTATION_Y = 30.0F; + private static final float ROTATION_X_LIMIT = 50.0F; + private float rotationX = DEFAULT_ROTATION_X; + @Setter + private float rotationY = DEFAULT_ROTATION_Y; + @Getter + @Setter + private Skin skin; + @Getter + @Setter + private Cape cape; + private final Account owner; + private boolean noCape, noCapeActive; + + public SkinWidget(int width, int height, Skin skin, @Nullable Cape cape, Account owner) { + super(0, 0, width, height, ""); + this.skin = skin; + this.cape = cape; + this.owner = owner; + } + + public SkinWidget(int width, int height, Skin skin, Account owner) { + this(width, height, skin, null, owner); + } + + public void noCape(boolean noCapeActive) { + noCape = true; + this.noCapeActive = noCapeActive; + } + + @Override + protected void drawWidget(int mouseX, int mouseY, float partialTick) { + float scale = FIT_SCALE * this.getHeight() / MODEL_HEIGHT; + float pivotY = -1.0625F; + + AxoIdentifier skinRl; + boolean classic; + SkinManager skinManager = Auth.getInstance().getSkinManager(); + CompletableFuture loader = skin == null ? null : skinManager.loadSkin(skin); + if (loader != null && loader.isDone()) { + skinRl = loader.join(); + classic = skin.classicVariant(); + } else { + var uuid = UUIDHelper.fromUndashed(owner.getUuid()); + classic = DefaultSkinUtils.getDefaultModelType(uuid).equals("default"); + skinRl = DefaultSkinUtils.getDefaultSkin(uuid); + } + var capeRl = cape == null ? null : skinManager.loadCape(cape); + + SkinRenderer.render(classic, (Identifier) skinRl, (Identifier) capeRl, this.rotationX, this.rotationY, pivotY, this.getX(), this.getY(), this.getX() + getWidth(), this.getY() + getHeight(), scale); + } + + @Override + protected void onDrag(double mouseX, double mouseY, double dragX, double dragY) { + this.rotationX = MathHelper.clamp(this.rotationX - (float) dragY * ROTATION_SENSITIVITY, -ROTATION_X_LIMIT, ROTATION_X_LIMIT); + this.rotationY += (float) dragX * ROTATION_SENSITIVITY; + } + + @Override + public void playDownSound(SoundManager soundManager) { + + } + + public boolean isEquipped() { + return noCape ? noCapeActive : (cape != null ? cape.active() : skin != null && skin.active()); + } + + public CompletableFuture equip() { + var msApi = Auth.getInstance().getMsApi(); + if (noCape) { + return msApi.hideCape(owner); + } + if (cape != null) { + return cape.equip(msApi, owner); + } + if (skin != null) { + return skin.equip(msApi, owner); + } + return msApi.resetSkin(owner); + } + + public Asset getFocusedAsset() { + return noCape ? null : cape != null ? cape : skin; + } +} diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java index 6b62c2429..71a514923 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/hud/HudEditScreen.java @@ -36,12 +36,11 @@ import io.github.axolotlclient.modules.hud.snapping.SnappingHelper; import io.github.axolotlclient.modules.hud.util.DrawPosition; import io.github.axolotlclient.modules.hud.util.Rectangle; -import io.github.axolotlclient.util.GLFWUtil; +import io.github.axolotlclient.util.WindowAccess; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.widget.ButtonWidget; import net.minecraft.client.resource.language.I18n; -import org.lwjgl.glfw.GLFW; /** * This implementation of Hud modules is based on KronHUD. @@ -55,10 +54,10 @@ public class HudEditScreen extends Screen { private static final BooleanOption snapping = new BooleanOption("snapping", true); private static final OptionCategory hudEditScreenCategory = OptionCategory.create("hudEditScreen"); private static final int GRAB_TOLERANCE = 5; - private static final long MOVE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_ALL_CURSOR); - private static final long DEFAULT_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_ARROW_CURSOR); - private static final long NWSE_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NWSE_CURSOR), - NESW_RESIZE_CURSOR = GLFW.glfwCreateStandardCursor(GLFW.GLFW_RESIZE_NESW_CURSOR); + private final long MOVE_CURSOR = WindowAccess.getInstance().createCursor(WindowAccess.Cursor.RESIZE_ALL); + private final long DEFAULT_CURSOR = WindowAccess.getInstance().createCursor(WindowAccess.Cursor.ARROW); + private final long NWSE_RESIZE_CURSOR = WindowAccess.getInstance().createCursor(WindowAccess.Cursor.RESIZE_NWSE), + NESW_RESIZE_CURSOR = WindowAccess.getInstance().createCursor(WindowAccess.Cursor.RESIZE_NESW); public static boolean isSnappingEnabled() { return snapping.get(); @@ -106,7 +105,7 @@ private void updateSnapState() { private void setCursor(long cursor) { if (cursor > 0 && cursor != currentCursor) { currentCursor = cursor; - GLFWUtil.runUsingGlfwHandle(ctx -> GLFW.glfwSetCursor(ctx, cursor)); + WindowAccess.getInstance().setCursor(cursor); } } @@ -287,6 +286,7 @@ public void removed() { super.removed(); setCursor(DEFAULT_CURSOR); mode = ModificationMode.NONE; + WindowAccess.getInstance().destroyCursors(DEFAULT_CURSOR, NWSE_RESIZE_CURSOR, NESW_RESIZE_CURSOR, MOVE_CURSOR); } @Override diff --git a/1.8.9/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotUtils.java b/1.8.9/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotUtils.java index c6e6e9548..96b58f5df 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotUtils.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotUtils.java @@ -54,9 +54,8 @@ public class ScreenshotUtils extends AbstractModule { private static final ScreenshotUtils Instance = new ScreenshotUtils(); private final OptionCategory category = OptionCategory.create("screenshotUtils"); private final BooleanOption enabled = new BooleanOption("enabled", false); - private final GenericOption openViewer = new GenericOption("imageViewer", "openViewer", () -> { - Minecraft.getInstance().openScreen(new GalleryScreen(Minecraft.getInstance().screen)); - }); + private final GenericOption openViewer = new GenericOption("imageViewer", "openViewer", + () -> Minecraft.getInstance().openScreen(new GalleryScreen(Minecraft.getInstance().screen))); private final Map actions = new LinkedHashMap<>(); diff --git a/1.8.9/src/main/java/io/github/axolotlclient/util/GLFWUtil.java b/1.8.9/src/main/java/io/github/axolotlclient/util/GLFWUtil.java deleted file mode 100644 index 797ab0162..000000000 --- a/1.8.9/src/main/java/io/github/axolotlclient/util/GLFWUtil.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright © 2025 moehreag & Contributors - * - * This file is part of AxolotlClient. - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser 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 - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * - * For more information, see the LICENSE file. - */ - -package io.github.axolotlclient.util; - -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; -import java.util.function.Consumer; - -import net.ornithemc.osl.lifecycle.api.client.MinecraftClientEvents; -import org.lwjgl.opengl.Display; - -public class GLFWUtil { - - private static MethodHandle getHandle; - - static { - try { - getHandle = MethodHandles.lookup().findStatic(Class.forName("org.lwjgl.opengl.Display"), "getHandle", MethodType.methodType(long.class)); - } catch (Throwable ignored) { - } - } - - private static long windowHandle = -1; - - public static long getWindowHandle() { - if (windowHandle == -1) { - try { - windowHandle = (long) getHandle.invoke(); - } catch (Throwable ignored) { - } - } - return windowHandle; - } - - // Since the reflection used for this only works with legacy-lwjgl3 it's possible for us not being able to access the window handle. - // This however should not lead to a crash despite compiling against glfw. - public static boolean isHandleAvailable() { - return getWindowHandle() != -1; - } - - public static void runUsingGlfwHandle(Consumer action) { - if (!Display.isCreated()) { - MinecraftClientEvents.READY.register(mc -> { - if (isHandleAvailable()) { - action.accept(getWindowHandle()); - } - }); - } else if (isHandleAvailable()) { - action.accept(getWindowHandle()); - } - } -} diff --git a/1.8.9/src/main/java/io/github/axolotlclient/util/Util.java b/1.8.9/src/main/java/io/github/axolotlclient/util/Util.java index db735461e..a3ad3b689 100644 --- a/1.8.9/src/main/java/io/github/axolotlclient/util/Util.java +++ b/1.8.9/src/main/java/io/github/axolotlclient/util/Util.java @@ -239,11 +239,15 @@ public static float lerp(float start, float end, float percent) { } public static Identifier getTexture(GraphicsOption option) { - return getTexture(option.get(), option.getName()); + return getTexture(option.get(), "graphics_"+option.getName()); } public static Identifier getTexture(Graphics graphics, String name) { - Identifier id = new Identifier("axolotlclient", "graphics_" + name.toLowerCase(Locale.ROOT)); + Identifier id = new Identifier("axolotlclient", name.toLowerCase(Locale.ROOT)); + return getTexture(graphics, id); + } + + public static Identifier getTexture(Graphics graphics, Identifier id) { try { DynamicTexture texture; if (Minecraft.getInstance().getTextureManager().get(id) == null) { @@ -262,7 +266,7 @@ public static Identifier getTexture(Graphics graphics, String name) { texture.upload(); } catch (IOException e) { - AxolotlClient.LOGGER.error("Failed to bind texture for " + name + ": ", e); + AxolotlClient.LOGGER.error("Failed to bind texture for " + id + ": ", e); } return id; } diff --git a/1.8.9/src/main/java/io/github/axolotlclient/util/WindowAccess.java b/1.8.9/src/main/java/io/github/axolotlclient/util/WindowAccess.java new file mode 100644 index 000000000..d0c1ca33e --- /dev/null +++ b/1.8.9/src/main/java/io/github/axolotlclient/util/WindowAccess.java @@ -0,0 +1,222 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.util; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +import org.lwjgl.glfw.GLFW; +import org.lwjgl.sdl.SDLMouse; + +public sealed abstract class WindowAccess permits WindowAccess.GLFWAccess, WindowAccess.SDLAccess, WindowAccess.NoOpAccess { + + private static final boolean GLFW_AVAILABLE = checkGlfwAvailable(); + private static final boolean SDL_AVAILABLE = checkSDLAvailable(); + + public static boolean isGlfwAvailable() { + return WindowAccess.GLFW_AVAILABLE; + } + + public static boolean isSdlAvailable() { + return WindowAccess.SDL_AVAILABLE; + } + + public enum Cursor { + RESIZE_ALL, + ARROW, + RESIZE_NWSE, + RESIZE_NESW + } + + public abstract long createCursor(Cursor cursor); + + public abstract void setCursor(long cursor); + + public abstract void destroyCursors(long... cursors); + + public abstract boolean rawMouseMotionAvailable(); + + public abstract void setRawMouseMotion(boolean enabled); + + private static final WindowAccess INSTANCE = create(); + + public static WindowAccess getInstance() { + return INSTANCE; + } + + private static boolean checkGlfwAvailable() { + try { + Class.forName("org.lwjgl.glfw.GLFW"); + return true; + } catch (Throwable ignored) { + } + return false; + } + + private static boolean checkSDLAvailable() { + try { + Class.forName("org.lwjgl.sdl.SDL"); + return true; + } catch (Throwable ignored) { + } + return false; + } + + private static WindowAccess create() { + if (GLFW_AVAILABLE) { + return new GLFWAccess(); + } else if (SDL_AVAILABLE) { + return new SDLAccess(); + } + return new NoOpAccess(); + } + + final static class NoOpAccess extends WindowAccess { + + @Override + public long createCursor(Cursor cursor) { + return 0; + } + + @Override + public void setCursor(long cursor) { + + } + + @Override + public void destroyCursors(long... cursors) { + + } + + @Override + public boolean rawMouseMotionAvailable() { + return false; + } + + @Override + public void setRawMouseMotion(boolean enabled) { + + } + } + + final static class SDLAccess extends WindowAccess { + + @Override + public long createCursor(Cursor cursor) { + return SDLMouse.SDL_CreateSystemCursor(switch (cursor) { + case RESIZE_ALL -> SDLMouse.SDL_SYSTEM_CURSOR_MOVE; + case ARROW -> SDLMouse.SDL_SYSTEM_CURSOR_DEFAULT; + case RESIZE_NWSE -> SDLMouse.SDL_SYSTEM_CURSOR_NWSE_RESIZE; + case RESIZE_NESW -> SDLMouse.SDL_SYSTEM_CURSOR_NESW_RESIZE; + }); + } + + @Override + public void setCursor(long cursor) { + SDLMouse.SDL_SetCursor(cursor); + } + + @Override + public void destroyCursors(long... cursors) { + for (long c : cursors) { + SDLMouse.SDL_DestroyCursor(c); + } + } + + @Override + public boolean rawMouseMotionAvailable() { + return false; + } + + @Override + public void setRawMouseMotion(boolean enabled) { + + } + } + + final static class GLFWAccess extends WindowAccess { + + @Override + public long createCursor(Cursor cursor) { + return GLFW.glfwCreateStandardCursor(switch (cursor) { + case RESIZE_ALL -> GLFW.GLFW_RESIZE_ALL_CURSOR; + case ARROW -> GLFW.GLFW_ARROW_CURSOR; + case RESIZE_NWSE -> GLFW.GLFW_RESIZE_NWSE_CURSOR; + case RESIZE_NESW -> GLFW.GLFW_RESIZE_NESW_CURSOR; + }); + } + + @Override + public void setCursor(long cursor) { + GLFW.glfwSetCursor(WindowHandleAccess.getWindowHandle(), cursor); + } + + @Override + public void destroyCursors(long... cursors) { + for (long c : cursors) { + GLFW.glfwDestroyCursor(c); + } + } + + @Override + public boolean rawMouseMotionAvailable() { + return WindowHandleAccess.isHandleAvailable(); + } + + @Override + public void setRawMouseMotion(boolean enabled) { + GLFW.glfwSetInputMode(WindowHandleAccess.getWindowHandle(), GLFW.GLFW_RAW_MOUSE_MOTION, enabled ? 1 : 0); + } + } + + public static class WindowHandleAccess { + + private static MethodHandle getHandle; + + static { + try { + getHandle = MethodHandles.lookup().findStatic(Class.forName("org.lwjgl.opengl.Display"), "getHandle", MethodType.methodType(long.class)); + } catch (Throwable ignored) { + } + } + + private static long windowHandle = -1; + + public static long getWindowHandle() { + if (windowHandle == -1) { + try { + windowHandle = (long) getHandle.invoke(); + } catch (Throwable ignored) { + } + } + return windowHandle; + } + + // Since the reflection used for this only works with legacy-lwjgl3 it's possible for us not being able to access the window handle. + // This however should not lead to a crash despite compiling against glfw. + public static boolean isHandleAvailable() { + return getWindowHandle() != -1; + } + } +} diff --git a/1.8.9/src/main/resources/axolotlclient.mixins.json b/1.8.9/src/main/resources/axolotlclient.mixins.json index 53595af66..0e0b07819 100644 --- a/1.8.9/src/main/resources/axolotlclient.mixins.json +++ b/1.8.9/src/main/resources/axolotlclient.mixins.json @@ -15,6 +15,8 @@ "ClientPlayerEntityMixin", "ClientPlayNetworkHandlerMixin", "ClientWorldMixin", + "ConfigVanillaButtonWidgetMixin", + "ConfirmScreenMixin", "ConnectScreenMixin", "ControlsOptionsScreenMixin", "DebugHudMixin", diff --git a/1.8.9/src/main/resources/fabric.mod.json b/1.8.9/src/main/resources/fabric.mod.json index 6eaebc52a..d07fd43a4 100644 --- a/1.8.9/src/main/resources/fabric.mod.json +++ b/1.8.9/src/main/resources/fabric.mod.json @@ -122,7 +122,8 @@ "osl-keybinds": "*", "osl-entrypoints": "*", "axolotlclientconfig": "*", - "axolotlclient-common": "*" + "axolotlclient-common": "*", + "legacy-lwjgl3": ">1.2.8" } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 70208d620..345d840ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### 3.1.6 - Implement more options for the Inventory HUD (#173) +- Add Skin Manager (#176) ### 3.1.5 diff --git a/build.gradle.kts b/build.gradle.kts index 8c94b4349..3886eedb7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,6 +54,7 @@ allprojects { } mavenLocal() mavenCentral() + maven("https://central.sonatype.com/repository/maven-snapshots") } } @@ -91,7 +92,7 @@ subprojects { return@forEach } val oldName = old.fileName.toString() - val oldVer = oldName.substring(0, oldName.indexOf("+")) + val oldVer = oldName.substringBefore("+") val mcVer = oldName.substring(oldName.indexOf("+") + 1, oldName.length - 4).removeSuffix("-sources") if (!project.version.toString().contains(mcVer)) { return@forEach diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 85cd9d7e0..78f7d782b 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { testRuntimeOnly(compileOnly("org.apache.commons:commons-lang3:3.3.2")!!) testRuntimeOnly(compileOnly("it.unimi.dsi:fastutil:8.2.1")!!) testRuntimeOnly(compileOnly("org.lwjgl:lwjgl-glfw:3.3.2")!!) + testRuntimeOnly(compileOnly("org.lwjgl:lwjgl-tinyfd:3.2.2")!!) + testRuntimeOnly(compileOnly("org.lwjgl:lwjgl-sdl:3.4.0-SNAPSHOT")!!) shadow(implementation("io.github.CDAGaming:DiscordIPC:0.10.2") { isTransitive = false @@ -32,7 +34,7 @@ dependencies { shadow(implementation("com.kohlschutter.junixsocket:junixsocket-common:2.10.1")!!) shadow(implementation("com.kohlschutter.junixsocket:junixsocket-native-common:2.10.1")!!) - shadow(implementation("com.github.mizosoft.methanol:methanol:1.8.0")!!) + shadow(implementation("com.github.mizosoft.methanol:methanol:1.8.3")!!) shadow(implementation("io.nayuki:qrcodegen:1.8.0")!!) compileOnly("net.hypixel:mod-api:1.0.1") diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/Account.java b/common/src/main/java/io/github/axolotlclient/modules/auth/Account.java index f7e759496..e07ae64f9 100644 --- a/common/src/main/java/io/github/axolotlclient/modules/auth/Account.java +++ b/common/src/main/java/io/github/axolotlclient/modules/auth/Account.java @@ -24,7 +24,6 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import com.google.gson.JsonObject; @@ -83,8 +82,9 @@ public static Account deserialize(JsonObject object) { return new Account(uuid, name, authToken, msaToken, refreshToken, expiration); } - public CompletableFuture> refresh(MSAuth auth) { - return auth.refreshToken(refreshToken, this); + public CompletableFuture refresh(MSApi auth) { + if (isOffline()) return CompletableFuture.failedFuture(new UnsupportedOperationException()); + return auth.refresh(this); } public boolean isOffline() { diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/Accounts.java b/common/src/main/java/io/github/axolotlclient/modules/auth/Accounts.java index 1b9d8667d..2ff257eab 100644 --- a/common/src/main/java/io/github/axolotlclient/modules/auth/Accounts.java +++ b/common/src/main/java/io/github/axolotlclient/modules/auth/Accounts.java @@ -27,10 +27,12 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.AxolotlClientConfig.api.options.OptionCategory; import io.github.axolotlclient.util.GsonHelper; import io.github.axolotlclient.util.Logger; import lombok.Getter; @@ -38,9 +40,11 @@ @Getter public abstract class Accounts { + public final OptionCategory category = OptionCategory.create("auth"); + private final List accounts = new ArrayList<>(); protected Account current; - protected MSAuth auth; + protected MSApi msApi; public void load() { Path legacy = AxolotlClientCommon.resolveConfigFile("../accounts.json"); @@ -117,7 +121,7 @@ public boolean allowOfflineAccounts() { return !accounts.isEmpty() && !accounts.stream().allMatch(Account::isOffline); } - abstract void showAccountsExpiredScreen(Account account); + abstract CompletableFuture showAccountsExpiredScreen(Account account); abstract void displayDeviceCode(DeviceFlowData data); } diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/MSApi.java b/common/src/main/java/io/github/axolotlclient/modules/auth/MSApi.java new file mode 100644 index 000000000..666e2ee10 --- /dev/null +++ b/common/src/main/java/io/github/axolotlclient/modules/auth/MSApi.java @@ -0,0 +1,541 @@ +/* + * Copyright © 2024 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth; + +import java.io.FileNotFoundException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; + +import com.github.mizosoft.methanol.FormBodyPublisher; +import com.github.mizosoft.methanol.MediaType; +import com.github.mizosoft.methanol.MultipartBodyPublisher; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.github.axolotlclient.AxolotlClientCommon; +import io.github.axolotlclient.modules.auth.skin.Cape; +import io.github.axolotlclient.modules.auth.skin.Skin; +import io.github.axolotlclient.util.GsonHelper; +import io.github.axolotlclient.util.JsonBuilders; +import io.github.axolotlclient.util.Logger; +import io.github.axolotlclient.util.NetworkUtil; +import lombok.ToString; + +// Partly oriented on In-Game-Account-Switcher by The-Fireplace, VidTu +public class MSApi { + + private static final String CLIENT_ID = "938592fc-8e01-4c6d-b56d-428c7d9cf5ea"; // AxolotlClient MSA ClientID + private static final String SCOPES = "XboxLive.signin offline_access"; + private static final String XBL_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"; + private static final String MS_DEVICE_CODE_LOGIN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode?mkt="; + private static final String MS_TOKEN_LOGIN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + private static final String XBL_XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"; + private static final String MC_LOGIN_WITH_XBOX_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"; + + private final Supplier languageSupplier; + private final Logger logger; + private final Accounts accounts; + private final HttpClient client; + + public static MSApi INSTANCE; + + public MSApi(Accounts accounts, Supplier languageSupplier) { + this.logger = AxolotlClientCommon.getInstance().getLogger(); + this.client = NetworkUtil.createHttpClient(); + this.accounts = accounts; + this.languageSupplier = languageSupplier; + INSTANCE = this; + } + + public CompletableFuture startDeviceAuth() { + + String[] lang = languageSupplier.get().replace("_", "-").split("-"); + logger.debug("starting ms device auth flow"); + // https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code#device-authorization-response + HttpRequest.Builder builder = HttpRequest.newBuilder() + .POST(FormBodyPublisher.newBuilder() + .query("client_id", CLIENT_ID) + .query("scope", SCOPES).build()) + .header("content-type", "application/x-www-form-urlencoded") + .uri(URI.create(MS_DEVICE_CODE_LOGIN_URL + lang[0] + "-" + lang[1].toUpperCase(Locale.ROOT))); + return requestJson(builder.build()) + .thenApplyAsync(object -> { + int expiresIn = object.get("expires_in").getAsInt(); + String deviceCode = object.get("device_code").getAsString(); + String userCode = object.get("user_code").getAsString(); + String verificationUri = object.get("verification_uri").getAsString(); + int interval = object.get("interval").getAsInt(); + String message = object.get("message").getAsString(); + logger.debug("displaying device code to user"); + DeviceFlowData data = new DeviceFlowData(message, verificationUri, deviceCode, userCode, expiresIn, interval); + accounts.displayDeviceCode(data); + return data; + }).thenComposeAsync(data -> { + logger.debug("waiting for user authorization..."); + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < data.getExpiresIn() * 1000L && !data.isCancelled()) { + if ((System.currentTimeMillis() - start) % data.getInterval() == 0) { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().POST( + FormBodyPublisher.newBuilder().query("client_id", CLIENT_ID) + .query("device_code", data.getDeviceCode()) + .query("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .build() + ) + .uri(URI.create(MS_TOKEN_LOGIN_URL)); + JsonObject response = requestJson(requestBuilder.build()).join(); + + if (response.has("refresh_token") && response.has("access_token")) { + data.setStatus("auth.working"); + return authenticateFromMSTokens(response.get("access_token").getAsString(), + response.get("refresh_token").getAsString()) + .thenApply(a -> { + int index = accounts.getAccounts().indexOf(a); + Account loginAccount; + if (index == -1) { + accounts.getAccounts().add(a); + loginAccount = a; + } else { + var prev = accounts.getAccounts().get(index); + prev.setAuthToken(a.getAuthToken()); + prev.setExpiration(a.getExpiration()); + prev.setMsaToken(a.getMsaToken()); + prev.setName(a.getName()); + prev.setRefreshToken(a.getRefreshToken()); + loginAccount = prev; + } + accounts.login(loginAccount); + accounts.save(); + data.setStatus("auth.finished"); + return loginAccount; + }); + } + + if (response.has("error")) { + String error = response.get("error").getAsString(); + switch (error) { + case "authorization_pending": + continue; + case "bad_verification_code": + throw new IllegalStateException("Bad verification code! " + response); + case "authorization_declined": + case "expired_token": + default: + break; + } + } + } + } + return CompletableFuture.failedStage(new TimeoutException()); + }); + } + + private CompletableFuture authenticateFromMSTokens(String accessToken, String refreshToken) { + return CompletableFuture.supplyAsync(() -> { + logger.debug("getting xbl token... "); + XblData xbl = authXbl(accessToken).join(); + logger.debug("getting xsts token..."); + XblData xsts = authXstsMC(xbl.token()).join(); + logger.debug("getting mc auth token..."); + MCXblData mc = authMC(xsts.displayClaims().uhs(), xsts.token()).join(); + + JsonObject profileJson = getMCProfile(mc.accessToken()).join(); + if (profileJson.has("error") && "NOT_FOUND".equals(profileJson.get("error").getAsString())) { + AxolotlClientCommon.getInstance().getNotificationProvider().addStatus("auth.notif.login.failed", "auth.notif.login.failed.no_profile"); + throw new IllegalStateException(); + } + logger.debug("retrieving entitlements..."); + if (!checkOwnership(mc.accessToken()).join()) { + AxolotlClientCommon.getInstance().getNotificationProvider().addStatus("auth.notif.login.failed", "auth.notif.login.failed.no_entitlement"); + logger.warn("Failed to check for game ownership!"); + throw new IllegalStateException(); + } + logger.debug("getting profile..."); + MCProfile profile = MCProfile.get(profileJson); + return new Account(profile.name(), profile.id(), mc.accessToken(), mc.expiration(), refreshToken, accessToken); + }); + } + + public record MCProfile(String id, String name, List skins, List capes) { + public static MCProfile get(JsonObject json) { + return new MCProfile(json.get("id").getAsString(), json.get("name").getAsString(), + GsonHelper.jsonArrayToStream(json.getAsJsonArray("skins")) + .map(s -> OnlineSkin.get(s.getAsJsonObject())) + .toList(), GsonHelper.jsonArrayToStream(json.getAsJsonArray("capes")) + .map(s -> OnlineCape.get(s.getAsJsonObject())) + .toList()); + } + + @ToString + public static final class OnlineSkin implements Skin { + public static final String VARIANT_CLASSIC = "CLASSIC"; + public static final String VARIANT_SLIM = "SLIM"; + public static final String STATE_ACTIVE = "ACTIVE"; + private final String id; + private final String state; + private final String url; + private boolean classicVariant; + private final String textureKey; + + public OnlineSkin(String id, String state, String url, String variant, + String textureKey) { + this.id = id; + this.state = state; + this.url = url; + this.classicVariant = VARIANT_CLASSIC.equals(variant); + this.textureKey = textureKey; + } + + public static OnlineSkin get(JsonObject object) { + String url = object.get("url").getAsString(); + return new OnlineSkin(object.get("id").getAsString(), + object.get("state").getAsString(), + url, + object.get("variant").getAsString(), + url.substring(url.lastIndexOf("/") + 1)); + } + + @Override + public boolean isOnline() { + return true; + } + + public CompletableFuture image() { + return INSTANCE.client.sendAsync(HttpRequest.newBuilder(URI.create(url())).GET().build(), HttpResponse.BodyHandlers.ofByteArray()) + .thenApplyAsync(res -> { + if (res.statusCode() == 200) { + return res.body(); + } + throw new IllegalArgumentException("abnormal status: " + res.statusCode()); + }); + } + + public boolean classicVariant() { + return classicVariant; + } + + @Override + public void classicVariant(boolean classic) { + this.classicVariant = classic; + } + + public boolean active() { + return STATE_ACTIVE.equals(state()); + } + + @Override + public CompletableFuture equip(MSApi api, Account account) { + return api.setSkin(account, this); + } + + @Override + public boolean supportsDownload() { + return true; + } + + public String id() { + return id; + } + + public String state() { + return state; + } + + @Override + public String url() { + return url; + } + + public String variant() { + return classicVariant ? VARIANT_CLASSIC : VARIANT_SLIM; + } + + @Override + public String textureKey() { + return textureKey; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (OnlineSkin) obj; + return Objects.equals(this.id, that.id) && + Objects.equals(this.state, that.state) && + Objects.equals(this.url, that.url) && + Objects.equals(this.classicVariant, that.classicVariant) && + Objects.equals(this.textureKey, that.textureKey); + } + + @Override + public int hashCode() { + return Objects.hash(id, state, url, classicVariant, textureKey); + } + } + + public record OnlineCape(String id, String state, String url, String alias, String textureKey) implements Cape { + public static final String STATE_ACTIVE = "ACTIVE"; + + public static OnlineCape get(JsonObject object) { + String url = object.get("url").getAsString(); + return new OnlineCape(object.get("id").getAsString(), object.get("state").getAsString(), + url, object.get("alias").getAsString(), url.substring(url.lastIndexOf("/") + 1)); + } + + public CompletableFuture image() { + return INSTANCE.client.sendAsync(HttpRequest.newBuilder(URI.create(url())).GET().build(), HttpResponse.BodyHandlers.ofByteArray()) + .thenApplyAsync(res -> { + if (res.statusCode() == 200) { + return res.body(); + } + throw new IllegalArgumentException("abnormal status: " + res.statusCode()); + }); + } + + @Override + public boolean isOnline() { + return true; + } + + public boolean active() { + return STATE_ACTIVE.equals(state()); + } + + @Override + public CompletableFuture equip(MSApi api, Account account) { + return api.showCape(account, this); + } + } + } + + private CompletableFuture authXbl(String code) { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(XBL_AUTH_URL)) + .POST(HttpRequest.BodyPublishers.ofString(JsonBuilders.JsonObject.create() + .field("Properties", JsonBuilders.JsonObject.create() + .field("AuthMethod", "RPS") + .field("SiteName", "user.auth.xboxlive.com") + .field("RpsTicket", "d=" + code)) + .field("RelyingParty", "http://auth.xboxlive.com") + .field("TokenType", "JWT").asString())) + .header("content-type", "application/json") + .header("accept", "application/json"); + + return requestJson(requestBuilder.build()).thenApply(response -> new XblData(Instant.parse(response.get("IssueInstant").getAsString()), Instant.parse(response.get("NotAfter").getAsString()), + response.get("Token").getAsString(), new XblData.DisplayClaims(response.get("DisplayClaims").getAsJsonObject().get("xui").getAsJsonArray().get(0).getAsJsonObject().get("uhs").getAsString()))); + } + + private record XblData(Instant issueInstant, Instant notAfter, String token, DisplayClaims displayClaims) { + private record DisplayClaims(String uhs) { + } + } + + private CompletableFuture authXstsMC(String xblToken) { + var body = JsonBuilders.JsonObject.create() + .field("Properties", JsonBuilders.JsonObject.create() + .field("SandboxId", "RETAIL") + .field("UserTokens", JsonBuilders.JsonArray.create().field(xblToken))) + .field("RelyingParty", "rp://api.minecraftservices.com/") + .field("TokenType", "JWT"); + return requestJson(HttpRequest.newBuilder().POST(HttpRequest.BodyPublishers.ofString(body.asString())).uri(URI.create(XBL_XSTS_AUTH_URL)).build()) + .thenApply(response -> new XblData(Instant.parse(response.get("IssueInstant").getAsString()), Instant.parse(response.get("NotAfter").getAsString()), + response.get("Token").getAsString(), new XblData.DisplayClaims(response.get("DisplayClaims").getAsJsonObject().get("xui").getAsJsonArray().get(0).getAsJsonObject().get("uhs").getAsString()))); + } + + private CompletableFuture authMC(String userhash, String xsts) { + var body = JsonBuilders.JsonObject.create().field("identityToken", "XBL3.0 x=" + userhash + ";" + xsts); + return requestJson(HttpRequest.newBuilder(URI.create(MC_LOGIN_WITH_XBOX_URL)).POST(HttpRequest.BodyPublishers.ofString(body.asString())).build()) + .thenApply(response -> new MCXblData(response.get("username").getAsString(), + response.get("access_token").getAsString(), + Instant.now().plus(response.get("expires_in").getAsLong(), ChronoUnit.SECONDS))); + } + + private record MCXblData(String username, String accessToken, Instant expiration) { + } + + private CompletableFuture checkOwnership(String accessToken) { + return requestJson(HttpRequest + .newBuilder(URI.create("https://api.minecraftservices.com/entitlements/mcstore")) + .header("Authorization", "Bearer " + accessToken).build()) + .thenApply(res -> GsonHelper.jsonArrayToStream(res.get("items").getAsJsonArray()) + .anyMatch(e -> e.isJsonObject() && e.getAsJsonObject().has("name") + && "game_minecraft".equals(e.getAsJsonObject().get("name").getAsString()))); + } + + private CompletableFuture getMCProfile(String accessToken) { + return requestJson(HttpRequest.newBuilder().GET() + .uri(URI.create("https://api.minecraftservices.com/minecraft/profile")) + .header("Authorization", "Bearer " + accessToken).build()); + } + + public CompletableFuture refresh(Account account) { + logger.debug("refreshing auth code..."); + return requestJson(HttpRequest + .newBuilder(URI.create(MS_TOKEN_LOGIN_URL)) + .POST(FormBodyPublisher.newBuilder() + .query("client_id", CLIENT_ID) + .query("refresh_token", account.getRefreshToken()) + .query("scope", SCOPES) + .query("grant_type", "refresh_token").build()) + .header("Accept", "application/json").build()) + .thenCompose(response -> { + if (response.has("error_codes")) { + int errorCode = response.get("error_codes").getAsJsonArray().get(0).getAsInt(); + if (errorCode == 70000 || errorCode == 70012) { + return accounts.showAccountsExpiredScreen(account); + } else { + logger.warn("Login error, unexpected response: " + response); + AxolotlClientCommon.getInstance().getNotificationProvider().addStatus("auth.notif.refresh.error", "auth.notif.refresh.error.unexpected_response"); + throw new IllegalArgumentException(); + } + } + logger.debug("authenticating..."); + return authenticateFromMSTokens(response.get("access_token").getAsString(), + response.get("refresh_token").getAsString()).thenApply(refreshed -> { + account.setRefreshToken(refreshed.getRefreshToken()); + account.setAuthToken(refreshed.getAuthToken()); + account.setName(refreshed.getName()); + account.setMsaToken(refreshed.getMsaToken()); + account.setExpiration(refreshed.getExpiration()); + accounts.save(); + return account; + }); + }); + } + + private CompletableFuture requestJson(HttpRequest request) { + return client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(res -> GsonHelper.fromJson(res.body())); + } + + public CompletableFuture getProfile(Account account) { + return getMCProfile(account.getAuthToken()).thenApply(this::extractProfile); + } + + public record SkinBundle(String name, String id, CompletableFuture skin, String skinKey, + boolean classicModel) { + } + + public CompletableFuture getTextures(String uuid) { + return requestJson(HttpRequest.newBuilder().GET() + .uri(URI.create("https://sessionserver.mojang.com/session/minecraft/profile/" + uuid)) + .build()).thenApply(json -> { + var name = json.get("name").getAsString(); + var id = json.get("id").getAsString(); + var properties = json.get("properties").getAsJsonArray(); + for (JsonElement e : properties) { + if (e.isJsonObject()) { + var obj = e.getAsJsonObject(); + if (obj.has("name") && "textures".equals(obj.get("name").getAsString())) { + var b64 = obj.get("value").getAsString(); + var props = GsonHelper.fromJson(new String(Base64.getDecoder().decode(b64), StandardCharsets.UTF_8)); + var textures = props.get("textures").getAsJsonObject(); + if (textures.has("SKIN")) { + var skinObj = textures.get("SKIN").getAsJsonObject(); + var skinUrl = skinObj.get("url").getAsString(); + var skin = client.sendAsync(HttpRequest.newBuilder().uri(URI.create(skinUrl)).GET().build(), HttpResponse.BodyHandlers.ofByteArray()) + .thenApply(HttpResponse::body); + var skinKey = skinUrl.substring(skinUrl.lastIndexOf("/") + 1); + var classicModel = true; + if (skinObj.has("metadata")) { + var metadata = skinObj.get("metadata").getAsJsonObject(); + var model = metadata.get("model").getAsString(); + classicModel = MCProfile.OnlineSkin.VARIANT_CLASSIC.toLowerCase(Locale.ROOT).equals(model.toLowerCase(Locale.ROOT)); + } + return new SkinBundle(name, id, skin, skinKey, classicModel); + } + } + } + } + return null; + }); + } + + private MCProfile extractProfile(JsonObject profileJson) { + if (profileJson.has("error") && "NOT_FOUND".equals(profileJson.get("error").getAsString())) { + throw new IllegalStateException("profile not found"); + } + if (!profileJson.has("id")) { + logger.warn("Unexpected profile response: {}", profileJson); + throw new IllegalStateException("unexpected error"); + } + return MCProfile.get(profileJson); + } + + public CompletableFuture setSkin(Account account, MCProfile.OnlineSkin skin) { + return requestJson(HttpRequest.newBuilder() + .uri(URI.create("https://api.minecraftservices.com/minecraft/profile/skins")) + .header("Authorization", "Bearer " + account.getAuthToken()) + .POST(HttpRequest.BodyPublishers.ofString(JsonBuilders.JsonObject.create() + .field("variant", skin.variant()).field("url", skin.url()).asString())).build()) + .thenApply(this::extractProfile); + } + + public CompletableFuture uploadAndSetSkin(Account account, Skin.Local skin) { + try { + return requestJson(HttpRequest.newBuilder() + .uri(URI.create("https://api.minecraftservices.com/minecraft/profile/skins")) + .header("Authorization", "Bearer " + account.getAuthToken()) + .POST(MultipartBodyPublisher.newBuilder() + .textPart("variant", skin.classicVariant() ? "classic" : "slim") + .filePart("file", skin.file(), MediaType.IMAGE_PNG).build()).build()) + .thenApply(this::extractProfile); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + } + + public CompletableFuture resetSkin(Account account) { + return requestJson(HttpRequest.newBuilder().DELETE() + .uri(URI.create("https://api.minecraftservices.com/minecraft/profile/skins/active")) + .header("Authorization", "Bearer " + account.getAuthToken()) + .build()) + .thenApply(this::extractProfile); + } + + public CompletableFuture hideCape(Account account) { + return requestJson(HttpRequest.newBuilder().DELETE() + .uri(URI.create("https://api.minecraftservices.com/minecraft/profile/capes/active")) + .header("Authorization", "Bearer " + account.getAuthToken()) + .build()) + .thenApply(this::extractProfile); + } + + public CompletableFuture showCape(Account account, MCProfile.OnlineCape cape) { + return requestJson(HttpRequest.newBuilder() + .uri(URI.create("https://api.minecraftservices.com/minecraft/profile/capes/active")) + .header("Authorization", "Bearer " + account.getAuthToken()) + .PUT(HttpRequest.BodyPublishers.ofString(JsonBuilders.JsonObject.create().field("capeId", cape.id()).asString())).build()) + .thenApply(this::extractProfile); + } +} diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/MSAuth.java b/common/src/main/java/io/github/axolotlclient/modules/auth/MSAuth.java deleted file mode 100644 index 32d90b6bf..000000000 --- a/common/src/main/java/io/github/axolotlclient/modules/auth/MSAuth.java +++ /dev/null @@ -1,314 +0,0 @@ -/* - * Copyright © 2024 moehreag & Contributors - * - * This file is part of AxolotlClient. - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser 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 - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * - * For more information, see the LICENSE file. - */ - -package io.github.axolotlclient.modules.auth; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; - -import com.github.mizosoft.methanol.FormBodyPublisher; -import com.google.gson.JsonObject; -import io.github.axolotlclient.AxolotlClientCommon; -import io.github.axolotlclient.util.GsonHelper; -import io.github.axolotlclient.util.Logger; -import io.github.axolotlclient.util.NetworkUtil; - -// Partly oriented on In-Game-Account-Switcher by The-Fireplace, VidTu -public class MSAuth { - - private static final String CLIENT_ID = "938592fc-8e01-4c6d-b56d-428c7d9cf5ea"; // AxolotlClient MSA ClientID - private static final String SCOPES = "XboxLive.signin offline_access"; - private static final String XBL_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"; - private static final String MS_DEVICE_CODE_LOGIN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode?mkt="; - private static final String MS_TOKEN_LOGIN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; - private static final String XBL_XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"; - private static final String MC_LOGIN_WITH_XBOX_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"; - - private final Supplier languageSupplier; - private final Logger logger; - private final Accounts accounts; - private final HttpClient client; - - public static MSAuth INSTANCE; - - public MSAuth(Logger logger, Accounts accounts, Supplier languageSupplier) { - this.logger = logger; - this.accounts = accounts; - this.languageSupplier = languageSupplier; - this.client = getHttpClient(); - INSTANCE = this; - } - - public CompletableFuture startDeviceAuth() { - - String[] lang = languageSupplier.get().replace("_", "-").split("-"); - logger.debug("starting ms device auth flow"); - // https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code#device-authorization-response - HttpRequest.Builder builder = HttpRequest.newBuilder() - .POST(FormBodyPublisher.newBuilder() - .query("client_id", CLIENT_ID) - .query("scope", SCOPES).build()) - .header("ContentType", "application/x-www-form-urlencoded") - .uri(URI.create(MS_DEVICE_CODE_LOGIN_URL + lang[0] + "-" + lang[1].toUpperCase(Locale.ROOT))); - return requestJson(builder.build()) - .thenApply(object -> { - int expiresIn = object.get("expires_in").getAsInt(); - String deviceCode = object.get("device_code").getAsString(); - String userCode = object.get("user_code").getAsString(); - String verificationUri = object.get("verification_uri").getAsString(); - int interval = object.get("interval").getAsInt(); - String message = object.get("message").getAsString(); - logger.debug("displaying device code to user"); - DeviceFlowData data = new DeviceFlowData(message, verificationUri, deviceCode, userCode, expiresIn, interval); - accounts.displayDeviceCode(data); - return data; - }) - .thenApply(data -> { - logger.debug("waiting for user authorization..."); - long start = System.currentTimeMillis(); - while (System.currentTimeMillis() - start < data.getExpiresIn() * 1000L && !data.isCancelled()) { - if ((System.currentTimeMillis() - start) % data.getInterval() == 0) { - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().POST( - FormBodyPublisher.newBuilder().query("client_id", CLIENT_ID) - .query("device_code", data.getDeviceCode()) - .query("grant_type", "urn:ietf:params:oauth:grant-type:device_code") - .build() - ) - .uri(URI.create(MS_TOKEN_LOGIN_URL)); - JsonObject response = requestJson(requestBuilder.build()).join(); - - if (response.has("refresh_token") && response.has("access_token")) { - data.setStatus("auth.working"); - return authenticateFromMSTokens(response.get("access_token").getAsString(), - response.get("refresh_token").getAsString()) - .thenAccept(o -> { - o.ifPresent(a -> { - int index = accounts.getAccounts().indexOf(a); - if (index == -1) { - accounts.getAccounts().add(a); - } else { - accounts.getAccounts().set(index, a); - } - accounts.login(a); - accounts.save(); - data.setStatus("auth.finished"); - }); - }).join(); - } - - if (response.has("error")) { - String error = response.get("error").getAsString(); - switch (error) { - case "authorization_pending": - continue; - case "bad_verification_code": - throw new IllegalStateException("Bad verification code! " + response); - case "authorization_declined": - case "expired_token": - default: - break; - } - } - } - } - return null; - }); - } - - private CompletableFuture> authenticateFromMSTokens(String accessToken, String refreshToken) { - return CompletableFuture.supplyAsync(() -> { - logger.debug("getting xbl token... "); - XblData xbl = authXbl(accessToken).join(); - logger.debug("getting xsts token..."); - XblData xsts = authXstsMC(xbl.token()).join(); - logger.debug("getting mc auth token..."); - MCXblData mc = authMC(xsts.displayClaims().uhs(), xsts.token()).join(); - - JsonObject profileJson = getMCProfile(mc.accessToken()).join(); - if (profileJson.has("error") && "NOT_FOUND".equals(profileJson.get("error").getAsString())) { - AxolotlClientCommon.getInstance().getNotificationProvider().addStatus("auth.notif.login.failed", "auth.notif.login.failed.no_profile"); - return Optional.empty(); - } - logger.debug("retrieving entitlements..."); - if (!checkOwnership(mc.accessToken()).join()) { - AxolotlClientCommon.getInstance().getNotificationProvider().addStatus("auth.notif.login.failed", "auth.notif.login.failed.no_entitlement"); - logger.warn("Failed to check for game ownership!"); - return Optional.empty(); - } - logger.debug("getting profile..."); - MCProfile profile = MCProfile.get(profileJson); - return Optional.of(new Account(profile.name(), profile.id(), mc.accessToken(), mc.expiration(), refreshToken, accessToken)); - }); - } - - private record MCProfile(String id, String name, List skins, List capes) { - public static MCProfile get(JsonObject json) { - return new MCProfile(json.get("id").getAsString(), json.get("name").getAsString(), - GsonHelper.jsonArrayToStream(json.getAsJsonArray("skins")) - .map(s -> Skin.get(s.getAsJsonObject())) - .toList(), GsonHelper.jsonArrayToStream(json.getAsJsonArray("capes")) - .map(s -> Cape.get(s.getAsJsonObject())) - .toList()); - } - - public record Skin(String id, String state, String url, String variant, String textureKey) { - public static Skin get(JsonObject object) { - return new Skin(object.get("id").getAsString(), - object.get("state").getAsString(), - object.get("url").getAsString(), - object.get("variant").getAsString(), - object.get("textureKey").getAsString()); - } - } - - public record Cape(String id, String state, String url, String alias) { - public static Cape get(JsonObject object) { - return new Cape(object.get("id").getAsString(), object.get("state").getAsString(), object.get("url").getAsString(), object.get("alias").getAsString()); - } - } - - } - - private CompletableFuture authXbl(String code) { - JsonObject object = new JsonObject(); - JsonObject properties = new JsonObject(); - properties.addProperty("AuthMethod", "RPS"); - properties.addProperty("SiteName", "user.auth.xboxlive.com"); - properties.addProperty("RpsTicket", "d=" + code); - object.add("Properties", properties); - object.addProperty("RelyingParty", "http://auth.xboxlive.com"); - object.addProperty("TokenType", "JWT"); - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(XBL_AUTH_URL)) - .POST(HttpRequest.BodyPublishers.ofString(object.toString())) - .header("Content-Type", "application/json") - .header("Accept", "application/json"); - - return requestJson(requestBuilder.build()).thenApply(response -> new XblData(Instant.parse(response.get("IssueInstant").getAsString()), Instant.parse(response.get("NotAfter").getAsString()), - response.get("Token").getAsString(), new XblData.DisplayClaims(response.get("DisplayClaims").getAsJsonObject().get("xui").getAsJsonArray().get(0).getAsJsonObject().get("uhs").getAsString()))); - } - - private record XblData(Instant issueInstant, Instant notAfter, String token, DisplayClaims displayClaims) { - private record DisplayClaims(String uhs) { - } - } - - private CompletableFuture authXstsMC(String xblToken) { - String body = "{" + - " \"Properties\": {" + - " \"SandboxId\": \"RETAIL\"," + - " \"UserTokens\": [" + - " \"" + xblToken + "\"" + - " ]" + - " }," + - " \"RelyingParty\": \"rp://api.minecraftservices.com/\"," + - " \"TokenType\": \"JWT\"" + - " }"; - return requestJson(HttpRequest.newBuilder().POST(HttpRequest.BodyPublishers.ofString(body)).uri(URI.create(XBL_XSTS_AUTH_URL)).build()) - .thenApply(response -> new XblData(Instant.parse(response.get("IssueInstant").getAsString()), Instant.parse(response.get("NotAfter").getAsString()), - response.get("Token").getAsString(), new XblData.DisplayClaims(response.get("DisplayClaims").getAsJsonObject().get("xui").getAsJsonArray().get(0).getAsJsonObject().get("uhs").getAsString()))); - } - - private CompletableFuture authMC(String userhash, String xsts) { - String body = "{\"identityToken\": \"XBL3.0 x=" + userhash + ";" + xsts + "\"\n}"; - return requestJson(HttpRequest.newBuilder(URI.create(MC_LOGIN_WITH_XBOX_URL)).POST(HttpRequest.BodyPublishers.ofString(body)).build()) - .thenApply(response -> new MCXblData(response.get("username").getAsString(), - response.get("access_token").getAsString(), - Instant.now().plus(response.get("expires_in").getAsLong(), ChronoUnit.SECONDS))); - } - - private record MCXblData(String username, String accessToken, Instant expiration) { - } - - private CompletableFuture checkOwnership(String accessToken) { - return requestJson(HttpRequest - .newBuilder(URI.create("https://api.minecraftservices.com/entitlements/mcstore")) - .header("Authorization", "Bearer " + accessToken).build()) - .thenApply(res -> GsonHelper.jsonArrayToStream(res.get("items").getAsJsonArray()) - .anyMatch(e -> e.isJsonObject() && e.getAsJsonObject().has("name") - && "game_minecraft".equals(e.getAsJsonObject().get("name").getAsString()))); - } - - private CompletableFuture getMCProfile(String accessToken) { - return requestJson(HttpRequest.newBuilder().GET() - .uri(URI.create("https://api.minecraftservices.com/minecraft/profile")) - .header("Authorization", "Bearer " + accessToken).build()); - } - - private HttpClient getHttpClient() { - return NetworkUtil.createHttpClient(); - } - - public CompletableFuture> refreshToken(String token, Account account) { - return CompletableFuture.supplyAsync(() -> { - logger.debug("refreshing auth code... "); - HttpRequest.Builder requestBuilder = HttpRequest - .newBuilder(URI.create(MS_TOKEN_LOGIN_URL)) - .POST(FormBodyPublisher.newBuilder() - .query("client_id", CLIENT_ID) - .query("refresh_token", token) - .query("scope", SCOPES) - .query("grant_type", "refresh_token").build()) - .header("Accept", "application/json"); - - JsonObject response = requestJson(requestBuilder.build()).join(); - - if (response.has("error_codes")) { - int errorCode = response.get("error_codes").getAsJsonArray().get(0).getAsInt(); - if (errorCode == 70000 || errorCode == 70012) { - accounts.showAccountsExpiredScreen(account); - } else { - logger.warn("Login error, unexpected response: " + response); - AxolotlClientCommon.getInstance().getNotificationProvider().addStatus("auth.notif.refresh.error", "auth.notif.refresh.error.unexpected_response"); - } - return Optional.empty(); - } - - logger.debug("authenticating..."); - Optional opt = authenticateFromMSTokens(response.get("access_token").getAsString(), - response.get("refresh_token").getAsString()).join(); - opt.ifPresent(refreshed -> { - account.setRefreshToken(refreshed.getRefreshToken()); - account.setAuthToken(refreshed.getAuthToken()); - account.setName(refreshed.getName()); - account.setMsaToken(refreshed.getMsaToken()); - account.setExpiration(refreshed.getExpiration()); - accounts.save(); - }); - return opt; - }); - } - - private CompletableFuture requestJson(HttpRequest request) { - return client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply(res -> GsonHelper.fromJson(res.body())); - } -} diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Asset.java b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Asset.java new file mode 100644 index 000000000..d51be8f04 --- /dev/null +++ b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Asset.java @@ -0,0 +1,60 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.MSApi; + +public interface Asset { + + default boolean isOnline() { + return false; + } + + default boolean isLocal() { + return false; + } + + default Path file() { + return null; + } + + default String url() { + return null; + } + + default boolean supportsDownload() { + return false; + } + + CompletableFuture image(); + + boolean active(); + + CompletableFuture equip(MSApi api, Account account); + + String textureKey(); +} diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Cape.java b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Cape.java new file mode 100644 index 000000000..11ba1a670 --- /dev/null +++ b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Cape.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +public interface Cape extends Asset { + String alias(); +} diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Skin.java b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Skin.java new file mode 100644 index 000000000..a57237bc6 --- /dev/null +++ b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/Skin.java @@ -0,0 +1,196 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import io.github.axolotlclient.modules.auth.Account; +import io.github.axolotlclient.modules.auth.MSApi; +import io.github.axolotlclient.util.GsonHelper; +import org.jetbrains.annotations.NotNull; + +public interface Skin extends Asset { + boolean classicVariant(); + + void classicVariant(boolean classic); + + final class Local implements Skin { + public static final String META_DIR = ".meta"; + public static final String METADATA_SUFFIX = ".meta"; + public static final String CLASSIC_METADATA_KEY = "variant_classic"; + private boolean classic; + private final Path file; + private final String textureKey; + + public Local(boolean classic, Path file, String textureKey) { + this.classic = classic; + this.file = file; + this.textureKey = textureKey; + } + + @SuppressWarnings("unchecked") + public static Map readMetadata(Path skinFile) throws IOException { + var metadataFile = getMetadataFile(skinFile); + if (!Files.exists(metadataFile)) return null; + + try (var in = Files.newInputStream(metadataFile)) { + return (Map) GsonHelper.read(in); + } + } + + public static void writeMetadata(Path skinFile, Map metadata) throws IOException { + try (var out = Files.newOutputStream(getMetadataFile(skinFile)); + var writer = new OutputStreamWriter(out)) { + GsonHelper.GSON.toJson(metadata, writer); + } + } + + public static void deleteMetadata(Path skinFile) throws IOException { + Files.deleteIfExists(getMetadataFile(skinFile)); + } + + private static @NotNull Path getMetadataFile(Path skinFile) { + return skinFile.resolveSibling(META_DIR).resolve(skinFile.getFileName().toString() + METADATA_SUFFIX); + } + + @Override + public boolean classicVariant() { + return classic; + } + + @Override + public void classicVariant(boolean classic) { + if (classic != this.classic) { + try { + var metadata = readMetadata(file()); + if (metadata == null) metadata = new HashMap<>(); + metadata.put(CLASSIC_METADATA_KEY, classic); + writeMetadata(file(), metadata); + } catch (IOException ignored) { + } + } + this.classic = classic; + } + + @Override + public CompletableFuture image() { + return CompletableFuture.supplyAsync(() -> { + try { + return Files.readAllBytes(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + @Override + public boolean active() { + return false; + } + + @Override + public CompletableFuture equip(MSApi api, Account account) { + return api.uploadAndSetSkin(account, this); + } + + @Override + public boolean isLocal() { + return true; + } + + @Override + public Path file() { + return file; + } + + @Override + public String textureKey() { + return textureKey; + } + } + + record Shared(Local local, MSApi.MCProfile.OnlineSkin online) implements Skin { + + @Override + public boolean classicVariant() { + return local.classicVariant(); + } + + @Override + public void classicVariant(boolean classic) { + local.classicVariant(classic); + } + + @Override + public CompletableFuture image() { + return local.image(); + } + + @Override + public boolean active() { + return online.active(); + } + + @Override + public CompletableFuture equip(MSApi api, Account account) { + return online.equip(api, account); + } + + @Override + public String textureKey() { + return local.textureKey(); + } + + @Override + public boolean isOnline() { + return true; + } + + @Override + public boolean isLocal() { + return true; + } + + @Override + public Path file() { + return local.file(); + } + + @Override + public String url() { + return online.url(); + } + + @Override + public boolean supportsDownload() { + return true; + } + } +} diff --git a/common/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinImportUtil.java b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinImportUtil.java new file mode 100644 index 000000000..a2a8fc8e4 --- /dev/null +++ b/common/src/main/java/io/github/axolotlclient/modules/auth/skin/SkinImportUtil.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.modules.auth.skin; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import net.fabricmc.loader.api.FabricLoader; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.util.tinyfd.TinyFileDialogs; + +public class SkinImportUtil { + public static CompletableFuture> openImportSkinDialog() { + return CompletableFuture.supplyAsync(() -> { + try (MemoryStack stack = MemoryStack.stackPush()) { + var pointers = stack.pointers(stack.UTF8("*.png")); + @SuppressWarnings("DataFlowIssue") var result = TinyFileDialogs.tinyfd_openFileDialog("Import Skins", + FabricLoader.getInstance().getGameDir().toString(), pointers, null, true); + if (result != null) { + return Arrays.stream(result.split("\\|")) + .map(Path::of).toList(); + } + return List.of(); + } + }); + } +} diff --git a/common/src/main/java/io/github/axolotlclient/modules/hypixel/AutoGG.java b/common/src/main/java/io/github/axolotlclient/modules/hypixel/AutoGG.java index b6bb7200d..aff67d7ac 100644 --- a/common/src/main/java/io/github/axolotlclient/modules/hypixel/AutoGG.java +++ b/common/src/main/java/io/github/axolotlclient/modules/hypixel/AutoGG.java @@ -159,7 +159,7 @@ private List addToList(String... strings) { private void onMessage(ReceiveChatMessageEvent event) { String message = event.getOriginalMessage(); - if (client.br$isLocalServer()) { + if (!client.br$isLocalServer()) { serverMap.keySet().forEach(s -> { if (serverMap.get(s).get() && client.br$getServerAddress().contains(s)) { if (gf.get()) { diff --git a/common/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotCopying.java b/common/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotCopying.java index 3ba5f40fc..bd7f1eb47 100644 --- a/common/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotCopying.java +++ b/common/src/main/java/io/github/axolotlclient/modules/screenshotUtils/ScreenshotCopying.java @@ -29,41 +29,57 @@ import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import io.github.axolotlclient.AxolotlClientCommon; -import lombok.AllArgsConstructor; import lombok.experimental.UtilityClass; import org.jetbrains.annotations.NotNull; import org.lwjgl.glfw.GLFW; +import org.lwjgl.sdl.SDLClipboard; +import org.lwjgl.sdl.SDL_ClipboardCleanupCallback; +import org.lwjgl.sdl.SDL_ClipboardDataCallback; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; @UtilityClass public class ScreenshotCopying { static { - boolean wayland = false; + boolean sdl = false; try { - wayland = GLFW.glfwGetPlatform() == GLFW.GLFW_PLATFORM_WAYLAND; - } catch (Throwable ignored) { + Class.forName("org.lwjgl.sdl.SDL"); + sdl = true; + } catch (Throwable ignored) {} + SDL_AVAILABLE = sdl; + boolean wayland = false; + if (!sdl) { + try { + wayland = GLFW.glfwGetPlatform() == GLFW.GLFW_PLATFORM_WAYLAND; + } catch (Throwable ignored) { + } } IS_WAYLAND = wayland; } + private static final boolean SDL_AVAILABLE; private static final boolean IS_WAYLAND; public void copy(Path file) { - if (IS_WAYLAND) { - copyWayland(file); - } else { - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new FileTransferable(file.toFile()), null); + if (!SDL_AVAILABLE || !copySdl(file)) { + if (IS_WAYLAND) { + copyWayland(file); + } else { + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new FileTransferable(file.toFile()), null); + } } } private void copyWayland(Path f) { try { - ProcessBuilder builder = new ProcessBuilder("bash", "-c", "wl-copy -t image/png < '" + f.toAbsolutePath()+"'"); + ProcessBuilder builder = new ProcessBuilder("bash", "-c", "wl-copy -t image/png < '" + f.toAbsolutePath() + "'"); Process p = builder.start(); p.waitFor(); } catch (IOException | InterruptedException ignored) { @@ -72,24 +88,50 @@ private void copyWayland(Path f) { } public void copy(byte[] image) { - if (IS_WAYLAND) { - try { - Path i = Files.createTempFile("axolotlclient_screenshot", ".png"); - Files.write(i, image); - copyWayland(i); - Files.delete(i); - } catch (IOException e) { - AxolotlClientCommon.getInstance().getLogger().error("Failed to copy image using temporary file!"); + if (!SDL_AVAILABLE || !SDLFence.copySdl(image)) { + if (IS_WAYLAND) { + try { + Path i = Files.createTempFile("axolotlclient_screenshot", ".png"); + Files.write(i, image); + copyWayland(i); + Files.delete(i); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().error("Failed to copy image using temporary file!"); + } + } else { + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageTransferable(image), null); } - } else { - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageTransferable(image), null); } } - @AllArgsConstructor - protected static class ImageTransferable implements Transferable { - private final byte[] image; + private boolean copySdl(Path p) { + try { + return SDLFence.copySdl(Files.readAllBytes(p)); + } catch (IOException e) { + AxolotlClientCommon.getInstance().getLogger().error("Failed to read screenshot!", e); + } + return false; + } + + // Classloading fence... + static class SDLFence { + private static boolean copySdl(byte[] data) { + try (MemoryStack stack = MemoryStack.stackPush()) { + var mimetypesBuf = stack.UTF8("image/png"); + var mimetypes = stack.pointers(mimetypesBuf); + var dataBuf = ByteBuffer.allocateDirect(data.length).put(data).flip(); + var pointer = MemoryUtil.memAddress(dataBuf); + return SDLClipboard.SDL_SetClipboardData(SDL_ClipboardDataCallback.create((userdata, mime_type, size) -> { + var sizeBuf = MemoryUtil.memLongBuffer(size, 8); + sizeBuf.put(0, data.length); + return userdata; + }), + SDL_ClipboardCleanupCallback.create(userdata -> MemoryUtil.memFree(dataBuf)), pointer, mimetypes); + } + } + } + protected record ImageTransferable(byte[] image) implements Transferable { @Override public DataFlavor[] getTransferDataFlavors() { return new DataFlavor[]{DataFlavor.imageFlavor}; @@ -107,10 +149,7 @@ public Object getTransferData(DataFlavor flavor) throws IOException { } } - @AllArgsConstructor - protected static class FileTransferable implements Transferable { - private final File file; - + protected record FileTransferable(File file) implements Transferable { @Override public DataFlavor[] getTransferDataFlavors() { return new DataFlavor[]{DataFlavor.javaFileListFlavor}; @@ -122,7 +161,7 @@ public boolean isDataFlavorSupported(DataFlavor flavor) { } @Override - public Object getTransferData(DataFlavor flavor) { + public @NotNull Object getTransferData(DataFlavor flavor) { final ArrayList files = new ArrayList<>(); files.add(file); return files; diff --git a/common/src/main/java/io/github/axolotlclient/util/JsonBuilders.java b/common/src/main/java/io/github/axolotlclient/util/JsonBuilders.java new file mode 100644 index 000000000..157dc3cc3 --- /dev/null +++ b/common/src/main/java/io/github/axolotlclient/util/JsonBuilders.java @@ -0,0 +1,132 @@ +/* + * Copyright © 2025 moehreag & Contributors + * + * This file is part of AxolotlClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.util; + +import com.google.gson.JsonElement; + +public final class JsonBuilders { + public static final class JsonArray { + private final com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); + + public static JsonArray create() { + return new JsonArray(); + } + + private JsonArray() { + } + + public JsonArray field(JsonElement ele) { + arr.add(ele); + return this; + } + + public JsonArray field(JsonArray builder) { + return field(builder.build()); + } + + public JsonArray field(JsonObject builder) { + return field(builder.build()); + } + + public JsonArray field(Number prop) { + arr.add(prop); + return this; + } + + public JsonArray field(String prop) { + arr.add(prop); + return this; + } + + public JsonArray field(Boolean prop) { + arr.add(prop); + return this; + } + + public JsonArray field(Character prop) { + arr.add(prop); + return this; + } + + public com.google.gson.JsonArray build() { + return arr; + } + + public String asString() { + return arr.toString(); + } + } + + public static final class JsonObject { + + private final com.google.gson.JsonObject obj = new com.google.gson.JsonObject(); + + public static JsonObject create() { + return new JsonObject(); + } + + private JsonObject() { + } + + public JsonObject field(String name, JsonElement ele) { + obj.add(name, ele); + return this; + } + + public JsonObject field(String name, JsonObject builder) { + return field(name, builder.build()); + } + + public JsonObject field(String name, JsonArray builder) { + return field(name, builder.build()); + } + + public JsonObject field(String name, Number prop) { + obj.addProperty(name, prop); + return this; + } + + public JsonObject field(String name, String prop) { + obj.addProperty(name, prop); + return this; + } + + public JsonObject field(String name, Boolean prop) { + obj.addProperty(name, prop); + return this; + } + + public JsonObject field(String name, Character prop) { + obj.addProperty(name, prop); + return this; + } + + public com.google.gson.JsonObject build() { + return obj; + } + + public String asString() { + return obj.toString(); + } + } +} diff --git a/common/src/main/java/io/github/axolotlclient/util/Watcher.java b/common/src/main/java/io/github/axolotlclient/util/Watcher.java index e01b81bbd..fd450696e 100644 --- a/common/src/main/java/io/github/axolotlclient/util/Watcher.java +++ b/common/src/main/java/io/github/axolotlclient/util/Watcher.java @@ -27,6 +27,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import io.github.axolotlclient.AxolotlClientCommon; import org.jetbrains.annotations.Nullable; @@ -36,21 +37,15 @@ public class Watcher implements AutoCloseable { private static final ScheduledExecutorService thread = Executors.newSingleThreadScheduledExecutor(); private final WatchService watcher; private final Path path; + private final Predicate fileFilter; - public Watcher(Path root) throws IOException { + public Watcher(Path root, Predicate fileFilter) throws IOException { this.path = root; + this.fileFilter = fileFilter; this.watcher = path.getFileSystem().newWatchService(); try { this.watchDir(path); - try (DirectoryStream directoryStream = Files.newDirectoryStream(path)) { - - for (Path path : directoryStream) { - if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { - this.watchDir(path); - } - } - } } catch (Exception e) { this.watcher.close(); throw e; @@ -58,10 +53,10 @@ public Watcher(Path root) throws IOException { } @Nullable - public static Watcher create(Path path) { + public static Watcher create(Path path, Predicate fileFilter) { try { Files.createDirectories(path); - return new Watcher(path); + return new Watcher(path, fileFilter); } catch (IOException var2) { AxolotlClientCommon.getInstance().getLogger().warn("Failed to initialize directory {} monitoring", path, var2); return null; @@ -69,7 +64,11 @@ public static Watcher create(Path path) { } public static Watcher createSelfTicking(Path path, Runnable onUpdate) { - var watcher = create(path); + return createSelfTicking(path, s -> true, onUpdate); + } + + public static Watcher createSelfTicking(Path path, Predicate fileFilter, Runnable onUpdate) { + var watcher = create(path, fileFilter); if (watcher != null) { thread.scheduleAtFixedRate(() -> { try { @@ -97,12 +96,8 @@ public boolean pollForChanges() throws IOException { WatchKey watchKey; while ((watchKey = this.watcher.poll()) != null) { for (WatchEvent watchEvent : watchKey.pollEvents()) { - bl = true; - if (watchKey.watchable() == this.path && watchEvent.kind() == StandardWatchEventKinds.ENTRY_CREATE) { - Path path = this.path.resolve((Path) watchEvent.context()); - if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { - this.watchDir(path); - } + if (watchKey.watchable() == this.path && watchEvent.context() != null) { + bl |= this.fileFilter.test(((Path)watchEvent.context()).toString()); } } diff --git a/common/src/main/resources/assets/axolotlclient/lang/en_us.json b/common/src/main/resources/assets/axolotlclient/lang/en_us.json index 76e1914ea..99cd105aa 100644 --- a/common/src/main/resources/assets/axolotlclient/lang/en_us.json +++ b/common/src/main/resources/assets/axolotlclient/lang/en_us.json @@ -811,5 +811,32 @@ "bedwars.stats_overlay.auto_activate.tooltip": "Automatically activate on game start. Requires the overlay to be in toggle mode.", "inventoryhud.item_background": "Item Background", "inventoryhud.item_background_color": "Item Background Color", - "inventoryhud.always_show_item_backgrounds": "Always Show Item Backgrounds" + "inventoryhud.always_show_item_backgrounds": "Always Show Item Backgrounds", + "skins.manage": "Manage Skins", + "skins.nav.skins": "Skins", + "skins.nav.capes": "Capes", + "skins.error.failed_to_load": "Failed to load skins", + "skins.error.failed_to_load_desc": "Your log file may include more information.", + "skins.capes.no_cape": "No Cape", + "skins.manage.equipped": "Equipped", + "skins.manage.equip": "Equip", + "skins.loading": "Loading Skins...", + "skins.manage.equipping": "Equipping...", + "skins.manage.delete": "Delete Skin", + "skins.manage.delete.confirm": "Confirm Deletion", + "skins.manage.delete.confirm.desc": "This Skin's file will be deleted permanently!\n Are you sure?", + "skins.manage.delete.confirm.desc_active": "This Skin's file will be deleted permanently!\n Are you sure? The skin will not be un-equipped.", + "skins.manage.animations": "Skin Manger Animations", + "skins.manage.download": "Download Skin", + "skins.manage.import.local": "Import Skins", + "skins.manage.import.online": "Download Skin", + "skins.notification.title": "Skin Management", + "skins.notification.not_copied": "Skipped file %s because it does not seem like a valid skin file!", + "skins.manage.variant.classic": "Use Classic (Wide) Variant", + "skins.manage.variant.slim": "Use Slim Variant", + "skins.manage.import.online.input": "Enter Name or UUID:", + "skins.notification.import.online.failed_to_download": "Failed to download Skin of %s!", + "skins.notification.import.online.downloaded": "Downloaded Skin of %s!", + "skins.notification.import.online.failed_to_save": "Failed to save Skin of %s!", + "skins.notification.import.online.not_found": "Could not find user %s!" } diff --git a/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/delete.png b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/delete.png new file mode 100644 index 000000000..cd0b75860 Binary files /dev/null and b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/delete.png differ diff --git a/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/download.png b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/download.png new file mode 100644 index 000000000..f425b2471 Binary files /dev/null and b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/download.png differ diff --git a/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/folder.png b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/folder.png new file mode 100644 index 000000000..08fc56bbc Binary files /dev/null and b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/folder.png differ diff --git a/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/slim.png b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/slim.png new file mode 100644 index 000000000..ee507a885 Binary files /dev/null and b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/slim.png differ diff --git a/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/wide.png b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/wide.png new file mode 100644 index 000000000..63ed18ae2 Binary files /dev/null and b/common/src/main/resources/assets/axolotlclient/textures/gui/sprites/wide.png differ diff --git a/gradle.properties b/gradle.properties index ab4f34cc5..35215e96c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,6 +30,6 @@ fapi_121=0.116.6 fabric_cts8=0.42.0+1.16 osl=0.16.3 -legacy_lwgjl3=1.2.5+1.8.9 +legacy_lwgjl3=1.2.9+1.8.9 -config=3.0.17 +config=3.0.19