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
+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