diff --git a/Cargo.lock b/Cargo.lock index 027bc20..fb5b941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,6 +514,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -562,6 +564,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1554,6 +1562,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2044,9 +2062,24 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -2179,6 +2212,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -2311,6 +2354,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3137,6 +3190,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -3356,6 +3415,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4448,6 +4531,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -5425,6 +5550,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "libwebp-sys", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5479,6 +5613,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -5752,6 +5892,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5800,13 +5949,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5843,6 +6009,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5861,6 +6033,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5879,12 +6057,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5903,6 +6093,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5921,6 +6117,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5939,6 +6141,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5957,6 +6165,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -6071,11 +6285,13 @@ dependencies = [ "sqlx", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-specta", "tokio", "tokio-tungstenite 0.28.0", "uuid", + "webp", "windows 0.58.0", "wyd-common", ] @@ -6085,6 +6301,8 @@ name = "wyd-common" version = "0.1.0" dependencies = [ "serde", + "serde_json", + "specta", ] [[package]] @@ -6095,6 +6313,7 @@ dependencies = [ "base64 0.22.1", "ed25519-dalek", "futures-util", + "image", "serde_json", "tokio", "uuid", @@ -6292,6 +6511,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.12.0" diff --git a/src-common/Cargo.toml b/src-common/Cargo.toml index 96ef6a9..e372823 100644 --- a/src-common/Cargo.toml +++ b/src-common/Cargo.toml @@ -6,3 +6,7 @@ publish = false [dependencies] serde = { version = "1", features = ["derive"] } +specta = { version = "2.0.0-rc.21", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/src-common/src/lib.rs b/src-common/src/lib.rs index e9f5d77..03f5d8f 100644 --- a/src-common/src/lib.rs +++ b/src-common/src/lib.rs @@ -1,6 +1,11 @@ use serde::{Deserialize, Serialize}; +use specta::Type; pub const VERSION: u8 = 1; +pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024; +pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024; +pub const MAX_IMAGE_DIMENSION: u32 = 480; +pub const MAX_TEXT_CHARS: usize = 500; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -30,16 +35,90 @@ pub enum ClientMessage { payload: String, signature: String, }, + Interaction { + interaction_id: String, + recipient_id: String, + payload: String, + signature: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InteractionDeliveryStatus { + Delivered, + Unavailable, + Busy, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum InteractionContent { + Text { + text: String, + }, + Wave, + Image { + #[serde(rename = "mediaType")] + #[specta(rename = "mediaType")] + media_type: String, + data: String, + }, +} + +impl InteractionContent { + pub fn validate(&self) -> Result<(), String> { + match self { + Self::Text { text } if text.trim().is_empty() => { + Err("Message cannot be empty".to_owned()) + } + Self::Text { text } if text.chars().count() > MAX_TEXT_CHARS => { + Err(format!("Message exceeds {MAX_TEXT_CHARS} characters")) + } + Self::Image { media_type, .. } + if !matches!(media_type.as_str(), "image/webp" | "image/jpeg") => + { + Err("Image must be WebP or JPEG".to_owned()) + } + Self::Image { data, .. } if data.is_empty() || data.len() > MAX_IMAGE_B64_SIZE => { + Err(format!( + "Compressed image must be at most {} KiB", + MAX_IMAGE_B64_SIZE / 1024 + )) + } + _ => Ok(()), + } + } } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ServerMessage { - Challenge { version: u8, challenge: String }, + Challenge { + version: u8, + challenge: String, + }, Registered, - FriendProfileUpdated { profile: Profile }, - FriendProfiles { profiles: Vec }, - FriendLiveData { friend_id: String, payload: String }, + FriendProfileUpdated { + profile: Profile, + }, + FriendProfiles { + profiles: Vec, + }, + FriendLiveData { + friend_id: String, + payload: String, + }, + FriendInteraction { + interaction_id: String, + friend_id: String, + payload: String, + }, + InteractionDelivery { + interaction_id: String, + status: InteractionDeliveryStatus, + }, } pub fn register_bytes(challenge: &str, profile: &Profile, friends: &[String]) -> Vec { @@ -56,6 +135,19 @@ pub fn message_bytes(payload: &str) -> Vec { format!("wyd-message-v{VERSION}\n{payload}").into_bytes() } +pub fn interaction_bytes(interaction_id: &str, recipient_id: &str, payload: &str) -> Vec { + let mut bytes = format!("wyd-interaction-v{VERSION}\0").into_bytes(); + for value in [ + interaction_id.as_bytes(), + recipient_id.as_bytes(), + payload.as_bytes(), + ] { + bytes.extend_from_slice(&(value.len() as u64).to_be_bytes()); + bytes.extend_from_slice(value); + } + bytes +} + pub fn profile_bytes(profile: &Profile) -> Vec { format!( "wyd-profile-v{VERSION}\n{}\n{}", @@ -67,3 +159,40 @@ pub fn profile_bytes(profile: &Profile) -> Vec { pub fn friends_bytes(friends: &[String]) -> Vec { format!("wyd-friends-v{VERSION}\n{}", friends.join("\n")).into_bytes() } + +#[cfg(test)] +mod tests { + use super::{InteractionContent, MAX_TEXT_CHARS, interaction_bytes}; + + #[test] + fn interaction_signature_bytes_are_length_delimited() { + assert_ne!( + interaction_bytes("one", "two", "three\nfour"), + interaction_bytes("one\ntwo", "three", "four"), + ); + assert_ne!( + interaction_bytes("id", "recipient-a", "payload"), + interaction_bytes("id", "recipient-b", "payload"), + ); + } + + #[test] + fn interaction_content_enforces_shared_wire_invariants() { + assert!(InteractionContent::Wave.validate().is_ok()); + assert!( + InteractionContent::Text { + text: " ".to_owned() + } + .validate() + .is_err() + ); + assert!( + InteractionContent::Text { + text: "x".repeat(MAX_TEXT_CHARS + 1) + } + .validate() + .is_err() + ); + assert!(serde_json::from_str::(r#"{"type":"unknown"}"#).is_err()); + } +} diff --git a/src-server/Cargo.toml b/src-server/Cargo.toml index 0ebb484..5bf0a97 100644 --- a/src-server/Cargo.toml +++ b/src-server/Cargo.toml @@ -10,6 +10,7 @@ tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "s base64 = "0.22.1" ed25519-dalek = "3.0.0" futures-util = "0.3" +image = { version = "0.25", default-features = false, features = ["jpeg", "webp"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } wyd-common = { path = "../src-common" } diff --git a/src-server/src/network/interactions.rs b/src-server/src/network/interactions.rs new file mode 100644 index 0000000..98f3dbe --- /dev/null +++ b/src-server/src/network/interactions.rs @@ -0,0 +1,372 @@ +use std::io::Cursor; + +use axum::extract::ws::Message; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use tokio::sync::mpsc; +use uuid::Uuid; +use wyd_common::{ + InteractionContent, InteractionDeliveryStatus, MAX_IMAGE_DIMENSION, + MAX_INTERACTION_PAYLOAD_BYTES, ServerMessage, interaction_bytes, +}; + +use super::{Clients, verify}; + +pub(super) async fn relay( + clients: &Clients, + public_key: &str, + connection_id: Uuid, + interaction_id: &str, + recipient_id: &str, + payload: String, + signature: &str, +) -> Option { + let (source_key, recipient) = { + let clients = clients.lock().await; + let source = clients + .get(public_key) + .filter(|client| client.connection_id == connection_id)?; + let recipient = clients + .get(recipient_id) + .filter(|recipient| { + source.friends.iter().any(|friend| friend == recipient_id) + && recipient.friends.iter().any(|friend| friend == public_key) + }) + .map(|recipient| recipient.sender.clone()); + (source.key, recipient) + }; + + if !verify( + &source_key, + &interaction_bytes(interaction_id, recipient_id, &payload), + signature, + ) { + return None; + } + if payload.len() > MAX_INTERACTION_PAYLOAD_BYTES + || recipient_id == public_key + || !payload_is_valid(&payload) + { + return Some(InteractionDeliveryStatus::Rejected); + } + let Some(recipient) = recipient else { + return Some(InteractionDeliveryStatus::Unavailable); + }; + + let message = Message::Text( + serde_json::to_string(&ServerMessage::FriendInteraction { + interaction_id: interaction_id.to_owned(), + friend_id: public_key.to_owned(), + payload, + }) + .unwrap() + .into(), + ); + Some(match recipient.try_send(message) { + Ok(()) => InteractionDeliveryStatus::Delivered, + Err(mpsc::error::TrySendError::Full(_)) => InteractionDeliveryStatus::Busy, + Err(mpsc::error::TrySendError::Closed(_)) => InteractionDeliveryStatus::Unavailable, + }) +} + +fn payload_is_valid(payload: &str) -> bool { + let Ok(content) = serde_json::from_str::(payload) else { + return false; + }; + if content.validate().is_err() { + return false; + } + let InteractionContent::Image { media_type, data } = content else { + return true; + }; + + let Ok(bytes) = STANDARD.decode(data) else { + return false; + }; + let expected_format = match media_type.as_str() { + "image/webp" => image::ImageFormat::WebP, + "image/jpeg" => image::ImageFormat::Jpeg, + _ => return false, + }; + if image::guess_format(&bytes).ok() != Some(expected_format) { + return false; + } + + let Ok((width, height)) = + image::ImageReader::with_format(Cursor::new(bytes), expected_format).into_dimensions() + else { + return false; + }; + width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION +} + +#[cfg(test)] +mod tests { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use ed25519_dalek::{Signer, SigningKey}; + use image::{Rgb, RgbImage}; + use wyd_common::{MAX_TEXT_CHARS, Profile}; + + use super::*; + use crate::network::Client; + + fn jpeg_payload(width: u32, height: u32) -> String { + let image = RgbImage::from_pixel(width, height, Rgb([30, 90, 150])); + let mut bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 50) + .encode_image(&image) + .unwrap(); + serde_json::json!({ + "type": "image", + "mediaType": "image/jpeg", + "data": STANDARD.encode(bytes), + }) + .to_string() + } + + #[test] + fn payload_validation_enforces_the_shared_schema_and_image_details() { + assert!(payload_is_valid(r#"{"type":"wave"}"#)); + assert!(!payload_is_valid(r#"{"type":"unknown"}"#)); + assert!(!payload_is_valid(r#"{"type":"text","text":" "}"#)); + assert!(!payload_is_valid( + &serde_json::json!({ + "type": "text", + "text": "x".repeat(MAX_TEXT_CHARS + 1), + }) + .to_string() + )); + assert!(payload_is_valid(&jpeg_payload( + MAX_IMAGE_DIMENSION, + MAX_IMAGE_DIMENSION + ))); + assert!(!payload_is_valid(&jpeg_payload(MAX_IMAGE_DIMENSION + 1, 1))); + assert!(!payload_is_valid( + r#"{"type":"image","mediaType":"image/jpeg","data":"not-base64"}"# + )); + } + + #[tokio::test] + async fn relay_is_targeted_signed_and_reports_delivery_state() { + let signing_key = SigningKey::from_bytes(&[8; 32]); + let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); + let connection_id = Uuid::new_v4(); + let (source_sender, _source_receiver) = mpsc::channel(1); + let (target_sender, mut target_receiver) = mpsc::channel(1); + let (one_sided_sender, mut one_sided_receiver) = mpsc::channel(1); + let clients = Clients::default(); + let mut registry = clients.lock().await; + registry.insert( + public_key.clone(), + Client { + connection_id, + key: signing_key.verifying_key(), + profile: Profile { + id: public_key.clone(), + display_name: "Source".to_owned(), + }, + friends: vec!["target".to_owned(), "one-sided".to_owned()], + sender: source_sender, + }, + ); + registry.insert( + "target".to_owned(), + Client { + connection_id: Uuid::new_v4(), + key: signing_key.verifying_key(), + profile: Profile { + id: "target".to_owned(), + display_name: "Target".to_owned(), + }, + friends: vec![public_key.clone()], + sender: target_sender.clone(), + }, + ); + registry.insert( + "one-sided".to_owned(), + Client { + connection_id: Uuid::new_v4(), + key: signing_key.verifying_key(), + profile: Profile { + id: "one-sided".to_owned(), + display_name: "One-sided".to_owned(), + }, + friends: Vec::new(), + sender: one_sided_sender, + }, + ); + drop(registry); + + let interaction_id = "interaction-1"; + let payload = r#"{"type":"wave"}"#.to_owned(); + let signature = URL_SAFE_NO_PAD.encode( + signing_key + .sign(&interaction_bytes(interaction_id, "target", &payload)) + .to_bytes(), + ); + assert_eq!( + relay( + &clients, + &public_key, + connection_id, + interaction_id, + "target", + payload.clone(), + &signature, + ) + .await, + Some(InteractionDeliveryStatus::Delivered) + ); + assert!(target_receiver.recv().await.is_some()); + assert!(one_sided_receiver.try_recv().is_err()); + + assert!( + target_sender + .try_send(Message::Ping(Vec::new().into())) + .is_ok() + ); + let busy_id = "interaction-2"; + let busy_signature = URL_SAFE_NO_PAD.encode( + signing_key + .sign(&interaction_bytes(busy_id, "target", &payload)) + .to_bytes(), + ); + assert_eq!( + relay( + &clients, + &public_key, + connection_id, + busy_id, + "target", + payload.clone(), + &busy_signature, + ) + .await, + Some(InteractionDeliveryStatus::Busy) + ); + + let unavailable_signature = URL_SAFE_NO_PAD.encode( + signing_key + .sign(&interaction_bytes(interaction_id, "one-sided", &payload)) + .to_bytes(), + ); + assert_eq!( + relay( + &clients, + &public_key, + connection_id, + interaction_id, + "one-sided", + payload.clone(), + &unavailable_signature, + ) + .await, + Some(InteractionDeliveryStatus::Unavailable) + ); + assert_eq!( + relay( + &clients, + &public_key, + Uuid::new_v4(), + interaction_id, + "target", + payload.clone(), + &signature, + ) + .await, + None + ); + assert_eq!( + relay( + &clients, + &public_key, + connection_id, + interaction_id, + "target", + "tampered".to_owned(), + &signature, + ) + .await, + None + ); + } + + #[tokio::test] + async fn relay_rejects_invalid_payloads_before_delivery() { + let signing_key = SigningKey::from_bytes(&[9; 32]); + let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); + let connection_id = Uuid::new_v4(); + let (source_sender, _source_receiver) = mpsc::channel(1); + let (target_sender, mut target_receiver) = mpsc::channel(1); + let clients = Clients::default(); + let mut registry = clients.lock().await; + registry.insert( + public_key.clone(), + Client { + connection_id, + key: signing_key.verifying_key(), + profile: Profile { + id: public_key.clone(), + display_name: "Source".to_owned(), + }, + friends: vec!["target".to_owned()], + sender: source_sender, + }, + ); + registry.insert( + "target".to_owned(), + Client { + connection_id: Uuid::new_v4(), + key: signing_key.verifying_key(), + profile: Profile { + id: "target".to_owned(), + display_name: "Target".to_owned(), + }, + friends: vec![public_key.clone()], + sender: target_sender, + }, + ); + drop(registry); + + for (interaction_id, payload) in [ + ("unknown", r#"{"type":"unknown"}"#.to_owned()), + ( + "oversized", + serde_json::json!({ + "type": "wave", + "padding": "x".repeat(MAX_INTERACTION_PAYLOAD_BYTES), + }) + .to_string(), + ), + ( + "long-text", + serde_json::json!({ + "type": "text", + "text": "x".repeat(MAX_TEXT_CHARS + 1), + }) + .to_string(), + ), + ("oversized-image", jpeg_payload(MAX_IMAGE_DIMENSION + 1, 1)), + ] { + let signature = URL_SAFE_NO_PAD.encode( + signing_key + .sign(&interaction_bytes(interaction_id, "target", &payload)) + .to_bytes(), + ); + assert_eq!( + relay( + &clients, + &public_key, + connection_id, + interaction_id, + "target", + payload, + &signature, + ) + .await, + Some(InteractionDeliveryStatus::Rejected) + ); + } + assert!(target_receiver.try_recv().is_err()); + } +} diff --git a/src-server/src/network/mod.rs b/src-server/src/network/mod.rs index 2c1519e..63aa55b 100644 --- a/src-server/src/network/mod.rs +++ b/src-server/src/network/mod.rs @@ -18,6 +18,8 @@ use wyd_common::{ register_bytes, }; +mod interactions; + type Clients = Arc>>; struct Client { @@ -25,9 +27,7 @@ struct Client { key: VerifyingKey, #[allow(dead_code)] // Used when presence and profile lookup are exposed. profile: Profile, - #[allow(dead_code)] // Used when friend-authorized message routing is added. friends: Vec, - #[allow(dead_code)] // Used when server-side message routing is added. sender: mpsc::Sender, } @@ -112,6 +112,31 @@ async fn connected(mut socket: WebSocket, clients: Clients) { break; } } + Ok(ClientMessage::Interaction { + interaction_id, + recipient_id, + payload, + signature, + }) => { + let Some(status) = interactions::relay( + &clients, + &public_key, + connection_id, + &interaction_id, + &recipient_id, + payload, + &signature, + ).await else { + break; + }; + let response = ServerMessage::InteractionDelivery { + interaction_id, + status, + }; + if writer.send(Message::Text(serde_json::to_string(&response).unwrap().into())).await.is_err() { + break; + } + } Ok(ClientMessage::ProfileUpdated { profile, signature }) => { if !update_profile(&clients, &public_key, connection_id, profile, &signature).await { break; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8aac7d3..6237fec 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,10 +19,12 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "unstable"] } +tauri-plugin-dialog = "2" tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22.1" +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "webp"] } lazy_static = "1.5.0" ed25519-dalek = "3.0.0" getrandom = "0.4.3" @@ -35,13 +37,13 @@ tauri-specta = { version = "2.0.0-rc.21", features = ["derive", "typescript"] } specta = "2.0.0-rc.21" specta-typescript = { version = "0.0.9" } uuid = { version = "1", features = ["v4"] } +webp = { version = "0.3.1", default-features = false } wyd-common = { path = "../src-common" } [dev-dependencies] tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] } [target.'cfg(target_os = "windows")'.dependencies] -image = { version = "0.25", default-features = false, features = ["png"] } windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_Globalization", diff --git a/src-tauri/src/images.rs b/src-tauri/src/images.rs new file mode 100644 index 0000000..5255de5 --- /dev/null +++ b/src-tauri/src/images.rs @@ -0,0 +1,220 @@ +use std::io::Cursor; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; +use image::{DynamicImage, ImageReader, Limits, Rgb, RgbImage}; +use tauri::{AppHandle, State}; +use tauri_plugin_dialog::DialogExt; +use wyd_common::{InteractionContent, MAX_IMAGE_B64_SIZE, MAX_IMAGE_DIMENSION}; + +use crate::network::Network; + +const MAX_SOURCE_BYTES: usize = 25 * 1024 * 1024; +const MAX_SOURCE_DIMENSION: u32 = 16_384; +const MAX_DECODE_ALLOC: u64 = 128 * 1024 * 1024; +const SCALES: [f32; 3] = [1.0, 0.8, 0.65]; +const JPEG_QUALITIES: [u8; 4] = [50, 38, 26, 18]; + +#[derive(Debug, Clone)] +struct EncodedImage { + media_type: String, + data: String, +} + +#[tauri::command] +#[specta::specta] +pub async fn pick_and_send_image( + recipient_id: String, + app: AppHandle, + network: State<'_, Network>, +) -> Result { + let Some(file) = app + .dialog() + .file() + .add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"]) + .blocking_pick_file() + else { + return Ok(false); + }; + let path = file + .as_path() + .ok_or_else(|| "The selected image is not a local file".to_owned())? + .to_owned(); + let encoded = tauri::async_runtime::spawn_blocking(move || compress_image_file(&path)) + .await + .map_err(|error| format!("Image compression task failed: {error}"))??; + + send_encoded_image(&network, recipient_id, encoded).await?; + Ok(true) +} + +#[tauri::command] +#[specta::specta] +pub async fn send_image_bytes( + recipient_id: String, + bytes: Vec, + network: State<'_, Network>, +) -> Result<(), String> { + let encoded = tauri::async_runtime::spawn_blocking(move || compress_image_bytes(&bytes)) + .await + .map_err(|error| format!("Image compression task failed: {error}"))??; + send_encoded_image(&network, recipient_id, encoded).await +} + +async fn send_encoded_image( + network: &Network, + recipient_id: String, + encoded: EncodedImage, +) -> Result<(), String> { + network + .send_interaction( + recipient_id, + InteractionContent::Image { + media_type: encoded.media_type, + data: encoded.data, + }, + ) + .await +} + +fn compress_image_file(path: &std::path::Path) -> Result { + let metadata = + std::fs::metadata(path).map_err(|_| "The selected image could not be read".to_owned())?; + if metadata.len() > MAX_SOURCE_BYTES as u64 { + return Err(format!( + "The selected image must be at most {} MiB", + MAX_SOURCE_BYTES / 1024 / 1024 + )); + } + let bytes = + std::fs::read(path).map_err(|_| "The selected image could not be read".to_owned())?; + compress_image_bytes(&bytes) +} + +fn compress_image_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("Choose an image file".to_owned()); + } + if bytes.len() > MAX_SOURCE_BYTES { + return Err(format!( + "The selected image must be at most {} MiB", + MAX_SOURCE_BYTES / 1024 / 1024 + )); + } + + let mut reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|_| "The selected image could not be decoded".to_owned())?; + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_SOURCE_DIMENSION); + limits.max_image_height = Some(MAX_SOURCE_DIMENSION); + limits.max_alloc = Some(MAX_DECODE_ALLOC); + reader.limits(limits); + let source = reader + .decode() + .map_err(|_| "The selected image could not be decoded".to_owned())?; + let source = composite_onto_white(source); + let (width, height) = scaled_dimensions(source.width(), source.height()); + + for scale in SCALES { + let width = ((width as f32 * scale).round() as u32).max(1); + let height = ((height as f32 * scale).round() as u32).max(1); + let resized = image::imageops::resize(&source, width, height, FilterType::Triangle); + + for quality in JPEG_QUALITIES { + let encoded = + webp::Encoder::from_rgb(resized.as_raw(), width, height).encode(quality as f32); + let data = STANDARD.encode(&*encoded); + if data.len() <= MAX_IMAGE_B64_SIZE { + return Ok(EncodedImage { + media_type: "image/webp".to_owned(), + data, + }); + } + } + + for quality in JPEG_QUALITIES { + let mut encoded = Vec::new(); + JpegEncoder::new_with_quality(&mut encoded, quality) + .encode_image(&resized) + .map_err(|_| "Image compression failed".to_owned())?; + let data = STANDARD.encode(encoded); + if data.len() <= MAX_IMAGE_B64_SIZE { + return Ok(EncodedImage { + media_type: "image/jpeg".to_owned(), + data, + }); + } + } + } + + Err("Image is still too detailed after compression".to_owned()) +} + +fn scaled_dimensions(width: u32, height: u32) -> (u32, u32) { + let largest = width.max(height); + if largest <= MAX_IMAGE_DIMENSION { + return (width, height); + } + + let scale = MAX_IMAGE_DIMENSION as f64 / largest as f64; + ( + ((width as f64 * scale).round() as u32).max(1), + ((height as f64 * scale).round() as u32).max(1), + ) +} + +fn composite_onto_white(image: DynamicImage) -> RgbImage { + let rgba = image.into_rgba8(); + RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| { + let pixel = rgba.get_pixel(x, y).0; + let alpha = pixel[3] as u16; + let blend = |channel: u8| ((channel as u16 * alpha + 255 * (255 - alpha)) / 255) as u8; + Rgb([blend(pixel[0]), blend(pixel[1]), blend(pixel[2])]) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{ImageFormat, Rgba, RgbaImage}; + + fn png_bytes(width: u32, height: u32) -> Vec { + let image = RgbaImage::from_pixel(width, height, Rgba([20, 80, 160, 128])); + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut bytes, ImageFormat::Png) + .unwrap(); + bytes.into_inner() + } + + #[test] + fn compresses_and_limits_image_payload() { + let compressed = compress_image_bytes(&png_bytes(900, 600)).unwrap(); + + assert_eq!(compressed.media_type, "image/webp"); + assert!(!compressed.data.is_empty()); + assert!(compressed.data.len() <= MAX_IMAGE_B64_SIZE); + assert!(STANDARD.decode(compressed.data).is_ok()); + } + + #[test] + fn rejects_empty_and_invalid_input() { + assert_eq!( + compress_image_bytes(&[]).unwrap_err(), + "Choose an image file" + ); + assert_eq!( + compress_image_bytes(b"not an image").unwrap_err(), + "The selected image could not be decoded" + ); + } + + #[test] + fn preserves_aspect_ratio_within_dimension_limit() { + assert_eq!(scaled_dimensions(960, 480), (480, 240)); + assert_eq!(scaled_dimensions(120, 80), (120, 80)); + } +} diff --git a/src-tauri/src/interactions/mod.rs b/src-tauri/src/interactions/mod.rs new file mode 100644 index 0000000..5be2322 --- /dev/null +++ b/src-tauri/src/interactions/mod.rs @@ -0,0 +1,172 @@ +use std::collections::{HashSet, VecDeque}; +use std::sync::Mutex; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Manager, State}; +use tauri_specta::Event; +use wyd_common::InteractionContent; + +use crate::network::Network; + +const SEEN_INTERACTION_LIMIT: usize = 256; + +fn validate_content(content: &InteractionContent) -> Result<(), String> { + content.validate()?; + if let InteractionContent::Image { data, .. } = content { + STANDARD + .decode(data) + .map_err(|_| "Image contains invalid Base64 data".to_owned())?; + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct FriendInteractionReceived { + pub interaction_id: String, + pub friend_id: String, + pub content: InteractionContent, +} + +#[derive(Default)] +struct SeenInteractions { + ids: HashSet, + order: VecDeque, +} + +impl SeenInteractions { + fn insert(&mut self, id: String) -> bool { + if !self.ids.insert(id.clone()) { + return false; + } + self.order.push_back(id); + if self.order.len() > SEEN_INTERACTION_LIMIT + && let Some(oldest) = self.order.pop_front() + { + self.ids.remove(&oldest); + } + true + } +} + +#[derive(Default)] +pub struct InteractionState(Mutex); + +pub(crate) fn receive( + handle: &AppHandle, + interaction_id: String, + friend_id: String, + payload: &str, +) { + let Ok(content) = serde_json::from_str::(payload) else { + eprintln!("failed to decode friend interaction"); + return; + }; + if let Err(error) = validate_content(&content) { + eprintln!("rejected friend interaction: {error}"); + return; + } + + let dedupe_id = format!("{friend_id}\0{interaction_id}"); + let state = handle.state::(); + let Ok(mut seen) = state.0.lock() else { + eprintln!("failed to lock received interaction state"); + return; + }; + if !seen.insert(dedupe_id) { + return; + } + drop(seen); + + if let Err(error) = (FriendInteractionReceived { + interaction_id, + friend_id, + content, + }) + .emit(handle) + { + eprintln!("failed to emit friend interaction: {error}"); + } +} + +#[tauri::command] +#[specta::specta] +pub async fn send_interaction( + recipient_id: String, + content: InteractionContent, + network: State<'_, Network>, +) -> Result<(), String> { + validate_content(&content)?; + network.send_interaction(recipient_id, content).await +} + +#[cfg(test)] +mod tests { + use super::*; + use wyd_common::MAX_TEXT_CHARS; + + #[test] + fn validates_interaction_content_limits() { + assert!(validate_content(&InteractionContent::Wave).is_ok()); + assert!( + InteractionContent::Text { + text: "hello".to_owned() + } + .validate() + .is_ok() + ); + assert!( + InteractionContent::Text { + text: " ".to_owned() + } + .validate() + .is_err() + ); + assert!( + InteractionContent::Text { + text: "x".repeat(MAX_TEXT_CHARS + 1) + } + .validate() + .is_err() + ); + assert!( + InteractionContent::Image { + media_type: "image/png".to_owned(), + data: STANDARD.encode([1, 2, 3]), + } + .validate() + .is_err() + ); + } + + #[test] + fn image_media_type_uses_the_typescript_wire_name() { + let content = InteractionContent::Image { + media_type: "image/webp".to_owned(), + data: STANDARD.encode([1, 2, 3]), + }; + let value = serde_json::to_value(&content).unwrap(); + + assert_eq!(value["mediaType"], "image/webp"); + assert!(value.get("media_type").is_none()); + assert!(matches!( + serde_json::from_value(value).unwrap(), + InteractionContent::Image { media_type, .. } if media_type == "image/webp" + )); + } + + #[test] + fn received_interaction_ids_are_bounded_and_deduplicated() { + let mut seen = SeenInteractions::default(); + assert!(seen.insert("first".to_owned())); + assert!(!seen.insert("first".to_owned())); + for index in 0..SEEN_INTERACTION_LIMIT { + assert!(seen.insert(format!("id-{index}"))); + } + assert_eq!(seen.ids.len(), SEEN_INTERACTION_LIMIT); + assert!(seen.insert("first".to_owned())); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 547bab0..a7e940c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ mod cursor; mod db; mod friends; +mod images; +mod interactions; mod keypair; mod live_data; mod network; @@ -16,6 +18,7 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box> let handle = app.handle(); db::init(handle).await?; keypair::init(handle).await?; + app.manage(interactions::InteractionState::default()); network::init(handle).await?; app.manage(ufa::ForegroundAppState::default()); app.manage(cursor::CursorState::default()); @@ -38,6 +41,7 @@ pub fn run() { .expect("Failed to export TypeScript bindings"); tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .invoke_handler(specta_builder.invoke_handler()) .setup(move |app| { @@ -67,6 +71,9 @@ fn specta_builder() -> tauri_specta::Builder { profile::update_profile, keypair::get_public_key, network::list_statuses, + images::pick_and_send_image, + images::send_image_bytes, + interactions::send_interaction, ui::scene::update_scene_hitboxes, ]) .events(tauri_specta::collect_events![ @@ -77,6 +84,7 @@ fn specta_builder() -> tauri_specta::Builder { cursor::CursorPositionChanged, ufa::ForegroundAppChanged, ufa::FriendForegroundAppChanged, + interactions::FriendInteractionReceived, ]) } diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index 971a97a..0332a13 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -3,20 +3,22 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; +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, watch}; +use tokio::sync::{mpsc, oneshot, watch}; use tokio_tungstenite::tungstenite::Message; use wyd_common::{ - ClientMessage, Profile, ServerMessage, friends_bytes, message_bytes, profile_bytes, - register_bytes, + ClientMessage, InteractionContent, InteractionDeliveryStatus, Profile, ServerMessage, + friends_bytes, interaction_bytes, message_bytes, profile_bytes, register_bytes, }; 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}; @@ -49,10 +51,18 @@ pub struct NetworkStatusChanged { struct Connection { remote: Remote, sender: mpsc::Sender, + interaction_sender: mpsc::Sender, task: tauri::async_runtime::JoinHandle<()>, generation: u64, } +struct InteractionRequest { + interaction_id: String, + recipient_id: String, + payload: String, + response: oneshot::Sender, +} + pub struct Network { connections: Mutex>, statuses: Statuses, @@ -86,6 +96,66 @@ impl Network { self.profile.send_replace(profile); } + pub async fn send_interaction( + &self, + recipient_id: String, + content: InteractionContent, + ) -> Result<(), String> { + if recipient_id == self.keypair.public_key() { + return Err("Interactions can only be sent to a friend".to_owned()); + } + let payload = serde_json::to_string(&content).map_err(|error| error.to_string())?; + let interaction_id = uuid::Uuid::new_v4().to_string(); + let senders: Vec<_> = self + .connections + .lock() + .map_err(|error| error.to_string())? + .values() + .map(|connection| connection.interaction_sender.clone()) + .collect(); + if senders.is_empty() { + return Err("No relay connections are configured".to_owned()); + } + + let mut responses = Vec::new(); + for sender in senders { + let (response, receiver) = oneshot::channel(); + let request = InteractionRequest { + interaction_id: interaction_id.clone(), + recipient_id: recipient_id.clone(), + payload: payload.clone(), + response, + }; + if sender.try_send(request).is_ok() { + responses.push(receiver); + } + } + if responses.is_empty() { + return Err("Relay connections are busy or disconnected".to_owned()); + } + + let mut pending: FuturesUnordered<_> = responses + .into_iter() + .map(|response| tokio::time::timeout(Duration::from_secs(5), response)) + .collect(); + let mut statuses = Vec::new(); + while let Some(result) = pending.next().await { + if let Ok(Ok(status)) = result { + if status == InteractionDeliveryStatus::Delivered { + return Ok(()); + } + statuses.push(status); + } + } + if statuses.contains(&InteractionDeliveryStatus::Busy) { + return Err("Friend is busy; try again".to_owned()); + } + if statuses.contains(&InteractionDeliveryStatus::Rejected) { + return Err("Relay rejected the interaction".to_owned()); + } + Err("Friend is no longer available".to_owned()) + } + fn sync_remotes(&self, handle: &AppHandle, remotes: Vec) -> Result<(), String> { let desired: HashMap<_, _> = remotes .into_iter() @@ -113,6 +183,7 @@ impl Network { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); let (sender, receiver) = mpsc::channel(32); + let (interaction_sender, interaction_receiver) = mpsc::channel(16); set_initial( &self.statuses, &remote, @@ -128,12 +199,14 @@ impl Network { self.friends.subscribe(), self.keypair.clone(), receiver, + interaction_receiver, )); connections.insert( remote.id.clone(), Connection { remote, sender, + interaction_sender, task, generation, }, @@ -195,6 +268,7 @@ async fn run( mut friends: watch::Receiver>, keypair: AppKeypair, mut outgoing: mpsc::Receiver, + mut active_outgoing: mpsc::Receiver, ) { loop { changed( @@ -213,6 +287,7 @@ async fn run( &mut friends, &keypair, &mut outgoing, + &mut active_outgoing, ) .await { @@ -238,6 +313,7 @@ async fn connect( friends: &mut watch::Receiver>, keypair: &AppKeypair, outgoing: &mut mpsc::Receiver, + active_outgoing: &mut mpsc::Receiver, ) -> Result<(), Box> { let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?; let (mut writer, mut reader) = socket.split(); @@ -272,6 +348,11 @@ async fn connect( return Err("server rejected registration".into()); } while outgoing.try_recv().is_ok() {} + while let Ok(request) = active_outgoing.try_recv() { + let _ = request + .response + .send(InteractionDeliveryStatus::Unavailable); + } send(&mut writer, &ClientMessage::SyncFriendProfiles).await?; changed( handle, @@ -281,6 +362,7 @@ async fn connect( ConnectionState::Connected, ); + let mut pending_interactions = HashMap::new(); loop { tokio::select! { payload = outgoing.recv() => { @@ -290,6 +372,21 @@ async fn connect( 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); + } changed = profiles.changed() => { changed.map_err(|_| "profile sender closed")?; let current = profiles.borrow_and_update().clone(); @@ -337,6 +434,18 @@ async fn connect( } } } + 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); + } + } _ => {} }, Message::Ping(data) => writer.send(Message::Pong(data)).await?, diff --git a/src-tauri/src/ufa/macos.rs b/src-tauri/src/ufa/macos.rs index df15160..63020b2 100644 --- a/src-tauri/src/ufa/macos.rs +++ b/src-tauri/src/ufa/macos.rs @@ -1,6 +1,6 @@ use super::types::AppMeta; -use base64::engine::general_purpose::STANDARD; use base64::Engine; +use base64::engine::general_purpose::STANDARD; use lazy_static::lazy_static; use objc2::runtime::{AnyClass, AnyObject, Sel}; use objc2::{class, msg_send, sel}; diff --git a/src-tauri/src/ufa/windows.rs b/src-tauri/src/ufa/windows.rs index 4398e00..1665cc9 100644 --- a/src-tauri/src/ufa/windows.rs +++ b/src-tauri/src/ufa/windows.rs @@ -4,16 +4,16 @@ use std::iter; use std::os::windows::ffi::OsStringExt; use std::path::Path; use std::ptr; -use windows::core::PCWSTR; use windows::Win32::Foundation::HWND; use windows::Win32::Globalization::GetUserDefaultLangID; use windows::Win32::System::ProcessStatus::GetModuleFileNameExW; use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; -use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; +use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook, UnhookWinEvent}; use windows::Win32::UI::WindowsAndMessaging::{ - DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowTextW, GetWindowThreadProcessId, - EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT, + DispatchMessageW, EVENT_SYSTEM_FOREGROUND, GetForegroundWindow, GetMessageW, GetWindowTextW, + GetWindowThreadProcessId, MSG, WINEVENT_OUTOFCONTEXT, }; +use windows::core::PCWSTR; pub fn listen_for_active_app_changes(callback: F) where @@ -286,13 +286,13 @@ impl Drop for IconHandle { } fn get_active_app_icon_b64(exe_path: &str) -> Option { - use base64::engine::general_purpose::STANDARD; use base64::Engine; + use base64::engine::general_purpose::STANDARD; use windows::Win32::Graphics::Gdi::{ - CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, GetObjectW, - SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + BI_RGB, BITMAP, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC, + DIB_RGB_COLORS, DeleteDC, DeleteObject, GetDIBits, GetObjectW, SelectObject, }; - use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON}; + use windows::Win32::UI::Shell::{SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGetFileInfoW}; use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO}; // Check cache first diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 7e5786b..26fec50 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -44,6 +44,15 @@ async getPublicKey() : Promise { async listStatuses() : Promise { return await TAURI_INVOKE("list_statuses"); }, +async pickAndSendImage(recipientId: string) : Promise { + return await TAURI_INVOKE("pick_and_send_image", { recipientId }); +}, +async sendImageBytes(recipientId: string, bytes: number[]) : Promise { + return await TAURI_INVOKE("send_image_bytes", { recipientId, bytes }); +}, +async sendInteraction(recipientId: string, content: InteractionContent) : Promise { + return await TAURI_INVOKE("send_interaction", { recipientId, content }); +}, async updateSceneHitboxes(hitboxes: SceneHitbox[]) : Promise { return await TAURI_INVOKE("update_scene_hitboxes", { hitboxes }); } @@ -56,6 +65,7 @@ export const events = __makeEvents__<{ cursorPositionChanged: CursorPositionChanged, foregroundAppChanged: ForegroundAppChanged, friendForegroundAppChanged: FriendForegroundAppChanged, +friendInteractionReceived: FriendInteractionReceived, friendsChanged: FriendsChanged, networkStatusChanged: NetworkStatusChanged, profileChanged: ProfileChanged, @@ -64,6 +74,7 @@ remotesChanged: RemotesChanged cursorPositionChanged: "cursor-position-changed", foregroundAppChanged: "foreground-app-changed", friendForegroundAppChanged: "friend-foreground-app-changed", +friendInteractionReceived: "friend-interaction-received", friendsChanged: "friends-changed", networkStatusChanged: "network-status-changed", profileChanged: "profile-changed", @@ -95,7 +106,9 @@ raw: CursorPosition; mapped: CursorPosition } export type ForegroundAppChanged = { meta: AppMeta } export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta } +export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent } export type FriendsChanged = { friends: User[] } +export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string } export type NetworkStatusChanged = { statuses: ConnectionStatus[] } export type ProfileChanged = { profile: User } export type Remote = { id: string; address: string; name: string | null; port: number | null } diff --git a/src/lib/components/popover.svelte b/src/lib/components/popover.svelte index 938f04f..f4bc927 100644 --- a/src/lib/components/popover.svelte +++ b/src/lib/components/popover.svelte @@ -15,6 +15,9 @@ triggerClass?: string; panelClass?: string; panelStyle?: string; + closeOnOutsidePointer?: boolean; + closeOnWindowBlur?: boolean; + closeOnFocusOut?: boolean; }; let { @@ -29,6 +32,9 @@ triggerClass = "", panelClass = "", panelStyle, + closeOnOutsidePointer: dismissOnOutsidePointer = true, + closeOnWindowBlur: dismissOnWindowBlur = true, + closeOnFocusOut: dismissOnFocusOut = true, }: Props = $props(); let root = $state(); @@ -57,6 +63,7 @@ function closeOnOutsidePointer(event: PointerEvent) { if ( open && + dismissOnOutsidePointer && root && event.target instanceof Node && !root.contains(event.target) @@ -68,6 +75,7 @@ function closeOnFocusOut(event: FocusEvent) { if ( open && + dismissOnFocusOut && root && (!(event.relatedTarget instanceof Node) || !root.contains(event.relatedTarget)) @@ -77,7 +85,7 @@ } function closeOnWindowBlur() { - if (open) setOpen(false); + if (open && dismissOnWindowBlur) setOpen(false); } diff --git a/src/lib/listeners/index.ts b/src/lib/listeners/index.ts index 85a0321..5d90fb7 100644 --- a/src/lib/listeners/index.ts +++ b/src/lib/listeners/index.ts @@ -1,5 +1,6 @@ import { initConnectionStatusesListener } from "./connection-status"; import { initFriendsListener } from "./friends"; +import { initInteractionListener } from "./interactions"; import { initLiveMetadataListeners } from "./live-metadata"; import { initProfileListener } from "./profile"; import { initRemotesListener } from "./remotes"; @@ -16,6 +17,7 @@ export function initAppListeners(): Unlisten { initProfileListener(), initConnectionStatusesListener(), initLiveMetadataListeners(), + initInteractionListener(), ]) .then((results) => { const listeners = results.flatMap((result) => diff --git a/src/lib/listeners/interactions.ts b/src/lib/listeners/interactions.ts new file mode 100644 index 0000000..c7c385a --- /dev/null +++ b/src/lib/listeners/interactions.ts @@ -0,0 +1,12 @@ +import { writable } from "svelte/store"; +import { events, type FriendInteractionReceived } from "$lib/bindings"; + +export const incomingInteraction = writable( + null, +); + +export async function initInteractionListener() { + return events.friendInteractionReceived.listen((event) => { + incomingInteraction.set(event.payload); + }); +} diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index f5e6a21..6f43823 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -1,22 +1,55 @@ + + event.key === "Escape" && onClose()} /> + + diff --git a/src/routes/scene/popovers/interaction-bubble.svelte b/src/routes/scene/popovers/interaction-bubble.svelte new file mode 100644 index 0000000..bf86de0 --- /dev/null +++ b/src/routes/scene/popovers/interaction-bubble.svelte @@ -0,0 +1,86 @@ + + + + + diff --git a/src/routes/scene/popovers/user-interaction.svelte b/src/routes/scene/popovers/user-interaction.svelte new file mode 100644 index 0000000..5960287 --- /dev/null +++ b/src/routes/scene/popovers/user-interaction.svelte @@ -0,0 +1,236 @@ + + +
+
+

{username}

+
+ +
+ {#if mode !== null} + + {/if} + +
+

Currently enjoying

+
+ {#if foregroundApp?.ico} + + {/if} +
+

+ {foregroundApp?.local ?? + foregroundApp?.unlocal ?? + "Waiting for data"} +

+ {#if foregroundApp?.local && foregroundApp.unlocal} +

+ {foregroundApp.unlocal} +

+ {/if} +
+
+
+ + {#if !isLocal} +
+
+ + + +
+ + {#if mode === "message"} +
{ + event.preventDefault(); + void sendMessage(); + }} + > + +
+ + {message.length}/500 + + +
+
+ {:else if mode === "image"} +
+
+

Choose or paste an image

+

+ Compressed to 480 px · 150 KiB +

+ +
+
+ {/if} + + {#if error} + + {/if} +
+ {/if} +
+
diff --git a/src/routes/scene/scene-user-popover-content.svelte b/src/routes/scene/scene-user-popover-content.svelte deleted file mode 100644 index 10d69b3..0000000 --- a/src/routes/scene/scene-user-popover-content.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - -
-
-
-

{username}

-
-
- -
-
-

Currently enjoying

-
- {#if foregroundApp?.ico} - - {/if} -
-

- {foregroundApp?.local ?? - foregroundApp?.unlocal ?? - "Waiting for data"} -

- {#if foregroundApp?.local && foregroundApp.unlocal} -

- {foregroundApp.unlocal} -

- {/if} -
-
-
-
-