feat: mojang account configuration (skins / capes)

This commit is contained in:
LynithDev 2025-09-27 11:11:45 +02:00
parent 96c0ca4d5a
commit 3b8afb3289
3 changed files with 208 additions and 19 deletions

View file

@ -193,11 +193,33 @@ pub trait TauriLauncherApi {
// endregion: modpack
// MARK: API: minecraft
// region: minecraft
#[taurpc(alias = "fetchMinecraftProfile")]
async fn fetch_player_profile(
uuid: String,
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile>;
#[taurpc(alias = "fetchLoggedInProfile")]
async fn fetch_logged_in_profile(
access_token: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile>;
#[taurpc(alias = "uploadSkinBytes")]
async fn upload_skin_bytes(
access_token: String,
skin_data: Vec<u8>,
image_format: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin>;
#[taurpc(alias = "changeSkin")]
async fn change_skin(
access_token: String,
skin_url: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin>;
// endregion: minecraft
// MARK: API: Other
async fn open(input: String) -> LauncherResult<()>;
}
@ -674,6 +696,38 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile> {
crate::utils::minecraft::fetch_player_profile(&uuid).await
}
async fn fetch_logged_in_profile(
self,
access_token: String,
) -> LauncherResult<crate::utils::minecraft::MojangFullPlayerProfile> {
crate::utils::minecraft::fetch_logged_in_profile(&access_token).await
}
async fn upload_skin_bytes(
self,
access_token: String,
skin_data: Vec<u8>,
image_format: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin> {
crate::utils::minecraft::upload_skin_bytes(
&access_token,
skin_data,
&image_format,
skin_variant,
)
.await
}
async fn change_skin(
self,
access_token: String,
skin_url: String,
skin_variant: crate::utils::minecraft::SkinVariant,
) -> LauncherResult<crate::utils::minecraft::MojangSkin> {
crate::utils::minecraft::change_skin(&access_token, &skin_url, skin_variant).await
}
// endregion: minecraft
// MARK: Impl: Other

View file

@ -5,7 +5,7 @@ use std::path::Path;
use std::sync::{Arc, LazyLock};
use bytes::Bytes;
use reqwest::Method;
use reqwest::{Method, RequestBuilder};
use serde::de::DeserializeOwned;
use tokio::sync::Semaphore;
use tokio_stream::StreamExt;
@ -50,34 +50,32 @@ pub(crate) static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
});
pub async fn request(method: Method, url: &str) -> LauncherResult<reqwest::Response> {
request_builder(
method,
url,
None::<fn(reqwest::RequestBuilder) -> reqwest::Result<_>>,
)
.await
let req = build_request(method, url).build()?;
send_request(req).await
}
pub async fn request_builder(
method: Method,
url: &str,
builder: Option<impl Fn(reqwest::RequestBuilder) -> reqwest::Result<reqwest::Request>>,
) -> LauncherResult<reqwest::Response> {
pub fn build_request(method: Method, url: &str) -> RequestBuilder {
let client = &REQWEST_CLIENT;
client.request(method.clone(), url)
}
pub async fn send_request(request: reqwest::Request) -> LauncherResult<reqwest::Response> {
let semaphore = SemaphoreStore::fetch().await;
let permit = semaphore.acquire().await;
let client = &REQWEST_CLIENT;
let mut attempt = 0;
let res = loop {
attempt += 1;
let req = client.request(method.clone(), url);
let req = match builder {
Some(ref builder) => builder(req),
None => req.build(),
}
.map_err(LauncherError::from)?;
let req = request.try_clone().ok_or_else(|| {
LauncherError::from(anyhow::anyhow!(
"failed to clone request for retry attempt {attempt}"
))
})?;
match client.execute(req).await {
Err(err) => {

View file

@ -1,5 +1,8 @@
use std::collections::HashMap;
use base64::Engine;
use reqwest::Method;
use reqwest::{Method, multipart};
use serde_json::json;
use crate::error::LauncherResult;
use crate::utils::http;
@ -14,6 +17,50 @@ pub struct MojangPlayerProfile {
pub cape_url: Option<String>,
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum SkinVariant {
Classic,
Slim,
}
impl std::fmt::Display for SkinVariant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkinVariant::Classic => write!(f, "classic"),
SkinVariant::Slim => write!(f, "slim"),
}
}
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MojangFullPlayerProfile {
pub uuid: String,
pub username: String,
pub skins: Vec<MojangSkin>,
pub capes: Vec<MojangCape>,
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MojangSkin {
pub id: String,
pub state: String,
pub url: String,
pub variant: SkinVariant,
}
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct MojangCape {
pub id: String,
pub state: String,
pub url: String,
pub alias: String,
}
pub async fn fetch_player_profile(uuid: &str) -> LauncherResult<MojangPlayerProfile> {
#[derive(serde::Deserialize)]
struct Properties {
@ -98,3 +145,93 @@ pub async fn fetch_player_profile(uuid: &str) -> LauncherResult<MojangPlayerProf
cape_url: decoded.cape.as_ref().map(|c| c.url.clone()),
})
}
pub async fn fetch_logged_in_profile(
access_token: &str,
) -> LauncherResult<MojangFullPlayerProfile> {
let mut headers: HashMap<&str, &str> = HashMap::with_capacity(1);
let bearer = &format!("Bearer {access_token}");
headers.insert("Authorization", bearer);
http::fetch_json_advanced::<MojangFullPlayerProfile>(
Method::GET,
"https://api.minecraftservices.com/minecraft/profile",
None,
Some(headers),
None,
None,
)
.await
}
pub async fn upload_skin_bytes(
access_token: &str,
skin_data: Vec<u8>,
image_format: &str,
skin_variant: SkinVariant,
) -> LauncherResult<MojangSkin> {
let bytes_part =
multipart::Part::bytes(skin_data).mime_str(&format!("image/{}", image_format))?;
let form = multipart::Form::new()
.text("variant", skin_variant.to_string())
.part("data", bytes_part);
let req = http::build_request(
Method::POST,
"https://api.minecraftservices.com/minecraft/profile/skins",
)
.header("Authorization", &format!("Bearer {access_token}"))
.header("Content-Type", "multipart/form-data")
.multipart(form)
.build()?;
let res = http::send_request(req).await?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(
anyhow::anyhow!("failed to upload skin: HTTP {}: {}", status.as_u16(), text).into(),
);
}
let full_profile = res.json::<MojangFullPlayerProfile>().await?;
dbg!(&full_profile);
full_profile
.skins
.first()
.cloned()
.ok_or_else(|| anyhow::anyhow!("no skins found in response").into())
}
pub async fn change_skin(
access_token: &str,
skin_url: &str,
skin_variant: SkinVariant,
) -> LauncherResult<MojangSkin> {
let mut headers: HashMap<&str, &str> = HashMap::with_capacity(1);
let bearer = &format!("Bearer {access_token}");
headers.insert("Authorization", bearer);
let profile = http::fetch_json_advanced::<MojangFullPlayerProfile>(
Method::POST,
"https://api.minecraftservices.com/minecraft/profile/skins",
Some(json!({
"url": skin_url,
"variant": skin_variant.to_string(),
})),
Some(headers),
None,
None,
)
.await?;
profile
.skins
.first()
.cloned()
.ok_or_else(|| anyhow::anyhow!("no skins found in response").into())
}