diff --git a/README.md b/README.md index eccbfa4..5f0621c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ works. | `0018-Update-from-Modrinth-Enhanced-s-...` | Updates come from this project's own signed releases rather than Modrinth's. | | `0019-Add-accounts-from-other-...` | Sign in to Drasl, Blessing Skin and other account servers, as with Ely.by. | | `0020-Keep-the-settings-tabs-clear-of-...` | The settings tab list scrolls instead of running over the app version on Linux. | +| `0021-Show-and-change-skins-of-custom-...` | A custom server account's skin is shown and changed on the skin page, and its head in the account list. | ### Offline accounts @@ -87,7 +88,10 @@ players asks which one to play as. On Drasl, a player who signed up through another service uses the Minecraft token from their account page as the password; the dialog says so. -The account list shows which server an account is on. +The account list shows which server an account is on, with the head of the skin worn there. The +skin page shows that skin, and picking or adding one uploads it to the server through +authlib-injector's texture API, which Drasl, Blessing Skin and LittleSkin all have. Capes are changed +on the server's website: it only knows the one uploaded there, and taking it off would delete it. ### Sidebar and news diff --git a/patches/0021-Show-and-change-skins-of-custom-server-accounts.patch b/patches/0021-Show-and-change-skins-of-custom-server-accounts.patch new file mode 100644 index 0000000..fdbbee3 --- /dev/null +++ b/patches/0021-Show-and-change-skins-of-custom-server-accounts.patch @@ -0,0 +1,572 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Thu, 17 Sep 2026 15:09:57 +0200 +Subject: [PATCH] Show and change skins of custom server accounts + +A custom server account got the skin page of an offline one: Steve, and +nothing it could change. Its profile now comes from the server's session +server, as the game gets it, so the page shows the skin and cape it +wears there, and the account list and title bar show its head instead of +asking mc-heads.net, which only knows Mojang's players. + +Picking or adding a skin uploads it through authlib-injector's texture +API, which Drasl, Blessing Skin and LittleSkin all have, and resetting +it removes it there. The skin library works as for a Microsoft account. + +Capes are left alone. Such a server only knows the one cape uploaded on +its website, and taking it off would delete it, so the cape picker gives +way to a note. The textures are handed to the page as data URLs, since +these servers send no CORS headers for them. + +Ely.by accounts get their heads the same way; their skin page stays the +Ely.by one. +--- + .../src/components/ui/AccountsCard.vue | 31 ++- + .../src/components/ui/skin/EditSkinModal.vue | 15 +- + apps/app-frontend/src/pages/Skins.vue | 1 + + packages/app-lib/src/api/minecraft_skins.rs | 4 +- + packages/app-lib/src/state/minecraft_auth.rs | 242 +++++++++++++++++- + .../src/state/minecraft_skins/mojang_api.rs | 64 +++-- + 6 files changed, 326 insertions(+), 31 deletions(-) + +diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue +index 03a61f2..364aef0 100644 +--- a/apps/app-frontend/src/components/ui/AccountsCard.vue ++++ b/apps/app-frontend/src/components/ui/AccountsCard.vue +@@ -185,11 +185,14 @@ type MinecraftCredential = { + profile: { + id: string + name: string ++ skins?: ProfileSkin[] + } + /** The Yggdrasil server a non-Microsoft account is on, such as Ely.by. */ + auth_server?: string | null + } + ++type ProfileSkin = { state: string; url: string; textureKey?: string } ++ + const accounts: Ref = ref([]) + const offlineAccountModal = ref>() + const elyAccountModal = ref>() +@@ -200,6 +203,8 @@ const defaultUser = ref() + const equippedSkin = ref(null) + const equippedHeadUrl = ref() + let headRequest = 0 ++// Heads of accounts on other servers, which mc-heads.net does not know. ++const serverHeads = ref>({}) + + async function updateHeadUrl(skin: Skin | null) { + const request = ++headRequest +@@ -214,13 +219,30 @@ async function updateHeadUrl(skin: Skin | null) { + onUnmounted(() => { + headRequest++ + if (equippedHeadUrl.value) URL.revokeObjectURL(equippedHeadUrl.value) ++ Object.values(serverHeads.value).forEach((url) => URL.revokeObjectURL(url)) + }) + ++async function updateServerHeads() { ++ const heads: Record = {} ++ for (const account of accounts.value) { ++ const skin = account.profile.skins?.find((s) => s.state === 'ACTIVE') ++ if (!account.auth_server || !skin?.textureKey) continue ++ heads[account.profile.id] = await getPlayerHeadUrl({ ++ texture_key: skin.textureKey, ++ texture: skin.url, ++ } as Skin).catch(() => '') ++ } ++ const previous = serverHeads.value ++ serverHeads.value = heads ++ Object.values(previous).forEach((url) => URL.revokeObjectURL(url)) ++} ++ + async function refreshValues() { + defaultUser.value = await get_default_user().catch(handleError) + const userList = await users().catch(handleError) + accounts.value = Array.isArray(userList) ? [...userList] : [] + accounts.value.sort((a, b) => (a.profile?.name ?? '').localeCompare(b.profile?.name ?? '')) ++ void updateServerHeads() + + try { + const skins = await get_available_skins() +@@ -272,7 +294,10 @@ const avatarUrl = computed(() => { + return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128` + } + if (selectedAccount.value?.profile?.id) { +- return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128` ++ return ( ++ serverHeads.value[selectedAccount.value.profile.id] || ++ `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128` ++ ) + } + return 'https://launcher-files.modrinth.com/assets/steve_head.png' + }) +@@ -308,7 +333,9 @@ function getAccountAvatarUrl(account: MinecraftCredential) { + return cachedUrl + } + } +- return `https://mc-heads.net/avatar/${account.profile.id}/128` ++ return ( ++ serverHeads.value[account.profile.id] || `https://mc-heads.net/avatar/${account.profile.id}/128` ++ ) + } + + async function setAccount(account: MinecraftCredential) { +diff --git a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue +index ee32e2e..7d9b78d 100644 +--- a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue ++++ b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue +@@ -47,7 +47,11 @@ + + + +-
++
++

{{ formatMessage(messages.capeSection) }}

++

{{ formatMessage(messages.capesOnServer) }}

++
++
+

{{ formatMessage(messages.capeSection) }}

+
+ ('') + + const variant = ref('CLASSIC') + const selectedCape = ref(undefined) +-const props = defineProps<{ capes?: Cape[]; demo?: boolean }>() ++// An account on another server keeps the cape uploaded there. ++const props = defineProps<{ capes?: Cape[]; demo?: boolean; capesLocked?: boolean }>() + + const selectedCapeTexture = computed(() => selectedCape.value?.texture) + const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default') +@@ -379,7 +388,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) { + currentSkin.value = null + uploadedTextureUrl.value = skinTextureUrl + variant.value = await determineModelType(skinTextureUrl.original) +- selectedCape.value = undefined ++ selectedCape.value = props.capesLocked ? props.capes?.find((c) => c.is_equipped) : undefined + + await loadPreviewSkin() + +diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue +index 9a10f0e..3b58ea9 100644 +--- a/apps/app-frontend/src/pages/Skins.vue ++++ b/apps/app-frontend/src/pages/Skins.vue +@@ -1448,6 +1448,7 @@ await loadAccountSkins() + bool { + }) + } + +-fn get_fallback_default_skin() -> crate::Result<&'static Skin> { ++pub(crate) fn get_fallback_default_skin() -> crate::Result<&'static Skin> { + assets::DEFAULT_SKINS + .iter() + .find(|skin| { +diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs +index 0245152..b916a6d 100644 +--- a/packages/app-lib/src/state/minecraft_auth.rs ++++ b/packages/app-lib/src/state/minecraft_auth.rs +@@ -290,6 +290,10 @@ const AUTHLIB_REFRESH_TOKEN_PREFIX: &str = "modrinth-enhanced:authlib:"; + /// Ely.by's Yggdrasil endpoints. + const ELY_AUTHSERVER: &str = "https://authserver.ely.by/auth"; + ++/// Where Ely.by answers the game's questions about players. ++const ELY_SESSIONSERVER: &str = ++ "https://authserver.ely.by/api/authlib-injector/sessionserver"; ++ + /// An account server that speaks Yggdrasil, the protocol Mojang's own used to, + /// and that authlib-injector can point the game at. + #[derive(Debug, Clone)] +@@ -334,6 +338,195 @@ impl AuthServer { + INSECURE_REQWEST_CLIENT.post(url).json(&body).send().await + } + ++ /// The player's profile as this server hands it to the game, with the skin ++ /// and cape it has. ++ /// ++ /// The textures come along as data URLs: the skin page draws capes straight ++ /// from their URL, and these servers neither send CORS headers nor always ++ /// use https. ++ async fn session_profile( ++ &self, ++ id: Uuid, ++ ) -> Result { ++ #[derive(Deserialize)] ++ struct SessionProfile { ++ name: String, ++ #[serde(default)] ++ properties: Vec, ++ } ++ ++ #[derive(Deserialize)] ++ struct Property { ++ name: String, ++ value: String, ++ } ++ ++ #[derive(Deserialize, Default)] ++ struct TexturesProperty { ++ #[serde(default)] ++ textures: Textures, ++ } ++ ++ #[derive(Deserialize, Default)] ++ struct Textures { ++ #[serde(rename = "SKIN")] ++ skin: Option, ++ #[serde(rename = "CAPE")] ++ cape: Option, ++ } ++ ++ #[derive(Deserialize)] ++ struct Texture { ++ url: Url, ++ #[serde(default)] ++ metadata: Option, ++ } ++ ++ #[derive(Deserialize)] ++ struct TextureMetadata { ++ model: Option, ++ } ++ ++ let step = MinecraftAuthStep::MinecraftProfile; ++ let sessionserver = match self { ++ Self::Ely => ELY_SESSIONSERVER.to_owned(), ++ Self::Authlib(root) => format!("{root}/sessionserver"), ++ }; ++ ++ let response = INSECURE_REQWEST_CLIENT ++ .get(format!( ++ "{sessionserver}/session/minecraft/profile/{}", ++ id.simple() ++ )) ++ .query(&[("unsigned", "true")]) ++ .timeout(std::time::Duration::from_secs(10)) ++ .send() ++ .await ++ .map_err(|source| MinecraftAuthenticationError::Request { ++ source, ++ step, ++ })?; ++ ++ let status = response.status(); ++ let text = response.text().await.map_err(|source| { ++ MinecraftAuthenticationError::Request { source, step } ++ })?; ++ let profile = serde_json::from_str::(&text).map_err( ++ |source| MinecraftAuthenticationError::DeserializeResponse { ++ source, ++ raw: text, ++ step, ++ status_code: status, ++ }, ++ )?; ++ ++ let textures = profile ++ .properties ++ .iter() ++ .find(|property| property.name == "textures") ++ .and_then(|property| BASE64_STANDARD.decode(&property.value).ok()) ++ .and_then(|json| { ++ serde_json::from_slice::(&json).ok() ++ }) ++ .unwrap_or_default() ++ .textures; ++ ++ let skin = match textures.skin { ++ Some(texture) => Some(MinecraftSkin { ++ id: texture_id(&texture.url), ++ state: MinecraftCharacterExpressionState::Active, ++ texture_key: texture ++ .url ++ .path_segments() ++ .and_then(|mut segments| segments.next_back()) ++ .map(|name| Arc::from(name.trim_end_matches(".png"))), ++ variant: match texture ++ .metadata ++ .and_then(|metadata| metadata.model) ++ { ++ Some(model) if model == "slim" => { ++ MinecraftSkinVariant::Slim ++ } ++ _ => MinecraftSkinVariant::Classic, ++ }, ++ url: texture_data_url(texture.url).await, ++ name: None, ++ }), ++ // A player without a skin of their own wears a default one. ++ None => crate::api::minecraft_skins::get_fallback_default_skin() ++ .ok() ++ .map(|default| MinecraftSkin { ++ id: Uuid::nil(), ++ state: MinecraftCharacterExpressionState::Active, ++ url: Arc::clone(&default.texture), ++ texture_key: Some(Arc::clone(&default.texture_key)), ++ variant: default.variant, ++ name: default.name.as_deref().map(str::to_owned), ++ }), ++ }; ++ ++ let capes = match textures.cape { ++ Some(texture) => vec![MinecraftCape { ++ id: texture_id(&texture.url), ++ state: MinecraftCharacterExpressionState::Active, ++ url: texture_data_url(texture.url).await, ++ name: Arc::from("Cape"), ++ }], ++ None => Vec::new(), ++ }; ++ ++ Ok(MinecraftProfile { ++ id, ++ name: profile.name, ++ skins: skin.into_iter().collect(), ++ capes, ++ fetch_time: Some(Instant::now()), ++ }) ++ } ++ ++ /// Uploads a skin or cape to this server, or with no form removes it, per ++ /// authlib-injector's texture API. Ely.by has no such API. ++ pub(crate) async fn change_texture( ++ &self, ++ credentials: &Credentials, ++ kind: &str, ++ form: Option, ++ ) -> crate::Result<()> { ++ let Self::Authlib(root) = self else { ++ return Err(crate::ErrorKind::OtherError(format!( ++ "Skins on {} cannot be changed from here", ++ self.name() ++ )) ++ .into()); ++ }; ++ ++ let url = format!( ++ "{root}/api/user/profile/{}/{kind}", ++ credentials.offline_profile.id.simple() ++ ); ++ let request = match form { ++ Some(form) => INSECURE_REQWEST_CLIENT.put(url).multipart(form), ++ None => INSECURE_REQWEST_CLIENT.delete(url), ++ }; ++ ++ let response = request ++ .bearer_auth(&credentials.access_token) ++ .send() ++ .await ++ .map_err(|error| { ++ crate::ErrorKind::OtherError(format!( ++ "Could not reach {}: {error}", ++ self.name() ++ )) ++ })?; ++ ++ if !response.status().is_success() { ++ return Err(self.error(response).await); ++ } ++ ++ Ok(()) ++ } ++ + fn refresh_token(&self, client_token: &str) -> String { + match self { + Self::Ely => format!("{ELY_REFRESH_TOKEN_PREFIX}{client_token}"), +@@ -811,10 +1004,10 @@ impl Credentials { + &self, + cache_intent: OnlineProfileCacheIntent, + ) -> Option> { +- // Neither offline nor Yggdrasil accounts have a Mojang profile, so skip +- // the request that would only ever fail and fall back to the profile +- // recorded locally, which already holds the right id and name. +- if self.is_offline() || self.yggdrasil().is_some() { ++ // Offline accounts have a profile nowhere, so skip the request that ++ // would only ever fail and fall back to the profile recorded locally, ++ // which already holds the right id and name. ++ if self.is_offline() { + return None; + } + +@@ -864,7 +1057,15 @@ impl Credentials { + stale_profile + }; + +- match minecraft_profile(&self.access_token).await { ++ // A Yggdrasil account's skin and cape are on its own server. ++ let fetched = match self.auth_server() { ++ Some(server) => { ++ server.session_profile(self.offline_profile.id).await ++ } ++ None => minecraft_profile(&self.access_token).await, ++ }; ++ ++ match fetched { + Ok(profile) => { + let profile = Arc::new(profile); + let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile)); +@@ -1959,6 +2160,37 @@ impl Deref for MaybeOnlineMinecraftProfile<'_> { + } + } + ++/// A stable id for a texture from another account server, which has none. ++fn texture_id(url: &Url) -> Uuid { ++ Uuid::from_bytes(md5::compute(url.as_str()).0) ++} ++ ++/// A texture from another account server as a data URL, or as it was if it ++/// cannot be fetched. ++async fn texture_data_url(url: Url) -> Arc { ++ let texture = async { ++ INSECURE_REQWEST_CLIENT ++ .get(url.clone()) ++ .timeout(std::time::Duration::from_secs(10)) ++ .send() ++ .await? ++ .error_for_status()? ++ .bytes() ++ .await ++ }; ++ ++ match texture.await { ++ Ok(bytes) => { ++ crate::api::minecraft_skins::png_util::blob_to_data_url(bytes) ++ .unwrap_or_else(|| Arc::new(url)) ++ } ++ Err(error) => { ++ tracing::warn!("Could not fetch the texture at {url}: {error}"); ++ Arc::new(url) ++ } ++ } ++} ++ + #[tracing::instrument(skip(token))] + async fn minecraft_profile( + token: &str, +diff --git a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs +index 9e89537..d037c47 100644 +--- a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs ++++ b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs +@@ -25,6 +25,12 @@ impl MinecraftCapeOperation { + credentials: &Credentials, + cape_id: Uuid, + ) -> crate::Result<()> { ++ // Another account server only has the one cape uploaded there, and it ++ // is always worn. ++ if credentials.auth_server().is_some() { ++ return Ok(()); ++ } ++ + update_profile_cache_from_response( + INSECURE_REQWEST_CLIENT + .put("https://api.minecraftservices.com/minecraft/profile/capes/active") +@@ -45,6 +51,11 @@ impl MinecraftCapeOperation { + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { ++ // Taking it off there deletes it, which is left to the server's website. ++ if credentials.auth_server().is_some() { ++ return Ok(()); ++ } ++ + update_profile_cache_from_response( + INSECURE_REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") +@@ -75,26 +86,37 @@ impl MinecraftSkinOperation { + TextureStream::Error: Into>, + Bytes: From, + { ++ let variant = match variant { ++ MinecraftSkinVariant::Slim => "slim", ++ MinecraftSkinVariant::Classic => "classic", ++ _ => { ++ return Err(ErrorKind::OtherError( ++ "Cannot equip skin of unknown model variant".into(), ++ ) ++ .into()); ++ } ++ }; ++ let file = Part::stream(Body::wrap_stream(texture)) ++ .mime_str("image/png")? ++ .file_name("skin.png"); ++ ++ // Another account server takes the skin through authlib-injector's ++ // texture API, where the wide model is no model at all. The profile is ++ // read again afterwards. ++ if let Some(server) = credentials.auth_server() { ++ let model = if variant == "slim" { "slim" } else { "" }; ++ let form = reqwest::multipart::Form::new() ++ .text("model", model) ++ .part("file", file); ++ server ++ .change_texture(credentials, "skin", Some(form)) ++ .await?; ++ return Ok(None); ++ } ++ + let form = reqwest::multipart::Form::new() +- .text( +- "variant", +- match variant { +- MinecraftSkinVariant::Slim => "slim", +- MinecraftSkinVariant::Classic => "classic", +- _ => { +- return Err(ErrorKind::OtherError( +- "Cannot equip skin of unknown model variant".into(), +- ) +- .into()); +- } +- }, +- ) +- .part( +- "file", +- Part::stream(Body::wrap_stream(texture)) +- .mime_str("image/png")? +- .file_name("skin.png"), +- ); ++ .text("variant", variant) ++ .part("file", file); + + let profile = update_profile_cache_from_response( + INSECURE_REQWEST_CLIENT +@@ -115,6 +137,10 @@ impl MinecraftSkinOperation { + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { ++ if let Some(server) = credentials.auth_server() { ++ return server.change_texture(credentials, "skin", None).await; ++ } ++ + update_profile_cache_from_response( + INSECURE_REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") diff --git a/scripts/check.sh b/scripts/check.sh index b77a6bf..5a6f164 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -112,6 +112,10 @@ check "the game is pointed at the account's server" \ contains "$WORKTREE/packages/app-lib/src/launcher/mod.rs" 'server.api_root()' check "the account card offers it" \ contains "$WORKTREE/apps/app-frontend/src/components/ui/AccountsCard.vue" 'authlibAccountModal?.show' +check "their skins are uploaded to the server" \ + contains "$WORKTREE/packages/app-lib/src/state/minecraft_skins/mojang_api.rs" 'change_texture(credentials, "skin"' +check "their profile comes from the server" \ + contains "$WORKTREE/packages/app-lib/src/state/minecraft_auth.rs" 'server.session_profile(self.offline_profile.id)' log "Ely.by skins" check "the frontend can tell an Ely.by account" \