Merge commit '8faea1663ae0c6d1190a5043054197b6a58019f3' into feature-clean

This commit is contained in:
2025-07-05 23:11:27 +03:00
512 changed files with 16548 additions and 3058 deletions

View File

@@ -9,7 +9,7 @@ use std::path::PathBuf;
use sysinfo::{MemoryRefreshKind, RefreshKind};
use crate::util::io;
use crate::util::jre::extract_java_majorminor_version;
use crate::util::jre::extract_java_version;
use crate::{
LoadingBarType, State,
util::jre::{self},
@@ -38,9 +38,9 @@ pub async fn find_filtered_jres(
Ok(if let Some(java_version) = java_version {
jres.into_iter()
.filter(|jre| {
let jre_version = extract_java_majorminor_version(&jre.version);
let jre_version = extract_java_version(&jre.version);
if let Ok(jre_version) = jre_version {
jre_version.1 == java_version
jre_version == java_version
} else {
false
}
@@ -135,7 +135,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
#[cfg(target_os = "macos")]
{
base_path = base_path
.join(format!("zulu-{}.jre", java_version))
.join(format!("zulu-{java_version}.jre"))
.join("Contents")
.join("Home")
.join("bin")
@@ -157,8 +157,8 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
}
// Validates JRE at a given at a given path
pub async fn check_jre(path: PathBuf) -> crate::Result<Option<JavaVersion>> {
Ok(jre::check_java_at_filepath(&path).await)
pub async fn check_jre(path: PathBuf) -> crate::Result<JavaVersion> {
jre::check_java_at_filepath(&path).await
}
// Test JRE at a given path
@@ -166,11 +166,11 @@ pub async fn test_jre(
path: PathBuf,
major_version: u32,
) -> crate::Result<bool> {
let Some(jre) = jre::check_java_at_filepath(&path).await else {
let Ok(jre) = jre::check_java_at_filepath(&path).await else {
return Ok(false);
};
let (major, _) = extract_java_majorminor_version(&jre.version)?;
Ok(major == major_version)
let version = extract_java_version(&jre.version)?;
Ok(version == major_version)
}
// Gets maximum memory in KiB.

View File

@@ -39,21 +39,27 @@ pub struct LatestLogCursor {
#[serde(transparent)]
pub struct CensoredString(String);
impl CensoredString {
pub fn censor(mut s: String, credentials_set: &Vec<Credentials>) -> Self {
pub fn censor(mut s: String, credentials_list: &[Credentials]) -> Self {
let username = whoami::username();
s = s
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
.replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\");
for credentials in credentials_set {
for credentials in credentials_list {
// 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
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")
.replace(&credentials.username, "{MINECRAFT_USERNAME}")
.replace(
&credentials.id.as_simple().to_string(),
&credentials.offline_profile.name,
"{MINECRAFT_USERNAME}",
)
.replace(
&credentials.offline_profile.id.as_simple().to_string(),
"{MINECRAFT_UUID}",
)
.replace(
&credentials.id.as_hyphenated().to_string(),
&credentials.offline_profile.id.as_hyphenated().to_string(),
"{MINECRAFT_UUID}",
);
}
@@ -210,7 +216,7 @@ pub async fn get_output_by_filename(
.await?
.into_iter()
.map(|x| x.1)
.collect();
.collect::<Vec<_>>();
// Load .gz file into String
if let Some(ext) = path.extension() {
@@ -350,7 +356,7 @@ pub async fn get_generic_live_log_cursor(
.await?
.into_iter()
.map(|x| x.1)
.collect();
.collect::<Vec<_>>();
let output = CensoredString::censor(output, &credentials);
Ok(LatestLogCursor {
cursor,

View File

@@ -31,8 +31,8 @@ pub async fn offline_auth(
#[tracing::instrument]
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?;
let users = Credentials::get_active(&state.pool).await?;
Ok(users.map(|x| x.id))
let user = Credentials::get_active(&state.pool).await?;
Ok(user.map(|user| user.offline_profile.id))
}
#[tracing::instrument]

View File

@@ -0,0 +1,530 @@
//! Theseus skin management interface
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
pub use bytes::Bytes;
use futures::{StreamExt, TryStreamExt, 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,
},
},
};
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;
#[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<str>,
/// The URL of the cape PNG texture.
pub texture: Arc<Url>,
/// 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<str>,
/// The name of the skin, if available.
pub name: Option<Arc<str>>,
/// 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<Uuid>,
/// The URL of the skin PNG texture. Can also be a data URL.
pub texture: Arc<Url>,
/// 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(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),
}
/// 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<Vec<Cape>> {
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<Vec<Skin>> {
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: png_util::blob_to_data_url(
custom_skin.texture_blob(&state.pool).await?,
)
.or_else(|| {
// Fall back to a placeholder texture if the DB somehow contains corrupt data
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(),
})
}
});
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::<Vec<_>>()
.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(&current_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(skip(texture_blob))]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape_override: Option<Cape>,
) -> 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 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<Cape>) -> 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,
png_util::url_to_data_stream(&skin.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(())
}
/// 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, processed is returned texture as a byte array in PNG format.
#[tracing::instrument]
pub async fn normalize_skin_texture(
texture: &UrlOrBlob,
) -> crate::Result<Bytes> {
png_util::normalize_skin_texture(texture).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<Bytes> {
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())
}
}
}
/// 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<Uuid>,
) -> 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(())
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -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/<texture_key>`.
pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = 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,
}]
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -0,0 +1,323 @@
//! Miscellaneous PNG utilities for Minecraft skins.
use std::sync::Arc;
use base64::Engine;
use bytemuck::{AnyBitPattern, NoUninit};
use bytes::Bytes;
use data_url::DataUrl;
use futures::{Stream, TryStreamExt, future::Either, stream};
use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge};
use url::Url;
use crate::{
ErrorKind, minecraft_skins::UrlOrBlob, util::fetch::REQWEST_CLIENT,
};
pub async fn url_to_data_stream(
url: &Url,
) -> crate::Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>> {
if url.scheme() == "data" {
let data = DataUrl::process(url.as_str())?.decode_to_vec()?.0.into();
Ok(Either::Left(stream::once(async { Ok(data) })))
} else {
let response = REQWEST_CLIENT
.get(url.as_str())
.header("Accept", "image/png")
.send()
.await
.and_then(|response| response.error_for_status())?;
Ok(Either::Right(response.bytes_stream()))
}
}
pub fn blob_to_data_url(png_data: impl AsRef<[u8]>) -> Option<Arc<Url>> {
let png_data = png_data.as_ref();
is_png(png_data).then(|| {
Url::parse(&format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(png_data)
))
.unwrap()
.into()
})
}
pub fn is_png(png_data: &[u8]) -> bool {
/// The initial 8 bytes of a PNG file, used to identify it as such.
///
/// Reference: <https://www.w3.org/TR/png-3/#3PNGsignature>
const PNG_SIGNATURE: &[u8] =
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
png_data.starts_with(PNG_SIGNATURE)
}
pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> {
if !is_png(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(
png_data
.get(16..20)
.ok_or(ErrorKind::InvalidPng)?
.try_into()
.unwrap(),
);
let height = u32::from_be_bytes(
png_data
.get(20..24)
.ok_or(ErrorKind::InvalidPng)?
.try_into()
.unwrap(),
);
Ok((width, height))
}
/// 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, processed is returned texture as a byte array in PNG format.
pub async fn normalize_skin_texture(
texture: &UrlOrBlob,
) -> crate::Result<Bytes> {
let texture_stream = SyncIoBridge::new(Box::pin(
match texture {
UrlOrBlob::Url(url) => Either::Left(
url_to_data_stream(url)
.await?
.map_err(std::io::Error::other)
.into_async_read(),
),
UrlOrBlob::Blob(blob) => Either::Right(
stream::once({
let blob = Bytes::clone(blob);
async { Ok(blob) }
})
.into_async_read(),
),
}
.compat(),
));
tokio::task::spawn_blocking(|| {
let mut png_reader = {
let mut decoder = png::Decoder::new(texture_stream);
decoder.set_transformations(
png::Transformations::normalize_to_color8(),
);
decoder.read_info()
}?;
// The code below assumes that the skin texture has valid dimensions.
// This also serves as a way to bail out early for obviously invalid or
// adversarial textures
if png_reader.info().width != 64
|| ![64, 32].contains(&png_reader.info().height)
{
Err(ErrorKind::InvalidSkinTexture)?;
}
let is_legacy_skin = png_reader.info().height == 32;
let mut texture_buf = if is_legacy_skin {
// Legacy skins have half the height, so duplicate the rows to
// turn them into a 64x64 texture
vec![0; png_reader.output_buffer_size() * 2]
} else {
// Modern skins are left as-is
vec![0; png_reader.output_buffer_size()]
};
let texture_buf_color_type = png_reader.output_color_type().0;
png_reader.next_frame(&mut texture_buf)?;
if is_legacy_skin {
convert_legacy_skin_texture(
&mut texture_buf,
texture_buf_color_type,
png_reader.info(),
)?;
}
let mut encoded_png = vec![];
let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64);
png_encoder.set_color(texture_buf_color_type);
png_encoder.set_depth(png::BitDepth::Eight);
png_encoder.set_filter(png::FilterType::NoFilter);
png_encoder.set_compression(png::Compression::Fast);
// Keeping color space information properly set, to handle the occasional
// strange PNG with non-sRGB chromacities and/or different grayscale spaces
// that keeps most people wondering, is what sets a carefully crafted image
// manipulation routine apart :)
if let Some(source_chromacities) =
png_reader.info().source_chromaticities.as_ref().copied()
{
png_encoder.set_source_chromaticities(source_chromacities);
}
if let Some(source_gamma) =
png_reader.info().source_gamma.as_ref().copied()
{
png_encoder.set_source_gamma(source_gamma);
}
if let Some(source_srgb) = png_reader.info().srgb.as_ref().copied() {
png_encoder.set_source_srgb(source_srgb);
}
let mut png_writer = png_encoder.write_header()?;
png_writer.write_image_data(&texture_buf)?;
png_writer.finish()?;
Ok(encoded_png.into())
})
.await?
}
/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the
/// native 64x64 format used by modern Minecraft clients.
///
/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method.
#[inline]
fn convert_legacy_skin_texture(
texture_buf: &mut [u8],
texture_color_type: png::ColorType,
texture_info: &png::Info,
) -> crate::Result<()> {
/// The skin faces the game client copies around, in order, when converting a
/// legacy skin to the native 64x64 format.
const FACE_COPY_PARAMETERS: &[(
usize,
usize,
isize,
isize,
usize,
usize,
)] = &[
(4, 16, 16, 32, 4, 4),
(8, 16, 16, 32, 4, 4),
(0, 20, 24, 32, 4, 12),
(4, 20, 16, 32, 4, 12),
(8, 20, 8, 32, 4, 12),
(12, 20, 16, 32, 4, 12),
(44, 16, -8, 32, 4, 4),
(48, 16, -8, 32, 4, 4),
(40, 20, 0, 32, 4, 12),
(44, 20, -8, 32, 4, 12),
(48, 20, -16, 32, 4, 12),
(52, 20, -8, 32, 4, 12),
];
for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS {
macro_rules! do_copy {
($pixel_type:ty) => {
copy_rect_mirror_horizontally::<$pixel_type>(
// This cast should never fail because all pixels have a depth of 8 bits
// after the transformations applied during decoding
::bytemuck::try_cast_slice_mut(texture_buf).map_err(|_| ErrorKind::InvalidPng)?,
&texture_info,
*x,
*y,
*off_x,
*off_y,
*width,
*height,
)
};
}
match texture_color_type.samples() {
1 => do_copy!(rgb::Gray<u8>),
2 => do_copy!(rgb::GrayAlpha<u8>),
3 => do_copy!(rgb::Rgb<u8>),
4 => do_copy!(rgb::Rgba<u8>),
_ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations
};
}
Ok(())
}
/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf`
/// whose top-left corner is at coordinates `(x, y)` to a destination rectangle whose top-left
/// corner is at coordinates `(x + off_x, y + off_y)`, while mirroring (i.e., flipping) the
/// pixels horizontally.
///
/// Equivalent to Mojang's Blaze3D `NativeImage#copyRect(int, int, int, int, int, int,
/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`,
/// respectively.
#[allow(clippy::too_many_arguments)]
fn copy_rect_mirror_horizontally<PixelType: NoUninit + AnyBitPattern>(
texture_buf: &mut [PixelType],
texture_info: &png::Info,
x: usize,
y: usize,
off_x: isize,
off_y: isize,
width: usize,
height: usize,
) {
for row in 0..height {
for col in 0..width {
let src_x = x + col;
let src_y = y + row;
let dst_x = (x as isize + off_x) as usize + (width - 1 - col);
let dst_y = (y as isize + off_y) as usize + row;
texture_buf[dst_x + dst_y * texture_info.width as usize] =
texture_buf[src_x + src_y * texture_info.width as usize];
}
}
}
#[cfg(test)]
#[tokio::test]
async fn normalize_skin_texture_works() {
let legacy_png_data = &include_bytes!("assets/default/MissingNo.png")[..];
let expected_normalized_png_data =
&include_bytes!("assets/test/MissingNo_normalized.png")[..];
let normalized_png_data =
normalize_skin_texture(&UrlOrBlob::Blob(legacy_png_data.into()))
.await
.expect("Failed to normalize skin texture");
let decode_to_pixels = |png_data: &[u8]| {
let decoder = png::Decoder::new(png_data);
let mut reader = decoder.read_info().expect("Failed to read PNG info");
let mut buffer = vec![0; reader.output_buffer_size()];
reader
.next_frame(&mut buffer)
.expect("Failed to decode PNG");
(buffer, reader.info().clone())
};
let (normalized_pixels, normalized_info) =
decode_to_pixels(&normalized_png_data);
let (expected_pixels, expected_info) =
decode_to_pixels(expected_normalized_png_data);
// Check that dimensions match
assert_eq!(normalized_info.width, expected_info.width);
assert_eq!(normalized_info.height, expected_info.height);
assert_eq!(normalized_info.color_type, expected_info.color_type);
// Check that pixel data matches
assert_eq!(
normalized_pixels, expected_pixels,
"Pixel data doesn't match"
);
}

View File

@@ -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;

View File

@@ -642,9 +642,8 @@ pub async fn run(
}
/// Run Minecraft using a profile, and credentials for authentication
/// Returns Arc pointer to RwLock to Child
#[tracing::instrument(skip(credentials))]
pub async fn run_credentials(
async fn run_credentials(
path: &str,
credentials: &Credentials,
quick_play_type: &QuickPlayType,

View File

@@ -24,6 +24,8 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
#[tracing::instrument]
pub async fn cancel_directory_change() -> crate::Result<()> {
// This is called to handle state initialization errors due to folder migrations
// failing, so fetching a DB connection pool from `State::get` is not reliable here
let pool = crate::state::db::connect().await?;
let mut settings = Settings::get(&pool).await?;

View File

@@ -26,6 +26,8 @@ use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tokio::io::AsyncWriteExt;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url;
@@ -394,25 +396,27 @@ async fn get_server_worlds_in_profile(
.await
.ok();
let first_server_index = worlds.len();
for (index, server) in servers.into_iter().enumerate() {
if server.hidden {
// TODO: Figure out whether we want to hide or show direct connect servers
continue;
}
let icon = server.icon.and_then(|icon| {
Url::parse(&format!("data:image/png;base64,{icon}")).ok()
});
let last_played = join_log
.as_ref()
.and_then(|log| {
let address = parse_server_address(&server.ip).ok()?;
log.get(&(address.0.to_owned(), address.1))
})
.copied();
let world = World {
name: server.name,
last_played,
icon: icon.map(Either::Right),
last_played: join_log
.as_ref()
.and_then(|log| {
let (host, port) = parse_server_address(&server.ip).ok()?;
log.get(&(host.to_owned(), port))
})
.copied(),
icon: server
.icon
.and_then(|icon| {
Url::parse(&format!("data:image/png;base64,{icon}")).ok()
})
.map(Either::Right),
display_status: DisplayStatus::Normal,
details: WorldDetails::Server {
index,
@@ -423,6 +427,30 @@ async fn get_server_worlds_in_profile(
worlds.push(world);
}
if let Some(join_log) = join_log {
let mut futures = JoinSet::new();
for (index, world) in worlds.iter().enumerate().skip(first_server_index)
{
if world.last_played.is_some() {
continue;
}
if let WorldDetails::Server { address, .. } = &world.details
&& let Ok((host, port)) = parse_server_address(address)
{
let host = host.to_owned();
futures.spawn(async move {
resolve_server_address(&host, port)
.await
.ok()
.map(|x| (index, x))
});
}
}
for (index, address) in futures.join_all().await.into_iter().flatten() {
worlds[index].last_played = join_log.get(&address).copied();
}
}
Ok(())
}
@@ -943,9 +971,13 @@ async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {

View File

@@ -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,35 @@ 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("Invalid PNG: {0}")]
PngDecodingError(#[from] png::DecodingError),
#[error("PNG encoding error: {0}")]
PngEncodingError(#[from] png::EncodingError),
#[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<ErrorKind>,
pub source: tracing_error::TracedError<std::sync::Arc<ErrorKind>>,
pub raw: Arc<ErrorKind>,
pub source: tracing_error::TracedError<Arc<ErrorKind>>,
}
impl std::error::Error for Error {
@@ -148,7 +174,7 @@ impl std::fmt::Display for Error {
impl<E: Into<ErrorKind>> From<E> for Error {
fn from(source: E) -> Self {
let error = Into::<ErrorKind>::into(source);
let boxed_error = std::sync::Arc::new(error);
let boxed_error = Arc::new(error);
Self {
raw: boxed_error.clone(),

View File

@@ -13,7 +13,7 @@ use daedalus::{
modded::SidedDataEntry,
};
use dunce::canonicalize;
use std::collections::HashSet;
use hashlink::LinkedHashSet;
use std::io::{BufRead, BufReader};
use std::{collections::HashMap, path::Path};
use uuid::Uuid;
@@ -24,7 +24,7 @@ const TEMPORARY_REPLACE_CHAR: &str = "\n";
pub fn get_class_paths(
libraries_path: &Path,
libraries: &[Library],
client_path: &Path,
launcher_class_path: &[&Path],
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<String> {
@@ -48,20 +48,22 @@ pub fn get_class_paths(
Some(get_lib_path(libraries_path, &library.name, false))
})
.collect::<Result<HashSet<_>, _>>()?;
.collect::<Result<LinkedHashSet<_>, _>>()?;
cps.insert(
canonicalize(client_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Specified class path {} does not exist",
client_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_string(),
);
for launcher_path in launcher_class_path {
cps.insert(
canonicalize(launcher_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Specified class path {} does not exist",
launcher_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_string(),
);
}
Ok(cps
.into_iter()
@@ -211,7 +213,7 @@ fn parse_jvm_argument(
}
#[allow(clippy::too_many_arguments)]
pub fn get_minecraft_arguments(
pub async fn get_minecraft_arguments(
arguments: Option<&[Argument]>,
legacy_arguments: Option<&str>,
credentials: &Credentials,
@@ -224,6 +226,9 @@ pub fn get_minecraft_arguments(
java_arch: &str,
quick_play_type: &QuickPlayType,
) -> crate::Result<Vec<String>> {
let access_token = credentials.access_token.clone();
let profile = credentials.maybe_online_profile().await;
if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new();
@@ -233,9 +238,9 @@ pub fn get_minecraft_arguments(
|arg| {
parse_minecraft_argument(
arg,
&credentials.access_token,
&credentials.username,
credentials.id,
&access_token,
&profile.name,
profile.id,
version,
asset_index_name,
game_directory,
@@ -255,9 +260,9 @@ pub fn get_minecraft_arguments(
for x in legacy_arguments.split(' ') {
parsed_arguments.push(parse_minecraft_argument(
&x.replace(' ', TEMPORARY_REPLACE_CHAR),
&credentials.access_token,
&credentials.username,
credentials.id,
&access_token,
&profile.name,
profile.id,
version,
asset_index_name,
game_directory,

View File

@@ -9,7 +9,7 @@ use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
};
use crate::util::io;
use crate::{State, process, state as st};
use crate::{State, get_resource_file, process, state as st};
use chrono::Utc;
use daedalus as d;
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
@@ -20,6 +20,7 @@ use serde::Deserialize;
use st::Profile;
use std::fmt::Write;
use std::path::PathBuf;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
mod args;
@@ -127,12 +128,10 @@ pub async fn get_java_version_from_profile(
version_info: &VersionInfo,
) -> crate::Result<Option<JavaVersion>> {
if let Some(java) = profile.java_path.as_ref() {
let java = crate::api::jre::check_jre(std::path::PathBuf::from(java))
.await
.ok()
.flatten();
let java =
crate::api::jre::check_jre(std::path::PathBuf::from(java)).await;
if let Some(java) = java {
if let Ok(java) = java {
return Ok(Some(java));
}
}
@@ -292,13 +291,7 @@ pub async fn install_minecraft(
};
// Test jre version
let java_version = crate::api::jre::check_jre(java_version.clone())
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Java path invalid or non-functional: {java_version:?}"
))
})?;
let java_version = crate::api::jre::check_jre(java_version.clone()).await?;
if set_java {
java_version.upsert(&state.pool).await?;
@@ -563,14 +556,7 @@ pub async fn launch_minecraft(
// Test jre version
let java_version =
crate::api::jre::check_jre(java_version.path.clone().into())
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Java path invalid or non-functional: {}",
java_version.path
))
})?;
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
let client_path = state
.directories
@@ -606,33 +592,43 @@ pub async fn launch_minecraft(
io::create_dir_all(&natives_dir).await?;
}
command
.args(
args::get_jvm_arguments(
args.get(&d::minecraft::ArgumentType::Jvm)
.map(|x| x.as_slice()),
&natives_dir,
let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
command.args(
args::get_jvm_arguments(
args.get(&d::minecraft::ArgumentType::Jvm)
.map(|x| x.as_slice()),
&natives_dir,
&state.directories.libraries_dir(),
&state.directories.log_configs_dir(),
&args::get_class_paths(
&state.directories.libraries_dir(),
&state.directories.log_configs_dir(),
&args::get_class_paths(
&state.directories.libraries_dir(),
version_info.libraries.as_slice(),
&client_path,
&java_version.architecture,
minecraft_updated,
)?,
&version_jar,
*memory,
Vec::from(java_args),
version_info.libraries.as_slice(),
&[&main_class_path, &client_path],
&java_version.architecture,
quick_play_type,
version_info
.logging
.as_ref()
.and_then(|x| x.get(&LoggingSide::Client)),
)?
.into_iter(),
)
minecraft_updated,
)?,
&version_jar,
*memory,
Vec::from(java_args),
&java_version.architecture,
quick_play_type,
version_info
.logging
.as_ref()
.and_then(|x| x.get(&LoggingSide::Client)),
)?
.into_iter(),
);
// The java launcher code requires internal JDK code in Java 25+ in order to support JEP 512
if java_version.parsed_version >= 25 {
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
}
command
.arg("com.modrinth.theseus.MinecraftLaunch")
.arg(version_info.main_class.clone())
.args(
args::get_minecraft_arguments(
@@ -648,7 +644,8 @@ pub async fn launch_minecraft(
*resolution,
&java_version.architecture,
quick_play_type,
)?
)
.await?
.into_iter(),
)
.current_dir(instance_path.clone());
@@ -658,7 +655,7 @@ pub async fn launch_minecraft(
if std::env::var("CARGO").is_ok() {
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
}
// Java options should be set in instance options (the existence of _JAVA_OPTIONS overwites them)
// Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them)
command.env_remove("_JAVA_OPTIONS");
command.envs(env_args);
@@ -748,6 +745,40 @@ pub async fn launch_minecraft(
post_exit_hook,
state.directories.profile_logs_dir(&profile.path),
version_info.logging.is_some(),
main_class_keep_alive,
async |process: &ProcessMetadata, stdin| {
let process_start_time = process.start_time.to_rfc3339();
let profile_created_time = profile.created.to_rfc3339();
let profile_modified_time = profile.modified.to_rfc3339();
let system_properties = [
("modrinth.process.startTime", Some(&process_start_time)),
("modrinth.profile.created", Some(&profile_created_time)),
("modrinth.profile.icon", profile.icon_path.as_ref()),
(
"modrinth.profile.link.project",
profile.linked_data.as_ref().map(|x| &x.project_id),
),
(
"modrinth.profile.link.version",
profile.linked_data.as_ref().map(|x| &x.version_id),
),
("modrinth.profile.modified", Some(&profile_modified_time)),
("modrinth.profile.name", Some(&profile.name)),
];
for (key, value) in system_properties {
let Some(value) = value else {
continue;
};
stdin.write_all(b"property\t").await?;
stdin.write_all(key.as_bytes()).await?;
stdin.write_u8(b'\t').await?;
stdin.write_all(value.as_bytes()).await?;
stdin.write_u8(b'\n').await?;
}
stdin.write_all(b"launch\n").await?;
stdin.flush().await?;
Ok(())
},
)
.await
}

View File

@@ -1,5 +1,4 @@
use crate::state::DirectoryInfo;
use sqlx::migrate::MigrateDatabase;
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions,
};
@@ -20,14 +19,11 @@ pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
let uri = format!("sqlite:{}", settings_dir.join("app.db").display());
if !Sqlite::database_exists(&uri).await? {
Sqlite::create_database(&uri).await?;
}
let conn_options = SqliteConnectOptions::from_str(&uri)?
.busy_timeout(Duration::from_secs(30))
.journal_mode(SqliteJournalMode::Wal)
.optimize_on_close(true, None);
.optimize_on_close(true, None)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(100)
@@ -36,5 +32,33 @@ pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
sqlx::migrate!().run(&pool).await?;
if let Err(err) = stale_data_cleanup(&pool).await {
tracing::warn!(
"Failed to clean up stale data from state database: {err}"
);
}
Ok(pool)
}
/// Cleans up data from the database that is no longer referenced, but must be
/// kept around for a little while to allow users to recover from accidental
/// deletions.
async fn stale_data_cleanup(pool: &Pool<Sqlite>) -> crate::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query!(
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone)]
pub struct JavaVersion {
pub major_version: u32,
pub parsed_version: u32,
pub version: String,
pub architecture: String,
pub path: String,
@@ -30,7 +30,7 @@ impl JavaVersion {
.await?;
Ok(res.map(|x| JavaVersion {
major_version,
parsed_version: major_version,
version: x.full_version,
architecture: x.architecture,
path: x.path,
@@ -52,7 +52,7 @@ impl JavaVersion {
acc.insert(
x.major_version as u32,
JavaVersion {
major_version: x.major_version as u32,
parsed_version: x.major_version as u32,
version: x.full_version,
architecture: x.architecture,
path: x.path,
@@ -70,7 +70,7 @@ impl JavaVersion {
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let major_version = self.major_version as i32;
let major_version = self.parsed_version as i32;
sqlx::query!(
"

View File

@@ -19,6 +19,8 @@ use std::path::PathBuf;
use tokio::sync::Semaphore;
use uuid::Uuid;
use super::MinecraftProfile;
pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()>
where
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
@@ -83,7 +85,7 @@ where
settings.prev_custom_dir = Some(old_launcher_root_str.clone());
for (_, legacy_version) in legacy_settings.java_globals.0 {
if let Ok(Some(java_version)) =
if let Ok(java_version) =
check_jre(PathBuf::from(legacy_version.path)).await
{
java_version.upsert(exec).await?;
@@ -117,13 +119,16 @@ where
.await
{
let minecraft_users_len = minecraft_auth.users.len();
for (uuid, credential) in minecraft_auth.users {
for (uuid, legacy_credentials) in minecraft_auth.users {
Credentials {
id: credential.id,
username: credential.username,
access_token: credential.access_token,
refresh_token: credential.refresh_token,
expires: credential.expires,
offline_profile: MinecraftProfile {
id: legacy_credentials.id,
name: legacy_credentials.username,
..MinecraftProfile::default()
},
access_token: legacy_credentials.access_token,
refresh_token: legacy_credentials.refresh_token,
expires: legacy_credentials.expires,
active: minecraft_auth.default_user == Some(uuid)
|| minecraft_users_len == 1,
}

View File

@@ -5,25 +5,38 @@ use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
use chrono::{DateTime, Duration, TimeZone, Utc};
use dashmap::DashMap;
use futures::TryStreamExt;
use heck::ToTitleCase;
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
use rand::Rng;
use rand::rngs::OsRng;
use reqwest::Response;
use reqwest::header::HeaderMap;
use reqwest::{Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::json;
use sha2::Digest;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::future::Future;
use std::hash::{BuildHasherDefault, DefaultHasher};
use std::io;
use std::ops::Deref;
use std::sync::Arc;
use std::time::Instant;
use tokio::runtime::{Handle, RuntimeFlavor};
use tokio::sync::Mutex;
use tokio::task;
use url::Url;
use uuid::Uuid;
#[derive(Debug, Clone, Copy)]
pub enum MinecraftAuthStep {
GetDeviceToken,
SisuAuthenicate,
SisuAuthenticate,
GetOAuthToken,
RefreshOAuthToken,
SisuAuthorize,
@@ -53,7 +66,7 @@ pub enum MinecraftAuthenticationError {
raw: String,
#[source]
source: serde_json::Error,
status_code: reqwest::StatusCode,
status_code: StatusCode,
},
#[error("Request failed during step {step:?}: {source}")]
Request {
@@ -172,21 +185,35 @@ pub async fn login_finish(
minecraft_entitlements(&minecraft_token.access_token).await?;
let mut credentials = Credentials {
id: Uuid::default(),
username: String::default(),
offline_profile: MinecraftProfile::default(),
access_token: minecraft_token.access_token,
refresh_token: oauth_token.value.refresh_token,
expires: oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64),
active: true,
};
credentials.get_profile().await?;
// During login, we need to fetch the online profile at least once to get the
// player UUID and name to use for the offline profile, in order for that offline
// profile to make sense. It's also important to modify the returned credentials
// object, as otherwise continued usage of it will skip the profile cache due to
// the dummy UUID
let online_profile = credentials
.online_profile()
.await
.ok_or(io::Error::other("Failed to fetch player profile"))?;
credentials.offline_profile = MinecraftProfile {
id: online_profile.id,
name: online_profile.name.clone(),
..credentials.offline_profile
};
credentials.upsert(exec).await?;
Ok(credentials)
}
// Patched by AstralRinth
#[tracing::instrument]
pub async fn offline_auth(
name: &str,
@@ -196,35 +223,78 @@ pub async fn offline_auth(
let access_token = "null".to_string();
let refresh_token = "null".to_string();
let credentials = Credentials {
id: random_uuid,
username: name.to_string(),
let mut credentials = Credentials {
offline_profile: MinecraftProfile::default(),
access_token: access_token,
refresh_token: refresh_token,
expires: Utc::now() + Duration::days(365 * 99),
active: true,
};
credentials.offline_profile = MinecraftProfile {
id: random_uuid,
name: name.to_string(),
..credentials.offline_profile
};
credentials.upsert(exec).await?;
Ok(credentials)
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Deserialize, Debug)]
pub struct Credentials {
pub id: Uuid,
pub username: String,
/// The offline profile of the user these credentials are for.
///
/// Such a profile can only be relied upon to have a proper player UUID, which is
/// never changed. A potentially stale username may be available, but no other data
/// such as skins or capes is available.
#[serde(rename = "profile")]
pub offline_profile: MinecraftProfile,
pub access_token: String,
pub refresh_token: String,
pub expires: DateTime<Utc>,
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<MinecraftProfile>),
/// 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<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
impl Credentials {
/// 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<()> {
// 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(());
}
let oauth_token = oauth_refresh(&self.refresh_token).await?;
let (pair, current_date, _) =
DeviceTokenPair::refresh_and_get_device_token(
@@ -258,22 +328,118 @@ impl Credentials {
self.expires = oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64);
self.get_profile().await?;
self.upsert(exec).await?;
Ok(())
}
async fn get_profile(&mut self) -> crate::Result<()> {
let profile = minecraft_profile(&self.access_token).await?;
#[tracing::instrument(skip(self))]
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
let mut profile_cache = PROFILE_CACHE.lock().await;
self.id = profile.id.unwrap_or_default();
self.username = profile.name;
loop {
match profile_cache.entry(self.offline_profile.id) {
Entry::Occupied(entry) => {
match entry.get() {
ProfileCacheEntry::Hit(profile)
if profile.is_fresh() =>
{
return Some(Arc::clone(profile));
}
ProfileCacheEntry::Hit(_) => {
// The profile is stale, so remove it and try again
entry.remove();
continue;
}
// Auth errors must be handled with a backoff strategy because it
// has been experimentally found that Mojang quickly rate limits
// the profile data endpoint on repeated attempts with bad auth
ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token,
last_attempt,
} if &self.access_token != likely_expired_token
|| Instant::now()
.saturating_duration_since(*last_attempt)
> std::time::Duration::from_secs(60) =>
{
entry.remove();
continue;
}
ProfileCacheEntry::AuthErrorBackoff { .. } => {
return None;
}
}
}
Entry::Vacant(entry) => {
match minecraft_profile(&self.access_token).await {
Ok(profile) => {
let profile = Arc::new(profile);
let cache_entry =
ProfileCacheEntry::Hit(Arc::clone(&profile));
Ok(())
// When fetching a profile for the first time, the player UUID may
// be unknown (i.e., set to a dummy value), so make sure we don't
// cache it in the wrong place
if entry.key() != &profile.id {
profile_cache.insert(profile.id, cache_entry);
} else {
entry.insert(cache_entry);
}
return Some(profile);
}
Err(
err @ MinecraftAuthenticationError::DeserializeResponse {
status_code: StatusCode::UNAUTHORIZED,
..
},
) => {
tracing::warn!(
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
self.offline_profile.id
);
// We have to assume the player UUID key we have is correct here, which
// should always be the case assuming a non-adversarial server. In any
// case, any cache poisoning is inconsequential due to the entry expiration
// and the fact that we use at most one single dummy UUID
entry.insert(ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token: self.access_token.clone(),
last_attempt: Instant::now(),
});
return None;
}
Err(err) => {
tracing::warn!(
"Failed to fetch online profile for UUID {}: {err}",
self.offline_profile.id
);
return None;
}
}
}
}
}
}
/// Attempts to fetch the online profile for this user if possible, and if that fails
/// falls back to the known offline profile data.
///
/// See also the [`online_profile`](Self::online_profile) method.
pub async fn maybe_online_profile(
&self,
) -> MaybeOnlineMinecraftProfile<'_> {
let online_profile = self.online_profile().await;
online_profile.map_or_else(
|| MaybeOnlineMinecraftProfile::Offline(&self.offline_profile),
MaybeOnlineMinecraftProfile::Online,
)
}
/// Like [`get_active`](Self::get_active), but enforces credentials to be
/// successfully refreshed unless the network is unreachable or times out.
#[tracing::instrument]
pub async fn get_default_credential(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
@@ -281,37 +447,35 @@ impl Credentials {
let credentials = Self::get_active(exec).await?;
if let Some(mut creds) = credentials {
if creds.expires < Utc::now() {
let res = creds.refresh(exec).await;
let res = creds.refresh(exec).await;
match res {
Ok(_) => Ok(Some(creds)),
Err(err) => {
if let ErrorKind::MinecraftAuthenticationError(
MinecraftAuthenticationError::Request {
ref source,
..
},
) = *err.raw
{
if source.is_connect() || source.is_timeout() {
return Ok(Some(creds));
}
match res {
Ok(_) => Ok(Some(creds)),
Err(err) => {
if let ErrorKind::MinecraftAuthenticationError(
MinecraftAuthenticationError::Request {
ref source,
..
},
) = *err.raw
{
if source.is_connect() || source.is_timeout() {
return Ok(Some(creds));
}
Err(err)
}
Err(err)
}
} else {
Ok(Some(creds))
}
} else {
Ok(None)
}
}
/// Fetches the currently selected credentials from the database, attempting
/// to refresh them if they are expired.
pub async fn get_active(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Option<Self>> {
let res = sqlx::query!(
"
@@ -324,21 +488,31 @@ impl Credentials {
.fetch_optional(exec)
.await?;
Ok(res.map(|x| Self {
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
username: x.username,
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
}))
Ok(match res {
Some(x) => {
let mut credentials = Self {
offline_profile: MinecraftProfile {
id: Uuid::parse_str(&x.uuid).unwrap_or_default(),
name: x.username,
..MinecraftProfile::default()
},
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
};
credentials.refresh(exec).await.ok();
Some(credentials)
}
None => None,
})
}
pub async fn get_all(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<DashMap<Uuid, Self>> {
let res = sqlx::query!(
"
@@ -350,23 +524,27 @@ impl Credentials {
.fetch(exec)
.try_fold(DashMap::new(), |acc, x| {
let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default();
acc.insert(
uuid,
Self {
let mut credentials = Self {
offline_profile: MinecraftProfile {
id: uuid,
username: x.username,
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
name: x.username,
..MinecraftProfile::default()
},
);
access_token: x.access_token,
refresh_token: x.refresh_token,
expires: Utc
.timestamp_opt(x.expires, 0)
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
};
async move { Ok(acc) }
async move {
credentials.refresh(exec).await.ok();
acc.insert(uuid, credentials);
Ok(acc)
}
})
.await?;
@@ -377,8 +555,9 @@ impl Credentials {
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<()> {
let profile = self.maybe_online_profile().await;
let expires = self.expires.timestamp();
let uuid = self.id.as_hyphenated().to_string();
let uuid = profile.id.as_hyphenated().to_string();
if self.active {
sqlx::query!(
@@ -404,7 +583,7 @@ impl Credentials {
",
uuid,
self.active,
self.username,
profile.name,
self.access_token,
self.refresh_token,
expires,
@@ -434,6 +613,46 @@ impl Credentials {
}
}
impl Serialize for Credentials {
fn serialize<S: Serializer>(
&self,
serializer: S,
) -> Result<S::Ok, S::Error> {
// Opportunistically hydrate the profile with its online data if possible for frontend
// consumption, transparently handling all the possible Tokio runtime states the current
// thread may be in the most efficient way
let profile = match Handle::try_current().ok() {
Some(runtime)
if runtime.runtime_flavor() == RuntimeFlavor::CurrentThread =>
{
runtime.block_on(self.maybe_online_profile())
}
Some(runtime) => task::block_in_place(|| {
runtime.block_on(self.maybe_online_profile())
}),
None => tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_or_else(
|_| {
MaybeOnlineMinecraftProfile::Offline(
&self.offline_profile,
)
},
|runtime| runtime.block_on(self.maybe_online_profile()),
),
};
let mut ser = serializer.serialize_struct("Credentials", 5)?;
ser.serialize_field("profile", &*profile)?;
ser.serialize_field("access_token", &self.access_token)?;
ser.serialize_field("refresh_token", &self.refresh_token)?;
ser.serialize_field("expires", &self.expires)?;
ser.serialize_field("active", &self.active)?;
ser.end()
}
}
pub struct DeviceTokenPair {
pub token: DeviceToken,
pub key: DeviceTokenKey,
@@ -666,7 +885,7 @@ async fn sisu_authenticate(
"TitleId": "1794566092",
}),
key,
MinecraftAuthStep::SisuAuthenicate,
MinecraftAuthStep::SisuAuthenticate,
current_date,
)
.await?;
@@ -938,13 +1157,197 @@ async fn minecraft_token(
})
}
#[derive(Deserialize)]
struct MinecraftProfile {
pub id: Option<Uuid>,
pub name: String,
#[derive(
sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq,
)]
#[serde(rename_all = "UPPERCASE")]
#[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.
Slim,
/// The player model is unknown.
#[serde(other)]
Unknown, // Defensive handling of unexpected Mojang API return values to
// prevent breaking the entire profile parsing
}
#[tracing::instrument]
#[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "UPPERCASE")]
pub enum MinecraftCharacterExpressionState {
/// This expression is selected for being displayed ingame.
///
/// At the moment, at most one expression can be selected at a time.
Active,
/// This expression is not selected for being displayed ingame.
Inactive,
/// The expression selection status is unknown.
#[serde(other)]
Unknown, // Defensive handling of unexpected Mojang API return values to
// prevent breaking the entire profile parsing
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct MinecraftSkin {
/// The UUID of this skin object.
///
/// As of 2025-04-08, in the production Mojang profile endpoint this UUID
/// changes every time the player changes their skin, even if the skin
/// texture is the same as before.
pub id: Uuid,
/// The selection state of the skin.
///
/// As of 2025-04-08, in the production Mojang profile endpoint this
/// is always `ACTIVE`, as only a single skin representing the current
/// skin is returned.
pub state: MinecraftCharacterExpressionState,
/// The URL to the skin texture.
///
/// 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: Arc<Url>,
/// A hash of the skin texture.
///
/// As of 2025-04-08, in the production Mojang profile endpoint this
/// is always set and the same as the file name of the skin texture URL.
#[serde(
default, // Defensive handling of unexpected Mojang API return values to
// prevent breaking the entire profile parsing
rename = "textureKey"
)]
pub texture_key: Option<Arc<str>>,
/// The player model variant this skin is for.
pub variant: MinecraftSkinVariant,
/// User-friendly name for the skin.
///
/// As of 2025-04-08, in the production Mojang profile endpoint this is
/// only set if the player has not set a custom skin, and this skin object
/// is therefore the default skin for the player's UUID.
#[serde(
default,
rename = "alias",
deserialize_with = "normalize_skin_alias_case"
)]
pub name: Option<String>,
}
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<str> {
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<Option<String>, D::Error> {
// Skin aliases have been spotted to be returned in all caps, so make sure
// they are normalized to a prettier title case
Ok(<Option<Cow<'_, str>>>::deserialize(deserializer)?
.map(|alias| alias.to_title_case()))
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct MinecraftCape {
/// The UUID of the cape.
pub id: Uuid,
/// The selection state of the cape.
pub state: MinecraftCharacterExpressionState,
/// The URL to the cape texture.
pub url: Arc<Url>,
/// The user-friendly name for the cape.
#[serde(rename = "alias")]
pub name: Arc<str>,
}
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
pub struct MinecraftProfile {
/// The UUID of the player.
#[serde(default)]
pub id: Uuid,
/// The username of the player.
pub name: String,
/// The skins the player is known to have.
///
/// As of 2025-04-08, in the production Mojang profile endpoint every
/// player has a single skin.
pub skins: Vec<MinecraftSkin>,
/// The capes the player is known to have.
pub capes: Vec<MinecraftCape>,
/// The instant when the profile was fetched. See also [Self::is_fresh].
#[serde(skip)]
pub fetch_time: Option<Instant>,
}
impl MinecraftProfile {
/// Checks whether the profile data is fresh (i.e., highly likely to be
/// up-to-date because it was fetched recently) or stale. If it is not
/// known when this profile data has been fetched from Mojang servers (i.e.,
/// `fetch_time` is `None`), the profile is considered stale.
///
/// This can be used to determine if the profile data should be fetched again
/// from the Mojang API: the vanilla launcher was seen refreshing profile
/// data every 60 seconds when re-entering the skin selection screen, and
/// external applications may change this data at any time.
fn is_fresh(&self) -> bool {
self.fetch_time.is_some_and(|last_profile_fetch_time| {
Instant::now().saturating_duration_since(last_profile_fetch_time)
< 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> {
/// An online profile, fetched from the Mojang API.
Online(Arc<MinecraftProfile>),
/// An offline profile, which has not been fetched from the Mojang API.
Offline(&'profile MinecraftProfile),
}
impl Deref for MaybeOnlineMinecraftProfile<'_> {
type Target = MinecraftProfile;
fn deref(&self) -> &Self::Target {
match self {
Self::Online(profile) => profile,
Self::Offline(profile) => profile,
}
}
}
#[tracing::instrument(skip(token))]
async fn minecraft_profile(
token: &str,
) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
@@ -953,6 +1356,9 @@ async fn minecraft_profile(
.get("https://api.minecraftservices.com/minecraft/profile")
.header("Accept", "application/json")
.bearer_auth(token)
// Profiles may be refreshed periodically in response to user actions,
// so we want each refresh to be fast
.timeout(std::time::Duration::from_secs(10))
.send()
})
.await
@@ -969,14 +1375,23 @@ async fn minecraft_profile(
}
})?;
serde_json::from_str(&text).map_err(|source| {
MinecraftAuthenticationError::DeserializeResponse {
source,
raw: text,
step: MinecraftAuthStep::MinecraftProfile,
status_code: status,
}
})
let mut profile =
serde_json::from_str::<MinecraftProfile>(&text).map_err(|source| {
MinecraftAuthenticationError::DeserializeResponse {
source,
raw: text,
step: MinecraftAuthStep::MinecraftProfile,
status_code: status,
}
})?;
profile.fetch_time = Some(Instant::now());
tracing::debug!(
"Successfully fetched Minecraft profile for {}",
profile.name
);
Ok(profile)
}
#[derive(Deserialize)]
@@ -994,7 +1409,7 @@ async fn minecraft_entitlements(
.bearer_auth(token)
.send()
})
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
let status = res.status();
let text = res.text().await.map_err(|source| {

View File

@@ -0,0 +1,180 @@
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<Option<Self>> {
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<Uuid>,
}
impl CustomMinecraftSkin {
pub async fn add(
minecraft_user_id: Uuid,
texture_key: &str,
texture: &[u8],
variant: MinecraftSkinVariant,
cape_id: Option<Uuid>,
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 REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
texture_key, texture
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"INSERT OR REPLACE 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?;
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<impl Stream<Item = Self>> {
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<impl Stream<Item = Self>> {
// 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<Vec<u8>> {
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();
let cape_id = self.cape_id.map(|id| id.hyphenated());
sqlx::query!(
"DELETE FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
minecraft_user_id, self.texture_key, self.variant, cape_id
)
.execute(&mut *db.acquire().await?)
.await?;
Ok(())
}
}

View File

@@ -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<TextureStream>(
credentials: &Credentials,
texture: TextureStream,
variant: MinecraftSkinVariant,
) -> crate::Result<()>
where
TextureStream: TryStream + Send + 'static,
TextureStream::Error: Into<Box<dyn Error + Send + Sync>>,
Bytes: From<TextureStream::Ok>,
{
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::<MinecraftProfile>().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)));
}

View File

@@ -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::*;

View File

@@ -8,12 +8,14 @@ use quick_xml::Reader;
use quick_xml::events::Event;
use serde::Deserialize;
use serde::Serialize;
use std::fmt::Debug;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::process::{Child, ChildStdin, Command};
use uuid::Uuid;
const LAUNCHER_LOG_PATH: &str = "launcher_log.txt";
@@ -35,6 +37,7 @@ impl ProcessManager {
}
}
#[allow(clippy::too_many_arguments)]
pub async fn insert_new_process(
&self,
profile_path: &str,
@@ -42,24 +45,42 @@ impl ProcessManager {
post_exit_command: Option<String>,
logs_folder: PathBuf,
xml_logging: bool,
main_class_keep_alive: TempDir,
post_process_init: impl AsyncFnOnce(
&ProcessMetadata,
&mut ChildStdin,
) -> crate::Result<()>,
) -> crate::Result<ProcessMetadata> {
mc_command.stdout(std::process::Stdio::piped());
mc_command.stderr(std::process::Stdio::piped());
mc_command.stdin(std::process::Stdio::piped());
let mut mc_proc = mc_command.spawn().map_err(IOError::from)?;
let stdout = mc_proc.stdout.take();
let stderr = mc_proc.stderr.take();
let process = Process {
let mut process = Process {
metadata: ProcessMetadata {
uuid: Uuid::new_v4(),
start_time: Utc::now(),
profile_path: profile_path.to_string(),
},
child: mc_proc,
_main_class_keep_alive: main_class_keep_alive,
};
if let Err(e) = post_process_init(
&process.metadata,
&mut process.child.stdin.as_mut().unwrap(),
)
.await
{
tracing::error!("Failed to run post-process init: {e}");
let _ = process.child.kill().await;
return Err(e);
}
let metadata = process.metadata.clone();
if !logs_folder.exists() {
@@ -193,6 +214,7 @@ pub struct ProcessMetadata {
struct Process {
metadata: ProcessMetadata,
child: Child,
_main_class_keep_alive: TempDir,
}
#[derive(Debug, Default)]

View File

@@ -13,6 +13,7 @@ pub struct Settings {
pub theme: Theme,
pub default_page: DefaultPage,
pub collapsed_navigation: bool,
pub hide_nametag_skins_page: bool,
pub advanced_rendering: bool,
pub native_decorations: bool,
pub toggle_sidebar: bool,
@@ -56,7 +57,7 @@ impl Settings {
"
SELECT
max_concurrent_writes, max_concurrent_downloads,
theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,
theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
discord_rpc, developer_mode, telemetry, personalized_ads,
onboarded,
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
@@ -75,6 +76,7 @@ impl Settings {
theme: Theme::from_string(&res.theme),
default_page: DefaultPage::from_string(&res.default_page),
collapsed_navigation: res.collapsed_navigation == 1,
hide_nametag_skins_page: res.hide_nametag_skins_page == 1,
advanced_rendering: res.advanced_rendering == 1,
native_decorations: res.native_decorations == 1,
toggle_sidebar: res.toggle_sidebar == 1,
@@ -167,7 +169,8 @@ impl Settings {
migrated = $25,
toggle_sidebar = $26,
feature_flags = $27
feature_flags = $27,
hide_nametag_skins_page = $28
",
max_concurrent_writes,
max_concurrent_downloads,
@@ -195,7 +198,8 @@ impl Settings {
self.prev_custom_dir,
self.migrated,
self.toggle_sidebar,
feature_flags
feature_flags,
self.hide_nametag_skins_page
)
.execute(exec)
.await?;

View File

@@ -2,7 +2,6 @@
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
use std::{io::Write, path::Path};
use tempfile::NamedTempFile;
use tokio::task::spawn_blocking;
@@ -299,3 +298,44 @@ pub async fn metadata(
path: path.to_string_lossy().to_string(),
})
}
/// Gets a resource file from the executable. Returns `theseus::Result<(TempDir, PathBuf)>`.
#[macro_export]
macro_rules! get_resource_file {
(directory: $relative_dir:expr, file: $file_name:expr) => {
'get_resource_file: {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => {
break 'get_resource_file $crate::Result::Err(
$crate::util::io::IOError::from(e).into(),
);
}
};
let path = dir.path().join($file_name);
if let Err(e) = $crate::util::io::write(
&path,
include_bytes!(concat!($relative_dir, "/", $file_name)),
)
.await
{
break 'get_resource_file $crate::Result::Err(e.into());
}
let path = match $crate::util::io::canonicalize(path) {
Ok(path) => path,
Err(e) => {
break 'get_resource_file $crate::Result::Err(e.into());
}
};
$crate::Result::Ok((dir, path))
}
};
($relative_dir:literal / $file_name:literal) => {
get_resource_file!(directory: $relative_dir, file: $file_name)
};
(env $dir_env_name:literal / $file_name:literal) => {
get_resource_file!(directory: env!($dir_env_name), file: $file_name)
};
}

View File

@@ -7,7 +7,7 @@ use std::process::Command;
use std::{collections::HashSet, path::Path};
use tokio::task::JoinError;
use crate::State;
use crate::{State, get_resource_file};
#[cfg(target_os = "windows")]
use winreg::{
RegKey,
@@ -183,7 +183,6 @@ pub async fn get_all_jre() -> Result<Vec<JavaVersion>, JREError> {
// Gets all JREs from the PATH env variable
#[tracing::instrument]
async fn get_all_autoinstalled_jre_path() -> Result<HashSet<PathBuf>, JREError>
{
Box::pin(async move {
@@ -239,54 +238,49 @@ pub const JAVA_BIN: &str = if cfg!(target_os = "windows") {
pub async fn check_java_at_filepaths(
paths: HashSet<PathBuf>,
) -> HashSet<JavaVersion> {
let jres = stream::iter(paths.into_iter())
stream::iter(paths.into_iter())
.map(|p: PathBuf| {
tokio::task::spawn(async move { check_java_at_filepath(&p).await })
})
.buffer_unordered(64)
.collect::<Vec<_>>()
.await;
jres.into_iter().filter_map(|x| x.ok()).flatten().collect()
.filter_map(async |x| x.ok().and_then(Result::ok))
.collect()
.await
}
// For example filepath 'path', attempt to resolve it and get a Java version at this path
// If no such path exists, or no such valid java at this path exists, returns None
#[tracing::instrument]
pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
pub async fn check_java_at_filepath(path: &Path) -> crate::Result<JavaVersion> {
// Attempt to canonicalize the potential java filepath
// If it fails, this path does not exist and None is returned (no Java here)
let Ok(path) = io::canonicalize(path) else {
return None;
};
let path = io::canonicalize(path)?;
// Checks for existence of Java at this filepath
// Adds JAVA_BIN to the end of the path if it is not already there
let java = if path.file_name()?.to_str()? != JAVA_BIN {
let java = if path
.file_name()
.and_then(|x| x.to_str())
.is_some_and(|x| x != JAVA_BIN)
{
path.join(JAVA_BIN)
} else {
path
};
if !java.exists() {
return None;
return Err(JREError::NoExecutable(java).into());
};
let bytes = include_bytes!("../../library/JavaInfo.class");
let Ok(tempdir) = tempfile::tempdir() else {
return None;
};
let file_path = tempdir.path().join("JavaInfo.class");
io::write(&file_path, bytes).await.ok()?;
let (_temp, file_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
let output = Command::new(&java)
.arg("-cp")
.arg(file_path.parent().unwrap())
.arg("JavaInfo")
.arg(file_path)
.arg("com.modrinth.theseus.JavaInfo")
.env_remove("_JAVA_OPTIONS")
.output()
.ok()?;
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -308,64 +302,49 @@ pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
// Extract version info from it
if let Some(arch) = java_arch {
if let Some(version) = java_version {
if let Ok((_, major_version)) =
extract_java_majorminor_version(version)
{
if let Ok(version) = extract_java_version(version) {
let path = java.to_string_lossy().to_string();
return Some(JavaVersion {
major_version,
return Ok(JavaVersion {
parsed_version: version,
path,
version: version.to_string(),
architecture: arch.to_string(),
});
}
return Err(JREError::InvalidJREVersion(version.to_owned()).into());
}
}
None
Err(JREError::FailedJavaCheck(java).into())
}
/// Extract major/minor version from a java version string
/// Gets the minor version or an error, and assumes 1 for major version if it could not find
/// "1.8.0_361" -> (1, 8)
/// "20" -> (1, 20)
pub fn extract_java_majorminor_version(
version: &str,
) -> Result<(u32, u32), JREError> {
pub fn extract_java_version(version: &str) -> Result<u32, JREError> {
let mut split = version.split('.');
let major_opt = split.next();
let mut major;
// Try minor. If doesn't exist, in format like "20" so use major
let mut minor = if let Some(minor) = split.next() {
major = major_opt.unwrap_or("1").parse::<u32>()?;
minor.parse::<u32>()?
} else {
// Formatted like "20", only one value means that is minor version
major = 1;
major_opt
.ok_or_else(|| JREError::InvalidJREVersion(version.to_string()))?
.parse::<u32>()?
};
// Java start should always be 1. If more than 1, it is formatted like "17.0.1.2" and starts with minor version
if major > 1 {
minor = major;
major = 1;
let version = split.next().unwrap();
let version = version.split_once('-').map_or(version, |(x, _)| x);
let mut version = version.parse::<u32>()?;
if version == 1 {
version = split.next().map_or(Ok(1), |x| x.parse::<u32>())?;
}
Ok((major, minor))
Ok(version)
}
#[derive(thiserror::Error, Debug)]
pub enum JREError {
#[error("Command error : {0}")]
#[error("Command error: {0}")]
IOError(#[from] std::io::Error),
#[error("Env error: {0}")]
EnvError(#[from] env::VarError),
#[error("No JRE found for required version: {0}")]
NoJREFound(String),
#[error("No executable found at {0}")]
NoExecutable(PathBuf),
#[error("Could not check Java version at path {0}")]
FailedJavaCheck(PathBuf),
#[error("Invalid JRE version string: {0}")]
InvalidJREVersion(String),
@@ -376,9 +355,9 @@ pub enum JREError {
#[error("Join error: {0}")]
JoinError(#[from] JoinError),
#[error("No stored tag for Minecraft Version {0}")]
#[error("No stored tag for Minecraft version {0}")]
NoMinecraftVersionFound(String),
#[error("Error getting launcher sttae")]
#[error("Error getting launcher state")]
StateError,
}