diff --git a/Cargo.lock b/Cargo.lock index 53f8116402..2aac10a1d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1975,6 +1975,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "data-url" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" + [[package]] name = "dbus" version = "0.9.7" @@ -8089,6 +8095,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid 1.16.0", "webpki-roots 0.26.11", ] @@ -8172,6 +8179,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.16.0", "whoami", ] @@ -8211,6 +8219,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.16.0", "whoami", ] @@ -8237,6 +8246,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", + "uuid 1.16.0", ] [[package]] @@ -8992,6 +9002,7 @@ dependencies = [ "chrono", "daedalus", "dashmap", + "data-url", "dirs", "discord-rich-presence", "dunce", diff --git a/apps/app/build.rs b/apps/app/build.rs index 4fe82cfbc4..d415d2c2f2 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -99,6 +99,22 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "minecraft-skins", + InlinedPlugin::new() + .commands(&[ + "get_available_capes", + "get_available_skins", + "add_and_equip_custom_skin", + "set_default_cape", + "equip_skin", + "remove_custom_skin", + "unequip_skin", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "mr-auth", InlinedPlugin::new() diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index b9777b6d9f..f3e946eff0 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -25,6 +25,7 @@ "jre:default", "logs:default", "metadata:default", + "minecraft-skins:default", "mr-auth:default", "profile-create:default", "pack:default", diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs new file mode 100644 index 0000000000..42ff5ff340 --- /dev/null +++ b/apps/app/src/api/minecraft_skins.rs @@ -0,0 +1,82 @@ +use crate::api::Result; + +use theseus::minecraft_skins::{self, Bytes, Cape, MinecraftSkinVariant, Skin}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("minecraft-skins") + .invoke_handler(tauri::generate_handler![ + get_available_capes, + get_available_skins, + add_and_equip_custom_skin, + set_default_cape, + equip_skin, + remove_custom_skin, + unequip_skin, + ]) + .build() +} + +/// `invoke('plugin:minecraft-skins|get_available_capes')` +/// +/// See also: [minecraft_skins::get_available_capes] +#[tauri::command] +pub async fn get_available_capes() -> Result> { + Ok(minecraft_skins::get_available_capes().await?) +} + +/// `invoke('plugin:minecraft-skins|get_available_skins')` +/// +/// See also: [minecraft_skins::get_available_skins] +#[tauri::command] +pub async fn get_available_skins() -> Result> { + Ok(minecraft_skins::get_available_skins().await?) +} + +/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)` +/// +/// See also: [minecraft_skins::add_and_equip_custom_skin] +#[tauri::command] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> Result<()> { + Ok(minecraft_skins::add_and_equip_custom_skin( + texture_blob, + variant, + cape_override, + ) + .await?) +} + +/// `invoke('plugin:minecraft-skins|set_default_cape', cape)` +/// +/// See also: [minecraft_skins::set_default_cape] +#[tauri::command] +pub async fn set_default_cape(cape: Option) -> Result<()> { + Ok(minecraft_skins::set_default_cape(cape).await?) +} + +/// `invoke('plugin:minecraft-skins|equip_skin', skin)` +/// +/// See also: [minecraft_skins::equip_skin] +#[tauri::command] +pub async fn equip_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::equip_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)` +/// +/// See also: [minecraft_skins::remove_custom_skin] +#[tauri::command] +pub async fn remove_custom_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::remove_custom_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|unequip_skin')` +/// +/// See also: [minecraft_skins::unequip_skin] +#[tauri::command] +pub async fn unequip_skin() -> Result<()> { + Ok(minecraft_skins::unequip_skin().await?) +} diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 09d37e87ab..294e784f64 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -7,6 +7,7 @@ pub mod import; pub mod jre; pub mod logs; pub mod metadata; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 4291431df8..3cfe87e961 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -249,6 +249,7 @@ fn main() { .plugin(api::logs::init()) .plugin(api::jre::init()) .plugin(api::metadata::init()) + .plugin(api::minecraft_skins::init()) .plugin(api::pack::init()) .plugin(api::process::init()) .plugin(api::profile::init()) diff --git a/packages/app-lib/.sqlx/query-1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04.json b/packages/app-lib/.sqlx/query-1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04.json new file mode 100644 index 0000000000..7572f50bad --- /dev/null +++ b/packages/app-lib/.sqlx/query-1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "1937e191a7815a55274bb39a035e02a39bb04b45dbd727e5db5b5308deda4e04" +} diff --git a/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json new file mode 100644 index 0000000000..26c250c785 --- /dev/null +++ b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22" +} diff --git a/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json new file mode 100644 index 0000000000..cf3645df13 --- /dev/null +++ b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944" +} diff --git a/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json new file mode 100644 index 0000000000..f34447870a --- /dev/null +++ b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + "describe": { + "columns": [ + { + "name": "texture", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5" +} diff --git a/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json new file mode 100644 index 0000000000..ee92d633c4 --- /dev/null +++ b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523" +} diff --git a/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json new file mode 100644 index 0000000000..2c946cb4ec --- /dev/null +++ b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [ + { + "name": "id: Hyphenated", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246" +} diff --git a/packages/app-lib/.sqlx/query-9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3.json b/packages/app-lib/.sqlx/query-9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3.json new file mode 100644 index 0000000000..f8365f0f44 --- /dev/null +++ b/packages/app-lib/.sqlx/query-9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "9c2522e4518067192539ad270253ae1b3d75e80e52529e491e86ff370d6424b3" +} diff --git a/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json new file mode 100644 index 0000000000..4d0c3892f3 --- /dev/null +++ b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "texture_key", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "variant: MinecraftSkinVariant", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "cape_id: Hyphenated", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac" +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index fc76b3f7f8..3737b1c8c8 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Jai A "] edition = "2024" [dependencies] -bytes = "1" +bytes = { version = "1", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_ini = "0.2.0" @@ -30,6 +30,7 @@ sys-info = "0.9.0" sysinfo = "0.35.0" thiserror = "2.0.12" either = "1.13" +data-url = "0.3.1" tracing = "0.1.37" tracing-subscriber = { version = "0.3.18", features = ["chrono", "env-filter"] } @@ -43,7 +44,7 @@ indicatif = { version = "0.17.3", optional = true } async-tungstenite = { version = "0.29.1", features = ["tokio-runtime", "tokio-rustls-webpki-roots"] } futures = "0.3" -reqwest = { version = "0.12.15", features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls", "charset", "http2", "macos-system-configuration"], default-features = false } +reqwest = { version = "0.12.15", features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls", "charset", "http2", "macos-system-configuration", "multipart"], default-features = false } tokio = { version = "1", features = ["full"] } tokio-util = "0.7" async-recursion = "1.0.4" @@ -66,7 +67,7 @@ rand = "0.8" byteorder = "1.5.0" base64 = "0.22.1" -sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros" ] } +sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros", "uuid" ] } quartz_nbt = { version = "0.2", features = ["serde"] } hickory-resolver = "0.25" diff --git a/packages/app-lib/migrations/20250413162050_skin-selector.sql b/packages/app-lib/migrations/20250413162050_skin-selector.sql new file mode 100644 index 0000000000..f76e667b46 --- /dev/null +++ b/packages/app-lib/migrations/20250413162050_skin-selector.sql @@ -0,0 +1,36 @@ +CREATE TABLE default_minecraft_capes ( + minecraft_user_uuid TEXT NOT NULL, + id TEXT NOT NULL, + + PRIMARY KEY (minecraft_user_uuid, id), + FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE custom_minecraft_skins ( + minecraft_user_uuid TEXT NOT NULL, + texture_key TEXT NOT NULL, + variant TEXT NOT NULL CHECK (variant IN ('CLASSIC', 'SLIM', 'UNKNOWN')), + cape_id TEXT, + + PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id), + FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) + ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (texture_key) REFERENCES custom_minecraft_skin_textures(texture_key) + ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED +); + +CREATE TABLE custom_minecraft_skin_textures ( + texture_key TEXT NOT NULL, + texture PNG BLOB NOT NULL, + + PRIMARY KEY (texture_key) +); + +CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup + AFTER DELETE ON custom_minecraft_skins FOR EACH ROW + BEGIN + DELETE FROM custom_minecraft_skin_textures WHERE texture_key NOT IN ( + SELECT texture_key FROM custom_minecraft_skins + ); + END; diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index efbd8b7ead..265d9bcb45 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -45,7 +45,7 @@ impl CensoredString { .replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/") .replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\"); for credentials in credentials_list { - // Use the offline profile to guarantee that this function is does not cause + // Use the offline profile to guarantee that this function does not cause // Mojang API request, and is never delayed by a network request. The offline // profile is optimistically updated on upsert from time to time anyway s = s diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs new file mode 100644 index 0000000000..8a759d39df --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -0,0 +1,539 @@ +//! Theseus skin management interface + +use std::{ + borrow::Cow, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use base64::Engine; +pub use bytes::Bytes; +use data_url::DataUrl; +use futures::{Stream, StreamExt, TryStreamExt, future::Either, stream}; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +pub use crate::state::MinecraftSkinVariant; +use crate::{ + ErrorKind, State, + state::{ + MinecraftCharacterExpressionState, MinecraftProfile, + minecraft_skins::{ + CustomMinecraftSkin, DefaultMinecraftCape, mojang_api, + }, + }, + util::fetch::REQWEST_CLIENT, +}; + +use super::data::Credentials; + +mod assets { + mod default { + mod default_skins; + pub use default_skins::DEFAULT_SKINS; + } + pub use default::DEFAULT_SKINS; +} + +#[derive(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 the default one, used when the currently selected cape does not + /// override it. + pub is_default: bool, + /// Whether the cape is currently equipped in the Minecraft profile of its corresponding + /// player. + pub is_equipped: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Skin { + /// An opaque identifier for the skin texture, which can be used to identify it. + pub texture_key: Arc, + /// The name of the skin, if available. + pub name: Option>, + /// The variant of the skin model. + pub variant: MinecraftSkinVariant, + /// The UUID of the cape that this skin uses, if any. + /// + /// If `None`, the skin does not have an explicit cape set, and the default cape for + /// this player, if any, should be used. + 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, +} + +impl Skin { + /// Resolves the skin texture URL to a stream of bytes. + pub async fn resolve_texture( + &self, + ) -> crate::Result> + use<>> + { + if self.texture.scheme() == "data" { + let data = DataUrl::process(self.texture.as_str())? + .decode_to_vec()? + .0 + .into(); + + Ok(Either::Left(stream::once(async { Ok(data) }))) + } else { + let response = REQWEST_CLIENT + .get(self.texture.as_str()) + .header("Accept", "image/png") + .send() + .await + .and_then(|response| response.error_for_status())?; + + Ok(Either::Right(response.bytes_stream())) + } + } +} + +#[derive(Deserialize, Serialize, Debug)] +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, +} + +/// Retrieves the available capes for the currently selected Minecraft profile. At most one cape +/// can be equipped at a time. Also, at most one cape can be set as the default cape. +#[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 profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + Ok(profile + .capes + .iter() + .map(|cape| Cape { + id: cape.id, + name: Arc::clone(&cape.name), + texture: Arc::clone(&cape.url), + is_default: default_cape_id + .is_some_and(|default_cape_id| default_cape_id == cape.id), + is_equipped: cape.state + == MinecraftCharacterExpressionState::Active, + }) + .collect()) +} + +/// Retrieves the available skins for the currently selected Minecraft profile. At the moment, +/// this includes custom skins stored in the app database, default Mojang skins, and the currently +/// equipped skin, if different from the previous skins. Exactly one of the returned skins 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 profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let current_skin = profile.current_skin()?; + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + // Keep track of whether we have found the currently equipped skin, to potentially avoid marking + // several skins as equipped, and know if the equipped skin was found (see below) + let found_equipped_skin = Arc::new(AtomicBool::new(false)); + + let custom_skins = CustomMinecraftSkin::get_all(profile.id, &state.pool) + .await? + .then(|custom_skin| { + let found_equipped_skin = Arc::clone(&found_equipped_skin); + let state = Arc::clone(&state); + async move { + // Several custom skins may reuse the same texture for different cape or skin model + // variations, so check all attributes for correctness + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && custom_skin.texture_key == *current_skin.texture_key() + && custom_skin.variant == current_skin.variant + && custom_skin.cape_id + == if custom_skin.cape_id.is_some() { + current_cape_id + } else { + default_cape_id + }; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + name: None, + variant: custom_skin.variant, + cape_id: custom_skin.cape_id, + texture: texture_blob_to_data_url( + custom_skin.texture_blob(&state.pool).await?, + ), + source: SkinSource::Custom, + is_equipped, + texture_key: custom_skin.texture_key.into(), + }) + } + }); + + let default_skins = + stream::iter(assets::DEFAULT_SKINS.iter().map(|default_skin| { + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && default_skin.texture_key == current_skin.texture_key() + && default_skin.variant == current_skin.variant; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + texture_key: Arc::clone(&default_skin.texture_key), + name: default_skin.name.as_ref().cloned(), + variant: default_skin.variant, + cape_id: None, + texture: Arc::clone(&default_skin.texture), + source: SkinSource::Default, + is_equipped, + }) + })); + + let mut available_skins = custom_skins + .chain(default_skins) + .try_collect::>() + .await?; + + // If the currently equipped skin does not match any of the skins we know about, + // add it to the list of available skins as a custom external skin, set by an + // external service (e.g., the Minecraft launcher or website). This way we guarantee + // that the currently equipped skin is always returned as available + if !found_equipped_skin.load(Ordering::Acquire) { + available_skins.push(Skin { + texture_key: current_skin.texture_key(), + name: current_skin.name.as_deref().map(Arc::from), + 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 a custom skin to the app database and equips it for the currently selected +/// Minecraft profile. +#[tracing::instrument] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> crate::Result<()> { + let (skin_width, skin_height) = png_dimensions(&texture_blob)?; + if skin_width != 64 || ![32, 64].contains(&skin_height) { + return Err(ErrorKind::InvalidSkinTexture)?; + } + + let cape_override = cape_override.map(|cape| cape.id); + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + // We have to equip the skin first, as it's the Mojang API backend who knows + // how to compute the texture key we require, which we can then read from the + // updated player profile + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), + variant, + ) + .await?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + sync_cape(&state, &selected_credentials, &profile, cape_override).await?; + + CustomMinecraftSkin::add( + profile.id, + &profile.current_skin()?.texture_key(), + &texture_blob, + variant, + cape_override, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Sets the default cape for the currently selected Minecraft profile. If `None`, +/// the default cape will be removed. +/// +/// This cape will be used by any custom skin that does not have a cape override +/// set. If the currently equipped skin does not have a cape override set, the equipped +/// cape will also be changed to the new default cape. When neither the equipped skin +/// defines a cape override nor the default cape is set, the player will have no +/// cape equipped. +#[tracing::instrument] +pub async fn set_default_cape(cape: Option) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + let current_skin = get_available_skins() + .await? + .into_iter() + .find(|skin| skin.is_equipped) + .unwrap(); + + if let Some(cape) = cape { + // Synchronize the equipped cape with the new default cape, if the current skin uses + // the default cape + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::equip( + &selected_credentials, + cape.id, + ) + .await?; + } + + DefaultMinecraftCape::set(profile.id, cape.id, &state.pool).await?; + } else { + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::unequip_any( + &selected_credentials, + ) + .await?; + } + + DefaultMinecraftCape::remove(profile.id, &state.pool).await?; + } + + Ok(()) +} + +/// Equips the given skin for the currently selected Minecraft profile. If the skin is already +/// equipped, it will be re-equipped. +/// +/// This function does not check that the passed skin, if custom, exists in the app database, +/// giving the caller complete freedom to equip any skin at any time. +#[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)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + skin.resolve_texture().await?, + skin.variant, + ) + .await?; + + sync_cape(&state, &selected_credentials, &profile, skin.cape_id).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, + } + .remove( + selected_credentials.maybe_online_profile().await.id, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Unequips the currently equipped skin for the currently selected Minecraft profile, resetting +/// it to one of the default skins. The cape will be set to the default cape, or unequipped if +/// no default cape is set. +#[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)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::unequip_any(&selected_credentials) + .await?; + + sync_cape(&state, &selected_credentials, &profile, None).await?; + + Ok(()) +} + +/// Synchronizes the equipped cape with the selected cape if necessary, taking into +/// account the currently equipped cape, the default cape for the player, and if a +/// cape override is provided. +async fn sync_cape( + state: &State, + selected_credentials: &Credentials, + profile: &MinecraftProfile, + cape_override: Option, +) -> crate::Result<()> { + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let target_cape_id = match cape_override { + Some(cape_id) => Some(cape_id), + None => DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .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(()) +} + +fn texture_blob_to_data_url(texture_blob: Vec) -> Arc { + let data = if is_png(&texture_blob) { + Cow::Owned(texture_blob) + } else { + // Fall back to a placeholder texture if the DB somehow contains corrupt data + Cow::Borrowed( + &include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..], + ) + }; + + Url::parse(&format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(data) + )) + .unwrap() + .into() +} + +fn is_png(data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + data.starts_with(PNG_SIGNATURE) +} + +fn png_dimensions(data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + data.get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + data.get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png b/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png new file mode 100644 index 0000000000..54d69181d8 Binary files /dev/null and b/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png differ diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs new file mode 100644 index 0000000000..6d5d7f78c5 --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs @@ -0,0 +1,213 @@ +use std::sync::{Arc, LazyLock}; + +use url::Url; + +use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant}; + +use super::super::super::Skin; + +/// A list of default Minecraft skins to make available to the user. +/// +/// These skins were created by Mojang, and found by reverse engineering the +/// behavior of the Minecraft launcher. The textures are publicly available at +/// `https://textures.minecraft.net/texture/`. +pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| { + vec![Skin { + texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJklEQVR4Xu2aP2sUURTF/RRir2BAsQyC4GJjaSwEg1iJqRSDgiBooSIWoggiYm0l2KiNiKCNhaWFlYVWVhYWfoAxZ8xZfnvyJtmdkN2dMBcO7++8fefc+2Zn3ps9e7awX48G1e/ng0rpjztHRlJD7a5zXqmQ43XOTMQQOSPrd7UAf18u10jCTRHiuhyvc2ZCTR5mPUVwW47XOaP3GQXON4ljMXK8zll61eRc/vn5RfXt3eOhEFwau0IAk7bXGe6CBDAkhGExcrzOmUgy7Bn+jgDh99fXI3B9jjd3lqFd8jKJu815Lgf3Yx3Hz7LyOZ+pmwmWSCbZJMd6lykCyymAr835TN048SSek1bq0E7i7M/ooLAeg7+X85m6pdecFkVYW9t/vn8arvOSCKxznoQpylzcJD0pkh/x8hpR2f4LB2rygvIyi1DyMoUkYfdT/VwIkF7mHd2EN4OFoggUIAlTpLkQwJMcPuxsRv7Dm/9YL/MvzySV2tMkTO/z93I+Uzd6ww8x+b++QYh18nlDJHGP6ehi3m1zIUDa1VdXKmJxcbHGwsJCjeyf9vDjg+r++3s1OYPEc/xEjpem8Q06I/u1tpyQiVuI7J9mAQR5ebi01kXwuOePHRxBWwFEXlGb/VpbTrBtBNy+e27kncAilMhThBwvLQXYkQjgpNpEgMjz5YjLIcffrgDbjgB5ywPefHujunjicHXt1GJ16+zxOm84MhzeBidE7/MmqQmqjktrK2gupfETTX2SZ6OJnH7M5C+fPFKTF5SnCIK95bJ+zNd7Qk+fXRr5m1S+JICuKdWRVArtPOvTIUqTZ6PJ2yIq2PMmr1R1FCD7UQBPXqlFSPIkndeZ5NMvT4ap8yyX6vLa5NloJkKiJG8xSiJZAJKxByxCkncbyyScqa5vg+TZaAxzhr8FYIQICn+WSSYnrzTJUoCSF+llgQ9khO8vCbcnz0ZbXl6uDIa4iauOfYSVlZUayjOMk0AdBWsp12aKUurPcfIJlH99FIL3GyF5NprJmJAJlgiXQELpzSREAQy2u8z6JJ4ilMhPJECJ4Orq6oY6izIYDKqlpaUaypMQiSgvUZKQ692HQrluqgIkoQzxbFfqyFB+39G9Qxw6s7Dhbm/CJqY+vIZiZKq2JJ0CcCm0EoChTpIlwu7DsgmYsAgmSYtjgdg/2wnVJ+kSeUbCxAKYLEl5CSTxFEiQp3xnF/KBJFP3cfgT9jzbk7jJ580vRUiejabne5EysVzzJi34fYBlT5Tk9M9x/fTREaiO4vCaFIJI8ikC01YR0FtvvfW20+bdIG6Jc5uMcFuO0XnL8wLXc7doVxKX2fu7luBW5tDP+nFMewqJfPdPqE+OM1NrS7633nrrrbfeeuuWeU9QaHO4qmcGb4Z0coODAgiTHq/7XUEC6FB14i2uWVseeU8aAX5n6GwEpACTRoAE8BLoRATw5NjwuSLPHQ2JUtoI1QapX5ezLTFXb5VJ0ALwcDUPWRkh3CmmALl7zH5Kcx4zMxL3wWqeMpfg43gR4nkiy1nHNOcxM+NROb8dYPjnNwXsk+/y48K/n/VG7iHkXgI5bMtSgCRrTztCEnm+n6c9JahPzmNmxuMzLoNxvi/QqZMJlU53mqA+OY+ZWekoLc8V85id/enVccgbOY+ZWRJUeZLvCzovQBLiAWuJsFJHhvK55pNoE3IeMzOGOkmWCJeQAowjwlzdA0yWpDb7viBB4rwZJmmSn6t/gc2+LxAcCULp+wKu/63I70QE/AMDdqWZ7rX6YgAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("1abc803022d8300ab7578b189294cce39622d9a404cdc00d3feacfdf45be6981"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFQElEQVR4Xu2av2scRxzF9VeY9DZYEKPyEBh8pHFpuQhEhFTBqhIiEjAY4iI2JkVIMBgRUrsypHHShGCIGxcuXbhyYVepVKTwH7DR2+hzvHs7e9KelNWt2AeP+bGzc/PefGfvdubW1o7A3z9Nq/1fppXSt/c25lKo69SRVypmf4MDQqDEwaw/1wa8f7JdMwW3RQh12d/ggKC2GfZ6N4Fr2d/g4LPvUUC+zRzMyP4Gh5xVxFF+9+Jx9fqPhzMjfGmcCwMQzax7uIsyAMoIiBnZ3+AgkR72Hv5EgLj/6ukcqc/+Vg4Z2qVZduFcI+/LgXZe5/1nWfkcT+9AYElkik1xXk/ZTfByGsC9OZ7e4QNP4TlopYR2Cvf2Hh1uLH345+V4ekfOGmnRhIO1/c+b57N1XjLB68i7YDdlJR6SDMrFz83ygVDh4ueXavGi8gImlGbZjXTBtFP9ShiQs+xPdAQvIka5CW5ACnaTVsIABjn7sbNI/LPf/uNh2b/yEKmUmXbBPvv+eTme3uGzwY+Y/F5vGHEoPh+ILpw+iS7Pc20lDEh8/etXlXMymdRcX1+vme0TP/71Q/X9nw9qcdCFZ//J7C+h/p05Kdm+M3JACMeIbJ/AAFGzPFtahybQ72dXL8/xpAZIvKI323dGDnDZCPju/qdz7wSYUBLvJmR/iTYDTjUCfFDLRIDE+8uRL4fs/7QMWDoCNFt09u3vd6pbH12pvrkxqe5+cq3OQyKD8IY5IGbfH5IamOp8aR1FjaXUf4mL2qXeBiROH4b4L69v1OJF5d0EkdmirA/hfgaz9/MXc1+TypcM0D2lOhdUMpqyXytNjNLU24BmW0JFZh7xSlXnBmQ7N4DBK8WEFO+i8z4E7r18NEvJe7mt3k2inHobQIgLdfGYUTIJA1wMzmNCiueal11wprr/JEy9DXiYe/hjgEeIqPD3sovJwStNsW5AaQZzlv0HWZJnTNLbpN4Gtre3K+ghjnDVeRtxZ2enpvIeximgjoKD1NdkmlJq7/3411wyjfBnDky9DSAGQQgsCS7RBeVspiA3APp1yl6fopMYUBJ/LANKAnd3dxt1mDKdTqutra2ayrsgF6K8TElB1NPGjaLupAZ4PvU2kIIyxPO6UiJD+Q82L8z44cfrjac9ghGmNn6Pm5GprqXgpK93GZHXU28DHuousiSYNl5GAIIlMEViDgZ5+7zuVH0KahPvkdDJAMS6KJZACk+DRM0UT3Yxf4hkShvC38nM+/UU7eLz4VcyIfU2oN/3EoWwXPOIFnkf8DIDdXH65rh9c3OOqnNz/J40wpnCSyZ42jkCRowYMeL/BrtBviXu22ROrmUfg0eeF1Dvu0XnUrjA7J9bgUeB0M/640B7CiXme3+SdtnfmWBZ8SNGjBgxYsSIYYE9QXGZw1X9ZvANkcFtcLgBYtfjdd4VMEAHq522uM4aeeTdNQJ4ZxhsBKQBXSNABvgSWPkI8JNjyLminztCmVLaCNUGKa/Lea2NK/F2mQIxwA9X85DVI8R3itOA3EEubbPneHqHC+dgNU+ZS+Q4XkL8PBFS5/VeJs3x9A4/Kvf/Dnj4538KvE2+x58mcw+htJeQejojDUixzDQRksyz/TzpWUS1zfH0Dj8+82VwnP8X6NQJIW0nO4uotjme3lE6SstzxTxm9/Y+m13EwxxP70iBKnf5f8EyBnik5Hh6RwryA9aSYKVEhvK55lPsUczx9A4PdRdZElxiGtDFhJV4BiDWRS36f0HShfvDMMUmaZ/j6R2L/l8gEgli6f8FCPE0xbbxNCLgXy/vfcHbfYbuAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6ac6ca262d67bcfb3dbc924ba8215a18195497c780058a5749de674217721892"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEuklEQVR4Xu2aMYsUQRSEhYsMTARBRBRUxMsEUUMTDw1ExESDCzQRo9NQ5EBNNDMxMjAy0L/gbzH2B2giorBSA3XUfnbP7eytM7N3W1BMT/fb2VfVr2dmZ/bQoV3w5urxybtrpyYfNs40W+2rLapd4tend3bI4y0dLDwNsCkUTvH7wgCL94znfppA4fvGAM54F/H7woDdKoCCSR5v6ZCiswrcpmCSx1s62ACbkMshK+Dbi4dT3FcG5JWAy6AkPk3g8UYHn8gs7OXFo1OzrH0bIEpYjjM2qyP7tDXdry3z6R2ZbG5phoR/f7fVUG2Oe5/Hyr5RGuAEc5Y84x6T6F+fXk9RfRmjz1iUDUiDWA2OYz69w8lIgMV5rTthijdTTH6+JDQrIw1hPr3DiViAy9slr3Hh3NrajkC1BY3lktDWBpRmPs3wPvPpHZmMSz3b319tNeTsZ7+NcluC0wRXQ5rgfebTO7I0nZhPdlz7vrxNGRGxrgYdIy+HFG1DRlEBKd6Jib6+0wSK9/V+FtqINIT5DI7DG3cnyUuXrzQ8f2G9IeMJC9P2x9uNhr8/39vhkdubreTxiJ/vbzXH+fp8faoCGTc3aICF2wjGE7mUvFWV2AyJ5HeYsxhgI2WAhC/8lptJda0Anui8lBZpwJ8vD8ddAUrS5wefR/7HElhIBTABkoZkn9pKpEQaoFnzzCUlRqJEV4naHveYDfS+xhxv+ngidVZx7P6jSRspmPtOKBMTJdblafEWkEKdcI7lOi/F5Ji/j8ejzioo+MSDJ1NUnwXbkCQFpQFJinR8TSDbpX3PehrgGOqsomTA6cfPpgyw2KwK71NQlnaJjKcoGsBxHocGeJw6q6ABWQUlwSQF1QzIONKJp+C2+GQakKTOKiioxHkMsBgK4T7HSB+vK6mzCgoqsc0AihCVQE0QY9VXqhbH+kRaY15pktRZhYVxy74a0/WSaAqzQY6nCWmSYiSGvz/4Q4y/WToZkNd2ijN5/U/qju/jjbP/bMXt7e2G3s+YUjypMRpAM5JzGeA7PHPjxs3J5uZmsxU5TuaM5qyKNonrsxRLerw08yXxNIE6q8jb3DRhFvH6LA1w4rl8ZALjKLjGkgFq59rntrMBeyHFixaf54+2SmhjzjorgMLnqoBFQ+uWff8DfX1PZ/SVWF/f0xl9JTaKB6olHDgDfE1PLiK53Z4a8zvFUTxk9SMx9s+DNkFtY4NiNC88hoLfLbD/wOD6ySMTkf0HBouuAP5U9i9G/nLMPm15nF6xSANWWGGFFVZYJvCZoR+c+CkS44l8QDqahxxdQAPy8ZnIeCKv937z2/kx15CgAV0rQML9LvFAVkAugaWoAArmO4USM5ZPgUsvVbg/+K1ugoL46oxkvIT5CbAMSNEpmIYwj8EgURakdun/BTQgKdElAyg6+0ZngMXYAP6/gMx4GlASTTNGbUBWAYWXSANmJfMYDCUDupBvgmalv1/VYFNcJbUqyrHUsCfs1QC+55uVzGMwpAE0wu3ckjaANz219uhuiiTCl7TafUCOk5zZNtHZxzwGA1+Zd/1/AQ3YzQT3M4/BQEE2oSSevwtsQL7mznf8JQMcxzwGA/8v0JU0oCSaXGQF/AXuGcKOL5bNbAAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("4c05ab9e07b3505dc3ec11370c3bdce5570ad2fb2b562e9b9dd9cf271f81aa44"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEyElEQVR4Xu2aP4sUQRTEhYsMTARBRBRURDNB1NDEQwMRMdHgAk3ESA1FDtREM5OLDIwM9Cv4WYz9AJqIKKzUYB21v+mZ3bnb65u724Kip7vfzr6qefNnp/fQoRl4d+34ZOP6qcnH1TNNq762RW2X+O353U1yf3sOFp4G2BQKp/h9YYDF+4hnP02g8H1jAI/4EPH7woBZFUDBJPe355Ciswq8TcEk97fnYANsQp4OWQHfXz2a4r4yIO8EPA1K4tME7m908IXMwl5fOjp1lNW3AaKE5TxjszpyTK3pcbXMpzoy2WxphoT/2HjaUNucd5/7yrFRGuAE8yj5iHtOon9/fjtFjWWMPmNRNiANYjU4jvlUh5ORAIvzue6EKd5MMfn5ktCsjDSE+VSHE7EAl7dLXvPCuZWVTYHaFjSXp4RaG1A68mmG+8ynOjIZl3pu/3jztCGPfo7bKG9LcJrgakgT3Gc+1ZGl6cR8seO579vblBER62rQPvJ2SNE2ZBQVkOKdmOj7O02geN/v56GNSEOYz67j8Oq9SfLylasNz1+42JDxhIWp/fl+teGfL/c3eeTOWi+5P+LXh9ub+/r28mKrEhk/GDTAwm0E44k8ldyqSmyGRPI7zHkMSDNtgIQv7NGbSQ2tAF7ofCot0oC/Xx+NuwKUnK8Pvo7s5CmwrQpgAiQNyTFtK4kSaYCOmsg4CZEo0VWibc97LkvfY5r3Z0zv03HU28KxB48nfaRg9p1MJiVKrMvS4p1UCqWwvGjaAMZ4zPP+ztI+qbcFCj7x8NkUNWbBNiRJQWlAkiIdz4SzX5rjmI96GpAx1NtCyYDTT15MGWCxWRXuU1CWdomMpyAawHnGlgzIeeptgQZkFZQEkxTUZUDGkU46BffFk2kASb0tUFCJWzHAYiiEfc6R3t9WSb0tUFCJfQZQhKgv7hLEWI2VqsWxvpD2Me82JPW2YGFsOdbFdLskmsJskONpQpqkGIko/QbhAxB/t3ibelvIezvFmbz/J/XE9+nm2VYrrq+vN3Q/Y0rxpOZKBtCM5GAD/IRnrt68NVlbW2takfNkHtE8qqJN4nlZiiU9T0F94mnCXAbkY26aMI94fZYGOPE8fWQC4yi4i10GqJ/nPttBBmyHFC9afF4/+iqhjzzqJROypVHUu+PQecuxnULN75obNZOq+V1zo2ZSo3ipShw4A3xPTy4isXneGvN7xV1/0epXYhzfCmaJmTW/KxjNgsduwWsLHD8wuHHyyETk+IHBoiuAP5XzVyN/PeaY47i/KlikAUssscQSS+wl8J2hX5z4LRLjCb4g5UsOxo8ONCBfn4mMJ3i/96qvTWD86EADhlaAROda4oGrAJ4Co68ACuaaQokZy7fAfYsqpXHmUx0UxKUzkvHNUf7/BlgGUDBNYJ/5VIdEWZC2S/8voAFJiS4Z0GUC55hPdaQoG8D/F5AZTwO6BJcMGaUBWQUUXiINGErmUx0lA4aQK0FDqSqwGa6OvirK6lFLPYOxXQO4xjeUzKc60gAa4e1sSRtgQbnux3XA7I/mwUgifEvreg7IeZJHdJZgjjOf6uCS+dD/F9AACuzra5v5VAcF2YSSeP4usAG5xM31fRqQ44plPtXB/wsMJQ3oElziIirgH4/zgLkTkjiIAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("fece7017b1bb13926d1158864b283b8b930271f80a90482f174cca6a17e88236"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFK0lEQVR4Xu2awYscRRjFcw9JhLiHoMIGzCVLIIRlEQJGDDkIghA9RBBBBdkl4EEFPYm5CCrkorlo9CBBCJGQu168GTx49OYf4D+QY8tr+A1vXqom3e3uzHTSDx71dVV1zfe++qpmumsOHXoErl35svnq3W9bfvHW9Zmtel1/9875hczxRgcX7UGgROgHl87M8bEJgIv3bIAl8R6EHG90SMEeBNmPfQBy3WN/+tpnT0YAEC3BiGYDVL3KFA7VluONDoi+cfXH2exLGPUqS1mgOrXleKMDYnMvoG7RV+EoMsBFlda713kWeFBK91EPsx47/Vk6So5LnK9xtX2zd3Ouz/sX9mZ2isrgsFRK/dOfpUNO5Gy7GNpv7b7U3Pnw1ZlAXXufFOdLhHtKmZL+LB04p/L3z99o6Y7KRnwSEZTc73WeNbIZGzv9WTp8diRKO3g6Luxtbc+EyxZo1/3aH3Sv2kl7SgJJHVyLbwkcUamdWwIkRs6qFHPmIe26/+ZHt9s6dn/VaZ9gbPYBD8RaBMCdUfnT7sutGJaFBP68+0or7pOLmy1lq079yACV//7z+kygxGvpqFSdbPUjELSnP0sHsylnRM2gxOC47Jx5yBJwkTD7+md4n/Rn5Xjh5LkGvnhqp3n7xLnm8sZWS9nZP3Hvrz8b8fb9P5rvf/u1pWzqNe7557dn44tel+MlfGwCu6/PHC5elPD3nttpKTv7J3BQG+KbO5stZRMExpdgJ/U5XkJjEFTE6zOy32C4+KEZ8PXduw8FQHX7kQHMvsY6kAxw8SKzD7N/Qg5KLEFAvKi2HD+Z4yV8Se1LBijqZ585/ZAji0i6iqztXPuHjx1vnnr6REvZuRfkPQTI7ezDGJB6+pAZKlNnFRK0tXFqTlgKLvFRAdCYzgxACsB5t70tA+D9s5+YOqvIGVU29AlEBgBHyAJmPx3OAGSdszTzGYy8J3VWIaGaIQQTBAKRgkVvT/H6cKWwnGIJyGYTrIktiXAuEsw14+k6dVZRm+1SnYuHpQC4Y7X1nP1LAr1vtpcCOTgAtT2gFITskwFwJ3zWkxmEvJdrvkH6MnVW4Wsekrq1AHg2yNkkM0MAfBYR56UHANHefwhTZxUuFvvvH7abBw/uzQUCuyuPHNtoH440juxs934EgaARELXle8auTJ1VpEOiHM+6Gj07fOP03xbYpYzyDdipOrWlsK5MnVWkoL7M3/SiBMt5Nkrs7Kf6zaPPzoLAbwZsteUTpf/8TXp76qwiBfUls6fSvx6pW9Qu5nOBZ4DaUjji/fyB0oOQOqtQumvNp7Ck+qgv61qU7c674C7XIpmR/cikFJ9BKAWg1xKYMGHChAkTJkyYsKbg+Z7H3Np1MscZLfw9QR/mOBPGinz4cpsHOfGXj8/OPbDlOBMmTFgO/MWHOOR0mZekg97wrBr59mfI/wsIgE59taGN6g2Pv+4amgG8Ih91BsC+/y8gACqVAbzry35rA1/vKd5ZOoFSvQt2Upellsda/dJbJNyF1s4fPeVLglM4+0P6sTKkaBeZs579agEQEUydH5n1Ovs7aKQghOcBCSc+3l4KADOcJU+A1KUfK0MGAMGlOhcPfZY91WszT7DSj5UhhZZEy85DUNryOb4r+Xyu/f0ApdPbS+MMhq9x6OeGTk99goBjfcnnky1D6VoGAbES5ueIXf9fwPd8X6YfK0MKgl3/Y+Ci8gB0EdOPlSEF9WUGoEsgRpEBXSkxfsyNnaJdvPqkHyvD//1/QQZgkfiDyID/AMdpmCl88QvjAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("daf3d88ccb38f11f74814e92053d92f7728ddb1a7955652a60e30cb27ae6659f"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFPElEQVR4Xu2awatVVRTGnUcZ1BtIBk/ISY9A5PEQBA2jQSAE5sAgggriPYIGGuRIbBKo0MScqDUQEUQR5zVxZjRo2Mw/wH/A4YnvwO/yve/sc9895+npXD0LPvY6a6+z7/rWXnvfe86+e/bsID+duVRd/vrXGj9/8ctMl13X1786Ohc53tKJk/Yk0EL0+48/2IaXJgFO3qsBlMh7EnK8pZMk7EmQ/tInINc9+vlPL7waCYC0CEOaDVB2tUkcqC/HWzqB9LXvfp/NvohhV1uqAtnUl+MtnUA29wJs874Kl6ICnFRpvbvNq8CTUroPO0g7esYzuJQCFzlf4+q7unVzm8+3x7dmepLK5LBUSv4Zz+CiIHK2nQz9tzc/rO6dPTkjqGv3SXK+RLinVCkZz+BCcGofXTxdwwOVDvkEJGi5321eNdIZGz3jGVx8dkRKO3gGLtlaW58Rly6hX/drf9C96qfsaUkkNjCKbwkCUaudWwRERsGqFXLmAf26/+a5u7WN3V827ROMzT7giRhFAjwYtbc2T9RkWBYieGfzk5rcjx+t1pAum/yoALVPn3w2IyjyWjpqZZMuPxJBf8YzuDCbCkbQDIoMgUvPmQcsAScJ0tc/w30ynv9djhw4XIFjBzeqL/cdrk6trNWQnv4pD//5uxLu/vW4uvHnHzWkY9e4R99bn40vuC3HS8mxSS4/vtK/szh5QcS/eXejhvT0TyFAbYifb6zWkE4SGF+EHdhzvBSNIeK0kOez0r+zOPm+FXDlwYNGAmR7HhXA7JPo514BTl5g9kH6pygokSUJkBfUl+MncryUXFK7rgBl/dA77zcCmQfKVSCQXPuv7X2revPtfTWkZ+B5DwlyPX18T6Hf/bD7ZyXfhojQ2srBbcSScAk7JUBjOjIBSYCydt37nBh621juk3wbkjOqauiSiEwAAVMFzH4G3Ba0607Erz0p2d85ASKqGYIwSSARSVjw/iSvD1YJ68NZAtLZBNvIJslEG2G/LrXJtyFts12yOXlQSoAH1rae079E0H2zv5RIWu9Pvg2hAiDtxEtJSJ9MgAfhs57IJOS9XPMN0hfJtyG+5gGl25YArwYFm2BmFIATpB+d1hMAafffDZJvQ5ws+r+/rVfPnj3clgj0RfH63pX64UjjSM9+91OgnjQSor58z9gVybchGZCgwNPWBq8O3zj9twV6qaJ8A3bIpr4k1BXJtyFJqCvyN70gwgqejRI9/WRffWP/LAn8ZkBXXz5ROpIscJ/k25Ak1BXMnlr/esQ2r1/I5wKvAPUlaSfvZxDZLpwAlbvWfBJLyEe+rGtBugfvhBe5FqiM9KOSkngpCd52roBJJplkkkkmmWSSSUYqelLUEyKPtzzn52NvvgdY+HF47OLvCfogx5tk2SQfvrhG10Pa/R8O1ZDuD21CjjfJJJO8WPEXH0Kf02VekkrPlxzpPzrJtz99/l/gCdCJrzYzkpD+oxN/3dW3AnhFvtQVALr+v4AE8EOGc//RVoCv9yTvKJ1AyZ6EAbZsgZaJ2oxncJlH3Im2nT9mybe1EPZTpFEkIEk7yZz19GtLgABhbByZeQKEjGdwSUIQzwMSTny8v5QACGabCaDNeAaXTACESzYnD3yWvdRz5vN6NHtAEi2Rlp6HoPTlc3xXKBGul67dln3Jp7P4Ggd+bujw0icJHmQfUC19kXw6C2RFzM8RF/1/AT90+iLjGVySEFj0PwZJKA9Ad0LGM7gkoa7IBHRJwqgrYFGIhB9x5/l+G/DPeAaX3f6/IBOwCHlPQsbTVf4Dv55a+XG6bwMAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("226c617fde5b1ba569aa08bd2cb6fd84c93337532a872b3eb7bf66bdd5b395f8"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("e5cdc3243b2153ab28a159861be643a4fc1e3c17d291cdd3e57a7f370ad676f3"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("7cb3ba52ddd5cc82c0b050c3f920f87da36add80165846f479079663805433db"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("dc0fcfaf2aa040a83dc0de4e56058d1bbb2ea40157501f3e7d15dc245e493095"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6c160fbd16adbc4bff2409e70180d911002aebcfa811eb6ec3d1040761aea6dd"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEsklEQVR4Xu2aTWoUQRzFszGEgJqETFDUEDWJCiIGVCQuXCgo2bkRv3YKHkAXuvUGbrxC8Ag5hGcaeQNv+Oc3Vd1d7aR7xuTBo6qrqmvqvfro6q5ZWKjBvUsXho83V4cKn+8MRnGFTndeLmR9c4eUqBim0v5bA2LPx1D8sb99jE5nfXOHlAE04r83IAqXaBpxKgyQ8LcPto6FNuDni70JA5R2Kgw4/PBsJDaa4Gvlsb65Aw3glJDIKrK+mYOEpFb1VNwGsHyqLMO4fsTpw/Z0DjYyXt9cWx7F1ZNHnw8mQuWpTFUdDJnP9nQO9lCMex5L7J/v70dhpOd/7FHGaQBDtqdzRMESKepawiz09+v9EXPXKqt7fD9NcOgRNZMGuKctxtcSKry5e20sWHHBJqhsNM3XFM5wZgxQY+Jw13Uc6nXUfb4nTgsKToVsT+dwY+Kzm7TQL09ujRiFk67HAmVwDJnO9nQON4o7OW5oUr3ufN4nUmguZHt6x+Dl3nDz3dMRFb8/WBreXl0chyxPcN7HuPJU787XV0kqj/URv26sDM3YGSzXGhIdGcUrZHkiGqB4HPo2IGWC01kfQQMkXiOO5VqDBrQZAaanQ0yz0BxZH0EDZnIEcEH09TQMOLwzmO4IYAOaMA7bT2vnJph6miiN5ZpQgslvG4uVVBnqzILiKLCO8YfdaMUXl5bHI2Bl/fJEPu/JkeJP3IDc4hTp6VFlgESLgyvX58cAik+ZwDUiLkputOJRvEYD880qEywm0vfkqPwiAy4+3J4QlROrsqQFRCHRAJEGpO6hEb6OZtG4FJ1PnVlQUCljo6IBEu1pIDI/xZRoCmxK6syCgkpJA9wLNsDTICfQPNq7Oi7j+NwZEBlHgKdAjhIsquHxuhMD+LwuJcW40XFR9dOC5ZoYwN9rSurMIt7EN76q116Tzosa5h4hEq/Q0yPFaByv2Za6NjmfOrM42F0fkh8fbY3I9BS5R2DP5x6nTUnhFh+32QyLDND+nrQ4pqfIBosUzGuS+45YnuJpQgxbjYB5g1+3FTLvVEDCZ+ZQpQ/YAI0C5p0aSLxMYHoviF973DssY3joRtZ9PGUdgn9nd+P8Mbo+lu8MTQ2IaV7QnMcTpVjWcL5EO03xqns6QRsDKNrDO1XWyBmQK98Z2hggKC1FlovodajnUNfwuvwSTKueBe6+ShnrqhNYl98LKKiUsa4m4pqUifCboanPXQ5TdDnWkwX35aVkfdMGX52bkvWc4QxnOEMn4LlC6eFqXMjafODQU6bXLTEPU6L4Jsfr0QCd+pZ+4pLw3I6zE9CArkeA3yVmxoC2I0CbmjYjQOI7fU/gxqgJvYtUnJ/ITe7wTOf79/lNoK73p74+UBwF1pHC2xgQ43UCvUgyvTUoiO8KzBc9PfowQKjLL0KV+JQJXCMovM4ArQ/RAGKqvdsEOvaiqJxYHqyKFC7yxYasMqC4d9lbpaSgUlJ8U1JHa1BQKSmolBTWlG4/p4jIbwikyowN4AJVSgoqJYU1pdvPdE6XHMcG/Ct4PN0V2Y7eEBvFE9x4kltC3k/OlAH8v0Dp/wskJh5zxzP+FF2O7egN/L+AWPL/AhpQJf4kRsBfkTxADPB27yIAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("90e75cd429ba6331cd210b9bd19399527ee3bab467b5a9f61cb8a27b177f6789"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEu0lEQVR4Xu2awaoURxiF3XgRwUTFkYhG1KhRCCEXjASzyEIhwZ0bMepOIQ+QLHTrG7jJK0gewYfIM42chjOc+3XVdNc4t2fGew8cqrrq75o6p6uqq7vnxIkB/PjNV/Nfrp6bK310a9bllbrcdbWU7e0cSqIyLZV9sQbklc9UfPfg5gG6nO3tHEoG0Igv3oAULtE04kgYIOF/3rt2ILUB73/f7xmgsiNhwIeXDzuxaYKPVcf2dg40gFNCIpeR7W0dJKS0qpfyNoDxpVimuX7k9GF/Jgc7mcffnT/d5XUlP/71uJeqTjHL2mDKevZncvAKZd7zWGL/f/uiS5Oe/3lFmacBTNmfyZGCJVLUsYRZ6H9PH3SsHStW5/h8muDUI2orDfCVthgfS6jw7IdvF4KVF2yCYtM0H1M4060xQJ3J4a7jHOpD1Hk+J6cFBZdS9mdyuDN57yYt9O9fv++Ywkm3Y4EyOFOWsz+Tw53iTo4bmtJVdz3PEym0lrI/G8fsj/351ee/dVT+p9mp+Z1ze4uU8QTnfeZVp3Zv/fOkSNWxPeLD3dn83xtnF+RoZHwzJDqZ4pUynkgDlM+hbwNKJric7RElAyTcI4/xzaABq4wA053KMgutke0RJQO2bgRwQfTxugxIEz57BLADY5jD9vX5kz2W7iYqY9wYWjD55uLeUjqOenugOAocYv6oO6383qnTixFw9sKlXj3PqZHCD92A2uKU9PRYZoBEi7PL13fHAIovmcA1Ihckd1r5FK/RwHpzmQkpJOnzanTbowz4+uebPVE1sYolLSCFpAEiDSidQyN8nGaRFE4DROrtgYJamT+WBki0p4HI+hJLoimuldTbAwW1kgboR9MAT4OaQPPj/pVFjPM7Z0AyR4CnQI0SLKrDeTyJAbxft5Ji3OlcVH23YNwYA/h7raTeHjKYT3zcZpZIx0UNc48QiVfq6VFiGsdj9mVMvzKGent4fPvCnHx1/1pHlpfIPQKvfO12OpYUneJzq810tAHa35MWx/IS2WGRgnlMct+R8RReMiHT5hGwi/Ajt1LWHQlI+NZ8WNkEbIBGAeuODCReJrB8cuTbHl8Zxhgetsmhl6dsw/Bv3b545gDdJuMnwVgDssyLmev4RSljE46RaJcpP3TeoWIVAyjaQ7sUm6gZsOycQ8cqBggqK5FxxMaGeg1DHR+qb8Va2uLuq5XZ1pDAofqNgIJamW2NETcmJuEnw6RedTktMWPYXg/cl7eS7a0bfHRuJds7xjGOcYxDBb8rtH5czQWsW9XxkoPxhO4yG90O82NKih/zeZ0G+KuvTWA8IeG1HeckoAFTjwA/S2yNAauOAG9qWkeAxE/6jMCN0Rh6F6k8X5Gb3N2RjuP7gLFXf23rBMVR4BApfBUD3JeW9wFeLFneDAriswLrRU+PTRogjI1bimXiSyZwjaBwM9cB0uuE4tiftVzVFuizF0XVxPLDqkjhFj+GJQOaryqvVispqJUU30rqaQYFtZKCWklBreT0EPN9QIkZ01vEWklBraSgzyWnyRA5IJrhndqmyP5MjuwMv97mV9wW8vwat8IA/l+g9f8FEpGfuPl9v0bHsz+Tg/8XEFv+X0ADxohPE9ifVnwCzC7McvDpKlsAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("d5c4ee5ce20aed9e33e866c66caa37178606234b3721084bf01d13320fb2eb3f"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEwElEQVR4Xu1av2sUURgMqCABQQVBBK0SCdooMQQD5jSFkNgpKdIEwSZoZ6GYJohNUmlhqrSxsUlhYZM/If/TmdncrLOz3+7drcneXtyB4f36bvNm3vd2l32ZmOiD+3eudMHZqWtJyTrbn189LqVfb+xAwZ17N1LxqLsBH17MZniuDFDBXn5b6+TEkxjz640dXLSmP8r/wgDd+74VILJoC5wLA3TFNRsgHqRQv/mh7+v64vkwwO8BSoiE4KLSrzd2UNF+QwQpdnn6aobs9+s1DpraFDZ183Im5ZkFSjVBf6PX0WtEcSh9PrUjmiBIIVpX8V7yd5Foxvn10efzqR0qwifMvr03y91fn9a7v7+8S8qf79eSvkfT13OxINvs0zpNYJ/Pp3ZwQjpRXUkIhWAIp3gSY5Go6JqsM55tn0/t0ImxxCMM4iACQoGXT3ZS0agDaCMGffhNdC0aGhnUCAN8hShIyRV/+/RBQs0AJUX5yuv9QQ1B2+dTOzgRlFx5PsO/v15MX2jwaPu4NJdw6fZkJoaPPGaCXtMN8LbPp3bwrU3FuwkoIRomsKRoGqS/5fX0zfDurckM2e/zGTkWHu50lfPz8wlnZmYSenwOR0fp9lBj0Ycxv77TL5fDwUE34f5+Znt6WGX4hCicRnh8DsciVbyacNoGqNEeVhk+oSoZcNYGTB4epgY0MgPOdAscC9ct8M8Z8Gxurwvij6N8vvAjJftYkjp+cXc3Q0/PVHxv4kXxYKafQpW9uLS9tZWlxg4KChzUAB0HMdlL29sJU0E9oUxP74/iMyK9j/2RARsbJ6QBHB8UZQI9O3wchJALm5sJUdcURel9Hu8muFkZ8WJGMlZkADgoygQWGaB1iiH7CfL4UJi0c2mupHg1gRwULpDtqE/HSBeUWXEzQFc/MiC39/f/3uh4s9N6RI67zkK4Ab7CkSHa9pRODegJcQM83jMkFd9r66OON1Rtq+hKj8XIAN8GZeMURXoK66p6bBTvv3VhKj4ygKXrLASFFQnsZwAmqWKSDNAVZTb0BHl8aIDED2oAykoZ4OmtwpUeR1KQiqNwF8cYj8sYIOIR4yvrBqgRlQxYWVnpgn5zczLOqaIiM1Sgx6YGiGgVD6rgoizQLTC0AXi97XQ6GVGrq6s5oYjxWNR1i6hhmjk0gsI8RrePxiATIwM0K9wAlq6zEHzHpzgXSOFRHOpqAKnbiCVX1OP9XsMY9pcJ1/3vRrjOFi1atGjRosX4AR9Ai5h5fT44+ViajrVo0aJFixYtWtQN/5Q29OGqfELTDyEe1li4AUMfr4sBmZPlcYEbUCUD+JrLDBhrAypnwHFZ6airbujXX//I6WM6zpjcgaefEbDtbAoGEcls8DEwPOJWA9wEtpsCNwClG6Dj/QxID0vcCK83BS7QRfq49iUGlJ3xl5HQTOmZ498NnKdqoItnO+rTMdLP9/XG5wce2u/zGBmKBPtq67jGu1h99Hm7kY/FSGC/LaB0A4pEs5+lz2NkcIEoIwMic8DofM/Fq/DGZYCnv6+09kfbJNrnkXDv93mMDDxJpsAi+nE7WSY+Et44A3hcrqKG+f+CyAAV7Qaw7fMYGaL/G1CBFB7FoQ5BRfeByAiO+zyq4g8lK5z2I+oYkQAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("31f477eb1a7beee631c2ca64d06f8f68fa93a3386d04452ab27f43acdf1b60cb"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE5klEQVR4Xu1aMWsUQRgVVJCAoIIgglaJBG2UGIKCOU0hJHZKijRBsAnaWSjaiNjESgtTpT0bmxQWNvkJ+U9n3ube8ubNt7e5xOzdxX3wmNmZb/fmvflmdtm9M2dqcOfmxR44N325KFnn8ecXDwbSrzdxoODO7auleNTdgHfP5hKeKgNUsJff1zqZeBJ9fr2Jg4vW9Ef5Xxiga9+XAkRWLYFTYYDOuGYDxIMU6psf2r6tL54OA3wPUEIkBFeVfr2Jg4r2DRGk2OWZSwnZ7tcbO2hqU9j0tQtJyjMLlGqCnqPX0WtEcSh9PI0jGiBIIVpX8V7yvEg04/z6aPPxNA4V4QNm2/ar5d7vD+u9P1/eFOWvt2tF2/2ZK1ksyGO2aZ0msM3H0zg4IB2oziSEQjCEUzyJvkhUdE3WGc9jH0/j0IGxxC0M4iACQoHnj76WolEHcIwYtOGc6Fo0NDJoLAzwGaIgJWf89eO7BTUDlBTlM6/7gxqCYx9P4+BAUHLmeQ//8XKxfKDBre390nzBpRtTSQxvecwEvaYb4Mc+nsbBpzYV7yaghGiYwJKiaZCey+vpk+Gt61MJ2e7jGTke3vvaUy4sLBScnZ0t6PEZ9vbK5aHGog19fn2nXy7Dzk6vZLebLNHiN44LHxCF0wiPz7AvUsWrCSdhgO5H+B0PHxo+oKNkwEkbMLW7mxkwVhlwoktgX3S0BI6cAU/mt3sgfhzl04c/S7KNJan957a2EurMYFCl+P7Aq+LBpF2FiuCSbPv0KaWfUwcKPKwB2g9isOc3NwuWgvpCmZbeHsUnIr2N7S4ehOiNjQPSAI2rwyCBnh3eD0LI2Y8fC6Je/nC/9DaPdxPcrEwQ2T3ImNAAPacOgwRWGaB1iiHrBHl8IoyiVISnuJPi1QRlHVwgj6M27SNdUDLjZoDOfmRAtva7B5scyQ1Vy4h6juvN4Ab4DEeG6LGndGlAX4gb4PGeIaX4/rHe4nTH1zYVrfHF5luHyABfBoP6KYr0FNZZ9dgo3s+NRKkRkQFad70ZKKxKYJ0BGKSKKTJAZ5TZ0Bfk8aEBEh8ZoEaoASg9xvVm8PRW4UqPIylIxVG4i2OMxyUGiHjE6Ky6OBqgRniM682wsrLSA31zczLOqaIiM1Sgx5YGiGgVD6pYFxeZ4P2uNwMebzudTiJqdXU1E4oYj0Vdl4gapplDIyjMY3T5aAwyscoAzQw3QGNdbwY+41OcC6TwKA51NYDUZcSSM+rxvtcwhu0qxkWCfmv0GNfbokWLFi1atJgc4AVoFf3xmU+dSUyLFi1atGjRokVT8FdpQ39c1XeE+/S3QB4+dnADhv68bgbwTc+hX3GNGm7AUTOAj7qaARNpwLEyYL+uGTCWS0Df/vpLTu/TfsZkHzODbwS6JDKOGocRyWzwPjD5wqsGDDJB20YNNwClG6D9dQaUH0uqBLsho4YLdJHer22FAXXf9+uoWdI3xt8bVPGfGOjieRy1aR/p3/b9Q4d/8FCiz8fTOKoE+2xrv8arGL3f687vt8Kxui1GAuuWgNINGCRc+1j38TQOF4gyMiAyB4xS3YW7AUofT+Pw9PeZ1vZomURr3EUOygofT+Pgl2QKrKJ/bifrxFcJHxsD+LlcRQ3z/4LIABfsx9rm42kc0f8GVCCFR3GoQ0jVPuBZEfX5eIbFX3srPNN8aUvJAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b66bc80f002b10371e2fa23de6f230dd5e2f3affc2e15786f65bc9be4c6eb71a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFG0lEQVR4Xu2az4odRRjF8wYiuHIhOoIuRAYiMkNEREWYIRLjahJGmIUaYVAkszAQgwHJMBsRwexCiDAbxYVZj27i0ieY5/AFWk/DuZz8Ul33L/dPpg8cqrq+6rp1Tn3dfW/1PXduCNbW1pr19fVmc3Oz5fb2dluqTbGHn29UyfFWDhadwvOYgkmOt3KQYGeAV911kYJJjrdy6DNghAx4dPDOE8LdxvFWDqNkgMSmCXnM8VYOpSeA6Qw4vXNxINpU21NhQD4GZQAvB4stUTGOt3RIcbmyKlN8zYSM51i+T7heOpfzmTs8SU7OE0zhXQaUhHYZwPM4n7mjtMopykzhKc5kJrjNY/FzlsaAnffON+aVDzYGLE2cxmSfPDfHFC+/9XqRinE+c8d3V99uzH8f7LXUDUzHEqa68H/Xwc1NdUF19VFf1X1+jjmMnM/c8cPuhcbMx5eOVVpUF9Xn4NKbjz0Oc0zz9sdvPEa3cz5zR+kLDB9nFnvj/RdbZqbwsVca78G1MpfiMenJkvxSw5VPwSl6XHI+C8e7B382l249aqn63t5es7W11VJ19idu3jvfrH/6xxPZsfvtc41ir175pUqOR2icL398uR0vs5D9JgYNsHiT/QlNTAbwUvCkJfL5i3eLHMUAjSMjVUr4zDOJBoybAZpYyQCv3LQGzD0DJDrJ/kReAnkP8cox5UmOR8w8AzRZfbDKGt2HBmklPvrqmUHpDNAkvTqq58pl/2zPUmOo/snNF5oPv3h2UGa/Wow6O2FxF67/PRArYaKPFUuTMs7JizbGK0VxLN0vS4+9f/RaK9Bl9qvFqLMTNIAr7FhmwNWjf1p2ZYDaLZDi2J9muFxIBqgcZoCYcYkgT05OBnVNhuKypBk+z2NL2CSkzk6UDPAKD7sEbBCZ/dIIiaZZZI6tMShsVFJnJ2yAJ50Csy0vgezXdX5eJmrLrKBZN47uDz7T2eUMyzs9v1zVYtTZCa7euLQBeQ+RcApxvzzH/WWA6qVz8llf+jqeBmSMOjtBQeOyZIAEZAZYlDOEN1XXLdznqKRoGmATJjaAqZMDsd2xrJcMKKWySQN8fp6T/Sm6ZADnPbEB/ICuD0rSgExlZ0GmdM0AmqcY5zJsTmMbwB87/s6fvwHIjPNSyNTmypf6cvXz0lGMwi0+r32WYxmguzN5enraku2lODOAokttvAekaB5TPE3IcqIM6NGjR48ePXosLa4dvdQkD3/bGLAW4zgrC+32JLWn8M29V9qyFuM4Pbrw2Z2HDXn9p79asr0U53iTQPsBbJsbdm/92pAWyvZSnONNgu9//n0m46wszrwBX9++e7YN6PE0IXd3VM+dpVFeruaLk/39/ebw8LAl+y0taAC309ifyFdpOzs7rQnHx8dDz1sa0IAznwHeQDXZn1i5DPCmqDdAu5iboGkQ3/BmBtRinMfCwF3h3AX2Md8DZJzv+DMDajHOY2GgAVxhx7q2wWurXItxHgtDGsCXHF0GOK5jvtYelf782p5ALZYapgIN0DFfjdOAvAQobFT682t7ArVYapgKNsDXN69xMw3Ifn7FldTd39RTQMw29eE8JgZ/6/P3fokZl7BpKHF+rWWh+fwn3Yc6JgZ/6/P3fokZp6BxyVW2eD37Kd5t6kMdCwNfT+cLSrY7lvVMbaZ+yRzHOY+FgeJKpAlJC8oU58qTS5UB/LHD/w+UmPHS6tZMmPk9YFrwvwP8/0CJGZcoZwDF+5rn/WCWGfAfZVbEuZ74z00AAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("a3bd16079f764cd541e072e888fe43885e711f98658323db0f9a6045da91ee7a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJElEQVR4Xu2awYscRRjF8x+I4MmD6Ap6EFmIyC4RERVhl0g0p01YYQ9qhEWR7MFADAYky16CCOYWJMJeIjkk59VLPPoX7N/hP9D6Gt7w9jfV09O7Y+9Mth88qrq+6m/qffVV90zVnDvXgqWlpWp5eblaXV2tub6+Xpdqk+3JlysTSX8LB4tO4XlNwST9LRwk2BngWXddpGCS/hYOQwZMkQFPd94bE+42+ls4TJMBEptByGv6WziU3gCmM+DwzsWRaFNtz0QA8jWoAHA5WGyJstHf3CHF5cyqTPGTgpD29OXnhOulezme3uFBcnAeYApvCkBJaFMAeB/H0ztKs5yizBSe4kxmgtvsi58zNwHY+OB8ZV75aGXE0sAZmOyT96ZP8dN33ixSNo6nd/xw9d3K/OfBVk09wHQtYaoL/3UdPdxUF1RXH/VV3fenzzZyPL3j7uaFyszXl65VWlQT1Wfn0ttHXofp07x9+a0jdDvH0ztKX2D4OrPYGx++XDMzha+9kr8H18qci9ekB0vySw1nPgWn6K7keE4d7+/8UV269bSm6ltbW9Xa2lpN1dmfuHn/fLX8+eOx7Nj8/oVKttev/DaR9Ed8/dOrtS+XzEb27wwGwOJN9ic0KAWAS8EDlsgXL94rcpoAKIjy5dLiZ5ZRDEDXDNCgSgHwrJ00AL1ngEQn2Z/IJZDPEM8aU56kP2LmGaDB6oNVTqL7MECahU++eW5UOgM0QM+K6jlr2T/bs5QP1T+7+VL18VfPj0r2pZ1+qHcMFnfh+l8jsRIm+lq2DFLaOSDRgfEscVAsOavOItW3996oxblkX9rph3rHwABwhm3LDLi693fNpgxQuwVywOzPYLg8lQxQ2RYAMe0SQR4cHIzqGggHlSWD4fvsW6JOQuodQykAnuG2JeAAkdkvAyHRDBaZvuWDgrqSesfgAHjQKTDbcglkv6b7c5moLbOCwbqx9+voM51dzrB8wvPLlTnJTr1j4Ox1pQOQzxAJpxD3y3vcXwFQvXRPvuP5dZyvwFIf6h0DBXVlKQASkBlgUc4QPlRdt3Dfo5KCyMyCYwWAKZNO2G5b1ksBKKWyyQD4/rwn+1MQWRrfsQPAm5s+JMkAZCo7CzKlJwWAwZONY5lmXJ0CwB87/s6fvwHItHMpZGpz5kt9Ofu5dGSj6BSfa5/l1AHQ05k8PDysyfaSnRlA0aU2PgNSNK8pvBSELDtnwIABAwYMGDBgbnFt75Uqufv7yhG22elv4aCdnqT2FL67/1pN1dvs9DeA+OLOk4q8/vOfNdlestPfcaD9ALb1hs1bDyvSQtlestPfcfDjL49m4mdhceYD8O3te2c7AAOeJeTujuq5szTN4SoPTnZ3d6vt7e26FNl/7sAAcDuN/QkepW1sbNRUEPb391vvP3UwAGc+A7yBarI/sXAZ4E1Rb4A2MTdBM0A83WUG0M4DVo6nd3BXOHeBfc1zgLTzfJ8ZQHvapjr//7/BAHCGbWvaBucML3QG8JCjKQC265rH2V3Zth/QZqeezmAAdM2jcQYglwAFdWXbfkCbnXo6wwHw+uYaNzMA2c/HW0k/+fMNILJdfTmezuBvff7eLzHtEnYSSpiPtCwy3/tNdF/q6Qz+1ufv/RLTTkFdyRm2eL3zKdq0TX2pp3fwaDoPJ9luW9aZ1pn2TUsg6xxP76C4EhmEpIVkanPGmzgXGcAfO/z/QIlpz9TPGaZYcmbPgJOC/x3g/wdKTLvEOAMoXmvd6730TJhFBvwLF5puRHnAy2sAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEw0lEQVR4Xu2asYsVVxjFNdgasAmCRMiSRMXCBBZsXpRYSdhmsbOxtNFyOzGk0DKFCUIgTVKk0lIw5B+QNGkD+VvWkTNwlrO//e6+nd3ne2/MHDjMnTvfu++ec787M+/ed+rUHHx85nS38cm57sK5s53KPrr83def9bx348o+up7tjQ4pvDLCQtOErGN7owPF65hZkWIrsr3RIUVz9JkBFdne6JDzXUZwGlAwyfZGB4qnCRRMsr3RIef+1U/PH7gXWOgH/RTwqNsAZgDFpwlsb+1gIZzbeaPjNOC1jMljVcfPsj9LR4pj2UeN5sPbX3WP71zvqbLqKKwyJ9tKs/0Z9mfpyM4mLTqFk75uM9IICs1jxrE/S0d29Nn9rV7YUcRXJuhcbaRwiqcp7M/S4U7qhtUSL3x78aO9c5UFx9EEtVVNBU6XtTAgO2/RaYCzoqKupQH+bJpIM9KEtXhK+HHlRxcFJXdml3v6PGPNfARWbTKW/Vk6+OZGZmYkKXYeHcvPsD8rx+tfdrrkxsbGPjKe+P6Lze7PH58fEKw6XXO7T+/d3kfXsz3iv2tbnfj3l7e6J3dv9vzp4fbczx0ZNGBzc3NPvMqMJ9KAHH0a0CLbIyz+98uzPgsXnkm/PbrfJRdtwL+vf96jRWcd2yPeewZUBiQZT9iAvEeo/D4MWEgGqAMSquM/L5/0dGd8XsWYSkUJE925N3+8OHDHlwG+7nh9VtS1irr21+ff9JTgFO9pkG05TqTOJnL+UVwa0IrRl2Un3FGZIFqMO65O0gALE11n4RaVZce4HX6/4qiziUoYMyBNYFlflp2uRirFZHwVm1Q9jTBbBrh96myiJf4wE5I0IEczhWUGHDbCFEqRRyV1NpEGULyu8V6Q8Sp7NFIAhdAQm1AJzxiR95J8i6zo69TZBEVRcEu8aTFpQiU8syGnAGNTvMoUztdwCvc16myiJUzUnX+eARZDMht4vWIVn2Jdrl7HWU+dTfC5P5Q2JsmplFNqKCvRVRZkvUidTVDQUD64canJH3591ZP1Q0jRpt4AbYCpuqUbQHIEScbPYyW8Ep8miNTZRL7ni7PZrNve3u6Pouv5q9BMUSrzHpGkCT7n9TzPdNdR7/1Oe6c+j4N+G1hoim2dV6wE5ZxXfb5TMJ4mkTTAP34sWHU+WvygDJgwYcKECRMmrC20qvR2d7ckl9AcqyPbmTBhQg3+Zh9Ktjc65C+v1i+yvMY6tjc6cESHku1NmPA/h+8PJleQGE/kErkXN0a1wEEDhm6vayncJnjnVys9jFtbcFFzqAGjz4DKgCH/L0gDRpEBSvNc4OQiqN8bWougSvnc3Z23VeZz9mNlyPluAyiSMUnu6npLjMKzrOvsx8pAYSozA9IElisDPNIp3PuFPrIfKwNHNQ1omZCkAUelv59rAlwzMP2PlYWvF6QBfE2mCVW2cH8/t7pbVAz7sTJQVAo+TLxpQRTunR7S9ezHytASRrbiaEBug1XCvVXGfhwbTNeh5HN/KC0wDeB+YEXqODZyhKoRy5HLo8sUNJRM8dzjbxmw0DdDjuhQUtBQ0gCbUBmR/w2gjpXhpP8vyPTPe4F+B9CA3BZnP1aGk/6/QMK8v5/iMwMy9W0C+3FcvANG/3mGjdf2WwAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("f5dddb41dcafef616e959c2817808e0be741c89ffbfed39134a13e75b811863d"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE10lEQVR4Xu2asYsUSRjFvePSEzY5DkTB5TwVAxUWTMYVjeQwGcwuMdzkNtxMPC5YQwMVQTDRwEhDweP+ATExFe5v8fp4DW95++vqmS12bae1Hzyquvqb2nqvvurpqdpjx5bg+A/fNes/rTUn1n5sVHfp+m+XT7e8s3l+H93O/kaHFF4ywkLThGxjf6MDxavMrEixJbK/0SFFc/aZASWyv9Eh17uM4DKgYJL9jQ4UTxMomGR/o0Ou/Qsnf+48Cyz0q/4W8KzbAGYAxacJ7G/lYCFc2/mg4zLgvYzJstTGz3I8gyPFse5Ss7l981Jz7/aVlqqrjcJK5mRfabY/w/EMjhxs0qJTOOn7NiONoNAsM47jGRw50Idbt1phBxFfMkHX6iOFUzxN4XgGhwepB1afeOH6qe/3rlUXHEcT1FdpKXC5rIQBOXiLTgOcFSXqXhrgz6aJNCNNWIlvCX9d+auLgpI7s3MtfZ2xZn4FlvpkLMczOPjmRmZmJCl2GR3Lz3A8Xxxvn+40yfX19X1kPPHnmY3m7wdPOoLVpnvu9/6dm/vodvZHvP/1RvPvxVstVddk7P5+reWj7fnSzy8FDdjY2NgTrzrjiTQgZ58G9JH9ESn+xbnZXjYeWUY9v7vVJI/agI9vH+/RorON/RE24LNlQMmAJOMJG5DPCNWPyoDSEjhUBmgAEqryw+vdlh6Mr0sxptJQwkQP7N3LV50nvgzwfcfrs6Lulah7//xytWUKt3gvg+zPsSb1dpDrj+LSgL4Y/ZEcgAcqE0SL8aA1QBqQwtxm4RbkuvtxnPviGPw56u2gJIwZkCawrj+Ug2aK5uBNx5dik2qnEck+A/w3VFJvB33iF5mQpAE5myksM2DRDFMoBdaSejtIAyhe9/gsyHjVPRMpgEJoiE0oCc8Ykc+SJN818qFrUm8HFEXBfeJNi0kTSsIzG3IJMDbFq07RfBtNIzLGJfV20CdM1JN/mQEWQzIbeL/EUnwKTeGlV3K2H8gAfu/X0sYkuZRySdWSgnmdWcC4QQz4Y/NsL/969qYl22tIwUm9/dkAU22DGkByBknGLyNFW3hJfJpgI6i3g3zPF2ezWTOfz9tSdDt/FZopSnU+I5I0wde8n9cWnVmgd36nvVOfpWIOZICFpti+6xJLgnLNqz3fKRhPk8iSAf7hY8Fqc2nxB86ACRMmTJgwYcLKQrtK/3361Etuo3knSvdUsr8JEybsB3+z15L9jQ75y6vvF1neYxv7Gx04o7VkfxMmfOPw88HkDhLjCW6Re3NjNBscNKD2eF1b4WmCT329I8T4lQM3NWsN4IHK6DKgZEDN/xdwCax8BijNc4OTm6B+b+jbBFXK+yDT6c8s4HGZqWuOZ3DkercBFMmYJE9zfSRmE1I4S8VxPIODwlRnBqQJrJcMyFnO2c9zQ5ccz+DgrKYBfSYkaUAtuR/A/YIk9wqOZL8gDeBrMk0oZQvP9nm+v4iK5XgGB0Wl4EXiTQuh8DwRYnuWHM/g6BNG9sXRAB6DUTzvczzVYLrWkt/7tbS4NCBZaktSTzVyhkozljOXpesUVMucYZHn+4sMUCz1VIMzWksKqiUNsAmLjMj/D6CewXHY/y/I9M9ngc/3Swbk0TjHMzgO+/8FEuSz/RTPDEjm/wdwPLX4H8bzDGXhwa6jAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }] +}); diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 421d805c1f..2beb93ed76 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod jre; pub mod logs; pub mod metadata; pub mod minecraft_auth; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 587c9559a0..d38c10c3fd 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -1,5 +1,8 @@ //! Theseus error type +use std::sync::Arc; + use crate::{profile, util}; +use data_url::DataUrlError; use tracing_error::InstrumentError; #[derive(thiserror::Error, Debug)] @@ -125,12 +128,29 @@ pub enum ErrorKind { #[error("Error resolving DNS: {0}")] DNSError(#[from] hickory_resolver::ResolveError), + + #[error("An online profile for {user_name} is not available")] + OnlineMinecraftProfileUnavailable { user_name: String }, + + #[error("Invalid data URL: {0}")] + InvalidDataUrl(#[from] DataUrlError), + + #[error("Invalid data URL: {0}")] + InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64), + + #[error("Invalid PNG")] + InvalidPng, + + #[error( + "A skin texture must have a dimension of either 64x64 or 64x32 pixels" + )] + InvalidSkinTexture, } #[derive(Debug)] pub struct Error { - pub raw: std::sync::Arc, - pub source: tracing_error::TracedError>, + pub raw: Arc, + pub source: tracing_error::TracedError>, } impl std::error::Error for Error { @@ -148,7 +168,7 @@ impl std::fmt::Display for Error { impl> From for Error { fn from(source: E) -> Self { let error = Into::::into(source); - let boxed_error = std::sync::Arc::new(error); + let boxed_error = Arc::new(error); Self { raw: boxed_error.clone(), diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index 39e9e1a33a..26a1f313f4 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -235,13 +235,41 @@ pub struct Credentials { pub active: bool, } +/// An entry in the player profile cache, keyed by player UUID. +pub(super) enum ProfileCacheEntry { + /// A cached profile that is valid, even though it may be stale. + Hit(Arc), + /// A negative profile fetch result due to an authentication error, + /// from which we're recovering by holding off from repeatedly + /// attempting to fetch the profile until the token is refreshed + /// or some time has passed. + AuthErrorBackoff { + likely_expired_token: String, + last_attempt: Instant, + }, +} + +/// A thread-safe cache of online profiles, used to avoid fetching the +/// same profile multiple times as long as they don't get too stale. +/// +/// The cache has to be static because credential objects are short lived +/// and disposable, and in the future several threads may be interested in +/// profile data. +pub(super) static PROFILE_CACHE: Mutex< + HashMap>, +> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); + impl Credentials { - /// Refreshes the authentication tokens for this user if they are expired. + /// Refreshes the authentication tokens for this user if they are expired, or + /// very close to expiration. async fn refresh( &mut self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { - if self.expires > Utc::now() { + // Use a margin of 5 minutes to give e.g. Minecraft and potentially + // other operations that depend on a fresh token 5 minutes to complete + // from now, and deal with some classes of clock skew + if self.expires > Utc::now() + Duration::minutes(5) { return Ok(()); } @@ -283,31 +311,8 @@ impl Credentials { Ok(()) } - /// Fetches the online profile for this user if possible. - /// - /// Even if assuming a flawless network connection and Mojang backend, this may fail - /// if the current access token has expired. To ensure that does not happen, log in or - /// call [`refresh`](Self::refresh) before this method. #[tracing::instrument(skip(self))] pub async fn online_profile(&self) -> Option> { - enum ProfileCacheEntry { - Hit(Arc), - AuthErrorBackoff { - likely_expired_token: String, - last_attempt: Instant, - }, - } - - /// A thread-safe cache of online profiles, used to avoid fetching the - /// same profile multiple times as long as they don't get too stale. - /// - /// The cache has to be static because credential objects are short lived - /// and disposable, and in the future several threads may be interested in - /// profile data. - static PROFILE_CACHE: Mutex< - HashMap>, - > = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); - let mut profile_cache = PROFILE_CACHE.lock().await; loop { @@ -1126,9 +1131,12 @@ async fn minecraft_token( }) } -#[derive(Deserialize, Serialize, Debug, Copy, Clone)] +#[derive( + sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq, +)] #[serde(rename_all(deserialize = "UPPERCASE"))] -pub enum MinecraftSkinModelVariant { +#[sqlx(rename_all = "UPPERCASE")] +pub enum MinecraftSkinVariant { /// The classic player model, with arms that are 4 pixels wide. Classic, /// The slim player model, with arms that are 3 pixels wide. @@ -1139,7 +1147,7 @@ pub enum MinecraftSkinModelVariant { // prevent breaking the entire profile parsing } -#[derive(Deserialize, Serialize, Debug, Copy, Clone)] +#[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)] #[serde(rename_all(deserialize = "UPPERCASE"))] pub enum MinecraftCharacterExpressionState { /// This expression is selected for being displayed ingame. @@ -1173,7 +1181,7 @@ pub struct MinecraftSkin { /// As of 2025-04-08, in the production Mojang profile endpoint the file /// name for this URL is a hash of the skin texture, so that different /// players using the same skin texture will share a texture URL. - pub url: Url, + pub url: Arc, /// A hash of the skin texture. /// /// As of 2025-04-08, in the production Mojang profile endpoint this @@ -1183,9 +1191,9 @@ pub struct MinecraftSkin { // prevent breaking the entire profile parsing rename = "textureKey" )] - pub texture_key: Option, + pub texture_key: Option>, /// The player model variant this skin is for. - pub variant: MinecraftSkinModelVariant, + pub variant: MinecraftSkinVariant, /// User-friendly name for the skin. /// /// As of 2025-04-08, in the production Mojang profile endpoint this is @@ -1199,6 +1207,22 @@ pub struct MinecraftSkin { pub name: Option, } +impl MinecraftSkin { + /// Robustly computes the texture key for this skin, falling back to its + /// URL file name and finally to the skin UUID when necessary. + pub fn texture_key(&self) -> Arc { + self.texture_key.as_ref().cloned().unwrap_or_else(|| { + self.url + .path_segments() + .and_then(|mut path_segments| { + path_segments.next_back().map(String::from) + }) + .unwrap_or_else(|| self.id.as_simple().to_string()) + .into() + }) + } +} + fn normalize_skin_alias_case<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { @@ -1215,10 +1239,10 @@ pub struct MinecraftCape { /// The selection state of the cape. pub state: MinecraftCharacterExpressionState, /// The URL to the cape texture. - pub url: Url, + pub url: Arc, /// The user-friendly name for the cape. #[serde(rename = "alias")] - pub name: String, + pub name: Arc, } #[derive(Deserialize, Serialize, Debug, Default, Clone)] @@ -1256,6 +1280,27 @@ impl MinecraftProfile { < std::time::Duration::from_secs(60) }) } + + /// Returns the currently selected skin for this profile. + pub fn current_skin(&self) -> crate::Result<&MinecraftSkin> { + Ok(self + .skins + .iter() + .find(|skin| { + skin.state == MinecraftCharacterExpressionState::Active + }) + // There should always be one active skin, even when the player uses their default skin + .ok_or_else(|| { + ErrorKind::OtherError("No active skin found".into()) + })?) + } + + /// Returns the currently selected cape for this profile. + pub fn current_cape(&self) -> Option<&MinecraftCape> { + self.capes.iter().find(|cape| { + cape.state == MinecraftCharacterExpressionState::Active + }) + } } pub enum MaybeOnlineMinecraftProfile<'profile> { diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs new file mode 100644 index 0000000000..47c64f1a29 --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mod.rs @@ -0,0 +1,179 @@ +use futures::{Stream, StreamExt, stream}; +use uuid::{Uuid, fmt::Hyphenated}; + +use super::MinecraftSkinVariant; + +pub mod mojang_api; + +/// Represents the default cape for a Minecraft player. +#[derive(Debug, Clone)] +pub struct DefaultMinecraftCape { + /// The UUID of a cape for a Minecraft player, which comes from its profile. + /// + /// This UUID may or may not be different for every player, even if they refer to the same cape. + pub id: Uuid, +} + +impl DefaultMinecraftCape { + pub async fn set( + minecraft_user_id: Uuid, + cape_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = cape_id.as_hyphenated(); + + sqlx::query!( + "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)", + minecraft_user_id, cape_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } + + pub async fn get( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + Ok(sqlx::query_as!( + Self, + "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .fetch_optional(&mut *db.acquire().await?) + .await?) + } + + pub async fn remove( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + sqlx::query!( + "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } +} + +/// Represents a custom skin for a Minecraft player. +#[derive(Debug, Clone)] +pub struct CustomMinecraftSkin { + /// The key for the texture skin, which is akin to a hash that identifies it. + pub texture_key: String, + /// The variant of the skin model. + pub variant: MinecraftSkinVariant, + /// The UUID of the cape that this skin uses, which should match one of the + /// cape UUIDs the player has in its profile. + /// + /// If `None`, the skin does not have an explicit cape set, and the default + /// cape for this player, if any, should be used. + pub cape_id: Option, +} + +impl CustomMinecraftSkin { + pub async fn add( + minecraft_user_id: Uuid, + texture_key: &str, + texture: &[u8], + variant: MinecraftSkinVariant, + cape_id: Option, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = cape_id.map(|id| id.hyphenated()); + + let mut transaction = db.begin().await?; + + sqlx::query!( + "INSERT OR IGNORE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + minecraft_user_id, texture_key, variant, cape_id + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + texture_key, texture + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(()) + } + + pub async fn get_many( + minecraft_user_id: Uuid, + offset: u32, + count: u32, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + Ok(stream::iter(sqlx::query!( + "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \ + FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? \ + ORDER BY rowid ASC \ + LIMIT ? OFFSET ?", + minecraft_user_id, count, offset + ) + .fetch_all(&mut *db.acquire().await?) + .await?) + .map(|row| Self { + texture_key: row.texture_key, + variant: row.variant, + cape_id: row.cape_id.map(Uuid::from), + })) + } + + pub async fn get_all( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + // Limit ourselves to 2048 skins, so that memory usage even when storing base64 + // PNG data of a 64x64 texture with random pixels stays around ~150 MiB + Self::get_many(minecraft_user_id, 0, 2048, db).await + } + + pub async fn texture_blob( + &self, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + Ok(sqlx::query_scalar!( + "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + self.texture_key + ) + .fetch_one(&mut *db.acquire().await?) + .await?) + } + + pub async fn remove( + &self, + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + sqlx::query!( + "DELETE FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id = ?", + minecraft_user_id, self.texture_key, self.variant, self.cape_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } +} diff --git a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs new file mode 100644 index 0000000000..49b5249ed6 --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs @@ -0,0 +1,142 @@ +use std::{error::Error, sync::Arc, time::Instant}; + +use bytes::Bytes; +use futures::TryStream; +use reqwest::{Body, multipart::Part}; +use serde_json::json; +use uuid::Uuid; + +use super::MinecraftSkinVariant; +use crate::{ + ErrorKind, + data::Credentials, + state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry}, + util::fetch::REQWEST_CLIENT, +}; + +/// Provides operations for interacting with capes on a Minecraft player profile. +pub struct MinecraftCapeOperation; + +impl MinecraftCapeOperation { + pub async fn equip( + credentials: &Credentials, + cape_id: Uuid, + ) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .put("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .json(&json!({ + "capeId": cape_id.hyphenated(), + })) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +/// Provides operations for interacting with skins on a Minecraft player profile. +pub struct MinecraftSkinOperation; + +impl MinecraftSkinOperation { + pub async fn equip( + credentials: &Credentials, + texture: TextureStream, + variant: MinecraftSkinVariant, + ) -> crate::Result<()> + where + TextureStream: TryStream + Send + 'static, + TextureStream::Error: Into>, + Bytes: From, + { + 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"), + ); + + update_profile_cache_from_response( + REQWEST_CLIENT + .post( + "https://api.minecraftservices.com/minecraft/profile/skins", + ) + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .multipart(form) + .send() + .await + .and_then(|response| response.error_for_status())?, + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +async fn update_profile_cache_from_response(response: reqwest::Response) { + let Some(mut profile) = response.json::().await.ok() + else { + tracing::warn!( + "Failed to parse player profile from skin or cape operation response, not updating profile cache" + ); + return; + }; + + profile.fetch_time = Some(Instant::now()); + + PROFILE_CACHE + .lock() + .await + .insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile))); +} diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index ec6d5426e1..ab7a5e3e94 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -28,6 +28,8 @@ pub use self::discord::*; mod minecraft_auth; pub use self::minecraft_auth::*; +pub mod minecraft_skins; + mod cache; pub use self::cache::*;