messages, wavings and images

This commit is contained in:
2026-08-26 00:59:31 +08:00
parent 5d7d294a80
commit 74a5268420
22 changed files with 1779 additions and 89 deletions
Generated
+235 -1
View File
@@ -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"
+4
View File
@@ -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"
+133 -4
View File
@@ -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<Profile> },
FriendLiveData { friend_id: String, payload: String },
FriendProfileUpdated {
profile: Profile,
},
FriendProfiles {
profiles: Vec<Profile>,
},
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<u8> {
@@ -56,6 +135,19 @@ pub fn message_bytes(payload: &str) -> Vec<u8> {
format!("wyd-message-v{VERSION}\n{payload}").into_bytes()
}
pub fn interaction_bytes(interaction_id: &str, recipient_id: &str, payload: &str) -> Vec<u8> {
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<u8> {
format!(
"wyd-profile-v{VERSION}\n{}\n{}",
@@ -67,3 +159,40 @@ pub fn profile_bytes(profile: &Profile) -> Vec<u8> {
pub fn friends_bytes(friends: &[String]) -> Vec<u8> {
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::<InteractionContent>(r#"{"type":"unknown"}"#).is_err());
}
}
+1
View File
@@ -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" }
+372
View File
@@ -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<InteractionDeliveryStatus> {
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::<InteractionContent>(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());
}
}
+27 -2
View File
@@ -18,6 +18,8 @@ use wyd_common::{
register_bytes,
};
mod interactions;
type Clients = Arc<Mutex<HashMap<String, Client>>>;
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<String>,
#[allow(dead_code)] // Used when server-side message routing is added.
sender: mpsc::Sender<Message>,
}
@@ -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;
+3 -1
View File
@@ -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",
+220
View File
@@ -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<bool, String> {
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<u8>,
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<EncodedImage, String> {
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<EncodedImage, String> {
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<u8> {
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));
}
}
+172
View File
@@ -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<String>,
order: VecDeque<String>,
}
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<SeenInteractions>);
pub(crate) fn receive(
handle: &AppHandle,
interaction_id: String,
friend_id: String,
payload: &str,
) {
let Ok(content) = serde_json::from_str::<InteractionContent>(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::<InteractionState>();
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()));
}
}
+8
View File
@@ -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<dyn std::error::Error>>
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<tauri::Wry> {
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<tauri::Wry> {
cursor::CursorPositionChanged,
ufa::ForegroundAppChanged,
ufa::FriendForegroundAppChanged,
interactions::FriendInteractionReceived,
])
}
+112 -3
View File
@@ -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<String>,
interaction_sender: mpsc::Sender<InteractionRequest>,
task: tauri::async_runtime::JoinHandle<()>,
generation: u64,
}
struct InteractionRequest {
interaction_id: String,
recipient_id: String,
payload: String,
response: oneshot::Sender<InteractionDeliveryStatus>,
}
pub struct Network {
connections: Mutex<HashMap<String, Connection>>,
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<Remote>) -> 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<Vec<String>>,
keypair: AppKeypair,
mut outgoing: mpsc::Receiver<String>,
mut active_outgoing: mpsc::Receiver<InteractionRequest>,
) {
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<Vec<String>>,
keypair: &AppKeypair,
outgoing: &mut mpsc::Receiver<String>,
active_outgoing: &mut mpsc::Receiver<InteractionRequest>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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?,
+1 -1
View File
@@ -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};
+8 -8
View File
@@ -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<F>(callback: F)
where
@@ -286,13 +286,13 @@ impl Drop for IconHandle {
}
fn get_active_app_icon_b64(exe_path: &str) -> Option<String> {
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
+13
View File
@@ -44,6 +44,15 @@ async getPublicKey() : Promise<string> {
async listStatuses() : Promise<ConnectionStatus[]> {
return await TAURI_INVOKE("list_statuses");
},
async pickAndSendImage(recipientId: string) : Promise<boolean> {
return await TAURI_INVOKE("pick_and_send_image", { recipientId });
},
async sendImageBytes(recipientId: string, bytes: number[]) : Promise<null> {
return await TAURI_INVOKE("send_image_bytes", { recipientId, bytes });
},
async sendInteraction(recipientId: string, content: InteractionContent) : Promise<null> {
return await TAURI_INVOKE("send_interaction", { recipientId, content });
},
async updateSceneHitboxes(hitboxes: SceneHitbox[]) : Promise<null> {
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 }
+9 -1
View File
@@ -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<HTMLDivElement>();
@@ -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);
}
</script>
+2
View File
@@ -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) =>
+12
View File
@@ -0,0 +1,12 @@
import { writable } from "svelte/store";
import { events, type FriendInteractionReceived } from "$lib/bindings";
export const incomingInteraction = writable<FriendInteractionReceived | null>(
null,
);
export async function initInteractionListener() {
return events.friendInteractionReceived.listen((event) => {
incomingInteraction.set(event.payload);
});
}
+73 -4
View File
@@ -1,22 +1,55 @@
<script lang="ts">
import { onMount } from "svelte";
import Popover from "$lib/components/popover.svelte";
import { friends } from "$lib/listeners/friends";
import { incomingInteraction } from "$lib/listeners/interactions";
import { liveMetadata } from "$lib/listeners/live-metadata";
import SceneUserPopoverContent from "./scene-user-popover-content.svelte";
import { profile } from "$lib/listeners/profile";
import SceneImageViewer from "./popovers/image-viewer.svelte";
import SceneInteractionBubble from "./popovers/interaction-bubble.svelte";
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
import { startHitboxSync } from "./hitboxes";
let selectedUserId: string | null = null;
let selectedUserX: number | null = null;
let selectedUserId = $state<string | null>(null);
let selectedUserX = $state<number | null>(null);
let lockedPopoverUserId = $state<string | null>(null);
let viewedImage = $state<{ source: string; senderName: string } | null>(null);
onMount(startHitboxSync);
$effect(() => {
const current = $incomingInteraction;
if (!current) return;
const duration = current.content.type === "wave" ? 4_000 : 10_000;
const timer = window.setTimeout(
() => dismissInteraction(current.interactionId),
duration,
);
return () => window.clearTimeout(timer);
});
function displayName(userId: string) {
return $profile?.id === userId
? $profile.displayName
: ($friends.find((friend) => friend.id === userId)?.displayName ??
"Unknown user");
}
function dismissInteraction(interactionId: string) {
incomingInteraction.update((current) =>
current?.interactionId === interactionId ? null : current,
);
}
function setPopoverOpen(
userId: string,
cursorX: number,
open: boolean,
trigger: HTMLButtonElement,
) {
if (open && lockedPopoverUserId && lockedPopoverUserId !== userId) return;
selectedUserId = open ? userId : null;
if (!open && lockedPopoverUserId === userId) lockedPopoverUserId = null;
selectedUserX = open
? window.innerWidth > 0
? trigger.getBoundingClientRect().left / window.innerWidth
@@ -30,6 +63,8 @@
{#each Object.entries($liveMetadata.cursorPositions) as [userId, cursor], index (userId)}
{@const foregroundApp = $liveMetadata.foregroundApps.get(userId)}
{@const popoverId = `scene-user-${index}`}
{@const interaction =
$incomingInteraction?.friendId === userId ? $incomingInteraction : null}
{#if cursor}
{@const renderedX =
selectedUserId === userId ? selectedUserX : cursor.mapped.x}
@@ -37,6 +72,18 @@
class="absolute bottom-0 left-0 flex flex-col items-center gap-1 transition-transform duration-1000 ease-linear"
style:transform={`translateX(${(renderedX ?? cursor.mapped.x) * 100}vw)`}
>
{#if interaction}
<SceneInteractionBubble
{interaction}
senderName={displayName(userId)}
onDismiss={() => dismissInteraction(interaction.interactionId)}
onOpenImage={(source) => {
viewedImage = { source, senderName: displayName(userId) };
dismissInteraction(interaction.interactionId);
}}
/>
{/if}
{#if foregroundApp?.ico}
<img
src={`data:image/png;base64,${foregroundApp.ico}`}
@@ -55,6 +102,9 @@
triggerClass="scene-hitbox"
panelClass="scene-hitbox bottom-[calc(100%+0.75rem)] w-68"
panelStyle={`left: clamp(calc(0.5rem - ${(renderedX ?? cursor.mapped.x) * 100}vw), -8rem, calc(100vw - ${(renderedX ?? cursor.mapped.x) * 100}vw - 17.5rem))`}
closeOnOutsidePointer={lockedPopoverUserId !== userId}
closeOnWindowBlur={lockedPopoverUserId !== userId}
closeOnFocusOut={lockedPopoverUserId !== userId}
>
{#snippet trigger()}
<img
@@ -68,8 +118,19 @@
titleId={`${popoverId}-title`}
{userId}
isLocal={userId === $liveMetadata.localId}
{cursor}
{foregroundApp}
onModeChange={(active) =>
(lockedPopoverUserId = active ? userId : null)}
onDismiss={() => {
selectedUserId = null;
selectedUserX = null;
lockedPopoverUserId = null;
}}
onSent={() => {
selectedUserId = null;
selectedUserX = null;
lockedPopoverUserId = null;
}}
/>
</Popover>
</div>
@@ -77,3 +138,11 @@
{/each}
</div>
</div>
{#if viewedImage}
<SceneImageViewer
source={viewedImage.source}
senderName={viewedImage.senderName}
onClose={() => (viewedImage = null)}
/>
{/if}
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, scale } from "svelte/transition";
type Props = {
source: string;
senderName: string;
onClose: () => void;
};
let { source, senderName, onClose }: Props = $props();
let closeButton: HTMLButtonElement;
onMount(() => closeButton.focus({ preventScroll: true }));
</script>
<svelte:window onkeydown={(event) => event.key === "Escape" && onClose()} />
<div
class="scene-hitbox fixed inset-0 z-50 grid place-items-center bg-black/55 p-4 backdrop-blur-sm"
role="presentation"
onclick={(event) => event.currentTarget === event.target && onClose()}
transition:fade={{ duration: 150 }}
>
<div
class="relative grid max-h-full max-w-full place-items-center border border-white/20 bg-black/70 p-2 shadow-2xl"
role="dialog"
aria-modal="true"
aria-label={`Image from ${senderName}`}
transition:scale={{ start: 0.96, duration: 180 }}
>
<img
src={source}
alt={`Image sent by ${senderName}`}
class="max-h-[calc(100vh-3rem)] max-w-[calc(100vw-3rem)] object-contain"
/>
<div
class="absolute right-1 top-1 flex items-center gap-2 bg-black/70 px-1 py-1 text-white"
>
<span class="max-w-48 truncate pl-1 text-[0.65rem]">{senderName}</span>
<button
bind:this={closeButton}
type="button"
class="grid size-6 place-items-center text-lg leading-none hover:bg-white/15 focus-visible:outline focus-visible:outline-white"
aria-label="Close image"
onclick={onClose}
>
×
</button>
</div>
</div>
</div>
@@ -0,0 +1,86 @@
<script lang="ts">
import { fly } from "svelte/transition";
import type { FriendInteractionReceived } from "$lib/bindings";
type Props = {
interaction: FriendInteractionReceived;
senderName: string;
onDismiss: () => void;
onOpenImage: (source: string) => void;
};
let { interaction, senderName, onDismiss, onOpenImage }: Props = $props();
let imageSource = $derived(
interaction.content.type === "image"
? `data:${interaction.content.mediaType};base64,${interaction.content.data}`
: "",
);
</script>
<aside
class="scene-hitbox absolute bottom-[calc(100%+0.65rem)] left-1/2 z-30 w-36 -translate-x-1/2 overflow-hidden border border-base-300 bg-base-100/95 text-base-content shadow-lg backdrop-blur-sm"
role="status"
aria-live="polite"
transition:fly={{ y: 8, duration: 180 }}
>
<header class="flex items-center gap-1 border-b border-base-200 px-2 py-1">
<span class="min-w-0 flex-1 truncate text-[0.65rem] font-medium">
{senderName}
</span>
<button
type="button"
class="grid size-5 place-items-center text-xs text-base-content/50 transition-colors hover:text-base-content"
aria-label="Dismiss interaction"
onclick={onDismiss}
>
×
</button>
</header>
{#if interaction.content.type === "wave"}
<div class="flex items-center justify-center gap-2 px-3 py-2 text-xs">
<span
class="origin-bottom-right animate-[wiggle_650ms_ease-in-out_2] text-xl"
>👋</span
>
<span>waved</span>
</div>
{:else if interaction.content.type === "text"}
<p
class="max-h-24 overflow-auto whitespace-pre-wrap wrap-break-words px-2 py-2 text-xs leading-relaxed"
>
{interaction.content.text}
</p>
{:else}
<button
type="button"
class="block w-full cursor-zoom-in bg-base-200"
aria-label={`Open image from ${senderName}`}
onclick={() => onOpenImage(imageSource)}
>
<img
src={imageSource}
alt={`Preview sent by ${senderName}`}
class="h-24 w-full object-cover"
/>
<span class="block px-2 py-1 text-[0.6rem] text-base-content/60">
Click to enlarge
</span>
</button>
{/if}
</aside>
<style>
@keyframes wiggle {
0%,
100% {
transform: rotate(0deg);
}
30% {
transform: rotate(22deg);
}
65% {
transform: rotate(-12deg);
}
}
</style>
@@ -0,0 +1,236 @@
<script lang="ts">
import {
commands,
type AppMeta,
type InteractionContent,
} from "$lib/bindings";
import { friends } from "$lib/listeners/friends";
import { profile } from "$lib/listeners/profile";
type Props = {
titleId: string;
userId: string;
isLocal: boolean;
foregroundApp?: AppMeta;
onModeChange: (active: boolean) => void;
onDismiss: () => void;
onSent: () => void;
};
let {
titleId,
userId,
isLocal,
foregroundApp,
onModeChange,
onDismiss,
onSent,
}: Props = $props();
let mode = $state<"message" | "image" | null>(null);
let message = $state("");
let error = $state("");
let sending = $state(false);
let username = $derived(
$profile?.id === userId
? $profile.displayName
: ($friends.find((friend) => friend.id === userId)?.displayName ??
"Unknown user"),
);
function selectMode(nextMode: "message" | "image") {
mode = mode === nextMode ? null : nextMode;
onModeChange(mode !== null);
error = "";
}
async function sendContent(content: InteractionContent) {
sending = true;
error = "";
try {
await commands.sendInteraction(userId, content);
message = "";
mode = null;
onSent();
} catch (cause) {
error = String(cause);
} finally {
sending = false;
}
}
async function sendMessage() {
const text = message.trim();
if (text) await sendContent({ type: "text", text });
}
async function sendImage() {
await runImageSend(() => commands.pickAndSendImage(userId));
}
async function pasteImage(event: ClipboardEvent) {
if (mode !== "image" || sending) return;
const file = Array.from(event.clipboardData?.items ?? [])
.find((item) => item.type.startsWith("image/"))
?.getAsFile();
if (!file) return;
event.preventDefault();
await runImageSend(async () => {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
await commands.sendImageBytes(userId, bytes);
return true;
});
}
async function runImageSend(send: () => Promise<boolean>) {
if (sending) return;
sending = true;
error = "";
try {
const sent = await send();
if (sent) {
mode = null;
onSent();
}
} catch (cause) {
error = String(cause);
} finally {
sending = false;
}
}
</script>
<article class="w-68 overflow-hidden text-base-content" onpaste={pasteImage}>
<header
class="ml-2 flex w-max items-center gap-1 border border-b-0 border-base-300 bg-base-200 px-2 py-0.5"
>
<p id={titleId} class="max-w-52 truncate text-xs">{username}</p>
</header>
<div
class="relative flex flex-col gap-3 border border-base-200 bg-base-100 p-2 text-sm shadow-lg"
>
{#if mode !== null}
<button
class="absolute right-1 top-1 grid size-6 place-items-center text-lg leading-none btn btn-square btn-ghost"
aria-label="Close interaction popover"
onclick={onDismiss}
>
×
</button>
{/if}
<section>
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
<div class="mt-1 flex min-w-0 items-center gap-2">
{#if foregroundApp?.ico}
<img
src={`data:image/png;base64,${foregroundApp.ico}`}
alt=""
class="size-6 shrink-0 object-contain"
/>
{/if}
<div class="min-w-0">
<p class="truncate text-sm">
{foregroundApp?.local ??
foregroundApp?.unlocal ??
"Waiting for data"}
</p>
{#if foregroundApp?.local && foregroundApp.unlocal}
<p class="truncate text-xs text-base-content/50">
{foregroundApp.unlocal}
</p>
{/if}
</div>
</div>
</section>
{#if !isLocal}
<section class="border-t border-base-200 pt-2 w-full">
<div class="grid grid-cols-3 gap-1">
<button
type="button"
class:!bg-primary={mode === "message"}
class:!text-primary-content={mode === "message"}
class="btn btn-ghost btn-xs"
aria-pressed={mode === "message"}
onclick={() => selectMode("message")}
>
Message
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
disabled={sending}
onclick={() => sendContent({ type: "wave" })}
>
Wave
</button>
<button
type="button"
class:!bg-primary={mode === "image"}
class:!text-primary-content={mode === "image"}
class="btn btn-ghost btn-xs"
aria-pressed={mode === "image"}
onclick={() => selectMode("image")}
>
Image
</button>
</div>
{#if mode === "message"}
<form
class="mt-2 grid gap-1 border-l-2 border-primary bg-base-200/55 p-2"
onsubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
>
<textarea
bind:value={message}
maxlength="500"
rows="3"
class="textarea textarea-xs w-full resize-none bg-base-100"
placeholder={`Write to ${username}`}
aria-label={`Message ${username}`}></textarea>
<div class="flex items-center justify-between gap-2">
<span class="text-[0.6rem] text-base-content/45">
{message.length}/500
</span>
<button
type="submit"
class="btn btn-primary btn-xs"
disabled={sending || !message.trim()}
>
{sending ? "Sending…" : "Send"}
</button>
</div>
</form>
{:else if mode === "image"}
<div
class="mt-2 grid min-h-24 place-items-center border border-primary/60 bg-primary/5 p-2 text-center"
>
<div>
<p class="text-xs">Choose or paste an image</p>
<p class="mt-0.5 text-[0.6rem] text-base-content/45">
Compressed to 480 px · 150 KiB
</p>
<button
type="button"
class="btn btn-primary btn-xs mt-2"
disabled={sending}
onclick={sendImage}
>
{sending ? "Compressing…" : "Choose image"}
</button>
</div>
</div>
{/if}
{#if error}
<p class="mt-2 text-xs text-error" role="alert">{error}</p>
{/if}
</section>
{/if}
</div>
</article>
@@ -1,64 +0,0 @@
<script lang="ts">
import type { AppMeta, CursorPositions } from "$lib/bindings";
import { friends } from "$lib/listeners/friends";
import { profile } from "$lib/listeners/profile";
type Props = {
titleId: string;
userId: string;
isLocal: boolean;
cursor: CursorPositions;
foregroundApp?: AppMeta;
};
let { titleId, userId, isLocal, cursor, foregroundApp }: Props = $props();
let username = $derived(
$profile?.id === userId
? $profile.displayName
: ($friends.find((friend) => friend.id === userId)?.displayName ??
"Unknown user"),
);
function compactId(id: string) {
return id.length > 18 ? `${id.slice(0, 9)}…${id.slice(-7)}` : id;
}
</script>
<article class="w-68 overflow-hidden text-base-content">
<header
class="flex w-max items-center ml-2 gap-1 px-2 py-0.5 bg-base-200 border border-base-300 border-b-0"
>
<div class="min-w-0 flex-1">
<p id={titleId} class="truncate text-xs">{username}</p>
</div>
</header>
<div
class="grid gap-3 p-2 text-sm bg-base-100 border border-base-200 shadow-lg"
>
<section>
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
<div class="mt-1 flex min-w-0 items-center gap-2">
{#if foregroundApp?.ico}
<img
src={`data:image/png;base64,${foregroundApp.ico}`}
alt=""
class="size-6 shrink-0 object-contain"
/>
{/if}
<div class="min-w-0">
<p class="truncate text-sm">
{foregroundApp?.local ??
foregroundApp?.unlocal ??
"Waiting for data"}
</p>
{#if foregroundApp?.local && foregroundApp.unlocal}
<p class="truncate text-xs text-base-content/50">
{foregroundApp.unlocal}
</p>
{/if}
</div>
</div>
</section>
</div>
</article>