messages, wavings and images
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user