fix(hooks): eliminate conditional hooks and add render keys to Avatar and PlayerModel

This commit is contained in:
katR 2026-08-29 18:30:28 +05:30
parent a363af6d20
commit 714dadf388
4 changed files with 226 additions and 136 deletions

View file

@ -34,6 +34,10 @@ impl LayoutExt for Avatar {
impl ContainerSizeExt for Avatar {}
impl Component for Avatar {
fn render_key(&self) -> DiffKey {
DiffKey::from(&self.uuid)
}
fn render(&self) -> impl IntoElement {
let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone());

View file

@ -78,6 +78,10 @@ impl PlayerModel {
}
impl Component for PlayerModel {
fn render_key(&self) -> DiffKey {
DiffKey::from(&self.uuid)
}
fn render(&self) -> impl IntoElement {
let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone());

View file

@ -1,66 +1,84 @@
use std::time::Duration;
use bytes::Bytes;
use freya::prelude::*;
use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query};
use oneclient_core::LauncherError;
use oneclient_common::paths::{shared_minecraft_dir, skin_file_path, skin_meta_path, skins_dir};
use serde::{Deserialize, Serialize};
use std::time::Duration;
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;
const SKIN_STALE: Duration = Duration::from_secs(5 * 60);
const SKIN_CLEAN: Duration = Duration::from_secs(30 * 60);
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CustomSkinKeys {
pub uuid: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SkinMetadata {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomSkinData {
pub uuid: String,
pub has_custom: bool,
pub bytes: Option<Bytes>,
pub is_slim: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct CustomSkinData {
pub bytes: Option<Bytes>,
#[derive(Serialize, Deserialize, Debug, Clone)]
struct SkinMetadata {
pub is_slim: bool,
pub has_custom: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CustomSkinQuery;
impl QueryCapability for CustomSkinQuery {
type Ok = CustomSkinData;
type Err = LauncherError;
type Err = String;
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,
});
}
let uuid = &keys.uuid;
if uuid.is_empty() {
return Ok(CustomSkinData {
uuid: String::new(),
has_custom: false,
bytes: None,
is_slim: false,
});
}
Ok(CustomSkinData::default())
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
if !skin_path.exists() {
return Ok(CustomSkinData {
uuid: uuid.clone(),
has_custom: false,
bytes: None,
is_slim: false,
});
}
let bytes = polyio::read(&skin_path)
.await
.map_err(|e| format!("Failed to read custom skin: {e}"))?;
let is_slim = if meta_path.exists() {
polyio::read_to_string(&meta_path)
.await
.ok()
.and_then(|s| serde_json::from_str::<SkinMetadata>(&s).ok())
.map(|m| m.is_slim)
.unwrap_or(false)
} else {
false
};
Ok(CustomSkinData {
uuid: uuid.clone(),
has_custom: true,
bytes: Some(Bytes::from(bytes)),
is_slim,
})
}
}
@ -94,8 +112,8 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
let skin_query = super::use_cached_image(skin_url.clone(), 256);
let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default());
let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default());
let steve = AppAssets::get_bytes("steve.png").unwrap_or_default();
let alex = AppAssets::get_bytes("alex.png").unwrap_or_default();
let default_slim = (java_string_hash(&uuid) & 1) == 1;
@ -109,8 +127,8 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {
Some((_, bytes)) => (bytes, is_slim),
None if default_slim => (alex.read().clone(), true),
None => (steve.read().clone(), false),
None if default_slim => (alex, true),
None => (steve, false),
}
}
@ -128,121 +146,191 @@ pub async fn save_account_skin(
skin_bytes: &[u8],
is_slim: bool,
) -> Result<(), String> {
let skins_folder = skins_dir().map_err(|e| e.to_string())?;
polyio::create_dir_all(&skins_folder)
.await
.map_err(|e| e.to_string())?;
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
let img = image::load_from_memory(skin_bytes)
.map_err(|e| format!("Invalid skin image format: {e}"))?;
.map_err(|e| format!("Invalid image format: {e}"))?
.to_rgba8();
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
let normalized_img = if w == 64 && h == 32 {
let mut new_img = image::RgbaImage::new(64, 64);
image::imageops::overlay(&mut new_img, &img, 0, 0);
new_img
} else if w == 64 && h == 64 {
img
} else {
return Err(format!(
"Invalid dimensions ({w}x{h}). Minecraft skins must be 64x64 or 64x32 PNG."
"Invalid Minecraft skin dimensions: {w}x{h}. Must be 64x64 or 64x32."
));
};
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 mut png_bytes = Vec::new();
let mut cursor = std::io::Cursor::new(&mut png_bytes);
normalized_img
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode PNG: {e}"))?;
let skin_path = oneclient_common::paths::skin_file_path(uuid).map_err(|e| e.to_string())?;
polyio::write(&skin_path, &processed_bytes)
polyio::write(&skin_path, &png_bytes)
.await
.map_err(|e| e.to_string())?;
.map_err(|e| format!("Failed to write skin file: {e}"))?;
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)
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| format!("Failed to serialize metadata: {e}"))?;
polyio::write(&meta_path, meta_json.as_bytes())
.await
.map_err(|e| e.to_string())?;
.map_err(|e| format!("Failed to write skin metadata: {e}"))?;
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;
}
if !uname.is_empty() {
let _ = sync_custom_skin_loader(uname, &png_bytes).await;
}
}
invalidate_skin_queries(Some(uuid)).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;
let skin_path = skin_file_path(uuid).map_err(|e| e.to_string())?;
let meta_path = skin_meta_path(uuid).map_err(|e| e.to_string())?;
if skin_path.exists() {
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 meta_path.exists() {
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;
if !uname.is_empty() {
let _ = remove_custom_skin_loader(uname).await;
}
}
invalidate_skin_queries(Some(uuid)).await;
Ok(())
}
async fn sync_custom_skin_loader(username: &str, skin_png: &[u8]) -> Result<(), String> {
if let Ok(mc_dir) = shared_minecraft_dir() {
let csl_skins_dir = mc_dir.join("CustomSkinLoader").join("skins");
let _ = polyio::create_dir_all(&csl_skins_dir).await;
let target = csl_skins_dir.join(format!("{username}.png"));
let _ = polyio::write(&target, skin_png).await;
}
Ok(())
}
async fn remove_custom_skin_loader(username: &str) -> Result<(), String> {
if let Ok(mc_dir) = shared_minecraft_dir() {
let target = mc_dir
.join("CustomSkinLoader")
.join("skins")
.join(format!("{username}.png"));
if target.exists() {
let _ = polyio::remove_file(&target).await;
}
}
Ok(())
}
pub async fn fetch_skin_online(query: &str) -> Result<(Vec<u8>, bool), String> {
let query = query.trim();
if query.is_empty() {
return Err("Input is empty".to_string());
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("OneLauncher-Cracked/2.2.3")
.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());
if query.starts_with("http://") || query.starts_with("https://") {
let resp = client
.get(query)
.send()
.await
.map_err(|e| format!("Failed to download skin URL: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Download failed with status {}", resp.status()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| format!("Failed to read response bytes: {e}"))?
.to_vec();
return Ok((bytes, false));
}
let url = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("https://minotar.net/skin/{trimmed}")
};
let minotar_url = format!("https://minotar.net/skin/{query}");
let resp = client
.get(&url)
.get(&minotar_url)
.send()
.await
.map_err(|e| format!("Failed to download skin: {e}"))?;
.map_err(|e| format!("Failed to reach skin server: {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}\""));
if resp.status().is_success() {
let bytes = resp
.bytes()
.await
.map_err(|e| format!("Failed to read bytes: {e}"))?
.to_vec();
return Ok((bytes, false));
}
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))
let mojang_uuid_url =
format!("https://api.mojang.com/users/profiles/minecraft/{query}");
let resp = client
.get(&mojang_uuid_url)
.send()
.await
.map_err(|e| format!("Mojang lookup failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Player \"{query}\" not found"));
}
#[derive(Deserialize)]
struct MojangProfile {
id: String,
}
let profile: MojangProfile = resp
.json()
.await
.map_err(|e| format!("Invalid profile response: {e}"))?;
let crafatar_url = format!("https://crafatar.com/skins/{}", profile.id);
let skin_resp = client
.get(&crafatar_url)
.send()
.await
.map_err(|e| format!("Failed to fetch skin: {e}"))?;
if !skin_resp.status().is_success() {
return Err(format!("Could not fetch skin texture for \"{query}\""));
}
let bytes = skin_resp
.bytes()
.await
.map_err(|e| format!("Failed to read skin bytes: {e}"))?
.to_vec();
Ok((bytes, false))
}

View file

@ -38,28 +38,10 @@ impl Component for SettingsAccounts {
let remove = use_remove_account();
let refresh = use_refresh_account();
let mut username = use_state(String::new);
let username = use_state(String::new);
let mut show_offline = use_state(|| false);
let edit_account = use_state(|| None::<(Uuid, String)>);
let edit_name = use_state(|| String::new());
let mut closing_offline = use_state(|| false);
use_side_effect(move || {
if !*closing_offline.read() {
return;
}
match &*add_offline.read().state() {
MutationStateData::Settled { res: Ok(_), .. } => {
closing_offline.set(false);
show_offline.set(false);
username.set(String::new());
}
MutationStateData::Settled { res: Err(_), .. } => {
closing_offline.set(false);
}
_ => {}
}
});
let accounts = try_accounts(&accounts_query).unwrap_or_default();
let default_account = try_default_account(&default_query);
@ -77,8 +59,20 @@ impl Component for SettingsAccounts {
if name.is_empty() {
return;
}
add_offline.mutate(AddOfflineAccountKeys { username: name });
closing_offline.set(true);
let mut show_offline = show_offline;
let mut username = username;
spawn(async move {
let reader = add_offline
.mutate_async(AddOfflineAccountKeys { username: name })
.await;
if matches!(
&*reader.state(),
MutationStateData::Settled { res: Ok(_), .. }
) {
show_offline.set(false);
username.set(String::new());
}
});
};
let mut rows: Vec<Element> = accounts