From b6401a79a3d8fcb910c956990e9d38ccecd9747f Mon Sep 17 00:00:00 2001
From: Wind-Explorer
Date: Sat, 29 Aug 2026 01:41:59 +0800
Subject: [PATCH] minecraft skins!
---
Cargo.lock | 1 +
src-common/src/lib.rs | 56 ++-
src-server/src/network/interactions.rs | 5 +
src-server/src/network/mod.rs | 51 +++
src-server/src/network/profiles.rs | 22 +-
src-server/src/network/skins.rs | 327 +++++++++++++++
src-tauri/Cargo.toml | 1 +
.../20260828000000_add_skin_hashes.sql | 2 +
src-tauri/src/friends/mod.rs | 29 +-
src-tauri/src/lib.rs | 2 +
src-tauri/src/network/connection.rs | 392 ++++++++++++++++++
src-tauri/src/network/mod.rs | 377 +++--------------
src-tauri/src/profile/mod.rs | 52 ++-
src-tauri/src/skins.rs | 164 ++++++++
src-tauri/src/user/mod.rs | 1 +
src/lib/bindings.ts | 11 +-
.../control-panel/general-panel.svelte | 42 +-
src/lib/components/debug/profile.svelte | 2 +-
src/lib/skins.ts | 18 +
src/routes/scene/+page.svelte | 11 +
src/routes/scene/components/renderer.svelte | 3 +
.../scene/components/renderer/puppet/index.ts | 94 +++--
.../components/renderer/puppet/manager.ts | 2 +
.../scene/components/renderer/puppet/skin.ts | 176 ++++++++
static/default-skin.png | Bin 0 -> 2273 bytes
static/fa.png | Bin 195572 -> 0 bytes
static/svelte.svg | 1 -
static/tauri.svg | 6 -
static/vite.svg | 1 -
29 files changed, 1466 insertions(+), 383 deletions(-)
create mode 100644 src-server/src/network/skins.rs
create mode 100644 src-tauri/migrations/20260828000000_add_skin_hashes.sql
create mode 100644 src-tauri/src/network/connection.rs
create mode 100644 src-tauri/src/skins.rs
create mode 100644 src/lib/skins.ts
create mode 100644 src/routes/scene/components/renderer/puppet/skin.ts
create mode 100644 static/default-skin.png
delete mode 100644 static/fa.png
delete mode 100644 static/svelte.svg
delete mode 100644 static/tauri.svg
delete mode 100644 static/vite.svg
diff --git a/Cargo.lock b/Cargo.lock
index fb5b941..f79de09 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6280,6 +6280,7 @@ dependencies = [
"objc2-foundation",
"serde",
"serde_json",
+ "sha2 0.10.9",
"specta",
"specta-typescript",
"sqlx",
diff --git a/src-common/src/lib.rs b/src-common/src/lib.rs
index 3873d5e..6960734 100644
--- a/src-common/src/lib.rs
+++ b/src-common/src/lib.rs
@@ -1,17 +1,26 @@
use serde::{Deserialize, Serialize};
use specta::Type;
-pub const VERSION: u8 = 2;
+pub const VERSION: u8 = 3;
pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
+pub const MAX_SKIN_B64_SIZE: usize = 96 * 1024;
pub const MAX_IMAGE_DIMENSION: u32 = 480;
pub const MAX_TEXT_CHARS: usize = 500;
+pub fn is_skin_hash(hash: &str) -> bool {
+ hash.len() == 64
+ && hash
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub id: String,
pub display_name: String,
+ pub skin_hash: Option,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -36,6 +45,17 @@ pub enum ClientMessage {
request_id: String,
user_id: String,
},
+ RequestSkin {
+ request_id: String,
+ user_id: String,
+ skin_hash: String,
+ },
+ ProvideSkin {
+ request_id: String,
+ requester_id: String,
+ skin_hash: String,
+ data: Option,
+ },
Signed {
payload: String,
signature: String,
@@ -115,6 +135,17 @@ pub enum ServerMessage {
request_id: String,
profile: Option,
},
+ SkinRequested {
+ request_id: String,
+ requester_id: String,
+ skin_hash: String,
+ },
+ SkinResolved {
+ request_id: String,
+ user_id: String,
+ skin_hash: String,
+ data: Option,
+ },
FriendStatusChanged {
friend_id: String,
online: bool,
@@ -139,9 +170,10 @@ pub enum ServerMessage {
pub fn register_bytes(challenge: &str, profile: &Profile, friends: &[String]) -> Vec {
format!(
- "wyd-register-v{VERSION}\n{challenge}\n{}\n{}\n{}",
+ "wyd-register-v{VERSION}\n{challenge}\n{}\n{}\n{}\n{}",
profile.id,
profile.display_name,
+ profile.skin_hash.as_deref().unwrap_or(""),
friends.join("\n")
)
.into_bytes()
@@ -166,8 +198,10 @@ pub fn interaction_bytes(interaction_id: &str, recipient_id: &str, payload: &str
pub fn profile_bytes(profile: &Profile) -> Vec {
format!(
- "wyd-profile-v{VERSION}\n{}\n{}",
- profile.id, profile.display_name
+ "wyd-profile-v{VERSION}\n{}\n{}\n{}",
+ profile.id,
+ profile.display_name,
+ profile.skin_hash.as_deref().unwrap_or("")
)
.into_bytes()
}
@@ -178,7 +212,7 @@ pub fn friends_bytes(friends: &[String]) -> Vec {
#[cfg(test)]
mod tests {
- use super::{InteractionContent, MAX_TEXT_CHARS, interaction_bytes};
+ use super::{InteractionContent, MAX_TEXT_CHARS, Profile, interaction_bytes, profile_bytes};
#[test]
fn interaction_signature_bytes_are_length_delimited() {
@@ -211,4 +245,16 @@ mod tests {
);
assert!(serde_json::from_str::(r#"{"type":"unknown"}"#).is_err());
}
+
+ #[test]
+ fn profile_signature_bytes_include_the_skin_hash() {
+ let mut profile = Profile {
+ id: "id".to_owned(),
+ display_name: "Name".to_owned(),
+ skin_hash: None,
+ };
+ let without_skin = profile_bytes(&profile);
+ profile.skin_hash = Some("a".repeat(64));
+ assert_ne!(without_skin, profile_bytes(&profile));
+ }
}
diff --git a/src-server/src/network/interactions.rs b/src-server/src/network/interactions.rs
index ee9458b..dc2a214 100644
--- a/src-server/src/network/interactions.rs
+++ b/src-server/src/network/interactions.rs
@@ -161,6 +161,7 @@ mod tests {
profile: Profile {
id: public_key.clone(),
display_name: "Source".to_owned(),
+ skin_hash: None,
},
friends: vec!["target".to_owned(), "one-sided".to_owned()],
sender: source_sender,
@@ -174,6 +175,7 @@ mod tests {
profile: Profile {
id: "target".to_owned(),
display_name: "Target".to_owned(),
+ skin_hash: None,
},
friends: vec![public_key.clone()],
sender: target_sender.clone(),
@@ -187,6 +189,7 @@ mod tests {
profile: Profile {
id: "one-sided".to_owned(),
display_name: "One-sided".to_owned(),
+ skin_hash: None,
},
friends: Vec::new(),
sender: one_sided_sender,
@@ -305,6 +308,7 @@ mod tests {
profile: Profile {
id: public_key.clone(),
display_name: "Source".to_owned(),
+ skin_hash: None,
},
friends: vec!["target".to_owned()],
sender: source_sender,
@@ -318,6 +322,7 @@ mod tests {
profile: Profile {
id: "target".to_owned(),
display_name: "Target".to_owned(),
+ skin_hash: None,
},
friends: vec![public_key.clone()],
sender: target_sender,
diff --git a/src-server/src/network/mod.rs b/src-server/src/network/mod.rs
index c7161dd..3fd5931 100644
--- a/src-server/src/network/mod.rs
+++ b/src-server/src/network/mod.rs
@@ -18,6 +18,7 @@ use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_
mod interactions;
mod presence;
mod profiles;
+mod skins;
type Clients = Arc>>;
@@ -70,6 +71,13 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
else {
return;
};
+ if profile
+ .skin_hash
+ .as_deref()
+ .is_some_and(|hash| !wyd_common::is_skin_hash(hash))
+ {
+ return;
+ }
let Ok(key) = key(&profile.id) else { return };
if !verify(
&key,
@@ -208,6 +216,42 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
break;
}
}
+ Ok(ClientMessage::RequestSkin { request_id, user_id, skin_hash }) => {
+ match skins::request(
+ &clients,
+ &public_key,
+ connection_id,
+ request_id,
+ user_id,
+ skin_hash,
+ ).await {
+ skins::RequestOutcome::StaleSession => break,
+ skins::RequestOutcome::Forwarded => {}
+ skins::RequestOutcome::Unavailable(response) => {
+ if writer.send(response).await.is_err() {
+ break;
+ }
+ }
+ }
+ }
+ Ok(ClientMessage::ProvideSkin {
+ request_id,
+ requester_id,
+ skin_hash,
+ data,
+ }) => {
+ if !skins::provide(
+ &clients,
+ &public_key,
+ connection_id,
+ request_id,
+ requester_id,
+ skin_hash,
+ data,
+ ).await {
+ break;
+ }
+ }
_ => break,
}
Some(Ok(Message::Ping(data))) => {
@@ -301,6 +345,7 @@ mod tests {
let profile = Profile {
id: URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()),
display_name: "Wind".to_owned(),
+ skin_hash: None,
};
let friends = vec!["friend".to_owned()];
let registration = register_bytes("challenge", &profile, &friends);
@@ -347,6 +392,7 @@ mod tests {
profile: Profile {
id: public_key.clone(),
display_name: "Wind".to_owned(),
+ skin_hash: None,
},
friends: Vec::new(),
sender,
@@ -390,6 +436,7 @@ mod tests {
profile: Profile {
id: "source".to_owned(),
display_name: "Source".to_owned(),
+ skin_hash: None,
},
friends: vec!["mutual".to_owned()],
sender: source_sender,
@@ -403,6 +450,7 @@ mod tests {
profile: Profile {
id: "mutual".to_owned(),
display_name: "Mutual".to_owned(),
+ skin_hash: None,
},
friends: vec!["source".to_owned()],
sender: mutual_sender,
@@ -416,6 +464,7 @@ mod tests {
profile: Profile {
id: "one-way".to_owned(),
display_name: "One way".to_owned(),
+ skin_hash: None,
},
friends: vec!["source".to_owned()],
sender: one_way_sender,
@@ -494,6 +543,7 @@ mod tests {
profile: Profile {
id: public_key.clone(),
display_name: "Source".to_owned(),
+ skin_hash: None,
},
friends: vec!["mutual".to_owned(), "sender-only".to_owned()],
sender: source_sender,
@@ -516,6 +566,7 @@ mod tests {
profile: Profile {
id: id.to_owned(),
display_name: id.to_owned(),
+ skin_hash: None,
},
friends,
sender,
diff --git a/src-server/src/network/profiles.rs b/src-server/src/network/profiles.rs
index 9fbf6d3..cfd829d 100644
--- a/src-server/src/network/profiles.rs
+++ b/src-server/src/network/profiles.rs
@@ -19,7 +19,13 @@ pub(super) async fn update(
else {
return false;
};
- if profile.id != public_key || !verify(&client.key, &profile_bytes(&profile), signature) {
+ if profile.id != public_key
+ || profile
+ .skin_hash
+ .as_deref()
+ .is_some_and(|hash| !wyd_common::is_skin_hash(hash))
+ || !verify(&client.key, &profile_bytes(&profile), signature)
+ {
return false;
}
client.profile = profile.clone();
@@ -122,6 +128,7 @@ mod tests {
profile: Profile {
id: id.to_owned(),
display_name: display_name.to_owned(),
+ skin_hash: None,
},
friends,
sender,
@@ -164,6 +171,7 @@ mod tests {
let profile = Profile {
id: public_key.clone(),
display_name: "New".to_owned(),
+ skin_hash: Some("a".repeat(64)),
};
let signature =
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&profile)).to_bytes());
@@ -179,6 +187,17 @@ mod tests {
.display_name,
"New"
);
+ assert_eq!(
+ clients
+ .lock()
+ .await
+ .get(&public_key)
+ .unwrap()
+ .profile
+ .skin_hash
+ .as_deref(),
+ Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ );
let Message::Text(announcement) = friend_receiver
.recv()
.await
@@ -197,6 +216,7 @@ mod tests {
let stale_profile = Profile {
id: public_key.clone(),
display_name: "Stale".to_owned(),
+ skin_hash: None,
};
let stale_signature =
URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&stale_profile)).to_bytes());
diff --git a/src-server/src/network/skins.rs b/src-server/src/network/skins.rs
new file mode 100644
index 0000000..458ab21
--- /dev/null
+++ b/src-server/src/network/skins.rs
@@ -0,0 +1,327 @@
+use axum::extract::ws::Message;
+use uuid::Uuid;
+use wyd_common::{MAX_SKIN_B64_SIZE, ServerMessage};
+
+use super::{Clients, are_mutual_friends};
+
+pub(super) enum RequestOutcome {
+ StaleSession,
+ Forwarded,
+ Unavailable(Message),
+}
+
+pub(super) async fn request(
+ clients: &Clients,
+ requester_id: &str,
+ connection_id: Uuid,
+ request_id: String,
+ user_id: String,
+ skin_hash: String,
+) -> RequestOutcome {
+ let clients = clients.lock().await;
+ let Some(requester) = clients
+ .get(requester_id)
+ .filter(|client| client.connection_id == connection_id)
+ else {
+ return RequestOutcome::StaleSession;
+ };
+ let target = clients.get(&user_id).filter(|target| {
+ are_mutual_friends(requester_id, requester, &user_id, target)
+ && target.profile.skin_hash.as_deref() == Some(&skin_hash)
+ });
+
+ let Some(target) = target else {
+ return RequestOutcome::Unavailable(resolved(request_id, user_id, skin_hash, None));
+ };
+ let request = Message::Text(
+ serde_json::to_string(&ServerMessage::SkinRequested {
+ request_id: request_id.clone(),
+ requester_id: requester_id.to_owned(),
+ skin_hash: skin_hash.clone(),
+ })
+ .expect("skin request serializes")
+ .into(),
+ );
+ match target.sender.try_send(request) {
+ Ok(()) => RequestOutcome::Forwarded,
+ Err(_) => RequestOutcome::Unavailable(resolved(request_id, user_id, skin_hash, None)),
+ }
+}
+
+pub(super) async fn provide(
+ clients: &Clients,
+ provider_id: &str,
+ connection_id: Uuid,
+ request_id: String,
+ requester_id: String,
+ skin_hash: String,
+ data: Option,
+) -> bool {
+ if data
+ .as_ref()
+ .is_some_and(|data| data.len() > MAX_SKIN_B64_SIZE)
+ {
+ return false;
+ }
+
+ let clients = clients.lock().await;
+ let Some(provider) = clients
+ .get(provider_id)
+ .filter(|client| client.connection_id == connection_id)
+ .filter(|client| client.profile.skin_hash.as_deref() == Some(&skin_hash))
+ else {
+ return false;
+ };
+ let Some(requester) = clients
+ .get(&requester_id)
+ .filter(|requester| are_mutual_friends(provider_id, provider, &requester_id, requester))
+ else {
+ return true;
+ };
+ let _ = requester.sender.try_send(resolved(
+ request_id,
+ provider_id.to_owned(),
+ skin_hash,
+ data,
+ ));
+ true
+}
+
+fn resolved(
+ request_id: String,
+ user_id: String,
+ skin_hash: String,
+ data: Option,
+) -> Message {
+ Message::Text(
+ serde_json::to_string(&ServerMessage::SkinResolved {
+ request_id,
+ user_id,
+ skin_hash,
+ data,
+ })
+ .expect("skin response serializes")
+ .into(),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use base64::Engine;
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+ use ed25519_dalek::SigningKey;
+ use tokio::sync::mpsc;
+ use wyd_common::Profile;
+
+ use super::*;
+ use crate::network::Client;
+
+ fn client(
+ id: &str,
+ connection_id: Uuid,
+ friends: Vec,
+ skin_hash: Option,
+ sender: mpsc::Sender,
+ ) -> Client {
+ let signing_key = SigningKey::from_bytes(&[7; 32]);
+ Client {
+ connection_id,
+ key: signing_key.verifying_key(),
+ profile: Profile {
+ id: id.to_owned(),
+ display_name: id.to_owned(),
+ skin_hash,
+ },
+ friends,
+ sender,
+ }
+ }
+
+ #[tokio::test]
+ async fn mutual_friends_can_round_trip_the_advertised_skin() {
+ let requester_id = URL_SAFE_NO_PAD.encode([1_u8; 32]);
+ let provider_id = URL_SAFE_NO_PAD.encode([2_u8; 32]);
+ let skin_hash = "a".repeat(64);
+ let requester_connection = Uuid::new_v4();
+ let provider_connection = Uuid::new_v4();
+ let (requester_sender, mut requester_receiver) = mpsc::channel(2);
+ let (provider_sender, mut provider_receiver) = mpsc::channel(2);
+ let clients = Clients::default();
+ {
+ let mut clients = clients.lock().await;
+ clients.insert(
+ requester_id.clone(),
+ client(
+ &requester_id,
+ requester_connection,
+ vec![provider_id.clone()],
+ None,
+ requester_sender,
+ ),
+ );
+ clients.insert(
+ provider_id.clone(),
+ client(
+ &provider_id,
+ provider_connection,
+ vec![requester_id.clone()],
+ Some(skin_hash.clone()),
+ provider_sender,
+ ),
+ );
+ }
+
+ assert!(matches!(
+ request(
+ &clients,
+ &requester_id,
+ requester_connection,
+ "request".to_owned(),
+ provider_id.clone(),
+ skin_hash.clone(),
+ )
+ .await,
+ RequestOutcome::Forwarded
+ ));
+ let Message::Text(requested) = provider_receiver.recv().await.unwrap() else {
+ panic!("expected skin request")
+ };
+ assert!(matches!(
+ serde_json::from_str(&requested).unwrap(),
+ ServerMessage::SkinRequested { request_id, .. } if request_id == "request"
+ ));
+
+ assert!(
+ provide(
+ &clients,
+ &provider_id,
+ provider_connection,
+ "request".to_owned(),
+ requester_id,
+ skin_hash,
+ Some("png".to_owned()),
+ )
+ .await
+ );
+ let Message::Text(resolved) = requester_receiver.recv().await.unwrap() else {
+ panic!("expected skin response")
+ };
+ assert!(matches!(
+ serde_json::from_str(&resolved).unwrap(),
+ ServerMessage::SkinResolved { data: Some(data), .. } if data == "png"
+ ));
+ }
+
+ #[tokio::test]
+ async fn request_requires_the_exact_advertised_hash() {
+ let (requester_sender, _requester_receiver) = mpsc::channel(1);
+ let (provider_sender, mut provider_receiver) = mpsc::channel(1);
+ let requester_connection = Uuid::new_v4();
+ let requester_id = "requester".to_owned();
+ let provider_id = "provider".to_owned();
+ let clients = Clients::default();
+ {
+ let mut clients = clients.lock().await;
+ clients.insert(
+ requester_id.clone(),
+ client(
+ &requester_id,
+ requester_connection,
+ vec![provider_id.clone()],
+ None,
+ requester_sender,
+ ),
+ );
+ clients.insert(
+ provider_id.clone(),
+ client(
+ &provider_id,
+ Uuid::new_v4(),
+ vec![requester_id.clone()],
+ Some("a".repeat(64)),
+ provider_sender,
+ ),
+ );
+ }
+
+ let response = request(
+ &clients,
+ &requester_id,
+ requester_connection,
+ "request".to_owned(),
+ provider_id,
+ "b".repeat(64),
+ )
+ .await;
+ assert!(matches!(response, RequestOutcome::Unavailable(_)));
+ assert!(provider_receiver.try_recv().is_err());
+ }
+
+ async fn request_with_provider_sender(
+ provider_sender: mpsc::Sender,
+ ) -> RequestOutcome {
+ let (requester_sender, _requester_receiver) = mpsc::channel(1);
+ let requester_connection = Uuid::new_v4();
+ let requester_id = "requester".to_owned();
+ let provider_id = "provider".to_owned();
+ let skin_hash = "a".repeat(64);
+ let clients = Clients::default();
+ {
+ let mut clients = clients.lock().await;
+ clients.insert(
+ requester_id.clone(),
+ client(
+ &requester_id,
+ requester_connection,
+ vec![provider_id.clone()],
+ None,
+ requester_sender,
+ ),
+ );
+ clients.insert(
+ provider_id.clone(),
+ client(
+ &provider_id,
+ Uuid::new_v4(),
+ vec![requester_id.clone()],
+ Some(skin_hash.clone()),
+ provider_sender,
+ ),
+ );
+ }
+
+ request(
+ &clients,
+ &requester_id,
+ requester_connection,
+ "request".to_owned(),
+ provider_id,
+ skin_hash,
+ )
+ .await
+ }
+
+ #[tokio::test]
+ async fn closed_target_queue_returns_an_immediate_response() {
+ let (provider_sender, provider_receiver) = mpsc::channel(1);
+ drop(provider_receiver);
+
+ assert!(matches!(
+ request_with_provider_sender(provider_sender).await,
+ RequestOutcome::Unavailable(_)
+ ));
+ }
+
+ #[tokio::test]
+ async fn full_target_queue_returns_an_immediate_response() {
+ let (provider_sender, _provider_receiver) = mpsc::channel(1);
+ provider_sender
+ .try_send(Message::Text("occupied".into()))
+ .unwrap();
+
+ assert!(matches!(
+ request_with_provider_sender(provider_sender).await,
+ RequestOutcome::Unavailable(_)
+ ));
+ }
+}
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 8ee386b..fb1238b 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -24,6 +24,7 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22.1"
+sha2 = "0.10"
image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
lazy_static = "1.5.0"
ed25519-dalek = "3.0.0"
diff --git a/src-tauri/migrations/20260828000000_add_skin_hashes.sql b/src-tauri/migrations/20260828000000_add_skin_hashes.sql
new file mode 100644
index 0000000..0f91f0b
--- /dev/null
+++ b/src-tauri/migrations/20260828000000_add_skin_hashes.sql
@@ -0,0 +1,2 @@
+ALTER TABLE profile ADD COLUMN skin_hash TEXT;
+ALTER TABLE friends ADD COLUMN skin_hash TEXT;
diff --git a/src-tauri/src/friends/mod.rs b/src-tauri/src/friends/mod.rs
index 5a31910..c5c5b84 100644
--- a/src-tauri/src/friends/mod.rs
+++ b/src-tauri/src/friends/mod.rs
@@ -13,6 +13,7 @@ use crate::keypair::AppKeypair;
pub struct Friend {
pub id: String,
pub display_name: Option,
+ pub skin_hash: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
@@ -23,7 +24,7 @@ pub struct FriendsChanged {
pub(crate) async fn all(database: &AppDatabase) -> Result, sqlx::Error> {
sqlx::query_as::<_, Friend>(
- "SELECT id, display_name FROM friends \
+ "SELECT id, display_name, skin_hash FROM friends \
ORDER BY display_name IS NULL, display_name COLLATE NOCASE, id",
)
.fetch_all(database.pool())
@@ -38,17 +39,26 @@ async fn emit_changed(handle: &AppHandle, database: &AppDatabase) -> Result<(),
.map_err(db::command_error)
}
-async fn update_display_names(
+async fn update_profiles(
database: &AppDatabase,
profiles: &[wyd_common::Profile],
) -> Result {
let mut transaction = database.pool().begin().await?;
let mut changed = false;
for profile in profiles {
+ if profile
+ .skin_hash
+ .as_deref()
+ .is_some_and(|hash| !wyd_common::is_skin_hash(hash))
+ {
+ continue;
+ }
let result = sqlx::query(
- "UPDATE friends SET display_name = ?1 WHERE id = ?2 AND display_name IS NOT ?1",
+ "UPDATE friends SET display_name = ?1, skin_hash = ?2 \
+ WHERE id = ?3 AND (display_name IS NOT ?1 OR skin_hash IS NOT ?2)",
)
.bind(&profile.display_name)
+ .bind(&profile.skin_hash)
.bind(&profile.id)
.execute(&mut *transaction)
.await?;
@@ -71,7 +81,7 @@ pub(crate) async fn apply_profile_sync(
database: &AppDatabase,
profiles: Vec,
) -> Result {
- let changed = update_display_names(database, &profiles)
+ let changed = update_profiles(database, &profiles)
.await
.map_err(db::command_error)?;
if changed {
@@ -97,6 +107,7 @@ pub async fn create_friend(
let friend = Friend {
id,
display_name: None,
+ skin_hash: None,
};
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, NULL)")
.bind(&friend.id)
@@ -132,7 +143,7 @@ pub async fn get_friend(
database: State<'_, AppDatabase>,
id: String,
) -> Result
+
+
+
+
+
+ {
+ skinFile = event.currentTarget.files?.[0] ?? null;
+ updateDirty();
+ }}
+ />
+ 64×64 Minecraft-format PNG, base layer only.
+