diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3a0eaaef7..49936b43d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3252,6 +3252,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4847,6 +4857,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -6865,6 +6876,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.18" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 75267c6fa..4eabd3b47 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,7 +31,7 @@ tauri-plugin-dialog = "2.1.0" tauri-plugin-fs = "2.0.1" tauri-plugin-log = "2" structstruck = "0.4.1" -tauri-plugin-http = { version = "2", features = ["json", "stream"] } +tauri-plugin-http = { version = "2", features = ["json", "stream", "multipart"] } tauri-plugin-os = "2" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7.13", features = ["compat"] } diff --git a/src-tauri/src/account/commands.rs b/src-tauri/src/account/commands.rs index b15d629d5..33cf771a0 100644 --- a/src-tauri/src/account/commands.rs +++ b/src-tauri/src/account/commands.rs @@ -2,16 +2,20 @@ use crate::account::helpers::authlib_injector::info::{ fetch_auth_server_info, fetch_auth_url, get_auth_server_info_by_url, }; use crate::account::helpers::authlib_injector::jar::check_authlib_jar; +use crate::account::helpers::authlib_injector::texture::AuthlibInjectorTextureOperation; use crate::account::helpers::authlib_injector::{self}; +use crate::account::helpers::microsoft::texture::MicrosoftTextureOperation; +use crate::account::helpers::texture::{load_preset_skin_info, TextureOperation}; use crate::account::helpers::{microsoft, misc, offline}; use crate::account::models::{ AccountError, AccountInfo, AuthServer, DeviceAuthResponseInfo, Player, PlayerInfo, PlayerType, - PresetRole, SkinModel, TextureType, + PresetRole, SkinModel, Texture, TextureType, }; use crate::error::SJMCLResult; use crate::launcher_config::models::LauncherConfig; use crate::storage::Storage; use crate::utils::fs::get_app_resource_filepath; +use crate::utils::image::{load_image_from_dir, ImageWrapper}; use std::path::Path; use std::sync::Mutex; use tauri::{AppHandle, Manager}; @@ -33,7 +37,7 @@ pub fn retrieve_player_list(app: AppHandle) -> SJMCLResult> { #[tauri::command] pub async fn add_player_offline(app: AppHandle, username: String, uuid: String) -> SJMCLResult<()> { - let new_player = offline::login(&app, username, uuid).await?; + let new_player = offline::login::login(&app, username, uuid).await?; let account_binding = app.state::>(); let mut account_state = account_binding.lock()?; @@ -360,70 +364,149 @@ pub async fn add_player_from_selection(app: AppHandle, player: Player) -> SJMCLR } #[tauri::command] -pub fn update_player_skin_offline_preset( +pub async fn update_player_texture_preset( app: AppHandle, player_id: String, preset_role: PresetRole, ) -> SJMCLResult<()> { let account_binding = app.state::>(); - let mut account_state = account_binding.lock()?; - let player = account_state - .get_player_by_id_mut(player_id.clone()) - .ok_or(AccountError::NotFound)?; + let player = { + let account_state = account_binding.lock()?; + account_state + .get_player_by_id(player_id.clone()) + .ok_or(AccountError::NotFound)? + .clone() + }; - if player.player_type != PlayerType::Offline { - return Err(AccountError::Invalid.into()); - } + let (skin_path, model) = load_preset_skin_info(&app, preset_role.clone())?; - player.textures = offline::load_preset_skin(&app, preset_role)?; - account_state.save()?; + match player.player_type { + PlayerType::Offline => {} + PlayerType::ThirdParty => { + let _ = AuthlibInjectorTextureOperation::delete_cape(&app, &player).await; + AuthlibInjectorTextureOperation::upload_skin(&app, &player, &skin_path, model.clone()) + .await?; + } + PlayerType::Microsoft => { + let _ = MicrosoftTextureOperation::delete_cape(&app, &player).await; + MicrosoftTextureOperation::upload_skin(&app, &player, &skin_path, model.clone()).await?; + } + }; + + { + let mut account_state = account_binding.lock()?; + + if let Some(stored_player) = account_state.players.iter_mut().find(|p| p.id == player_id) { + stored_player.textures.clear(); + stored_player.textures.push(Texture { + texture_type: TextureType::Skin, + image: load_image_from_dir(&skin_path) + .ok_or(AccountError::TextureFormatIncorrect)? + .into(), + model, + preset: Some(preset_role), + }); + account_state.save()?; + } + } Ok(()) } #[tauri::command] -pub fn update_player_skin_offline_local( +pub async fn update_player_texture_local( app: AppHandle, player_id: String, image_path: String, texture_type: TextureType, skin_model: SkinModel, ) -> SJMCLResult<()> { + let account_binding = app.state::>(); + + let player = { + let account_state = account_binding.lock()?; + account_state + .get_player_by_id(player_id.clone()) + .ok_or(AccountError::NotFound)? + .clone() + }; + let image_path = if image_path == "dummy" { // this is an Easter Egg :) get_app_resource_filepath(&app, "assets/skins/dummy.png") - .map_err(|_| AccountError::TextureError)? + .map_err(|_| AccountError::TextureFormatIncorrect)? } else { Path::new(&image_path).to_path_buf() }; - let texture_img = - crate::utils::image::load_image_from_dir(&image_path).ok_or(AccountError::TextureError)?; - let account_binding = app.state::>(); - let mut account_state = account_binding.lock()?; + let textures = match player.player_type { + PlayerType::Offline => { + let image: ImageWrapper = load_image_from_dir(&image_path) + .ok_or(AccountError::TextureFormatIncorrect)? + .into(); + player + .textures + .iter() + .map(|texture| { + if texture.texture_type == texture_type { + Texture { + texture_type: texture_type.clone(), + image: image.clone(), + model: skin_model.clone(), + preset: None, + } + } else { + texture.clone() + } + }) + .collect() + } + PlayerType::ThirdParty => { + AuthlibInjectorTextureOperation::parse_skin( + &app, + &match texture_type { + TextureType::Cape => { + AuthlibInjectorTextureOperation::upload_cape(&app, &player, &image_path).await? + } + TextureType::Skin => { + AuthlibInjectorTextureOperation::upload_skin( + &app, + &player, + &image_path, + skin_model.clone(), + ) + .await? + } + }, + ) + .await? + } + PlayerType::Microsoft => { + MicrosoftTextureOperation::parse_skin( + &app, + &match texture_type { + TextureType::Cape => { + MicrosoftTextureOperation::upload_cape(&app, &player, &image_path).await? + } + TextureType::Skin => { + MicrosoftTextureOperation::upload_skin(&app, &player, &image_path, skin_model.clone()) + .await? + } + }, + ) + .await? + } + }; - let player = account_state - .get_player_by_id_mut(player_id.clone()) - .ok_or(AccountError::NotFound)?; + { + let mut account_state = account_binding.lock()?; - if player.player_type != PlayerType::Offline { - return Err(AccountError::Invalid.into()); + if let Some(stored_player) = account_state.players.iter_mut().find(|p| p.id == player_id) { + stored_player.textures.clear(); + stored_player.textures.extend(textures); + account_state.save()?; + } } - - // remove existing texture of the same type - player - .textures - .retain(|texture| texture.texture_type != texture_type); - - // add the new texture - player.textures.push(crate::account::models::Texture { - texture_type: texture_type.clone(), - image: texture_img.into(), - model: skin_model.clone(), - preset: None, - }); - - account_state.save()?; Ok(()) } diff --git a/src-tauri/src/account/helpers/authlib_injector/common.rs b/src-tauri/src/account/helpers/authlib_injector/common.rs index 31f57527e..39a843f9d 100644 --- a/src-tauri/src/account/helpers/authlib_injector/common.rs +++ b/src-tauri/src/account/helpers/authlib_injector/common.rs @@ -1,16 +1,10 @@ -use crate::account::helpers::authlib_injector::models::{MinecraftProfile, TextureInfo}; +use crate::account::helpers::authlib_injector::models::MinecraftProfile; +use crate::account::helpers::authlib_injector::texture::AuthlibInjectorTextureOperation; use crate::account::helpers::authlib_injector::{oauth, password}; -use crate::account::helpers::misc::fetch_image; -use crate::account::helpers::offline::load_preset_skin; -use crate::account::models::{ - AccountError, AuthServer, PlayerInfo, PlayerType, PresetRole, SkinModel, Texture, TextureType, -}; +use crate::account::helpers::texture::TextureOperation; +use crate::account::models::{AccountError, AuthServer, PlayerInfo, PlayerType}; use crate::error::SJMCLResult; -use base64::engine::general_purpose; -use base64::Engine; use serde_json::json; -use std::str::FromStr; -use strum::IntoEnumIterator; use tauri::{AppHandle, Manager}; use tauri_plugin_http::reqwest; use uuid::Uuid; @@ -46,44 +40,6 @@ pub async fn parse_profile( ) -> SJMCLResult { let uuid = Uuid::parse_str(&profile.id).map_err(|_| AccountError::ParseError)?; let name = profile.name.clone(); - let mut textures: Vec = vec![]; - - if let Some(texture_info_base64) = profile - .properties - .as_ref() - .and_then(|props| props.iter().find(|property| property.name == "textures")) - { - let texture_info = general_purpose::STANDARD - .decode(texture_info_base64.value.clone()) - .map_err(|_| AccountError::ParseError)? - .into_iter() - .map(|b| b as char) - .collect::(); - - let texture_info_value: TextureInfo = - serde_json::from_str(&texture_info).map_err(|_| AccountError::ParseError)?; - - for texture_type in TextureType::iter() { - if let Some(skin) = texture_info_value.textures.get(&texture_type.to_string()) { - textures.push(Texture { - image: fetch_image(app, skin.url.clone()).await?, - texture_type, - model: skin - .metadata - .as_ref() - .and_then(|metadata| metadata.get("model").cloned()) - .map(|model_str| SkinModel::from_str(&model_str).unwrap_or(SkinModel::Default)) - .unwrap_or_default(), - preset: None, - }); - } - } - } - - if textures.is_empty() { - // this player didn't have a texture, use preset Steve skin instead - textures = load_preset_skin(app, PresetRole::Steve)?; - } Ok( PlayerInfo { @@ -94,7 +50,7 @@ pub async fn parse_profile( auth_account, access_token, refresh_token, - textures, + textures: AuthlibInjectorTextureOperation::parse_skin(app, profile).await?, auth_server_url, } .with_generated_id(), diff --git a/src-tauri/src/account/helpers/authlib_injector/mod.rs b/src-tauri/src/account/helpers/authlib_injector/mod.rs index 585efaef3..ee4a6e5a9 100644 --- a/src-tauri/src/account/helpers/authlib_injector/mod.rs +++ b/src-tauri/src/account/helpers/authlib_injector/mod.rs @@ -5,3 +5,4 @@ pub mod jar; pub mod models; pub mod oauth; pub mod password; +pub mod texture; diff --git a/src-tauri/src/account/helpers/authlib_injector/texture.rs b/src-tauri/src/account/helpers/authlib_injector/texture.rs new file mode 100644 index 000000000..371efd6d1 --- /dev/null +++ b/src-tauri/src/account/helpers/authlib_injector/texture.rs @@ -0,0 +1,206 @@ +use crate::account::helpers::authlib_injector::common::retrieve_profile; +use crate::account::helpers::authlib_injector::models::{MinecraftProfile, TextureInfo}; +use crate::account::helpers::misc::fetch_image; +use crate::account::helpers::texture::{load_preset_skin_info, TextureOperation}; +use crate::account::models::{ + AccountError, PlayerInfo, PresetRole, SkinModel, Texture, TextureType, +}; +use crate::error::SJMCLResult; +use crate::utils::image::load_image_from_dir; +use base64::engine::general_purpose; +use base64::Engine; +use std::fs; +use std::path::Path; +use std::str::FromStr; +use strum::IntoEnumIterator; +use tauri::{AppHandle, Manager}; +use tauri_plugin_http::reqwest; +use tauri_plugin_http::reqwest::multipart::{Form, Part}; + +pub struct AuthlibInjectorTextureOperation; + +impl TextureOperation for AuthlibInjectorTextureOperation { + async fn parse_skin(app: &AppHandle, profile: &MinecraftProfile) -> SJMCLResult> { + let mut textures: Vec = vec![]; + + if let Some(texture_info_base64) = profile + .properties + .as_ref() + .and_then(|props| props.iter().find(|property| property.name == "textures")) + { + let texture_info = general_purpose::STANDARD + .decode(texture_info_base64.value.clone()) + .map_err(|_| AccountError::ParseError)? + .into_iter() + .map(|b| b as char) + .collect::(); + + let texture_info_value: TextureInfo = + serde_json::from_str(&texture_info).map_err(|_| AccountError::ParseError)?; + + for texture_type in TextureType::iter() { + if let Some(skin) = texture_info_value.textures.get(&texture_type.to_string()) { + textures.push(Texture { + image: fetch_image(app, skin.url.clone()).await?, + texture_type, + model: skin + .metadata + .as_ref() + .and_then(|metadata| metadata.get("model").cloned()) + .map(|model_str| SkinModel::from_str(&model_str).unwrap_or(SkinModel::Default)) + .unwrap_or_default(), + preset: None, + }); + } + } + } + + if textures.is_empty() { + // this player didn't have a texture, use preset Steve skin instead + let (skin_path, model) = load_preset_skin_info(app, PresetRole::Steve)?; + textures = vec![Texture { + texture_type: TextureType::Skin, + image: load_image_from_dir(&skin_path) + .ok_or(AccountError::TextureFormatIncorrect)? + .into(), + model, + preset: Some(PresetRole::Steve), + }]; + } + Ok(textures) + } + + async fn upload_skin( + app: &AppHandle, + player: &PlayerInfo, + file_path: &Path, + model: SkinModel, + ) -> SJMCLResult { + let client = app.state::(); + let auth_server_url = player.auth_server_url.clone().unwrap_or_default(); + let id = player.uuid.as_simple(); + let model = if model == SkinModel::Slim { "slim" } else { "" }; + let filename = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let file = fs::read(file_path).map_err(|_| AccountError::TextureFormatIncorrect)?; + let form = Form::new().text("model", model).part( + "file", + Part::bytes(file) + .file_name(filename) + .mime_str("image/png") + .map_err(|_| AccountError::TextureFormatIncorrect)?, + ); + + let response = client + .put(format!( + "{}/api/user/profile/{}/skin", + auth_server_url.clone(), + id.clone() + )) + .bearer_auth(player.access_token.clone().unwrap_or_default()) + .multipart(form) + .send() + .await + .map_err(|_| AccountError::NetworkError)?; + + if !response.status().is_success() { + log::error!("Failed to upload skin: {}", response.status()); + return Err(AccountError::NoTextureApi.into()); + } + + retrieve_profile(app, auth_server_url, id.to_string()).await + } + + async fn delete_skin(app: &AppHandle, player: &PlayerInfo) -> SJMCLResult { + let client = app.state::(); + let auth_server_url = player.auth_server_url.clone().unwrap_or_default(); + let id = player.uuid.as_simple(); + + let response = client + .delete(format!( + "{}/api/user/profile/{}/skin", + auth_server_url.clone(), + id.clone() + )) + .bearer_auth(player.access_token.clone().unwrap_or_default()) + .send() + .await + .map_err(|_| AccountError::NetworkError)?; + + if !response.status().is_success() { + log::error!("Failed to delete skin: {}", response.status()); + return Err(AccountError::NoTextureApi.into()); + } + + retrieve_profile(app, auth_server_url, id.to_string()).await + } + + async fn upload_cape( + app: &AppHandle, + player: &PlayerInfo, + file_path: &Path, + ) -> SJMCLResult { + let client = app.state::(); + let auth_server_url = player.auth_server_url.clone().unwrap_or_default(); + let id = player.uuid.as_simple(); + let filename = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let file = fs::read(file_path).map_err(|_| AccountError::TextureFormatIncorrect)?; + let form = Form::new().text("model", "").part( + "file", + Part::bytes(file) + .file_name(filename) + .mime_str("image/png") + .map_err(|_| AccountError::TextureFormatIncorrect)?, + ); + + let response = client + .put(format!( + "{}/api/user/profile/{}/cape", + auth_server_url.clone(), + id.clone() + )) + .bearer_auth(player.access_token.clone().unwrap_or_default()) + .multipart(form) + .send() + .await + .map_err(|_| AccountError::NetworkError)?; + + if !response.status().is_success() { + log::error!("Failed to upload cape: {}", response.status()); + return Err(AccountError::NoTextureApi.into()); + } + + retrieve_profile(app, auth_server_url, id.to_string()).await + } + + async fn delete_cape(app: &AppHandle, player: &PlayerInfo) -> SJMCLResult { + let client = app.state::(); + let auth_server_url = player.auth_server_url.clone().unwrap_or_default(); + let id = player.uuid.as_simple(); + + let response = client + .delete(format!( + "{}/api/user/profile/{}/cape", + auth_server_url.clone(), + id.clone() + )) + .bearer_auth(player.access_token.clone().unwrap_or_default()) + .send() + .await + .map_err(|_| AccountError::NetworkError)?; + + if !response.status().is_success() { + log::error!("Failed to delete cape: {}", response.status()); + return Err(AccountError::NoTextureApi.into()); + } + + retrieve_profile(app, auth_server_url, id.to_string()).await + } +} diff --git a/src-tauri/src/account/helpers/microsoft/mod.rs b/src-tauri/src/account/helpers/microsoft/mod.rs index 8b8b18c51..cf97f25c2 100644 --- a/src-tauri/src/account/helpers/microsoft/mod.rs +++ b/src-tauri/src/account/helpers/microsoft/mod.rs @@ -1,3 +1,4 @@ pub mod constants; pub mod models; pub mod oauth; +pub mod texture; diff --git a/src-tauri/src/account/helpers/microsoft/oauth.rs b/src-tauri/src/account/helpers/microsoft/oauth.rs index fb15b5f13..1690b9194 100644 --- a/src-tauri/src/account/helpers/microsoft/oauth.rs +++ b/src-tauri/src/account/helpers/microsoft/oauth.rs @@ -3,11 +3,11 @@ use crate::account::helpers::microsoft::constants::{ PROFILE_ENDPOINT, SCOPE, XSTS_AUTH_ENDPOINT, }; use crate::account::helpers::microsoft::models::{MinecraftProfile, XstsResponse}; -use crate::account::helpers::misc::{fetch_image, oauth_polling}; -use crate::account::helpers::offline::load_preset_skin; +use crate::account::helpers::microsoft::texture::MicrosoftTextureOperation; +use crate::account::helpers::misc::oauth_polling; +use crate::account::helpers::texture::TextureOperation; use crate::account::models::{ AccountError, DeviceAuthResponse, DeviceAuthResponseInfo, OAuthTokens, PlayerInfo, PlayerType, - PresetRole, SkinModel, Texture, TextureType, }; use crate::error::SJMCLResult; use serde_json::{json, Value}; @@ -140,7 +140,7 @@ async fn fetch_minecraft_profile( let response = client .get(PROFILE_ENDPOINT) - .header("Authorization", format!("Bearer {}", minecraft_token)) + .bearer_auth(minecraft_token) .send() .await .map_err(|_| AccountError::NetworkError)?; @@ -159,37 +159,6 @@ async fn parse_profile(app: &AppHandle, tokens: &OAuthTokens) -> SJMCLResult SJMCLResult SJMCLResult let client = app.state::(); let response = client .get(PROFILE_ENDPOINT) - .header( - "Authorization", - format!("Bearer {}", player.access_token.clone().unwrap_or_default()), - ) + .bearer_auth(player.access_token.clone().unwrap_or_default()) .send() .await .map_err(|_| AccountError::NetworkError)?; diff --git a/src-tauri/src/account/helpers/microsoft/texture.rs b/src-tauri/src/account/helpers/microsoft/texture.rs new file mode 100644 index 000000000..f6c1f275e --- /dev/null +++ b/src-tauri/src/account/helpers/microsoft/texture.rs @@ -0,0 +1,156 @@ +use crate::account::helpers::microsoft::models::MinecraftProfile; +use crate::account::helpers::misc::fetch_image; +use crate::account::helpers::texture::{load_preset_skin_info, TextureOperation}; +use crate::account::models::{ + AccountError, PlayerInfo, PresetRole, SkinModel, Texture, TextureType, +}; +use crate::error::SJMCLResult; +use crate::utils::image::load_image_from_dir; +use std::fs; +use std::path::Path; +use tauri::{AppHandle, Manager}; +use tauri_plugin_http::reqwest; +use tauri_plugin_http::reqwest::multipart::{Form, Part}; + +pub struct MicrosoftTextureOperation; + +impl TextureOperation for MicrosoftTextureOperation { + async fn parse_skin(app: &AppHandle, profile: &MinecraftProfile) -> SJMCLResult> { + let mut textures = vec![]; + if let Some(skins) = &profile.skins { + for skin in skins { + if skin.state == "ACTIVE" { + textures.push(Texture { + texture_type: TextureType::Skin, + image: fetch_image(app, skin.url.clone()).await?, + model: skin.variant.clone().unwrap_or_default(), + preset: None, + }); + } + } + } + if let Some(capes) = &profile.capes { + for cape in capes { + if cape.state == "ACTIVE" { + textures.push(Texture { + texture_type: TextureType::Cape, + image: fetch_image(app, cape.url.clone()).await?, + model: SkinModel::Default, + preset: None, + }); + } + } + } + + if textures.is_empty() { + // this player didn't have a texture, use preset Steve skin instead + let (skin_path, model) = load_preset_skin_info(app, PresetRole::Steve)?; + textures = vec![Texture { + texture_type: TextureType::Skin, + image: load_image_from_dir(&skin_path) + .ok_or(AccountError::TextureFormatIncorrect)? + .into(), + model, + preset: Some(PresetRole::Steve), + }]; + } + Ok(textures) + } + + async fn upload_skin( + app: &AppHandle, + player: &PlayerInfo, + file_path: &Path, + model: SkinModel, + ) -> SJMCLResult { + let client = app.state::(); + let variant = if model == SkinModel::Slim { + "slim" + } else { + "classic" + }; + let file = fs::read(file_path).map_err(|_| AccountError::TextureFormatIncorrect)?; + let form = Form::new().text("variant", variant).part( + "file", + Part::bytes(file) + .file_name("skin.png") + .mime_str("image/png") + .map_err(|_| AccountError::TextureFormatIncorrect)?, + ); + + let response = client + .post("https://api.minecraftservices.com/minecraft/profile/skins") + .bearer_auth(player.access_token.clone().unwrap_or_default()) + .multipart(form) + .send() + .await + .map_err(|_| AccountError::NetworkError)?; + + if !response.status().is_success() { + log::error!("Failed to upload skin: {}", response.status()); + return Err(AccountError::NoTextureApi.into()); + } + + Ok( + response + .json::() + .await + .map_err(|_| AccountError::ParseError)?, + ) + } + + async fn delete_skin(app: &AppHandle, player: &PlayerInfo) -> SJMCLResult { + let client = app.state::(); + + let response = client + .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") + .bearer_auth(player.access_token.clone().unwrap_or_default()) + .send() + .await + .map_err(|_| AccountError::NetworkError)?; + + if !response.status().is_success() { + log::error!("Failed to delete skin: {}", response.status()); + return Err(AccountError::NoTextureApi.into()); + } + + Ok( + response + .json::() + .await + .map_err(|_| AccountError::ParseError)?, + ) + } + + async fn upload_cape( + _app: &AppHandle, + _player: &PlayerInfo, + _file_path: &Path, + ) -> SJMCLResult { + Err(AccountError::NoTextureApi.into()) + } + + async fn delete_cape(app: &AppHandle, player: &PlayerInfo) -> SJMCLResult { + let client = app.state::(); + + let response = client + .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Accept", "application/json") + .bearer_auth(player.access_token.clone().unwrap_or_default()) + .send() + .await + .map_err(|_| AccountError::NetworkError)?; + + if !response.status().is_success() { + log::error!("Failed to delete cape: {}", response.status()); + return Err(AccountError::NoTextureApi.into()); + } + + Ok( + response + .json::() + .await + .map_err(|_| AccountError::ParseError)?, + ) + } +} diff --git a/src-tauri/src/account/helpers/mod.rs b/src-tauri/src/account/helpers/mod.rs index 46829d7be..60a81f70c 100644 --- a/src-tauri/src/account/helpers/mod.rs +++ b/src-tauri/src/account/helpers/mod.rs @@ -2,4 +2,4 @@ pub mod authlib_injector; pub mod microsoft; pub mod misc; pub mod offline; -pub mod skin; +pub mod texture; diff --git a/src-tauri/src/account/helpers/offline/login.rs b/src-tauri/src/account/helpers/offline/login.rs new file mode 100644 index 000000000..dce45b67a --- /dev/null +++ b/src-tauri/src/account/helpers/offline/login.rs @@ -0,0 +1,49 @@ +use crate::account::helpers::texture::load_preset_skin_info; +use crate::account::models::{ + AccountError, PlayerInfo, PlayerType, PresetRole, Texture, TextureType, +}; +use crate::error::SJMCLResult; +use crate::utils::image::load_image_from_dir; +use rand::seq::IteratorRandom; +use strum::IntoEnumIterator; +use tauri::AppHandle; +use uuid::Uuid; + +pub async fn login(app: &AppHandle, username: String, raw_uuid: String) -> SJMCLResult { + let name_with_prefix = format!("OfflinePlayer:{}", username); + let uuid = if let Ok(id) = Uuid::parse_str(&raw_uuid) { + id + } else { + if !raw_uuid.is_empty() { + // user uses custom UUID, but it's invalid + return Err(AccountError::Invalid)?; + } + Uuid::new_v5(&Uuid::NAMESPACE_URL, name_with_prefix.as_bytes()) + }; + let preset_role = PresetRole::iter() + .choose(&mut rand::rng()) + .unwrap_or(PresetRole::Steve); + let (preset_skin_path, model) = load_preset_skin_info(app, preset_role.clone())?; + let skin_img = + load_image_from_dir(&preset_skin_path).ok_or(AccountError::TextureFormatIncorrect)?; + + Ok( + PlayerInfo { + id: "".to_string(), + name: username.clone(), + uuid, + player_type: PlayerType::Offline, + auth_account: None, + auth_server_url: None, + access_token: None, + refresh_token: None, + textures: vec![Texture { + texture_type: TextureType::Skin, + image: skin_img.into(), + model, + preset: Some(preset_role), + }], + } + .with_generated_id(), + ) +} diff --git a/src-tauri/src/account/helpers/offline/mod.rs b/src-tauri/src/account/helpers/offline/mod.rs index abd952f1f..b5694c868 100644 --- a/src-tauri/src/account/helpers/offline/mod.rs +++ b/src-tauri/src/account/helpers/offline/mod.rs @@ -1,61 +1,2 @@ +pub mod login; pub mod yggdrasil_server; - -use crate::account::models::{ - AccountError, PlayerInfo, PlayerType, PresetRole, SkinModel, Texture, TextureType, -}; -use crate::error::SJMCLResult; -use crate::utils::fs::get_app_resource_filepath; -use crate::utils::image::load_image_from_dir; -use rand::seq::IteratorRandom; -use strum::IntoEnumIterator; -use tauri::AppHandle; -use uuid::Uuid; - -pub fn load_preset_skin(app: &AppHandle, preset_role: PresetRole) -> SJMCLResult> { - let texture_path = get_app_resource_filepath(app, &format!("assets/skins/{}.png", preset_role)) - .map_err(|_| AccountError::TextureError)?; - - let texture_img = load_image_from_dir(&texture_path).ok_or(AccountError::TextureError)?; - - Ok(vec![Texture { - texture_type: TextureType::Skin, - image: texture_img.into(), - model: if preset_role == PresetRole::Alex { - SkinModel::Slim - } else { - SkinModel::Default - }, - preset: Some(preset_role), - }]) -} - -pub async fn login(app: &AppHandle, username: String, raw_uuid: String) -> SJMCLResult { - let name_with_prefix = format!("OfflinePlayer:{}", username); - let uuid = if let Ok(id) = Uuid::parse_str(&raw_uuid) { - id - } else { - if !raw_uuid.is_empty() { - // user uses custom UUID, but it's invalid - return Err(AccountError::Invalid)?; - } - Uuid::new_v5(&Uuid::NAMESPACE_URL, name_with_prefix.as_bytes()) - }; - let preset_role = PresetRole::iter() - .choose(&mut rand::rng()) - .unwrap_or(PresetRole::Steve); - - Ok( - PlayerInfo { - id: "".to_string(), - name: username.clone(), - uuid, - player_type: PlayerType::Offline, - auth_account: None, - auth_server_url: None, - access_token: None, - refresh_token: None, - textures: load_preset_skin(app, preset_role)?, - } - .with_generated_id(), - ) -} diff --git a/src-tauri/src/account/helpers/skin.rs b/src-tauri/src/account/helpers/texture.rs similarity index 50% rename from src-tauri/src/account/helpers/skin.rs rename to src-tauri/src/account/helpers/texture.rs index 5061100ff..e6968c5d2 100644 --- a/src-tauri/src/account/helpers/skin.rs +++ b/src-tauri/src/account/helpers/texture.rs @@ -1,4 +1,13 @@ +use crate::account::models::AccountError; +use crate::account::models::PlayerInfo; +use crate::account::models::PresetRole; +use crate::account::models::SkinModel; +use crate::account::models::Texture; +use crate::error::SJMCLResult; +use crate::utils::fs::get_app_resource_filepath; use image::RgbaImage; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; pub fn draw_avatar(size: u32, img: &RgbaImage) -> RgbaImage { let (skin_width, _) = img.dimensions(); @@ -52,3 +61,30 @@ fn draw_image_section( } } } + +pub fn load_preset_skin_info( + app: &AppHandle, + preset_role: PresetRole, +) -> SJMCLResult<(PathBuf, SkinModel)> { + let skin_path = + get_app_resource_filepath(app, &format!("assets/skins/{}.png", preset_role.clone())) + .map_err(|_| AccountError::TextureFormatIncorrect)?; + let skin_model = match preset_role { + PresetRole::Alex => SkinModel::Slim, + _ => SkinModel::Default, + }; + Ok((skin_path, skin_model)) +} + +pub trait TextureOperation { + async fn parse_skin(app: &AppHandle, profile: &T) -> SJMCLResult>; + async fn upload_skin( + app: &AppHandle, + player: &PlayerInfo, + file_path: &Path, + model: SkinModel, + ) -> SJMCLResult; + async fn delete_skin(app: &AppHandle, player: &PlayerInfo) -> SJMCLResult; + async fn upload_cape(app: &AppHandle, player: &PlayerInfo, file_path: &Path) -> SJMCLResult; + async fn delete_cape(app: &AppHandle, player: &PlayerInfo) -> SJMCLResult; +} diff --git a/src-tauri/src/account/models.rs b/src-tauri/src/account/models.rs index bea321c99..ee9b7501a 100644 --- a/src-tauri/src/account/models.rs +++ b/src-tauri/src/account/models.rs @@ -1,6 +1,6 @@ use crate::account::constants::ACCOUNTS_FILE_NAME; use crate::account::helpers::authlib_injector::constants::PRESET_AUTH_SERVERS; -use crate::account::helpers::skin::draw_avatar; +use crate::account::helpers::texture::draw_avatar; use crate::storage::Storage; use crate::utils::image::ImageWrapper; use crate::APP_DATA_DIR; @@ -294,8 +294,8 @@ impl Default for AccountInfo { } impl AccountInfo { - pub fn get_player_by_id_mut(&mut self, id: String) -> Option<&mut PlayerInfo> { - self.players.iter_mut().find(|player| player.id == id) + pub fn get_player_by_id(&self, id: String) -> Option<&PlayerInfo> { + self.players.iter().find(|player| player.id == id) } } @@ -312,13 +312,14 @@ pub enum AccountError { Expired, Invalid, NotFound, - TextureError, + TextureFormatIncorrect, NetworkError, ParseError, Cancelled, NoDownloadApi, SaveError, NoMinecraftProfile, + NoTextureApi, } impl std::error::Error for AccountError {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7cee47f58..8bb7c6710 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,8 +89,8 @@ pub async fn run() { account::commands::add_player_3rdparty_password, account::commands::relogin_player_3rdparty_password, account::commands::add_player_from_selection, - account::commands::update_player_skin_offline_preset, - account::commands::update_player_skin_offline_local, + account::commands::update_player_texture_preset, + account::commands::update_player_texture_local, account::commands::delete_player, account::commands::refresh_player, account::commands::retrieve_auth_server_list, diff --git a/src/components/modals/manage-skin-modal.tsx b/src/components/modals/manage-skin-modal.tsx index 3f617f941..a526adf38 100644 --- a/src/components/modals/manage-skin-modal.tsx +++ b/src/components/modals/manage-skin-modal.tsx @@ -1,4 +1,8 @@ import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, Box, Button, FormControl, @@ -32,27 +36,32 @@ import SkinPreview from "@/components/skin-preview"; import { useLauncherConfig } from "@/contexts/config"; import { useGlobalData } from "@/contexts/global-data"; import { useToast } from "@/contexts/toast"; -import { PresetRole, SkinModel, TextureType } from "@/enums/account"; -import { Texture } from "@/models/account"; +import { + PlayerType, + PresetRole, + SkinModel, + TextureType, +} from "@/enums/account"; +import { Player } from "@/models/account"; import { AccountService } from "@/services/account"; import { base64ImgSrc } from "@/utils/string"; type SkinType = PresetRole | "default" | "upload"; interface ManageSkinModalProps extends Omit { - playerId: string; - skin?: Texture; - cape?: Texture; + player: Player; } const ManageSkinModal: React.FC = ({ - playerId, + player, isOpen, onClose, - skin, - cape, ...modalProps }) => { + let playerId = player.id; + let skin = player.textures.find((t) => t.textureType === TextureType.Skin); + let cape = player.textures.find((t) => t.textureType === TextureType.Cape); + const [selectedSkin, setSelectedSkin] = useState("default"); const [uploadSkinFilePath, setUploadSkinFilePath] = useState(""); const [uploadCapeFilePath, setUploadCapeFilePath] = useState(""); @@ -108,7 +117,7 @@ const ManageSkinModal: React.FC = ({ if (selectedSkin !== "upload") { setIsLoading(true); try { - const resp = await AccountService.updatePlayerSkinOfflinePreset( + const resp = await AccountService.updatePlayerTexturePreset( playerId, selectedSkin ); @@ -132,7 +141,7 @@ const ManageSkinModal: React.FC = ({ } else if (uploadSkinFilePath) { setIsLoading(true); try { - const skinResp = await AccountService.updatePlayerSkinOfflineLocal( + const skinResp = await AccountService.updatePlayerTextureLocal( playerId, uploadSkinFilePath, TextureType.Skin, @@ -140,7 +149,7 @@ const ManageSkinModal: React.FC = ({ ); if (skinResp.status === "success") { if (isCapeVisible && uploadCapeFilePath) { - const capeResp = await AccountService.updatePlayerSkinOfflineLocal( + const capeResp = await AccountService.updatePlayerTextureLocal( playerId, uploadCapeFilePath, TextureType.Cape, @@ -307,30 +316,46 @@ const ManageSkinModal: React.FC = ({ - - - {t("ManageSkinModal.cape")} - - - setUploadCapeFilePath(e.target.value)} - flex={1} - variant="filled" - disabled={!isCapeVisible} - /> - - - - - + {player.playerType === PlayerType.Microsoft ? ( + + + + + {t("ManageSkinModal.alert.microsoftCape.title")} + + + {t("ManageSkinModal.alert.microsoftCape.description")} + + + + ) : ( + + + {t("ManageSkinModal.cape")} + + + + setUploadCapeFilePath(e.target.value) + } + flex={1} + variant="filled" + disabled={!isCapeVisible} + /> + + + + + + )} )} diff --git a/src/components/modals/view-skin-modal.tsx b/src/components/modals/view-skin-modal.tsx deleted file mode 100644 index 3c2050c41..000000000 --- a/src/components/modals/view-skin-modal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { - Flex, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - ModalProps, -} from "@chakra-ui/react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import SkinPreview from "@/components/skin-preview"; -import { Texture } from "@/models/account"; -import { base64ImgSrc } from "@/utils/string"; - -interface ViewSkinModalProps extends Omit { - skin?: Texture; - cape?: Texture; -} - -const ViewSkinModal: React.FC = ({ - isOpen, - onClose, - skin, - cape, - ...modalProps -}) => { - const [isCapeVisible, setIsCapeVisible] = useState(true); - const { t } = useTranslation(); - - return ( - - - - {t("ViewSkinModal.skinView")} - - - - - - - - - ); -}; - -export default ViewSkinModal; diff --git a/src/components/player-menu.tsx b/src/components/player-menu.tsx index 0c3681d8d..c21af2072 100644 --- a/src/components/player-menu.tsx +++ b/src/components/player-menu.tsx @@ -16,7 +16,6 @@ import { LuCopy, LuEllipsis, LuRefreshCcw, LuTrash } from "react-icons/lu"; import { TbHanger } from "react-icons/tb"; import { CommonIconButton } from "@/components/common/common-icon-button"; import ManageSkinModal from "@/components/modals/manage-skin-modal"; -import ViewSkinModal from "@/components/modals/view-skin-modal"; import { useGlobalData } from "@/contexts/global-data"; import { useSharedModals } from "@/contexts/shared-modal"; import { useToast } from "@/contexts/toast"; @@ -123,9 +122,7 @@ export const PlayerMenu: React.FC = ({ ]), { icon: TbHanger, - label: t( - `PlayerMenu.label.${player.playerType === PlayerType.Offline ? "manageSkin" : "viewSkin"}` - ), + label: t("PlayerMenu.label.manageSkin"), onClick: onSkinModalOpen, }, { @@ -197,30 +194,11 @@ export const PlayerMenu: React.FC = ({ ))} )} - {player.playerType === PlayerType.Offline ? ( - texture.textureType === "SKIN" - )} - cape={player.textures.find( - (texture) => texture.textureType === "CAPE" - )} - /> - ) : ( - texture.textureType === "SKIN" - )} - cape={player.textures.find( - (texture) => texture.textureType === "CAPE" - )} - /> - )} + ); }; diff --git a/src/locales/en.json b/src/locales/en.json index c8dac3482..106750a59 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1357,6 +1357,12 @@ "label": "Model", "default": "Wide", "slim": "Slim" + }, + "alert": { + "microsoftCape": { + "title": "Cape Change Not Supported", + "description": "SJMCL cannot currently provide cape upload services for Microsoft accounts." + } } }, "ManualAddJavaPathModal": { @@ -1406,7 +1412,6 @@ "PlayerMenu": { "label": { "manageSkin": "Manage Skin", - "viewSkin": "View Skin", "delete": "Delete", "copyUUID": "Copy UUID" }, @@ -1948,25 +1953,25 @@ } } }, - "updatePlayerSkinOfflinePreset": { - "success": "Successfully updated player skin", + "updatePlayerTexturePreset": { + "success": "Successfully updated player texture", "error": { - "title": "Failed to update player skin", + "title": "Failed to update player texture", "description": { "NOT_FOUND": "The player does not exist", - "INVALID": "The player type is invalid", - "TEXTURE_ERROR": "Unable to get player texture" + "TEXTURE_FORMAT_INCORRECT": "Texture format is incorrect", + "NO_TEXTURE_API": "Texture modifying is not available for this server" } } }, - "updatePlayerSkinOfflineLocal": { - "success": "Successfully updated player skin", + "updatePlayerTextureLocal": { + "success": "Successfully updated player texture", "error": { - "title": "Failed to update player skin", + "title": "Failed to update player texture", "description": { "NOT_FOUND": "The player does not exist", - "INVALID": "The player type is invalid", - "TEXTURE_ERROR": "Unable to get player texture" + "TEXTURE_FORMAT_INCORRECT": "Texture format is incorrect", + "NO_TEXTURE_API": "Texture modifying is not available for this server" } } }, @@ -2524,9 +2529,6 @@ } } }, - "ViewSkinModal": { - "skinView": "View Skin" - }, "WelcomeAndTermsModal": { "header": { "title": "Welcome!" diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 84a8f3c99..418f1155b 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1357,6 +1357,12 @@ "label": "模型", "default": "宽型", "slim": "纤细" + }, + "alert": { + "microsoftCape": { + "title": "披风更改暂不可用", + "description": "SJMCL 暂无法为微软账户提供披风上传服务。" + } } }, "ManualAddJavaPathModal": { @@ -1406,7 +1412,6 @@ "PlayerMenu": { "label": { "manageSkin": "管理皮肤", - "viewSkin": "查看皮肤", "delete": "删除此角色", "copyUUID": "复制 UUID" }, @@ -1948,25 +1953,25 @@ } } }, - "updatePlayerSkinOfflinePreset": { - "success": "角色皮肤更新成功", + "updatePlayerTexturePreset": { + "success": "角色材质更新成功", "error": { - "title": "角色皮肤更新失败", + "title": "角色材质更新失败", "description": { "NOT_FOUND": "角色不存在", - "INVALID": "角色类型错误", - "TEXTURE_ERROR": "角色皮肤获取失败" + "TEXTURE_FORMAT_INCORRECT": "角色材质格式不正确", + "NO_TEXTURE_API": "暂不能使用该认证服务器的材质更改功能" } } }, - "updatePlayerSkinOfflineLocal": { - "success": "角色皮肤更新成功", + "updatePlayerTextureLocal": { + "success": "角色材质更新成功", "error": { - "title": "角色皮肤更新失败", + "title": "角色材质更新失败", "description": { "NOT_FOUND": "角色不存在", - "INVALID": "角色类型错误", - "TEXTURE_ERROR": "角色皮肤获取失败" + "TEXTURE_FORMAT_INCORRECT": "角色材质格式不正确", + "NO_TEXTURE_API": "暂不能使用该认证服务器的材质更改功能" } } }, @@ -2524,9 +2529,6 @@ } } }, - "ViewSkinModal": { - "skinView": "查看皮肤" - }, "WelcomeAndTermsModal": { "header": { "title": "欢迎!" diff --git a/src/services/account.ts b/src/services/account.ts index 370058774..6379bea13 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -144,38 +144,38 @@ export class AccountService { } /** - * UPDATE the skin of an offline player within preset roles (Steve, Alex). + * UPDATE the skin of a player within preset roles (Steve, Alex). * @param {string} playerId - The player ID of the player to be updated. * @param {string} presetRole - The preset role that the player's skin will be. * @returns {Promise>} */ @responseHandler("account") - static async updatePlayerSkinOfflinePreset( + static async updatePlayerTexturePreset( playerId: string, presetRole: string ): Promise> { - return await invoke("update_player_skin_offline_preset", { + return await invoke("update_player_texture_preset", { playerId, presetRole, }); } /** - * UPDATE the skin of an offline player using a local image file. + * UPDATE the texture of a player using a local image file. * @param {string} playerId - The player ID of the player to be updated. - * @param {string} imagePath - The local image file path of the new skin. + * @param {string} imagePath - The local image file path of the new texture. * @param {TextureType} textureType - The texture type ("skin" or "cape"). * @param {SkinModel} skinModel - The skin model ("slim" or "default") if the texture type is "skin". * @returns {Promise>} */ @responseHandler("account") - static async updatePlayerSkinOfflineLocal( + static async updatePlayerTextureLocal( playerId: string, imagePath: string, textureType: TextureType, skinModel: SkinModel ): Promise> { - return await invoke("update_player_skin_offline_local", { + return await invoke("update_player_texture_local", { playerId, imagePath, textureType,