From 3b8afb32890a0987241081c816792de096e7327f Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 27 Sep 2025 11:11:45 +0200 Subject: [PATCH] feat: mojang account configuration (skins / capes) --- packages/core/src/api/tauri/commands.rs | 54 +++++++++ packages/core/src/utils/http.rs | 34 +++--- packages/core/src/utils/minecraft.rs | 139 +++++++++++++++++++++++- 3 files changed, 208 insertions(+), 19 deletions(-) diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 560057f5..fca54f69 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -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; + #[taurpc(alias = "fetchLoggedInProfile")] + async fn fetch_logged_in_profile( + access_token: String, + ) -> LauncherResult; + + #[taurpc(alias = "uploadSkinBytes")] + async fn upload_skin_bytes( + access_token: String, + skin_data: Vec, + image_format: String, + skin_variant: crate::utils::minecraft::SkinVariant, + ) -> LauncherResult; + + #[taurpc(alias = "changeSkin")] + async fn change_skin( + access_token: String, + skin_url: String, + skin_variant: crate::utils::minecraft::SkinVariant, + ) -> LauncherResult; + // endregion: minecraft + // MARK: API: Other async fn open(input: String) -> LauncherResult<()>; } @@ -674,6 +696,38 @@ impl TauriLauncherApi for TauriLauncherApiImpl { ) -> LauncherResult { crate::utils::minecraft::fetch_player_profile(&uuid).await } + + async fn fetch_logged_in_profile( + self, + access_token: String, + ) -> LauncherResult { + crate::utils::minecraft::fetch_logged_in_profile(&access_token).await + } + + async fn upload_skin_bytes( + self, + access_token: String, + skin_data: Vec, + image_format: String, + skin_variant: crate::utils::minecraft::SkinVariant, + ) -> LauncherResult { + 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::change_skin(&access_token, &skin_url, skin_variant).await + } // endregion: minecraft // MARK: Impl: Other diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index aaf7d58d..d37612a6 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -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 = LazyLock::new(|| { }); pub async fn request(method: Method, url: &str) -> LauncherResult { - request_builder( - method, - url, - None:: 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 reqwest::Result>, -) -> LauncherResult { +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 { 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) => { diff --git a/packages/core/src/utils/minecraft.rs b/packages/core/src/utils/minecraft.rs index a275141a..d106fc60 100644 --- a/packages/core/src/utils/minecraft.rs +++ b/packages/core/src/utils/minecraft.rs @@ -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, } +#[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, + pub capes: Vec, +} + +#[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 { #[derive(serde::Deserialize)] struct Properties { @@ -98,3 +145,93 @@ pub async fn fetch_player_profile(uuid: &str) -> LauncherResult LauncherResult { + let mut headers: HashMap<&str, &str> = HashMap::with_capacity(1); + + let bearer = &format!("Bearer {access_token}"); + headers.insert("Authorization", bearer); + + http::fetch_json_advanced::( + 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, + image_format: &str, + skin_variant: SkinVariant, +) -> LauncherResult { + 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::().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 { + 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::( + 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()) +}