You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '8faea1663ae0c6d1190a5043054197b6a58019f3' into feature-clean
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
530
packages/app-lib/src/api/minecraft_skins.rs
Normal file
530
packages/app-lib/src/api/minecraft_skins.rs
Normal 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(¤t_skin.url),
|
||||
source: SkinSource::CustomExternal,
|
||||
is_equipped: true,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(available_skins)
|
||||
}
|
||||
|
||||
/// Adds a custom skin to the app database and equips it for the currently selected
|
||||
/// Minecraft profile.
|
||||
#[tracing::instrument(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 |
@@ -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 |
323
packages/app-lib/src/api/minecraft_skins/png_util.rs
Normal file
323
packages/app-lib/src/api/minecraft_skins/png_util.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
180
packages/app-lib/src/state/minecraft_skins/mod.rs
Normal file
180
packages/app-lib/src/state/minecraft_skins/mod.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
142
packages/app-lib/src/state/minecraft_skins/mojang_api.rs
Normal file
142
packages/app-lib/src/state/minecraft_skins/mojang_api.rs
Normal 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)));
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user