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, String> { - sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1") + sqlx::query_as::<_, Friend>("SELECT id, display_name, skin_hash FROM friends WHERE id = ?1") .bind(id) .fetch_optional(database.pool()) .await @@ -191,16 +202,18 @@ mod tests { .unwrap(); assert!( - update_display_names( + update_profiles( &database, &[ wyd_common::Profile { id: "friend-id".to_owned(), display_name: "New".to_owned(), + skin_hash: Some("a".repeat(64)), }, wyd_common::Profile { id: "missing".to_owned(), display_name: "Name".to_owned(), + skin_hash: None, }, ], ) @@ -208,11 +221,12 @@ mod tests { .unwrap() ); assert!( - !update_display_names( + !update_profiles( &database, &[wyd_common::Profile { id: "friend-id".to_owned(), display_name: "New".to_owned(), + skin_hash: Some("a".repeat(64)), }], ) .await @@ -223,6 +237,7 @@ mod tests { [Friend { id: "friend-id".to_owned(), display_name: Some("New".to_owned()), + skin_hash: Some("a".repeat(64)), }] ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 519ccc8..bd55ddf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod network; mod profile; mod puppet; mod remotes; +mod skins; mod ufa; mod ui; mod user; @@ -76,6 +77,7 @@ fn specta_builder() -> tauri_specta::Builder { network::list_statuses, network::list_friend_statuses, network::resolve_friend_display_name, + skins::resolve_skin, images::pick_and_send_image, images::send_image_bytes, interactions::send_interaction, diff --git a/src-tauri/src/network/connection.rs b/src-tauri/src/network/connection.rs new file mode 100644 index 0000000..a8b59ea --- /dev/null +++ b/src-tauri/src/network/connection.rs @@ -0,0 +1,392 @@ +use std::collections::HashMap; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use tauri::{AppHandle, Manager}; +use tokio::sync::{mpsc, oneshot, watch}; +use tokio_tungstenite::tungstenite::Message; +use wyd_common::{ + ClientMessage, InteractionDeliveryStatus, Profile, ServerMessage, friends_bytes, + interaction_bytes, message_bytes, profile_bytes, register_bytes, +}; + +use super::presence::FriendPresence; +use super::{ + ConnectionState, Statuses, apply_friend_presence_change, changed, update_friend_presence, +}; +use crate::db::AppDatabase; +use crate::friends; +use crate::interactions; +use crate::keypair::AppKeypair; +use crate::live_data::LiveData; +use crate::remotes::Remote; + +pub(super) struct InteractionRequest { + pub(super) interaction_id: String, + pub(super) recipient_id: String, + pub(super) payload: String, + pub(super) response: oneshot::Sender, +} + +pub(super) struct ProfileLookupRequest { + pub(super) request_id: String, + pub(super) user_id: String, + pub(super) response: oneshot::Sender>, +} + +pub(super) struct SkinLookupRequest { + pub(super) request_id: String, + pub(super) user_id: String, + pub(super) skin_hash: String, + pub(super) response: oneshot::Sender>, +} + +pub(super) struct ConnectionInputs { + pub(super) profiles: watch::Receiver, + pub(super) friends: watch::Receiver>, + pub(super) keypair: AppKeypair, + pub(super) live_data: mpsc::Receiver, + pub(super) interactions: mpsc::Receiver, + pub(super) profile_lookups: mpsc::Receiver, + pub(super) skin_lookups: mpsc::Receiver, +} + +pub(super) async fn run( + handle: AppHandle, + statuses: Statuses, + friend_presence: std::sync::Arc, + remote: Remote, + generation: u64, + mut inputs: ConnectionInputs, +) { + loop { + changed( + &handle, + &statuses, + &remote, + generation, + ConnectionState::Connecting, + ); + if let Err(error) = connect( + &handle, + &statuses, + &friend_presence, + &remote, + generation, + &mut inputs, + ) + .await + { + eprintln!("remote {} disconnected: {error}", remote.id); + } + apply_friend_presence_change(&handle, friend_presence.remove(&remote.id)); + changed( + &handle, + &statuses, + &remote, + generation, + ConnectionState::Disconnected, + ); + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +async fn connect( + handle: &AppHandle, + statuses: &Statuses, + friend_presence: &FriendPresence, + remote: &Remote, + generation: u64, + inputs: &mut ConnectionInputs, +) -> Result<(), Box> { + let ConnectionInputs { + profiles, + friends, + keypair, + live_data, + interactions: active_outgoing, + profile_lookups, + skin_lookups, + } = inputs; + let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?; + let (mut writer, mut reader) = socket.split(); + + let challenge = match recv(&mut reader).await? { + ServerMessage::Challenge { version, challenge } if version == wyd_common::VERSION => { + challenge + } + _ => return Err("server did not send a compatible challenge".into()), + }; + let current = profiles.borrow_and_update().clone(); + let registration_profile = Profile { + id: current.id, + display_name: current.display_name, + skin_hash: current.skin_hash, + }; + let registration_friends = friends.borrow_and_update().clone(); + send( + &mut writer, + &ClientMessage::Register { + signature: keypair.sign(®ister_bytes( + &challenge, + ®istration_profile, + ®istration_friends, + )), + profile: registration_profile, + friends: registration_friends, + }, + ) + .await?; + + if !matches!(recv(&mut reader).await?, ServerMessage::Registered) { + return Err("server rejected registration".into()); + } + while live_data.try_recv().is_ok() {} + while let Ok(request) = active_outgoing.try_recv() { + let _ = request + .response + .send(InteractionDeliveryStatus::Unavailable); + } + send(&mut writer, &ClientMessage::SyncFriendProfiles).await?; + send(&mut writer, &ClientMessage::SyncFriendStatuses).await?; + changed( + handle, + statuses, + remote, + generation, + ConnectionState::Connected, + ); + + let mut pending_interactions = HashMap::new(); + let mut pending_profile_lookups: HashMap>)> = + HashMap::new(); + let mut pending_skin_lookups: HashMap< + String, + (String, String, oneshot::Sender>), + > = HashMap::new(); + loop { + tokio::select! { + payload = live_data.recv() => { + let payload = payload.ok_or("network sender closed")?; + send(&mut writer, &ClientMessage::Signed { + signature: keypair.sign(&message_bytes(&payload)), + payload, + }).await?; + } + request = active_outgoing.recv() => { + let request = request.ok_or("interaction sender closed")?; + let signature = keypair.sign(&interaction_bytes( + &request.interaction_id, + &request.recipient_id, + &request.payload, + )); + send(&mut writer, &ClientMessage::Interaction { + interaction_id: request.interaction_id.clone(), + recipient_id: request.recipient_id, + payload: request.payload, + signature, + }).await?; + pending_interactions.insert(request.interaction_id, request.response); + } + request = profile_lookups.recv() => { + let request = request.ok_or("profile lookup sender closed")?; + if request.response.is_closed() { + continue; + } + pending_profile_lookups.retain(|_, (_, response)| !response.is_closed()); + send(&mut writer, &ClientMessage::ResolveProfile { + request_id: request.request_id.clone(), + user_id: request.user_id.clone(), + }).await?; + pending_profile_lookups.insert( + request.request_id, + (request.user_id, request.response), + ); + } + request = skin_lookups.recv() => { + let request = request.ok_or("skin lookup sender closed")?; + if request.response.is_closed() { + continue; + } + pending_skin_lookups.retain(|_, (_, _, response)| !response.is_closed()); + send(&mut writer, &ClientMessage::RequestSkin { + request_id: request.request_id.clone(), + user_id: request.user_id.clone(), + skin_hash: request.skin_hash.clone(), + }).await?; + pending_skin_lookups.insert( + request.request_id, + (request.user_id, request.skin_hash, request.response), + ); + } + changed = profiles.changed() => { + changed.map_err(|_| "profile sender closed")?; + let current = profiles.borrow_and_update().clone(); + let profile = Profile { + id: current.id, + display_name: current.display_name, + skin_hash: current.skin_hash, + }; + send(&mut writer, &ClientMessage::ProfileUpdated { + signature: keypair.sign(&profile_bytes(&profile)), + profile, + }).await?; + } + changed = friends.changed() => { + changed.map_err(|_| "friends sender closed")?; + let friends = friends.borrow_and_update().clone(); + send(&mut writer, &ClientMessage::FriendsUpdated { + signature: keypair.sign(&friends_bytes(&friends)), + friends, + }).await?; + } + message = reader.next() => match message.ok_or("server closed the socket")?? { + Message::Text(text) => match serde_json::from_str(&text)? { + ServerMessage::FriendProfileUpdated { profile } => { + let database = handle.state::(); + if let Err(error) = friends::apply_profile_update(handle, &database, profile).await { + eprintln!("failed to update friend profile: {error}"); + } + } + ServerMessage::FriendProfiles { profiles } => { + let database = handle.state::(); + if let Err(error) = friends::apply_profile_sync(handle, &database, profiles).await { + eprintln!("failed to synchronize friend profiles: {error}"); + } + } + ServerMessage::FriendStatusChanged { friend_id, online } => { + update_friend_presence( + handle, + friend_presence, + &remote.id, + friend_id, + online, + ); + } + ServerMessage::FriendStatuses { friend_ids } => { + apply_friend_presence_change( + handle, + friend_presence.replace(&remote.id, friend_ids), + ); + } + ServerMessage::FriendLiveData { friend_id, payload } => { + match serde_json::from_str(&payload) { + Ok(LiveData::Cursor { positions }) => { + crate::cursor::emit_position(handle, friend_id, positions); + } + Ok(LiveData::ForegroundApp { meta }) => { + crate::ufa::emit_friend_app(handle, friend_id, meta); + } + Err(error) => { + eprintln!("failed to decode friend live data: {error}"); + } + } + } + ServerMessage::FriendInteraction { + interaction_id, + friend_id, + payload, + } => { + interactions::receive(handle, interaction_id, friend_id, &payload); + } + ServerMessage::InteractionDelivery { interaction_id, status } => { + if let Some(response) = pending_interactions.remove(&interaction_id) { + let _ = response.send(status); + } + } + ServerMessage::ProfileResolved { request_id, profile } => { + if let Some((user_id, response)) = pending_profile_lookups.remove(&request_id) { + let display_name = profile + .filter(|profile| profile.id == user_id) + .map(|profile| profile.display_name); + let _ = response.send(display_name); + } + } + ServerMessage::SkinRequested { + request_id, + requester_id, + skin_hash, + } => { + let data = profiles + .borrow() + .skin_hash + .as_deref() + .filter(|current_hash| *current_hash == skin_hash) + .and_then(|_| crate::skins::read_local_base64(handle, &skin_hash)); + send(&mut writer, &ClientMessage::ProvideSkin { + request_id, + requester_id, + skin_hash, + data, + }).await?; + } + ServerMessage::SkinResolved { + request_id, + user_id, + skin_hash, + data, + } => { + if let Some((expected_user_id, expected_hash, response)) = + pending_skin_lookups.remove(&request_id) + { + let data = (user_id == expected_user_id && skin_hash == expected_hash) + .then_some(data) + .flatten(); + let _ = response.send(data); + } + } + _ => {} + }, + Message::Ping(data) => writer.send(Message::Pong(data)).await?, + Message::Close(_) => return Ok(()), + _ => {} + } + } + } +} + +async fn send( + writer: &mut S, + message: &ClientMessage, +) -> Result<(), Box> +where + S: futures_util::Sink + Unpin, + S::Error: std::error::Error + Send + Sync + 'static, +{ + writer + .send(Message::Text(serde_json::to_string(message)?.into())) + .await?; + Ok(()) +} + +async fn recv(reader: &mut S) -> Result> +where + S: futures_util::Stream> + Unpin, +{ + while let Some(message) = reader.next().await { + if let Message::Text(text) = message? { + return Ok(serde_json::from_str(&text)?); + } + } + Err("server closed the socket".into()) +} + +fn url(remote: &Remote) -> String { + let address = remote.address.trim_end_matches('/'); + let address = address + .strip_prefix("http://") + .or_else(|| address.strip_prefix("https://")) + .or_else(|| address.strip_prefix("ws://")) + .or_else(|| address.strip_prefix("wss://")) + .unwrap_or(address); + let scheme = if remote.address.starts_with("https://") || remote.address.starts_with("wss://") { + "wss" + } else { + "ws" + }; + let port = remote + .port + .map(|port| format!(":{port}")) + .unwrap_or_default(); + format!("{scheme}://{address}{port}/v1/ws") +} diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index 035208b..4c30d06 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -3,28 +3,25 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use futures_util::StreamExt; use futures_util::stream::FuturesUnordered; -use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{AppHandle, Manager, State}; use tauri_specta::Event; use tokio::sync::{mpsc, oneshot, watch}; -use tokio_tungstenite::tungstenite::Message; -use wyd_common::{ - ClientMessage, InteractionContent, InteractionDeliveryStatus, Profile, ServerMessage, - friends_bytes, interaction_bytes, message_bytes, profile_bytes, register_bytes, -}; +use wyd_common::{InteractionContent, InteractionDeliveryStatus}; use crate::db::AppDatabase; use crate::friends::{self, FriendsChanged}; -use crate::interactions; use crate::keypair::AppKeypair; use crate::live_data::LiveData; use crate::remotes::{self, Remote, RemotesChanged}; +mod connection; mod presence; +use connection::{ConnectionInputs, InteractionRequest, ProfileLookupRequest, SkinLookupRequest}; use presence::{Change as FriendPresenceChange, FriendPresence}; type Statuses = Arc>>; @@ -63,32 +60,11 @@ struct Connection { sender: mpsc::Sender, interaction_sender: mpsc::Sender, profile_lookup_sender: mpsc::Sender, + skin_lookup_sender: mpsc::Sender, task: tauri::async_runtime::JoinHandle<()>, generation: u64, } -struct InteractionRequest { - interaction_id: String, - recipient_id: String, - payload: String, - response: oneshot::Sender, -} - -struct ProfileLookupRequest { - request_id: String, - user_id: String, - response: oneshot::Sender>, -} - -struct ConnectionInputs { - profiles: watch::Receiver, - friends: watch::Receiver>, - keypair: AppKeypair, - live_data: mpsc::Receiver, - interactions: mpsc::Receiver, - profile_lookups: mpsc::Receiver, -} - pub struct Network { connections: Mutex>, statuses: Statuses, @@ -100,6 +76,10 @@ pub struct Network { } impl Network { + pub(crate) fn public_key(&self) -> &str { + self.keypair.public_key() + } + pub fn send_live_data(&self, data: LiveData) { let Ok(payload) = serde_json::to_string(&data) else { eprintln!("failed to serialize live data"); @@ -222,6 +202,53 @@ impl Network { Ok(None) } + pub(crate) async fn request_skin( + &self, + user_id: String, + skin_hash: String, + ) -> Result>, String> { + if user_id == self.keypair.public_key() { + return Ok(None); + } + + let senders: Vec<_> = self + .connections + .lock() + .map_err(|error| error.to_string())? + .values() + .map(|connection| connection.skin_lookup_sender.clone()) + .collect(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut responses = Vec::new(); + for sender in senders { + let (response, receiver) = oneshot::channel(); + if sender + .try_send(SkinLookupRequest { + request_id: request_id.clone(), + user_id: user_id.clone(), + skin_hash: skin_hash.clone(), + response, + }) + .is_ok() + { + responses.push(receiver); + } + } + + let mut pending: FuturesUnordered<_> = responses + .into_iter() + .map(|response| tokio::time::timeout(Duration::from_secs(3), response)) + .collect(); + while let Some(result) = pending.next().await { + if let Ok(Ok(Some(data))) = result + && let Some(bytes) = crate::skins::decode_response(&data, &skin_hash) + { + return Ok(Some(bytes)); + } + } + Ok(None) + } + fn sync_remotes(&self, handle: &AppHandle, remotes: Vec) -> Result<(), String> { let desired: HashMap<_, _> = remotes .into_iter() @@ -252,13 +279,14 @@ impl Network { let (sender, receiver) = mpsc::channel(32); let (interaction_sender, interaction_receiver) = mpsc::channel(16); let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16); + let (skin_lookup_sender, skin_lookup_receiver) = mpsc::channel(16); set_initial( &self.statuses, &remote, generation, ConnectionState::Connecting, ); - let task = tauri::async_runtime::spawn(run( + let task = tauri::async_runtime::spawn(connection::run( handle.clone(), self.statuses.clone(), self.friend_presence.clone(), @@ -271,6 +299,7 @@ impl Network { live_data: receiver, interactions: interaction_receiver, profile_lookups: profile_lookup_receiver, + skin_lookups: skin_lookup_receiver, }, )); connections.insert( @@ -280,6 +309,7 @@ impl Network { sender, interaction_sender, profile_lookup_sender, + skin_lookup_sender, task, generation, }, @@ -334,244 +364,6 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box> Ok(()) } -async fn run( - handle: AppHandle, - statuses: Statuses, - friend_presence: Arc, - remote: Remote, - generation: u64, - mut inputs: ConnectionInputs, -) { - loop { - changed( - &handle, - &statuses, - &remote, - generation, - ConnectionState::Connecting, - ); - if let Err(error) = connect( - &handle, - &statuses, - &friend_presence, - &remote, - generation, - &mut inputs, - ) - .await - { - eprintln!("remote {} disconnected: {error}", remote.id); - } - apply_friend_presence_change(&handle, friend_presence.remove(&remote.id)); - changed( - &handle, - &statuses, - &remote, - generation, - ConnectionState::Disconnected, - ); - tokio::time::sleep(Duration::from_secs(5)).await; - } -} - -async fn connect( - handle: &AppHandle, - statuses: &Statuses, - friend_presence: &FriendPresence, - remote: &Remote, - generation: u64, - inputs: &mut ConnectionInputs, -) -> Result<(), Box> { - let ConnectionInputs { - profiles, - friends, - keypair, - live_data, - interactions: active_outgoing, - profile_lookups, - } = inputs; - let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?; - let (mut writer, mut reader) = socket.split(); - - let challenge = match recv(&mut reader).await? { - ServerMessage::Challenge { version, challenge } if version == wyd_common::VERSION => { - challenge - } - _ => return Err("server did not send a compatible challenge".into()), - }; - let current = profiles.borrow_and_update().clone(); - let registration_profile = Profile { - id: current.id, - display_name: current.display_name, - }; - let registration_friends = friends.borrow_and_update().clone(); - send( - &mut writer, - &ClientMessage::Register { - signature: keypair.sign(®ister_bytes( - &challenge, - ®istration_profile, - ®istration_friends, - )), - profile: registration_profile, - friends: registration_friends, - }, - ) - .await?; - - if !matches!(recv(&mut reader).await?, ServerMessage::Registered) { - return Err("server rejected registration".into()); - } - while live_data.try_recv().is_ok() {} - while let Ok(request) = active_outgoing.try_recv() { - let _ = request - .response - .send(InteractionDeliveryStatus::Unavailable); - } - send(&mut writer, &ClientMessage::SyncFriendProfiles).await?; - send(&mut writer, &ClientMessage::SyncFriendStatuses).await?; - changed( - handle, - statuses, - remote, - generation, - ConnectionState::Connected, - ); - - let mut pending_interactions = HashMap::new(); - let mut pending_profile_lookups: HashMap>)> = - HashMap::new(); - loop { - tokio::select! { - payload = live_data.recv() => { - let payload = payload.ok_or("network sender closed")?; - send(&mut writer, &ClientMessage::Signed { - signature: keypair.sign(&message_bytes(&payload)), - payload, - }).await?; - } - request = active_outgoing.recv() => { - let request = request.ok_or("interaction sender closed")?; - let signature = keypair.sign(&interaction_bytes( - &request.interaction_id, - &request.recipient_id, - &request.payload, - )); - send(&mut writer, &ClientMessage::Interaction { - interaction_id: request.interaction_id.clone(), - recipient_id: request.recipient_id, - payload: request.payload, - signature, - }).await?; - pending_interactions.insert(request.interaction_id, request.response); - } - request = profile_lookups.recv() => { - let request = request.ok_or("profile lookup sender closed")?; - if request.response.is_closed() { - continue; - } - pending_profile_lookups.retain(|_, (_, response)| !response.is_closed()); - send(&mut writer, &ClientMessage::ResolveProfile { - request_id: request.request_id.clone(), - user_id: request.user_id.clone(), - }).await?; - pending_profile_lookups.insert( - request.request_id, - (request.user_id, request.response), - ); - } - changed = profiles.changed() => { - changed.map_err(|_| "profile sender closed")?; - let current = profiles.borrow_and_update().clone(); - let profile = Profile { - id: current.id, - display_name: current.display_name, - }; - send(&mut writer, &ClientMessage::ProfileUpdated { - signature: keypair.sign(&profile_bytes(&profile)), - profile, - }).await?; - } - changed = friends.changed() => { - changed.map_err(|_| "friends sender closed")?; - let friends = friends.borrow_and_update().clone(); - send(&mut writer, &ClientMessage::FriendsUpdated { - signature: keypair.sign(&friends_bytes(&friends)), - friends, - }).await?; - } - message = reader.next() => match message.ok_or("server closed the socket")?? { - Message::Text(text) => match serde_json::from_str(&text)? { - ServerMessage::FriendProfileUpdated { profile } => { - let database = handle.state::(); - if let Err(error) = friends::apply_profile_update(handle, &database, profile).await { - eprintln!("failed to update friend profile: {error}"); - } - } - ServerMessage::FriendProfiles { profiles } => { - let database = handle.state::(); - if let Err(error) = friends::apply_profile_sync(handle, &database, profiles).await { - eprintln!("failed to synchronize friend profiles: {error}"); - } - } - ServerMessage::FriendStatusChanged { friend_id, online } => { - update_friend_presence( - handle, - friend_presence, - &remote.id, - friend_id, - online, - ); - } - ServerMessage::FriendStatuses { friend_ids } => { - apply_friend_presence_change( - handle, - friend_presence.replace(&remote.id, friend_ids), - ); - } - ServerMessage::FriendLiveData { friend_id, payload } => { - match serde_json::from_str(&payload) { - Ok(LiveData::Cursor { positions }) => { - crate::cursor::emit_position(handle, friend_id, positions); - } - Ok(LiveData::ForegroundApp { meta }) => { - crate::ufa::emit_friend_app(handle, friend_id, meta); - } - Err(error) => { - eprintln!("failed to decode friend live data: {error}"); - } - } - } - ServerMessage::FriendInteraction { - interaction_id, - friend_id, - payload, - } => { - interactions::receive(handle, interaction_id, friend_id, &payload); - } - ServerMessage::InteractionDelivery { interaction_id, status } => { - if let Some(response) = pending_interactions.remove(&interaction_id) { - let _ = response.send(status); - } - } - ServerMessage::ProfileResolved { request_id, profile } => { - if let Some((user_id, response)) = pending_profile_lookups.remove(&request_id) { - let display_name = profile - .filter(|profile| profile.id == user_id) - .map(|profile| profile.display_name); - let _ = response.send(display_name); - } - } - _ => {} - }, - Message::Ping(data) => writer.send(Message::Pong(data)).await?, - Message::Close(_) => return Ok(()), - _ => {} - } - } - } -} - #[tauri::command] #[specta::specta] pub fn list_statuses( @@ -702,52 +494,6 @@ fn snapshot(statuses: &Statuses) -> Result, String> { Ok(statuses) } -async fn send( - writer: &mut S, - message: &ClientMessage, -) -> Result<(), Box> -where - S: futures_util::Sink + Unpin, - S::Error: std::error::Error + Send + Sync + 'static, -{ - writer - .send(Message::Text(serde_json::to_string(message)?.into())) - .await?; - Ok(()) -} - -async fn recv(reader: &mut S) -> Result> -where - S: futures_util::Stream> + Unpin, -{ - while let Some(message) = reader.next().await { - if let Message::Text(text) = message? { - return Ok(serde_json::from_str(&text)?); - } - } - Err("server closed the socket".into()) -} - -fn url(remote: &Remote) -> String { - let address = remote.address.trim_end_matches('/'); - let address = address - .strip_prefix("http://") - .or_else(|| address.strip_prefix("https://")) - .or_else(|| address.strip_prefix("ws://")) - .or_else(|| address.strip_prefix("wss://")) - .unwrap_or(address); - let scheme = if remote.address.starts_with("https://") || remote.address.starts_with("wss://") { - "wss" - } else { - "ws" - }; - let port = remote - .port - .map(|port| format!(":{port}")) - .unwrap_or_default(); - format!("{scheme}://{address}{port}/v1/ws") -} - fn friend_ids(friends: Vec, own_id: &str) -> Vec { let mut ids: Vec<_> = friends .into_iter() @@ -770,14 +516,17 @@ mod tests { Friend { id: "friend-b".to_owned(), display_name: Some("Old name".to_owned()), + skin_hash: None, }, Friend { id: "self".to_owned(), display_name: None, + skin_hash: None, }, Friend { id: "friend-a".to_owned(), display_name: Some("Any name".to_owned()), + skin_hash: None, }, ]; diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index 168e74d..5228bf7 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -16,19 +16,21 @@ pub struct ProfileChanged { } pub async fn get(database: &AppDatabase, public_key: &str) -> Result { - let display_name = - sqlx::query_scalar::<_, String>("SELECT display_name FROM profile WHERE id = ?1") - .bind(PROFILE_ID) - .fetch_one(database.pool()) - .await?; + let (display_name, skin_hash) = sqlx::query_as::<_, (String, Option)>( + "SELECT display_name, skin_hash FROM profile WHERE id = ?1", + ) + .bind(PROFILE_ID) + .fetch_one(database.pool()) + .await?; Ok(User { id: public_key.to_owned(), - display_name: if display_name.len() <= 0 { + display_name: if display_name.is_empty() { "Anonymous".to_string() } else { display_name }, + skin_hash, }) } @@ -36,12 +38,16 @@ async fn update( database: &AppDatabase, public_key: &str, display_name: String, + skin_hash: Option, ) -> Result { - sqlx::query("UPDATE profile SET display_name = ?1 WHERE id = ?2") - .bind(display_name) - .bind(PROFILE_ID) - .execute(database.pool()) - .await?; + sqlx::query( + "UPDATE profile SET display_name = ?1, skin_hash = COALESCE(?2, skin_hash) WHERE id = ?3", + ) + .bind(display_name) + .bind(skin_hash) + .bind(PROFILE_ID) + .execute(database.pool()) + .await?; get(database, public_key).await } @@ -74,8 +80,13 @@ pub async fn update_profile( keypair: State<'_, AppKeypair>, network: State<'_, Network>, display_name: String, + skin_data: Option>, ) -> Result { - let profile = update(&database, keypair.public_key(), display_name) + let skin_hash = skin_data + .as_deref() + .map(|data| crate::skins::store_local(&handle, data)) + .transpose()?; + let profile = update(&database, keypair.public_key(), display_name, skin_hash) .await .map_err(db::command_error)?; network.update_profile(profile.clone()); @@ -106,7 +117,7 @@ mod tests { async fn profile_uses_the_supplied_keypair_identity() { let database = database().await; - let profile = update(&database, "public-key", "Wind".to_owned()) + let profile = update(&database, "public-key", "Wind".to_owned(), None) .await .expect("update profile"); @@ -115,6 +126,7 @@ mod tests { User { id: "public-key".to_owned(), display_name: "Wind".to_owned(), + skin_hash: None, } ); assert_eq!( @@ -122,8 +134,20 @@ mod tests { User { id: "rotated-public-key".to_owned(), display_name: "Wind".to_owned(), + skin_hash: None, } ); + + let profile = update( + &database, + "public-key", + "Wind Two".to_owned(), + Some("a".repeat(64)), + ) + .await + .expect("update profile and skin together"); + assert_eq!(profile.display_name, "Wind Two"); + assert_eq!(profile.skin_hash, Some("a".repeat(64))); } #[tokio::test] @@ -136,6 +160,6 @@ mod tests { .await .expect("read profile columns"); - assert_eq!(columns, vec!["id", "display_name"]); + assert_eq!(columns, vec!["id", "display_name", "skin_hash"]); } } diff --git a/src-tauri/src/skins.rs b/src-tauri/src/skins.rs new file mode 100644 index 0000000..893c13e --- /dev/null +++ b/src-tauri/src/skins.rs @@ -0,0 +1,164 @@ +use std::io::Cursor; +use std::path::PathBuf; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Manager, State}; + +use crate::network::Network; + +const MAX_SKIN_BYTES: usize = 64 * 1024; +const SKIN_WIDTH: u32 = 64; +const SKIN_HEIGHT: u32 = 64; + +fn validate_hash(hash: &str) -> Result<(), String> { + if wyd_common::is_skin_hash(hash) { + Ok(()) + } else { + Err("Skin hash must be a 64-character SHA-256 value".to_owned()) + } +} + +fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn validate(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_SKIN_BYTES { + return Err(format!( + "Skin PNG must be at most {} KiB", + MAX_SKIN_BYTES / 1024 + )); + } + let dimensions = image::ImageReader::with_format(Cursor::new(bytes), image::ImageFormat::Png) + .into_dimensions() + .map_err(|_| "Skin must be a valid PNG image".to_owned())?; + if dimensions != (SKIN_WIDTH, SKIN_HEIGHT) { + return Err(format!("Skin must be {SKIN_WIDTH}×{SKIN_HEIGHT} pixels")); + } + image::load_from_memory_with_format(bytes, image::ImageFormat::Png) + .map_err(|_| "Skin must be a valid PNG image".to_owned())?; + Ok(hash(bytes)) +} + +fn local_path(handle: &AppHandle, hash: &str) -> Result { + Ok(handle + .path() + .app_data_dir() + .map_err(|error| error.to_string())? + .join("skins") + .join(format!("{hash}.png"))) +} + +fn cache_path(handle: &AppHandle, hash: &str) -> Result { + Ok(handle + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("skins") + .join(format!("{hash}.png"))) +} + +fn read_verified(path: PathBuf, expected_hash: &str) -> Result>, String> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + Ok(validate(&bytes) + .is_ok_and(|actual_hash| actual_hash == expected_hash) + .then_some(bytes)) +} + +pub(crate) fn decode_response(data: &str, expected_hash: &str) -> Option> { + if data.len() > wyd_common::MAX_SKIN_B64_SIZE { + return None; + } + let bytes = STANDARD.decode(data).ok()?; + (validate(&bytes).ok()?.as_str() == expected_hash).then_some(bytes) +} + +fn write(path: PathBuf, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "Skin path has no parent directory".to_owned())?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + std::fs::write(path, bytes).map_err(|error| error.to_string()) +} + +pub(crate) fn read_local_base64(handle: &AppHandle, skin_hash: &str) -> Option { + validate_hash(skin_hash).ok()?; + read_verified(local_path(handle, skin_hash).ok()?, skin_hash) + .ok() + .flatten() + .map(|bytes| STANDARD.encode(bytes)) +} + +pub(crate) fn store_local(handle: &AppHandle, data: &[u8]) -> Result { + let skin_hash = validate(data)?; + write(local_path(handle, &skin_hash)?, data)?; + Ok(skin_hash) +} + +#[tauri::command] +#[specta::specta] +pub async fn resolve_skin( + handle: AppHandle, + network: State<'_, Network>, + user_id: String, + skin_hash: String, +) -> Result, String> { + validate_hash(&skin_hash)?; + crate::user::validate_id(&user_id)?; + + let bytes = if user_id == network.public_key() { + read_verified(local_path(&handle, &skin_hash)?, &skin_hash)? + } else if let Some(bytes) = read_verified(cache_path(&handle, &skin_hash)?, &skin_hash)? { + Some(bytes) + } else { + let Some(bytes) = network.request_skin(user_id, skin_hash.clone()).await? else { + return Ok(None); + }; + write(cache_path(&handle, &skin_hash)?, &bytes)?; + Some(bytes) + }; + + Ok(bytes.map(|bytes| STANDARD.encode(bytes))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_non_png_and_invalid_hashes() { + assert!(validate(b"not a png").is_err()); + assert!(validate_hash("../skin").is_err()); + assert!(validate_hash(&"a".repeat(64)).is_ok()); + } + + #[test] + fn rejects_png_dimensions_before_full_validation() { + let mut bytes = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(SKIN_WIDTH + 1, SKIN_HEIGHT) + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + + assert_eq!( + validate(&bytes.into_inner()).unwrap_err(), + "Skin must be 64×64 pixels" + ); + } + + #[test] + fn accepts_a_64_by_64_png_and_returns_its_sha256_hash() { + let mut bytes = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(SKIN_WIDTH, SKIN_HEIGHT) + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + let bytes = bytes.into_inner(); + + assert_eq!(validate(&bytes).unwrap(), hash(&bytes)); + } +} diff --git a/src-tauri/src/user/mod.rs b/src-tauri/src/user/mod.rs index 48020b8..903d67f 100644 --- a/src-tauri/src/user/mod.rs +++ b/src-tauri/src/user/mod.rs @@ -11,6 +11,7 @@ use specta::Type; pub struct User { pub id: String, pub display_name: String, + pub skin_hash: Option, } pub(crate) fn validate_id(id: &str) -> Result<(), String> { diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index ae5b959..df26fc2 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -35,8 +35,8 @@ async deleteRemote(id: string) : Promise { async getProfile() : Promise { return await TAURI_INVOKE("get_profile"); }, -async updateProfile(displayName: string) : Promise { - return await TAURI_INVOKE("update_profile", { displayName }); +async updateProfile(displayName: string, skinData: number[] | null) : Promise { + return await TAURI_INVOKE("update_profile", { displayName, skinData }); }, async getPublicKey() : Promise { return await TAURI_INVOKE("get_public_key"); @@ -50,6 +50,9 @@ async listFriendStatuses() : Promise { async resolveFriendDisplayName(userId: string) : Promise { return await TAURI_INVOKE("resolve_friend_display_name", { userId }); }, +async resolveSkin(userId: string, skinHash: string) : Promise { + return await TAURI_INVOKE("resolve_skin", { userId, skinHash }); +}, async pickAndSendImage(recipientId: string) : Promise { return await TAURI_INVOKE("pick_and_send_image", { recipientId }); }, @@ -125,7 +128,7 @@ export type ForegroundAppChanged = { meta: AppMeta } * A configured public-key relationship with optional cached remote metadata. * The display name remains absent until learned from a signed remote profile. */ -export type Friend = { id: string; displayName: string | null } +export type Friend = { id: string; displayName: string | null; skinHash: string | null } export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta } export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent } export type FriendStatusesChanged = { friendIds: string[] } @@ -143,7 +146,7 @@ export type SceneHitbox = { x: number; y: number; width: number; height: number * Public user identity exchanged with peers and remote servers. `id` is the * user's Ed25519 public key and is not persisted as local profile metadata. */ -export type User = { id: string; displayName: string } +export type User = { id: string; displayName: string; skinHash: string | null } /** tauri-specta globals **/ diff --git a/src/lib/components/control-panel/general-panel.svelte b/src/lib/components/control-panel/general-panel.svelte index c5f2ba6..f1b3c7e 100644 --- a/src/lib/components/control-panel/general-panel.svelte +++ b/src/lib/components/control-panel/general-panel.svelte @@ -12,6 +12,8 @@ let dirty = $state(false); let busy = $state(false); let error = $state(""); + let skinFile = $state(null); + let skinInput = $state(null); $effect(() => { if (!dirty) displayName = $profile?.displayName ?? ""; @@ -21,12 +23,15 @@ onMount(() => register("general", { apply, reset }, { dirty, busy })); function updateDirty() { - dirty = displayName.trim() !== ($profile?.displayName ?? ""); + dirty = + displayName.trim() !== ($profile?.displayName ?? "") || skinFile !== null; error = ""; } function reset() { displayName = $profile?.displayName ?? ""; + skinFile = null; + if (skinInput) skinInput.value = ""; dirty = false; error = ""; } @@ -42,8 +47,13 @@ busy = true; error = ""; try { - await commands.updateProfile(nextName); + const skinData = skinFile + ? Array.from(new Uint8Array(await skinFile.arrayBuffer())) + : null; + await commands.updateProfile(nextName, skinData); displayName = nextName; + skinFile = null; + if (skinInput) skinInput.value = ""; dirty = false; return true; } catch (cause) { @@ -78,6 +88,34 @@ This name can be changed at any time.

+ +
+ + +
+ { + skinFile = event.currentTarget.files?.[0] ?? null; + updateDirty(); + }} + /> +

64×64 Minecraft-format PNG, base layer only.

+