//! # Minecraft Skins API //! //! ## Data Flow //! //! 1. Frontend calls `get_available_skins()` and `get_available_capes()` //! 2. Backend gets the selected Minecraft account and a recent Mojang profile. //! If skins and capes load at the same time, they share the recent profile //! instead of sending the same request twice. //! 3. The skin list is built from three places: //! - saved skin rows in the local app database //! - bundled Minecraft default skins //! - the active Mojang skin, if its texture is not represented by a saved //! skin or matching bundled default //! 4. While building the list, any saved skin with Mojang's active texture is //! updated to Mojang's current model variant and cape, then returned as the //! equipped skin. //! 5. Before changing a skin, the current non-default Mojang skin is preserved //! locally so switching away from an external skin does not lose it. //! 6. After a Mojang change, the returned profile is saved in memory when //! possible. If that response cannot be read, or a later step fails, the //! backend asks Mojang for the profile again. //! //! ## Ownership //! //! Mojang decides which skin and cape are currently equipped. The local database //! stores saved skin rows. A saved skin is the same saved skin when its //! `texture_key` matches; changing its model variant or cape updates that saved //! skin instead of creating another row. //! When a refreshed Mojang profile reports the same texture as a saved skin but //! a different cape or model variant, the saved skin is updated to match Mojang //! and returned as the equipped skin. //! A bundled default skin with no cape is redundant, so it is removed from the //! saved-skin database and represented by the default skin list instead. A //! bundled default skin with a cape is stored so the cape stays associated with //! that default card, but it is still returned as a default skin rather than a //! saved custom skin. //! //! `cape_id = Some(_)` means a skin should apply that specific cape. //! `cape_id = None` means the skin should have no cape. //! //! ## Consistency //! //! A Mojang request and a SQLite write cannot be one all-or-nothing operation. //! The backend handles this by reconciling refreshed Mojang profile data with //! saved rows, saving skins that might be lost before changing Mojang, saving //! uploaded skins with the texture key Mojang returns, and asking Mojang for the //! latest profile again whenever the result is unclear. use std::{ collections::HashMap, sync::{Arc, LazyLock}, time::Duration, }; pub use bytes::Bytes; use futures::{StreamExt, TryStreamExt, stream}; use serde::{Deserialize, Serialize}; use sha2::Digest; use tokio::sync::Mutex; use url::Url; use uuid::Uuid; pub use crate::state::MinecraftSkinVariant; use crate::{ ErrorKind, State, state::{ MinecraftCharacterExpressionState, MinecraftProfile, minecraft_skins::{ CustomMinecraftSkin, CustomMinecraftSkinInsertPosition, mojang_api, }, }, }; use super::data::Credentials; mod assets { mod default { mod default_skins; pub use default_skins::DEFAULT_SKINS; } pub use default::DEFAULT_SKINS; } mod png_util; const SKIN_CHANGE_DEBOUNCE: Duration = Duration::from_secs(10); static PENDING_SKIN_CHANGE: LazyLock> = LazyLock::new(|| Mutex::new(PendingSkinChangeState::default())); static SKIN_CHANGE_FLUSH_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); #[derive(Debug, Default)] struct PendingSkinChangeState { pending: HashMap, } #[derive(Debug)] struct PendingSkinChangeEntry { change: PendingSkinChange, generation: u64, } enum PendingEffectiveSkinChange { AddAndEquipCustom { texture_key: Arc, texture_blob: Bytes, variant: MinecraftSkinVariant, cape_id: Option, }, Equip { skin: Skin, }, Unequip, } impl PendingEffectiveSkinChange { fn is_unequip(&self) -> bool { matches!(self, Self::Unequip) } fn cape_id(&self) -> Option { match self { Self::AddAndEquipCustom { cape_id, .. } => *cape_id, Self::Equip { skin } => skin.cape_id, Self::Unequip => None, } } fn skin(&self) -> Option { match self { Self::AddAndEquipCustom { texture_key, texture_blob, variant, cape_id, } => Some(Skin { texture_key: Arc::clone(texture_key), name: None, section: None, variant: *variant, cape_id: *cape_id, texture: png_util::blob_to_data_url(texture_blob) .or_else(|| { png_util::blob_to_data_url(include_bytes!( "minecraft_skins/assets/default/MissingNo.png" )) }) .unwrap(), source: SkinSource::Custom, is_equipped: true, }), Self::Equip { skin } => Some(skin.clone()), Self::Unequip => None, } } } #[derive(Debug)] enum PendingSkinChange { AddAndEquipCustom { selected_credentials: Credentials, texture_blob: Bytes, variant: MinecraftSkinVariant, cape_id: Option, local_texture_key: Arc, }, Equip { selected_credentials: Credentials, skin: Skin, }, Unequip { selected_credentials: Credentials, }, } impl PendingSkinChange { fn profile_id(&self) -> Uuid { match self { Self::AddAndEquipCustom { selected_credentials, .. } | Self::Equip { selected_credentials, .. } | Self::Unequip { selected_credentials, } => selected_credentials.offline_profile.id, } } fn matches_skin(&self, skin: &Skin) -> bool { match self { Self::AddAndEquipCustom { local_texture_key, .. } => local_texture_key.as_ref() == skin.texture_key.as_ref(), Self::Equip { skin: pending_skin, .. } => pending_skin.texture_key == skin.texture_key, Self::Unequip { .. } => false, } } } #[derive(Clone, Deserialize, Serialize, Debug)] pub struct Cape { /// An identifier for this cape, potentially unique to the owning player. pub id: Uuid, /// The name of the cape. pub name: Arc, /// The URL of the cape PNG texture. pub texture: Arc, /// Whether the cape is currently equipped in the Minecraft profile of its corresponding /// player. pub is_equipped: bool, } #[derive(Clone, Deserialize, Serialize, Debug)] pub struct Skin { /// A key used to recognize this skin texture. pub texture_key: Arc, /// The name of the skin, if available. pub name: Option>, /// The section this skin should be grouped under, if available. #[serde(default)] pub section: Option>, /// The variant of the skin model. pub variant: MinecraftSkinVariant, /// The UUID of the cape that this skin uses, if any. /// /// If `None`, this skin uses no cape. pub cape_id: Option, /// The URL of the skin PNG texture. Can also be a data URL. pub texture: Arc, /// The source of the skin, which represents how the app knows about it. pub source: SkinSource, /// Whether the skin is currently equipped in the Minecraft profile of its corresponding /// player. pub is_equipped: bool, } #[derive(Clone, Deserialize, Serialize, Debug)] #[serde(rename_all = "snake_case")] pub enum SkinSource { /// A default Minecraft skin, which may be assigned to players at random by default. Default, /// A skin that is not the default, but is not a custom skin managed by our app either. CustomExternal, /// A custom skin we have set up in our app. Custom, } /// Represents either a URL or a blob for a Minecraft skin PNG texture. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub enum UrlOrBlob { Url(Url), Blob(Bytes), } /// Gets the capes for the selected Minecraft profile. /// Only one cape can be equipped. #[tracing::instrument] pub async fn get_available_capes() -> crate::Result> { let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; let Some(profile) = selected_credentials.online_profile_fresh().await else { return Ok(Vec::new()); }; let pending_skin_change = pending_effective_skin_change(profile.id).await; let pending_cape_id = pending_skin_change .as_ref() .map(PendingEffectiveSkinChange::cape_id); Ok(profile .capes .iter() .map(|cape| Cape { id: cape.id, name: Arc::clone(&cape.name), texture: Arc::clone(&cape.url), is_equipped: pending_cape_id.map_or_else( || cape.state == MinecraftCharacterExpressionState::Active, |cape_id| cape_id == Some(cape.id), ), }) .collect()) } /// Gets the skins for the selected Minecraft profile. /// Returns saved custom skins, bundled default skins, and the active Mojang skin if its texture /// is not represented by a saved skin or matching bundled default. /// /// Saved skins are identified by texture key. If Mojang reports that a saved skin is active with /// a different model variant or cape, the saved row is updated and returned as equipped. /// Exactly one returned skin is marked as equipped. #[tracing::instrument] pub async fn get_available_skins() -> crate::Result> { let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; let online_profile = selected_credentials.online_profile_fresh().await; let profile_id = online_profile .as_ref() .map_or(selected_credentials.offline_profile.id, |profile| { profile.id }); let current_skin = online_profile .as_ref() .map(|profile| profile.current_skin()) .transpose()?; let current_cape_id = online_profile .as_ref() .and_then(|profile| profile.current_cape()) .map(|cape| cape.id); let pending_skin_change = pending_effective_skin_change(profile_id).await; let pending_unequip = pending_skin_change .as_ref() .is_some_and(PendingEffectiveSkinChange::is_unequip); let pending_skin = pending_skin_change .as_ref() .and_then(PendingEffectiveSkinChange::skin); let fallback_default_skin = get_fallback_default_skin()?; let current_skin_texture_key = pending_skin.as_ref().map_or_else( || { if pending_unequip { Arc::clone(&fallback_default_skin.texture_key) } else if let Some(current_skin) = current_skin { current_skin.texture_key() } else { Arc::clone(&fallback_default_skin.texture_key) } }, |skin| skin.texture_key.clone(), ); let current_skin_variant = pending_skin.as_ref().map_or_else( || { if pending_unequip { fallback_default_skin.variant } else if let Some(current_skin) = current_skin { current_skin.variant } else { fallback_default_skin.variant } }, |skin| skin.variant, ); let current_cape_id = pending_skin.as_ref().map_or( if pending_unequip { None } else if current_skin.is_some() { current_cape_id } else { None }, |skin| skin.cape_id, ); let mut found_equipped_skin = false; let mut available_skins = Vec::new(); let mut custom_skins = Vec::new(); let mut saved_default_skins = Vec::new(); for mut custom_skin in CustomMinecraftSkin::get_all(profile_id, &state.pool) .await? .collect::>() .await { let is_saved_default_skin = is_bundled_skin(&custom_skin.texture_key, custom_skin.variant); let current_skin_sync = if pending_skin.is_some() || current_skin.is_none() { SavedSkinSync { is_current_skin: custom_skin.texture_key == current_skin_texture_key.as_ref() && custom_skin.variant == current_skin_variant && custom_skin.cape_id == current_cape_id, settings_changed: false, } } else { sync_saved_skin_with_current_profile( &mut custom_skin, ¤t_skin_texture_key, current_skin_variant, current_cape_id, ) }; let synced_texture_blob = if current_skin_sync.settings_changed { let texture_blob = custom_skin.texture_blob(&state.pool).await?; if is_saved_default_skin && custom_skin.cape_id.is_none() { custom_skin.remove(profile_id, &state.pool).await?; } else { CustomMinecraftSkin::add( profile_id, &custom_skin.texture_key, &texture_blob, custom_skin.variant, custom_skin.cape_id, CustomMinecraftSkinInsertPosition::Bottom, &state.pool, ) .await?; } Some(texture_blob) } else { None }; if is_saved_default_skin { if custom_skin.cape_id.is_some() { saved_default_skins.push(custom_skin); } continue; } let is_equipped = !found_equipped_skin && current_skin_sync.is_current_skin; found_equipped_skin |= is_equipped; let texture_blob = match synced_texture_blob { Some(texture_blob) => texture_blob, None => custom_skin.texture_blob(&state.pool).await?, }; custom_skins.push(Skin { name: None, section: None, variant: custom_skin.variant, cape_id: custom_skin.cape_id, texture: png_util::blob_to_data_url(texture_blob) .or_else(|| { png_util::blob_to_data_url(include_bytes!( "minecraft_skins/assets/default/MissingNo.png" )) }) .unwrap(), source: SkinSource::Custom, is_equipped, texture_key: custom_skin.texture_key.into(), }); } available_skins.extend(custom_skins); for default_skin in assets::DEFAULT_SKINS.iter() { let is_equipped = !found_equipped_skin && default_skin.texture_key == current_skin_texture_key && default_skin.variant == current_skin_variant; let saved_cape_id = saved_default_skins .iter() .find(|skin| { skin.texture_key.as_str() == default_skin.texture_key.as_ref() && skin.variant == default_skin.variant }) .and_then(|skin| skin.cape_id); found_equipped_skin |= is_equipped; available_skins.push(Skin { texture_key: Arc::clone(&default_skin.texture_key), name: default_skin.name.as_ref().cloned(), section: default_skin.section.as_ref().cloned(), variant: default_skin.variant, cape_id: if is_equipped { current_cape_id } else { saved_cape_id }, texture: Arc::clone(&default_skin.texture), source: SkinSource::Default, is_equipped, }); } // Keep the active Mojang skin visible even if the app has never saved it. if !found_equipped_skin { if let Some(mut skin) = pending_skin { skin.is_equipped = true; available_skins.push(skin); } else if let Some(current_skin) = current_skin { available_skins.push(Skin { texture_key: current_skin_texture_key, name: current_skin.name.as_deref().map(Arc::from), section: None, variant: current_skin_variant, cape_id: current_cape_id, texture: Arc::clone(¤t_skin.url), source: SkinSource::CustomExternal, is_equipped: true, }); } } Ok(available_skins) } /// Adds or updates a skin in the app database and equips it for the current profile. /// Bundled default skins are only persisted when they have an associated cape. #[tracing::instrument(skip(texture_blob))] pub async fn add_and_equip_custom_skin( texture_blob: Bytes, variant: MinecraftSkinVariant, cape: Option, ) -> crate::Result { let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?; if skin_width != 64 || ![32, 64].contains(&skin_height) { return Err(ErrorKind::InvalidSkinTexture)?; } let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; let cape_id = cape.map(|cape| cape.id); let local_texture_key = local_skin_texture_key(&texture_blob); CustomMinecraftSkin::add( selected_credentials.offline_profile.id, &local_texture_key, &texture_blob, variant, cape_id, CustomMinecraftSkinInsertPosition::Top, &state.pool, ) .await?; set_pending_skin_change(PendingSkinChange::AddAndEquipCustom { selected_credentials, texture_blob: Bytes::clone(&texture_blob), variant, cape_id, local_texture_key: Arc::clone(&local_texture_key), }) .await; Ok(Skin { texture_key: local_texture_key, name: None, section: None, variant, cape_id, texture: png_util::blob_to_data_url(texture_blob) .or_else(|| { png_util::blob_to_data_url(include_bytes!( "minecraft_skins/assets/default/MissingNo.png" )) }) .unwrap(), source: SkinSource::Custom, is_equipped: true, }) } async fn add_and_equip_custom_skin_now( selected_credentials: &Credentials, texture_blob: Bytes, variant: MinecraftSkinVariant, cape_id: Option, local_texture_key: &str, ) -> crate::Result<()> { let state = State::get().await?; let previous_profile = selected_credentials .online_profile_fresh() .await .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable { user_name: selected_credentials.offline_profile.name.clone(), })?; preserve_current_profile_skin(&state, &previous_profile).await?; // Mojang only gives us the new texture key after accepting the uploaded skin. // Use the profile from that response when possible, and fetch it only if that // response cannot be read. let profile = mojang_api::MinecraftSkinOperation::equip( selected_credentials, stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), variant, ) .await?; let profile = match profile { Some(profile) => profile, None => selected_credentials .refresh_online_profile() .await .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable { user_name: selected_credentials.offline_profile.name.clone(), })?, }; let equipped_skin = profile.current_skin()?; let equipped_skin_texture_key = equipped_skin.texture_key(); let equipped_skin_variant = equipped_skin.variant; let insert_position = if local_texture_key != equipped_skin_texture_key.as_ref() { CustomMinecraftSkin::get_by_texture( profile.id, local_texture_key, &state.pool, ) .await? .map_or(CustomMinecraftSkinInsertPosition::Top, |skin| { CustomMinecraftSkinInsertPosition::At(skin.display_order) }) } else { CustomMinecraftSkinInsertPosition::Top }; let persistence_result = if cape_id.is_none() && is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant) { CustomMinecraftSkin { texture_key: equipped_skin_texture_key.to_string(), variant: equipped_skin_variant, cape_id: None, display_order: 0, } .remove(profile.id, &state.pool) .await } else { CustomMinecraftSkin::add( profile.id, &equipped_skin_texture_key, &texture_blob, variant, cape_id, insert_position, &state.pool, ) .await }; if let Err(error) = persistence_result { refresh_profile_cache(selected_credentials).await; return Err(error); } if local_texture_key != equipped_skin_texture_key.as_ref() { CustomMinecraftSkin { texture_key: local_texture_key.to_string(), variant, cape_id, display_order: 0, } .remove(profile.id, &state.pool) .await?; } if let Err(error) = sync_cape(selected_credentials, &profile, cape_id).await { refresh_profile_cache(selected_credentials).await; return Err(error); } Ok(()) } /// Equips the given skin for the currently selected Minecraft profile, then applies its cape. /// If the skin is already equipped, it will be re-equipped. /// /// This does not check whether a custom skin exists in the app database. #[tracing::instrument] pub async fn equip_skin(skin: Skin) -> crate::Result<()> { let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; set_pending_skin_change(PendingSkinChange::Equip { selected_credentials, skin, }) .await; Ok(()) } async fn equip_skin_now( selected_credentials: &Credentials, skin: &Skin, ) -> crate::Result<()> { let state = State::get().await?; let profile = selected_credentials .online_profile_fresh() .await .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable { user_name: selected_credentials.offline_profile.name.clone(), })?; preserve_current_profile_skin(&state, &profile).await?; let texture_blob = png_util::url_to_data_stream(&skin.texture) .await? .try_fold(Vec::new(), |mut texture, chunk| async move { texture.extend_from_slice(&chunk); Ok(texture) }) .await?; let profile = mojang_api::MinecraftSkinOperation::equip( selected_credentials, stream::iter([Ok::<_, String>(Bytes::from(texture_blob.clone()))]), skin.variant, ) .await?; let profile = match profile { Some(profile) => profile, None => selected_credentials .refresh_online_profile() .await .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable { user_name: selected_credentials.offline_profile.name.clone(), })?, }; if let Err(error) = persist_equipped_skin(&state, &profile, skin, &texture_blob).await { refresh_profile_cache(selected_credentials).await; return Err(error); } if let Err(error) = sync_cape(selected_credentials, &profile, skin.cape_id).await { refresh_profile_cache(selected_credentials).await; return Err(error); } Ok(()) } async fn persist_equipped_skin( state: &State, profile: &MinecraftProfile, skin: &Skin, texture_blob: &[u8], ) -> crate::Result<()> { let equipped_skin = profile.current_skin()?; let equipped_skin_texture_key = equipped_skin.texture_key(); let equipped_skin_variant = equipped_skin.variant; let texture_key_changed = skin.texture_key.as_ref() != equipped_skin_texture_key.as_ref(); let insert_position = if texture_key_changed { CustomMinecraftSkin::get_by_texture( profile.id, &skin.texture_key, &state.pool, ) .await? .map_or( CustomMinecraftSkinInsertPosition::Bottom, |saved_skin| { CustomMinecraftSkinInsertPosition::At(saved_skin.display_order) }, ) } else { CustomMinecraftSkinInsertPosition::Bottom }; if skin.cape_id.is_none() && is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant) { CustomMinecraftSkin { texture_key: equipped_skin_texture_key.to_string(), variant: equipped_skin_variant, cape_id: None, display_order: 0, } .remove(profile.id, &state.pool) .await?; } else if texture_key_changed || !matches!(&skin.source, SkinSource::Custom) { CustomMinecraftSkin::add( profile.id, &equipped_skin_texture_key, texture_blob, equipped_skin_variant, skin.cape_id, insert_position, &state.pool, ) .await?; } if texture_key_changed { CustomMinecraftSkin { texture_key: skin.texture_key.to_string(), variant: skin.variant, cape_id: skin.cape_id, display_order: 0, } .remove(profile.id, &state.pool) .await?; } Ok(()) } /// Removes a custom skin from the app database. /// /// The player will continue to be equipped with the same skin and cape as before, even if /// the currently selected skin is the one being removed. This gives frontend code more options /// to decide between unequipping strategies: falling back to other custom skin, to a default /// skin, letting the user choose another skin, etc. #[tracing::instrument] pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> { let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; CustomMinecraftSkin { texture_key: skin.texture_key.to_string(), variant: skin.variant, cape_id: skin.cape_id, display_order: 0, } .remove(selected_credentials.offline_profile.id, &state.pool) .await?; cancel_pending_skin_change_for_skin( selected_credentials.offline_profile.id, &skin, ) .await; Ok(()) } /// Persists the custom saved skin order for the currently selected Minecraft profile. #[tracing::instrument] pub async fn set_custom_skin_order( texture_keys: Vec, ) -> crate::Result<()> { let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; CustomMinecraftSkin::set_order( selected_credentials.offline_profile.id, &texture_keys, &state.pool, ) .await } /// Adds or updates a saved skin locally without applying it to Mojang. /// /// This is used by the skin editor. If the edited skin is currently equipped, the caller should /// queue a separate equip operation after saving the local row. #[tracing::instrument(skip(texture_blob))] pub async fn save_custom_skin( mut skin: Skin, texture_blob: Bytes, variant: MinecraftSkinVariant, cape: Option, replace_texture: bool, ) -> crate::Result { let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?; if skin_width != 64 || ![32, 64].contains(&skin_height) { return Err(ErrorKind::InvalidSkinTexture)?; } let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; let old_texture_key = Arc::clone(&skin.texture_key); let texture_key = if replace_texture { local_skin_texture_key(&texture_blob) } else { Arc::clone(&skin.texture_key) }; let cape_id = cape.map(|cape| cape.id); let insert_position = if replace_texture && old_texture_key != texture_key { CustomMinecraftSkin::get_by_texture( selected_credentials.offline_profile.id, &old_texture_key, &state.pool, ) .await? .map_or(CustomMinecraftSkinInsertPosition::Bottom, |skin| { CustomMinecraftSkinInsertPosition::At(skin.display_order) }) } else { CustomMinecraftSkinInsertPosition::Bottom }; if cape_id.is_none() && is_bundled_skin(&texture_key, variant) { CustomMinecraftSkin { texture_key: texture_key.to_string(), variant, cape_id: None, display_order: 0, } .remove(selected_credentials.offline_profile.id, &state.pool) .await?; } else { CustomMinecraftSkin::add( selected_credentials.offline_profile.id, &texture_key, &texture_blob, variant, cape_id, insert_position, &state.pool, ) .await?; } if replace_texture && old_texture_key != texture_key { CustomMinecraftSkin { texture_key: old_texture_key.to_string(), variant: skin.variant, cape_id: skin.cape_id, display_order: 0, } .remove(selected_credentials.offline_profile.id, &state.pool) .await?; } skin.texture_key = texture_key; skin.variant = variant; skin.cape_id = cape_id; skin.texture = png_util::blob_to_data_url(texture_blob) .or_else(|| { png_util::blob_to_data_url(include_bytes!( "minecraft_skins/assets/default/MissingNo.png" )) }) .unwrap(); Ok(skin) } /// Unequips the currently equipped skin for the currently selected Minecraft profile, resetting /// it to one of the default skins and unequipping any cape. #[tracing::instrument] pub async fn unequip_skin() -> crate::Result<()> { let state = State::get().await?; let selected_credentials = Credentials::get_default_credential(&state.pool) .await? .ok_or(ErrorKind::NoCredentialsError)?; set_pending_skin_change(PendingSkinChange::Unequip { selected_credentials, }) .await; Ok(()) } async fn unequip_skin_now( selected_credentials: &Credentials, ) -> crate::Result<()> { let state = State::get().await?; let profile = selected_credentials .online_profile_fresh() .await .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable { user_name: selected_credentials.offline_profile.name.clone(), })?; preserve_current_profile_skin(&state, &profile).await?; mojang_api::MinecraftSkinOperation::unequip_any(selected_credentials) .await?; if let Err(error) = sync_cape(selected_credentials, &profile, None).await { refresh_profile_cache(selected_credentials).await; return Err(error); } Ok(()) } /// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling /// legacy 64x32 skins as the vanilla game client does. This function prioritizes /// PNG encoding speed over compression density, so the resulting textures are better /// suited for display purposes, not persistent storage or transmission. /// /// The normalized texture is returned as PNG bytes. #[tracing::instrument] pub async fn normalize_skin_texture( texture: &UrlOrBlob, ) -> crate::Result { png_util::normalize_skin_texture(texture).await } /// Sends any pending skin change immediately. /// /// This is used before launching Minecraft and before closing the app so the debounced /// skin selection is still applied for those boundary cases. #[tracing::instrument] pub async fn flush_pending_skin_change() -> crate::Result<()> { flush_pending_skin_change_inner(None).await } /// Sends any pending skin change for a specific Minecraft account immediately. #[tracing::instrument] pub async fn flush_pending_skin_change_for_profile( profile_id: Uuid, ) -> crate::Result<()> { flush_pending_skin_change_inner(Some(PendingSkinChangeFilter::Profile( profile_id, ))) .await } async fn set_pending_skin_change(change: PendingSkinChange) { let profile_id = change.profile_id(); let generation = { let mut state = PENDING_SKIN_CHANGE.lock().await; let generation = state .pending .get(&profile_id) .map_or(1, |entry| entry.generation.wrapping_add(1)); state .pending .insert(profile_id, PendingSkinChangeEntry { change, generation }); generation }; schedule_pending_skin_change_flush(profile_id, generation); } fn schedule_pending_skin_change_flush(profile_id: Uuid, generation: u64) { tokio::spawn(async move { tokio::time::sleep(SKIN_CHANGE_DEBOUNCE).await; if let Err(error) = flush_pending_skin_change_inner(Some( PendingSkinChangeFilter::Generation { profile_id, generation, }, )) .await { let _ = crate::event::emit::emit_warning(&format!( "Failed to apply pending Minecraft skin change: {error}" )) .await; } }); } async fn pending_effective_skin_change( profile_id: Uuid, ) -> Option { let state = PENDING_SKIN_CHANGE.lock().await; state .pending .get(&profile_id) .map(|entry| match &entry.change { PendingSkinChange::AddAndEquipCustom { texture_blob, variant, cape_id, local_texture_key, .. } => PendingEffectiveSkinChange::AddAndEquipCustom { texture_key: Arc::clone(local_texture_key), texture_blob: Bytes::clone(texture_blob), variant: *variant, cape_id: *cape_id, }, PendingSkinChange::Equip { skin, .. } => { PendingEffectiveSkinChange::Equip { skin: skin.clone() } } PendingSkinChange::Unequip { .. } => { PendingEffectiveSkinChange::Unequip } }) } async fn cancel_pending_skin_change_for_skin(profile_id: Uuid, skin: &Skin) { let mut state = PENDING_SKIN_CHANGE.lock().await; let should_cancel = state .pending .get(&profile_id) .is_some_and(|entry| entry.change.matches_skin(skin)); if should_cancel { state.pending.remove(&profile_id); } } #[derive(Clone, Copy, Debug)] enum PendingSkinChangeFilter { Generation { profile_id: Uuid, generation: u64 }, Profile(Uuid), } async fn flush_pending_skin_change_inner( filter: Option, ) -> crate::Result<()> { let _guard = SKIN_CHANGE_FLUSH_LOCK.lock().await; loop { let entry = { let mut state = PENDING_SKIN_CHANGE.lock().await; match filter { Some(PendingSkinChangeFilter::Generation { profile_id, generation, }) => { let Some(entry) = state.pending.get(&profile_id) else { return Ok(()); }; if entry.generation != generation { return Ok(()); } state.pending.remove(&profile_id) } Some(PendingSkinChangeFilter::Profile(profile_id)) => { state.pending.remove(&profile_id) } None => { let profile_id = state.pending.keys().next().copied(); profile_id.and_then(|profile_id| { state.pending.remove(&profile_id) }) } } }; let Some(entry) = entry else { return Ok(()); }; if let Err(error) = execute_pending_skin_change(&entry.change).await { let profile_id = entry.change.profile_id(); let generation = entry.generation; let mut state = PENDING_SKIN_CHANGE.lock().await; state.pending.entry(profile_id).or_insert(entry); schedule_pending_skin_change_flush(profile_id, generation); return Err(error); } if filter.is_some() { return Ok(()); } } } async fn execute_pending_skin_change( change: &PendingSkinChange, ) -> crate::Result<()> { match change { PendingSkinChange::AddAndEquipCustom { selected_credentials, texture_blob, variant, cape_id, local_texture_key, } => { add_and_equip_custom_skin_now( selected_credentials, Bytes::clone(texture_blob), *variant, *cape_id, local_texture_key, ) .await } PendingSkinChange::Equip { selected_credentials, skin, } => equip_skin_now(selected_credentials, skin).await, PendingSkinChange::Unequip { selected_credentials, } => unequip_skin_now(selected_credentials).await, } } /// Reads and validates a skin texture file from the given path. /// Returns the file content as bytes if it's a valid skin texture (PNG with 64x64 or 64x32 dimensions). #[tracing::instrument] pub async fn get_dragged_skin_data( path: &std::path::Path, ) -> crate::Result { if let Some(extension) = path.extension() { if extension.to_string_lossy().to_lowercase() != "png" { return Err(ErrorKind::InvalidSkinTexture.into()); } } else { return Err(ErrorKind::InvalidSkinTexture.into()); } tracing::debug!("Reading file: {:?}", path); if !path.exists() { tracing::error!("File does not exist: {:?}", path); return Err(ErrorKind::InvalidSkinTexture.into()); } let data = match tokio::fs::read(path).await { Ok(data) => { tracing::debug!( "File read successfully, size: {} bytes", data.len() ); data } Err(err) => { tracing::error!("Failed to read file: {}", err); return Err(err.into()); } }; let url_or_blob = UrlOrBlob::Blob(data.clone().into()); match normalize_skin_texture(&url_or_blob).await { Ok(_) => Ok(data.into()), Err(err) => { tracing::error!("Failed to normalize skin texture: {}", err); Err(ErrorKind::InvalidSkinTexture.into()) } } } async fn preserve_current_profile_skin( state: &State, profile: &MinecraftProfile, ) -> crate::Result<()> { let current_skin = profile.current_skin()?; let current_skin_texture_key = current_skin.texture_key(); let current_cape_id = profile.current_cape().map(|cape| cape.id); if is_bundled_skin_texture(¤t_skin_texture_key) { return Ok(()); } if let Some(saved_skin) = CustomMinecraftSkin::get_by_texture( profile.id, ¤t_skin_texture_key, &state.pool, ) .await? { if saved_skin.variant == current_skin.variant && saved_skin.cape_id == current_cape_id { return Ok(()); } let texture = saved_skin.texture_blob(&state.pool).await?; CustomMinecraftSkin::add( profile.id, ¤t_skin_texture_key, &texture, current_skin.variant, current_cape_id, CustomMinecraftSkinInsertPosition::Bottom, &state.pool, ) .await?; return Ok(()); } let texture = png_util::url_to_data_stream(¤t_skin.url) .await? .try_fold(Vec::new(), |mut texture, chunk| async move { texture.extend_from_slice(&chunk); Ok(texture) }) .await?; CustomMinecraftSkin::add( profile.id, ¤t_skin_texture_key, &texture, current_skin.variant, current_cape_id, CustomMinecraftSkinInsertPosition::Bottom, &state.pool, ) .await?; Ok(()) } async fn refresh_profile_cache(selected_credentials: &Credentials) { let _ = selected_credentials.refresh_online_profile().await; } fn is_bundled_skin_texture(texture_key: &str) -> bool { assets::DEFAULT_SKINS .iter() .any(|default_skin| default_skin.texture_key.as_ref() == texture_key) } fn is_bundled_skin(texture_key: &str, variant: MinecraftSkinVariant) -> bool { assets::DEFAULT_SKINS.iter().any(|default_skin| { default_skin.texture_key.as_ref() == texture_key && default_skin.variant == variant }) } fn get_fallback_default_skin() -> crate::Result<&'static Skin> { assets::DEFAULT_SKINS .iter() .find(|skin| { skin.name.as_deref() == Some("Steve") && skin.variant == MinecraftSkinVariant::Classic }) .or_else(|| { assets::DEFAULT_SKINS .iter() .find(|skin| skin.name.as_deref() == Some("Steve")) }) .or_else(|| assets::DEFAULT_SKINS.first()) .ok_or_else(|| { ErrorKind::OtherError("No bundled default skins found".into()) .as_error() }) } fn local_skin_texture_key(texture_blob: &[u8]) -> Arc { Arc::from(format!("local-{:x}", sha2::Sha256::digest(texture_blob))) } #[derive(Debug, PartialEq, Eq)] struct SavedSkinSync { is_current_skin: bool, settings_changed: bool, } fn sync_saved_skin_with_current_profile( saved_skin: &mut CustomMinecraftSkin, current_skin_texture_key: &str, current_skin_variant: MinecraftSkinVariant, current_cape_id: Option, ) -> SavedSkinSync { if saved_skin.texture_key != current_skin_texture_key { return SavedSkinSync { is_current_skin: false, settings_changed: false, }; } let settings_changed = saved_skin.variant != current_skin_variant || saved_skin.cape_id != current_cape_id; if settings_changed { saved_skin.variant = current_skin_variant; saved_skin.cape_id = current_cape_id; } SavedSkinSync { is_current_skin: true, settings_changed, } } /// Sets the equipped cape to the skin's associated cape, or no cape. async fn sync_cape( selected_credentials: &Credentials, profile: &MinecraftProfile, target_cape_id: Option, ) -> crate::Result<()> { let current_cape_id = profile.current_cape().map(|cape| cape.id); if current_cape_id != target_cape_id { match target_cape_id { Some(cape_id) => { mojang_api::MinecraftCapeOperation::equip( selected_credentials, cape_id, ) .await? } None => { mojang_api::MinecraftCapeOperation::unequip_any( selected_credentials, ) .await? } } } Ok(()) }