diff --git a/Cargo.lock b/Cargo.lock index 9293654f..ab41ab62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4498,8 +4498,11 @@ dependencies = [ "open", "polyio", "regex", + "reqwest 0.13.3", "rfd", "rust-embed", + "serde", + "serde_json", "sysinfo", "tokio", "tracing", diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 27ad57fd..74970a0e 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -42,6 +42,9 @@ notify.workspace = true anyhow.workspace = true regex.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +reqwest.workspace = true cargo-packager-updater = "=0.2.3" diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 76a148a7..8492fe17 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -100,9 +100,9 @@ fn navbar_center() -> impl IntoElement { nav_label: "Versions", }) .child(NavLink { - active: false, - target: NavTarget::External("https://store.polyfrost.org"), - nav_label: "Cosmetics", + active: route == Route::AccountSkins {}, + target: NavTarget::Route(Route::AccountSkins {}), + nav_label: "Skins", }) .child(NavLink { active: matches!( @@ -142,6 +142,7 @@ fn browse_target() -> Route { } #[derive(PartialEq, Clone)] +#[allow(dead_code)] enum NavTarget { Route(Route), External(&'static str), diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index efadde52..81846324 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -47,7 +47,8 @@ pub use queries::{ use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, use_package_search, use_package_updates, use_package_versions, use_package_versions_when, - use_player_profile, use_player_skin, use_provider_versions, use_refresh_account, + use_player_profile, use_player_skin, use_custom_skin, delete_account_skin, + fetch_skin_online, save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report, use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 85dd4317..0c4ebb3d 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -88,7 +88,10 @@ pub use settings_profiles::{ try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile, use_named_profiles, }; -pub use skin::use_player_skin; +pub use skin::{ + delete_account_skin, fetch_skin_online, invalidate_skin_queries, save_account_skin, + use_custom_skin, use_player_skin, +}; pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; pub use storage::{ StorageAction, StorageActionMutation, StorageReportQuery, UseStorageAction, diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs index 10ab6609..e37be05a 100644 --- a/packages/oneclient_app/src/hooks/queries/skin.rs +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -1,9 +1,92 @@ +use std::time::Duration; + use bytes::Bytes; use freya::prelude::*; +use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; +use serde::{Deserialize, Serialize}; use crate::AppAssets; +const SKIN_STALE: Duration = Duration::from_secs(60); +const SKIN_CLEAN: Duration = Duration::from_secs(60 * 60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CustomSkinQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CustomSkinKeys { + pub uuid: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SkinMetadata { + pub is_slim: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct CustomSkinData { + pub bytes: Option, + pub is_slim: bool, + pub has_custom: bool, +} + +impl QueryCapability for CustomSkinQuery { + type Ok = CustomSkinData; + type Err = LauncherError; + type Keys = CustomSkinKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let Ok(path) = oneclient_common::paths::skin_file_path(&keys.uuid) else { + return Ok(CustomSkinData::default()); + }; + + if let Ok(bytes) = polyio::read(&path).await { + if !bytes.is_empty() { + let mut is_slim = false; + if let Ok(meta_path) = oneclient_common::paths::skin_meta_path(&keys.uuid) { + if let Ok(meta_bytes) = polyio::read(&meta_path).await { + if let Ok(meta) = serde_json::from_slice::(&meta_bytes) { + is_slim = meta.is_slim; + } + } + } + + return Ok(CustomSkinData { + bytes: Some(Bytes::from(bytes)), + is_slim, + has_custom: true, + }); + } + } + + Ok(CustomSkinData::default()) + } +} + +pub fn use_custom_skin(uuid: String) -> UseQuery { + use_query( + Query::new(CustomSkinKeys { uuid }, CustomSkinQuery) + .stale_time(SKIN_STALE) + .clean_time(SKIN_CLEAN), + ) +} + +pub async fn invalidate_skin_queries(uuid: Option<&str>) { + if let Some(uuid) = uuid { + QueriesStorage::::invalidate_matching(CustomSkinKeys { + uuid: uuid.to_string(), + }) + .await; + } else { + QueriesStorage::::invalidate_all().await; + } +} + pub fn use_player_skin(uuid: String) -> (Bytes, bool) { + let custom_query = use_custom_skin(uuid.clone()); + let custom_data = crate::hooks::settled_or_loading(&custom_query); + let profile = super::use_player_profile(uuid.clone(), None::); let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile) @@ -16,6 +99,14 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) { let default_slim = (java_string_hash(&uuid) & 1) == 1; + if let Some(custom) = custom_data { + if custom.has_custom { + if let Some(bytes) = custom.bytes { + return (bytes, custom.is_slim); + } + } + } + match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) { Some((_, bytes)) => (bytes, is_slim), None if default_slim => (alex.read().clone(), true), @@ -23,10 +114,135 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) { } } -fn java_string_hash(s: &str) -> i32 { +pub fn java_string_hash(s: &str) -> i32 { let mut h: i32 = 0; for c in s.encode_utf16() { h = h.wrapping_mul(31).wrapping_add(c as i32); } h } + +pub async fn save_account_skin( + uuid: &str, + username: Option<&str>, + skin_bytes: &[u8], + is_slim: bool, +) -> Result<(), String> { + let img = image::load_from_memory(skin_bytes) + .map_err(|e| format!("Invalid skin image format: {e}"))?; + let (w, h) = (img.width(), img.height()); + + let processed_bytes = if (w == 64 && h == 64) || (w == 128 && h == 128) { + skin_bytes.to_vec() + } else if w == 64 && h == 32 { + let rgba = img.to_rgba8(); + let mut modern = image::RgbaImage::new(64, 64); + image::imageops::overlay(&mut modern, &rgba, 0, 0); + let mut out = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut out); + modern + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + out + } else { + return Err(format!( + "Invalid dimensions ({w}x{h}). Minecraft skins must be 64x64 or 64x32 PNG." + )); + }; + + let skins_dir = oneclient_common::paths::skins_dir().map_err(|e| e.to_string())?; + polyio::create_dir_all(&skins_dir) + .await + .map_err(|e| e.to_string())?; + + let skin_path = oneclient_common::paths::skin_file_path(uuid).map_err(|e| e.to_string())?; + polyio::write(&skin_path, &processed_bytes) + .await + .map_err(|e| e.to_string())?; + + let meta_path = oneclient_common::paths::skin_meta_path(uuid).map_err(|e| e.to_string())?; + let meta = SkinMetadata { is_slim }; + let meta_json = serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?; + polyio::write(&meta_path, &meta_json) + .await + .map_err(|e| e.to_string())?; + + invalidate_skin_queries(Some(uuid)).await; + + // Sync to CustomSkinLoader directory if username is provided + if let Some(uname) = username { + if let Ok(dot_mc) = oneclient_common::paths::shared_minecraft_dir() { + let csl_dir = dot_mc.join("CustomSkinLoader").join("skins"); + if polyio::create_dir_all(&csl_dir).await.is_ok() { + let _ = polyio::write(csl_dir.join(format!("{uname}.png")), &processed_bytes).await; + } + } + } + + Ok(()) +} + +pub async fn delete_account_skin(uuid: &str, username: Option<&str>) -> Result<(), String> { + if let Ok(skin_path) = oneclient_common::paths::skin_file_path(uuid) { + let _ = polyio::remove_file(skin_path).await; + } + if let Ok(meta_path) = oneclient_common::paths::skin_meta_path(uuid) { + let _ = polyio::remove_file(meta_path).await; + } + if let Some(uname) = username { + if let Ok(dot_mc) = oneclient_common::paths::shared_minecraft_dir() { + let csl_skin = dot_mc + .join("CustomSkinLoader") + .join("skins") + .join(format!("{uname}.png")); + let _ = polyio::remove_file(csl_skin).await; + } + } + invalidate_skin_queries(Some(uuid)).await; + Ok(()) +} + +pub async fn fetch_skin_online(query: &str) -> Result<(Vec, bool), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + + let trimmed = query.trim(); + if trimmed.is_empty() { + return Err("Please enter a username or skin URL".to_string()); + } + + let url = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("https://minotar.net/skin/{trimmed}") + }; + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("Failed to download skin: {e}"))?; + + if !resp.status().is_success() { + if !trimmed.starts_with("http") { + let mineskin_url = format!("https://mineskin.eu/skin/{trimmed}"); + if let Ok(m_resp) = client.get(&mineskin_url).send().await { + if m_resp.status().is_success() { + if let Ok(bytes) = m_resp.bytes().await { + let is_slim = (java_string_hash(trimmed) & 1) == 1; + return Ok((bytes.to_vec(), is_slim)); + } + } + } + } + return Err(format!("Could not find skin for \"{trimmed}\"")); + } + + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + let _ = image::load_from_memory(&bytes) + .map_err(|e| format!("Downloaded file is not a valid image: {e}"))?; + let is_slim = (java_string_hash(trimmed) & 1) == 1; + Ok((bytes.to_vec(), is_slim)) +} diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 1f26ba82..de01021b 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -293,6 +293,15 @@ fn hero( .on_press(on_open_offline) .child(Icon::new(IconType::Plus).size(16.)) .text("Add offline"), + ) + .child( + Button::new() + .secondary() + .on_press(|_| { + let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {}); + }) + .child(Icon::new(IconType::Colors).size(16.)) + .text("Change Skin"), ), ) .map(error, |el, msg| { @@ -628,6 +637,20 @@ impl Component for AccountRow { ) .into_element() })) + .child( + Button::new() + .ghost() + .icon() + .on_press(move |e: Event| { + e.stop_propagation(); + let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {}); + }) + .child( + Icon::new(IconType::Colors) + .size(18.) + .color(colors::fg_primary()), + ), + ) .child({ let is_offline = !is_microsoft; let mut edit_acc_state = self.edit_account; diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs index 5ea92368..66f25a37 100644 --- a/packages/oneclient_app/src/view/app/skins.rs +++ b/packages/oneclient_app/src/view/app/skins.rs @@ -1,18 +1,34 @@ use freya::prelude::*; +use oneclient_auth::MinecraftAccount; -use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel}; -use crate::hooks::{try_default_account, use_current_account}; +use crate::components::{Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput}; +use crate::hooks::{ + delete_account_skin, fetch_skin_online, save_account_skin, try_accounts, try_default_account, + use_accounts, use_current_account, use_custom_skin, +}; use crate::theme::colors; use crate::ui::border_all_color; -const ANIMATIONS: &[&str] = &["Idle", "Walking", "Running", "Crouch", "Flying"]; - #[derive(PartialEq)] pub struct AccountSkins; impl Component for AccountSkins { fn render(&self) -> impl IntoElement { - let account_uuid = try_default_account(&use_current_account()).map(|a| a.id.to_string()); + let current_account = use_current_account(); + let default_account = try_default_account(¤t_account); + let accounts = try_accounts(&use_accounts()).unwrap_or_default(); + + let selected_id = use_state(|| default_account.as_ref().map(|a| a.id)); + + // Keep selected account in sync if none is explicitly picked + let active_account = selected_id + .read() + .and_then(|id| accounts.iter().find(|a| a.id == id).cloned()) + .or_else(|| default_account.clone()); + + let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error) + let show_url_modal = use_state(|| false); + let is_loading = use_state(|| false); rect() .horizontal() @@ -20,16 +36,90 @@ impl Component for AccountSkins { .height(Size::fill()) .overflow(Overflow::Clip) .padding(40.) - .spacing(24.) - .child(preview_panel(account_uuid)) - .child(side_panel()) + .spacing(28.) + .child(preview_panel(active_account.clone(), status_msg)) + .child(side_panel( + active_account, + accounts, + selected_id, + status_msg, + show_url_modal, + is_loading, + )) + .maybe_child((*show_url_modal.read()).then(|| { + url_modal(selected_id, status_msg, show_url_modal, is_loading) + })) } } -fn preview_panel(account_uuid: Option) -> impl IntoElement { +fn preview_panel( + account: Option, + status_msg: State>, +) -> impl IntoElement { + let account_uuid = account.as_ref().map(|a| a.id.to_string()); + let custom_query = use_custom_skin(account_uuid.clone().unwrap_or_default()); + let custom_data = crate::hooks::settled_or_loading(&custom_query); + let is_slim = custom_data.as_ref().map_or(false, |d| d.is_slim); + let has_custom = custom_data.as_ref().map_or(false, |d| d.has_custom); + + let acc_id_copy = account.as_ref().map(|a| a.id); + let username_copy = account.as_ref().map(|a| a.username.clone()); + + let set_classic = { + let acc_id = acc_id_copy; + let uname = username_copy.clone(); + let mut status = status_msg; + move |_| { + let Some(id) = acc_id else { return }; + let uname = uname.clone(); + let uuid_str = id.to_string(); + spawn(async move { + if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) { + if let Ok(bytes) = polyio::read(&skin_path).await { + let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await; + status.set(Some(( + "Model set to Classic (4px arms)".to_string(), + false, + ))); + } else if let Some(steve_bytes) = crate::AppAssets::get_bytes("steve.png") { + let _ = save_account_skin(&uuid_str, uname.as_deref(), &steve_bytes, false) + .await; + status.set(Some(("Model set to Classic".to_string(), false))); + } + } + }); + } + }; + + let set_slim = { + let acc_id = acc_id_copy; + let uname = username_copy.clone(); + let mut status = status_msg; + move |_| { + let Some(id) = acc_id else { return }; + let uname = uname.clone(); + let uuid_str = id.to_string(); + spawn(async move { + if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) { + if let Ok(bytes) = polyio::read(&skin_path).await { + let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, true).await; + status.set(Some(( + "Model set to Slim (3px arms)".to_string(), + false, + ))); + } else if let Some(alex_bytes) = crate::AppAssets::get_bytes("alex.png") { + let _ = save_account_skin(&uuid_str, uname.as_deref(), &alex_bytes, true) + .await; + status.set(Some(("Model set to Slim".to_string(), false))); + } + } + }); + } + }; + rect() .vertical() - .width(Size::px(320.)) + .width(Size::px(340.)) .height(Size::fill()) .spacing(16.) .child( @@ -57,74 +147,456 @@ fn preview_panel(account_uuid: Option) -> impl IntoElement { .width(Size::fill()) .main_align(Alignment::Center) .spacing(8.) - .children( - ANIMATIONS - .iter() - .enumerate() - .map(|(i, name)| anim_chip(name, i == 0).into_element()), + .child( + rect() + .center() + .padding(Gaps::new_symmetric(6., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if !is_slim { + colors::brand() + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .on_press(set_classic) + .child( + label() + .text("Classic (4px)") + .font_size(12.) + .font_weight(if !is_slim { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .center() + .padding(Gaps::new_symmetric(6., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if is_slim { + colors::brand() + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .on_press(set_slim) + .child( + label() + .text("Slim (3px)") + .font_size(12.) + .font_weight(if is_slim { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(colors::fg_primary()), + ), ), ) + .maybe_child(has_custom.then(|| { + rect() + .center() + .child( + label() + .text("Custom Skin Active") + .font_size(11.) + .color(colors::success()), + ) + .into_element() + })) .into_element() } -fn anim_chip(name: &'static str, active: bool) -> impl IntoElement { - rect() - .center() - .padding(Gaps::new_symmetric(6., 12.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(if active { - colors::brand() - } else { - colors::component_bg() - }) - .cursor(CursorIcon::Pointer) - .child( - label() - .text(name) - .font_size(12.) - .color(colors::fg_primary()), - ) - .into_element() -} +fn side_panel( + active_account: Option, + accounts: Vec, + selected_id: State>, + status_msg: State>, + show_url_modal: State, + is_loading: State, +) -> impl IntoElement { + let username = active_account + .as_ref() + .map(|a| a.username.clone()) + .unwrap_or_else(|| "No Account Selected".to_string()); + let account_id = active_account.as_ref().map(|a| a.id); + let username_opt = active_account.as_ref().map(|a| a.username.clone()); + + // Import from local file + let id_for_file = account_id; + let uname_for_file = username_opt.clone(); + let mut status_file = status_msg; + let mut loading_file = is_loading; + + let import_from_file = move |_| { + let Some(id) = id_for_file else { return }; + let uname = uname_for_file.clone(); + let uuid_str = id.to_string(); + + loading_file.set(true); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Select Minecraft Skin PNG") + .add_filter("Minecraft Skin (*.png)", &["png"]) + .pick_file() + .await + { + let bytes = handle.read().await; + match save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await { + Ok(()) => { + status_file.set(Some(( + "Custom skin imported successfully!".to_string(), + false, + ))); + } + Err(err) => { + status_file.set(Some((err, true))); + } + } + } + loading_file.set(false); + }); + }; + + // Download/Export skin + let id_for_dl = account_id; + let uname_for_dl = username_opt.clone(); + let mut status_dl = status_msg; + + let download_skin = move |_| { + let Some(id) = id_for_dl else { return }; + let uname = uname_for_dl.clone().unwrap_or_else(|| "skin".to_string()); + let uuid_str = id.to_string(); + + spawn(async move { + let skin_bytes = + if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) { + polyio::read(&skin_path).await.ok() + } else { + None + }; + + let bytes_to_save = match skin_bytes { + Some(b) => b, + None => crate::AppAssets::get_bytes("steve.png") + .map(|b| b.to_vec()) + .unwrap_or_default(), + }; + + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Save Skin PNG") + .set_file_name(format!("{uname}_skin.png")) + .add_filter("PNG Image (*.png)", &["png"]) + .save_file() + .await + { + if polyio::write(handle.path(), &bytes_to_save).await.is_ok() { + status_dl.set(Some(("Skin exported successfully!".to_string(), false))); + } else { + status_dl.set(Some(("Failed to save skin file".to_string(), true))); + } + } + }); + }; + + // Remove / Reset skin + let id_for_reset = account_id; + let uname_for_reset = username_opt.clone(); + let mut status_reset = status_msg; + + let reset_skin = move |_| { + let Some(id) = id_for_reset else { return }; + let uname = uname_for_reset.clone(); + let uuid_str = id.to_string(); + + spawn(async move { + let _ = delete_account_skin(&uuid_str, uname.as_deref()).await; + status_reset.set(Some(("Skin reset to default Steve/Alex".to_string(), false))); + }); + }; + + let mut open_url_modal = show_url_modal; + let open_from_url = move |_| { + open_url_modal.set(true); + }; -fn side_panel() -> impl IntoElement { rect() .vertical() .width(Size::flex(1.0)) .height(Size::fill()) .overflow(Overflow::Clip) - .spacing(20.) + .spacing(18.) .child( - label() - .text("Skins") - .font_size(32.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), + rect() + .vertical() + .spacing(4.) + .child( + label() + .text("Skin Manager") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("Managing skin for: {username}")) + .font_size(14.) + .color(colors::fg_secondary()), + ), ) + .maybe_child((*status_msg.read()).as_ref().map(|(msg, is_err)| { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if *is_err { + colors::danger().with_a(30) + } else { + colors::success().with_a(30) + }) + .border(border_all_color( + 1., + if *is_err { + colors::danger() + } else { + colors::success() + }, + )) + .child( + Icon::new(if *is_err { + IconType::AlertTriangle + } else { + IconType::CheckCircle + }) + .size(16.) + .color(if *is_err { + colors::danger() + } else { + colors::success() + }), + ) + .child( + label() + .text(msg.clone()) + .font_size(13.) + .color(colors::fg_primary()), + ) + .into_element() + })) .child( rect() .horizontal() - .spacing(12.) - .child(action_button("Import Skin", IconType::Plus, true)) - .child(action_button("Download", IconType::Download01, false)) - .child(action_button("Remove", IconType::Trash01, false)), + .content(Content::wrap_spacing(8.)) + .spacing(8.) + .child( + Button::new() + .primary() + .on_press(import_from_file) + .child(Icon::new(IconType::FilePlus02).size(16.)) + .text("Upload File (.png)"), + ) + .child( + Button::new() + .secondary() + .on_press(open_from_url) + .child(Icon::new(IconType::Globe01).size(16.)) + .text("From Username / URL"), + ) + .child( + Button::new() + .secondary() + .on_press(download_skin) + .child(Icon::new(IconType::Download01).size(16.)) + .text("Export Skin"), + ) + .child( + Button::new() + .ghost() + .on_press(reset_skin) + .child(Icon::new(IconType::Trash01).size(16.)) + .text("Reset Skin"), + ), + ) + .child( + rect() + .vertical() + .spacing(8.) + .child(section_label("SWITCH ACCOUNT")) + .child( + rect() + .horizontal() + .content(Content::wrap_spacing(8.)) + .spacing(8.) + .children(accounts.into_iter().map(|acc| { + let id = acc.id; + let is_sel = selected_id.read().map_or(false, |sel| sel == id); + let mut sel_state = selected_id; + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(6., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if is_sel { + colors::brand().with_a(50) + } else { + colors::page_elevated() + }) + .border(border_all_color( + 1., + if is_sel { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_press(move |_| sel_state.set(Some(id))) + .child( + label() + .text(acc.username) + .font_size(13.) + .color(colors::fg_primary()), + ) + .into_element() + })), + ), + ) + .child( + rect() + .vertical() + .spacing(10.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("💡 Offline Skin Tips") + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("• Supports all standard Minecraft skins (64x64 or legacy 64x32 PNGs).") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text("• Interactive 3D preview: Click & drag on the character to rotate!") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text("• Skins automatically sync with CustomSkinLoader for in-game rendering on offline servers.") + .font_size(12.) + .color(colors::fg_secondary()), + ), ) - .child(section_label("SKIN HISTORY")) - .child(skin_grid(8)) - .child(section_label("CAPES")) - .child(skin_grid(4)) .into_element() } -fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement { - Button::new() - .variant(if primary { - ButtonVariant::Primary - } else { - ButtonVariant::Secondary - }) - .child(Icon::new(icon).size(16.).color(colors::fg_primary())) - .text(text) +fn url_modal( + selected_id: State>, + status_msg: State>, + show_url_modal: State, + is_loading: State, +) -> impl IntoElement { + let input_text = use_state(String::new); + let mut modal_open = show_url_modal; + let mut status = status_msg; + let mut loading = is_loading; + + let target_uuid = (*selected_id.peek()).map(|id| id.to_string()); + + let apply_url = move |_| { + let Some(ref uuid_str) = target_uuid else { + return; + }; + let uuid_str = uuid_str.clone(); + let query = input_text.peek().trim().to_string(); + if query.is_empty() { + return; + } + + loading.set(true); + modal_open.set(false); + + spawn(async move { + match fetch_skin_online(&query).await { + Ok((bytes, is_slim)) => { + let _ = save_account_skin(&uuid_str, None, &bytes, is_slim).await; + status.set(Some(( + format!("Skin for \"{query}\" applied successfully!"), + false, + ))); + } + Err(err) => { + status.set(Some((err, true))); + } + } + loading.set(false); + }); + }; + + OverlayPopup::new() + .position(Position::new_global().top(140.).left(280.)) + .on_close(move |()| modal_open.set(false)) + .child( + rect() + .vertical() + .width(Size::px(380.)) + .padding(Gaps::new_all(20.)) + .spacing(16.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(140, 0, 0, 0)))) + .child( + label() + .text("Import Skin by Username or URL") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("Enter any Minecraft player name (e.g. Technoblade, Notch) or a direct skin image URL:") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + TextInput::new(input_text) + .placeholder("Username (e.g. Dream) or https://...") + .width(Size::fill()), + ) + .child( + rect() + .horizontal() + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| modal_open.set(false)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(apply_url) + .text("Apply Skin"), + ), + ), + ) .into_element() } @@ -136,34 +608,3 @@ fn section_label(text: &'static str) -> impl IntoElement { .color(colors::fg_secondary()) .into_element() } - -fn skin_grid(count: usize) -> impl IntoElement { - rect() - .horizontal() - .width(Size::fill()) - .spacing(12.) - .children((0..count).map(|i| { - rect() - .center() - .width(Size::px(64.)) - .height(Size::px(80.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(colors::page_elevated()) - .border(border_all_color( - if i == 0 { 2. } else { 1. }, - if i == 0 { - colors::brand() - } else { - colors::component_border() - }, - )) - .cursor(CursorIcon::Pointer) - .child( - Icon::new(IconType::Users01) - .size(24.) - .color(colors::fg_secondary()), - ) - .into_element() - })) - .into_element() -} diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 09de0163..f111b47d 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -57,6 +57,18 @@ pub fn logs_dir() -> PathsResult { Ok(launcher_dir()?.join("logs")) } +pub fn skins_dir() -> PathsResult { + Ok(launcher_dir()?.join("skins")) +} + +pub fn skin_file_path(uuid: &str) -> PathsResult { + Ok(skins_dir()?.join(format!("{uuid}.png"))) +} + +pub fn skin_meta_path(uuid: &str) -> PathsResult { + Ok(skins_dir()?.join(format!("{uuid}.json"))) +} + pub fn java_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("java")) }