feat(skins): integrate full offline skin changer with 3D preview, model toggle, file import, and online URL lookup

This commit is contained in:
katR 2026-08-29 17:46:22 +05:30
parent 6a20bcfec5
commit a363af6d20
9 changed files with 798 additions and 95 deletions

3
Cargo.lock generated
View file

@ -4498,8 +4498,11 @@ dependencies = [
"open",
"polyio",
"regex",
"reqwest 0.13.3",
"rfd",
"rust-embed",
"serde",
"serde_json",
"sysinfo",
"tokio",
"tracing",

View file

@ -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"

View file

@ -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),

View file

@ -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,

View file

@ -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,

View file

@ -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<Bytes>,
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<Self::Ok, Self::Err> {
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::<SkinMetadata>(&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<CustomSkinQuery> {
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::<CustomSkinQuery>::invalidate_matching(CustomSkinKeys {
uuid: uuid.to_string(),
})
.await;
} else {
QueriesStorage::<CustomSkinQuery>::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::<String>);
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<u8>, 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))
}

View file

@ -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<PressEventData>| {
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;

View file

@ -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(&current_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<String>) -> impl IntoElement {
fn preview_panel(
account: Option<MinecraftAccount>,
status_msg: State<Option<(String, bool)>>,
) -> 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<String>) -> 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<MinecraftAccount>,
accounts: Vec<MinecraftAccount>,
selected_id: State<Option<uuid::Uuid>>,
status_msg: State<Option<(String, bool)>>,
show_url_modal: State<bool>,
is_loading: State<bool>,
) -> 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<Option<uuid::Uuid>>,
status_msg: State<Option<(String, bool)>>,
show_url_modal: State<bool>,
is_loading: State<bool>,
) -> 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()
}

View file

@ -57,6 +57,18 @@ pub fn logs_dir() -> PathsResult<PathBuf> {
Ok(launcher_dir()?.join("logs"))
}
pub fn skins_dir() -> PathsResult<PathBuf> {
Ok(launcher_dir()?.join("skins"))
}
pub fn skin_file_path(uuid: &str) -> PathsResult<PathBuf> {
Ok(skins_dir()?.join(format!("{uuid}.png")))
}
pub fn skin_meta_path(uuid: &str) -> PathsResult<PathBuf> {
Ok(skins_dir()?.join(format!("{uuid}.json")))
}
pub fn java_dir() -> PathsResult<PathBuf> {
Ok(launcher_dir()?.join("metadata").join("java"))
}