You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '8faea1663ae0c6d1190a5043054197b6a58019f3' into feature-clean
This commit is contained in:
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user