From 4c0cbc9c85ac9dcefce9b0e8a9c4578ad9065c44 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Mon, 10 Aug 2026 02:07:51 +0800 Subject: [PATCH] data between friends (with some crazy fancy UI) --- src-common/src/lib.rs | 1 + src-server/src/network/mod.rs | 157 ++++++- src-tauri/src/cursor/mod.rs | 32 +- src-tauri/src/lib.rs | 3 + src-tauri/src/live_data/mod.rs | 37 ++ src-tauri/src/network/mod.rs | 42 +- src-tauri/src/ufa/mod.rs | 19 +- src/lib/bindings.ts | 6 + src/lib/components/friends-live-data.svelte | 469 ++++++++++++++++++++ src/routes/+page.svelte | 11 +- 10 files changed, 757 insertions(+), 20 deletions(-) create mode 100644 src-tauri/src/live_data/mod.rs create mode 100644 src/lib/components/friends-live-data.svelte diff --git a/src-common/src/lib.rs b/src-common/src/lib.rs index d20d1c2..e9f5d77 100644 --- a/src-common/src/lib.rs +++ b/src-common/src/lib.rs @@ -39,6 +39,7 @@ pub enum ServerMessage { Registered, FriendProfileUpdated { profile: Profile }, FriendProfiles { profiles: Vec }, + FriendLiveData { friend_id: String, payload: String }, } pub fn register_bytes(challenge: &str, profile: &Profile, friends: &[String]) -> Vec { diff --git a/src-server/src/network/mod.rs b/src-server/src/network/mod.rs index f895fa3..2c1519e 100644 --- a/src-server/src/network/mod.rs +++ b/src-server/src/network/mod.rs @@ -108,12 +108,9 @@ async fn connected(mut socket: WebSocket, clients: Clients) { message = reader.next() => match message { Some(Ok(Message::Text(text))) => match serde_json::from_str(&text) { Ok(ClientMessage::Signed { payload, signature }) => { - let registered_key = clients.lock().await.get(&public_key) - .filter(|client| client.connection_id == connection_id) - .map(|client| client.key); - let Some(registered_key) = registered_key else { break }; - if !verify(®istered_key, &message_bytes(&payload), &signature) { break; } - // The message is authenticated. Domain routing comes next. + if !relay_live_data(&clients, &public_key, connection_id, payload, &signature).await { + break; + } } Ok(ClientMessage::ProfileUpdated { profile, signature }) => { if !update_profile(&clients, &public_key, connection_id, profile, &signature).await { @@ -226,6 +223,49 @@ async fn friend_profiles( Some(profiles) } +async fn relay_live_data( + clients: &Clients, + public_key: &str, + connection_id: Uuid, + payload: String, + signature: &str, +) -> bool { + let clients = clients.lock().await; + let Some(source) = clients + .get(public_key) + .filter(|client| client.connection_id == connection_id) + else { + return false; + }; + if !verify(&source.key, &message_bytes(&payload), signature) { + return false; + } + + let recipients: Vec<_> = clients + .iter() + .filter(|(recipient_id, recipient)| { + recipient_id.as_str() != public_key + && source.friends.iter().any(|friend| friend == *recipient_id) + && recipient.friends.iter().any(|friend| friend == public_key) + }) + .map(|(_, recipient)| recipient.sender.clone()) + .collect(); + drop(clients); + + let message = Message::Text( + serde_json::to_string(&ServerMessage::FriendLiveData { + friend_id: public_key.to_owned(), + payload, + }) + .unwrap() + .into(), + ); + for recipient in recipients { + let _ = recipient.try_send(message.clone()); + } + true +} + async fn remove(clients: &Clients, public_key: &str, connection_id: Uuid) { let mut clients = clients.lock().await; if clients @@ -499,4 +539,109 @@ mod tests { .is_none() ); } + + #[tokio::test] + async fn live_data_relay_requires_valid_session_signature_and_mutual_friendship() { + let signing_key = SigningKey::from_bytes(&[7; 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 (mutual_sender, mut mutual_receiver) = mpsc::channel(1); + let (sender_only_sender, mut sender_only_receiver) = mpsc::channel(1); + let (recipient_only_sender, mut recipient_only_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!["mutual".to_owned(), "sender-only".to_owned()], + sender: source_sender, + }, + ); + for (id, friends, sender) in [ + ("mutual", vec![public_key.clone()], mutual_sender), + ("sender-only", Vec::new(), sender_only_sender), + ( + "recipient-only", + vec![public_key.clone()], + recipient_only_sender, + ), + ] { + registry.insert( + id.to_owned(), + Client { + connection_id: Uuid::new_v4(), + key: signing_key.verifying_key(), + profile: Profile { + id: id.to_owned(), + display_name: id.to_owned(), + }, + friends, + sender, + }, + ); + } + drop(registry); + + let payload = r#"{"type":"cursor","positions":{}}"#.to_owned(); + let signature = + URL_SAFE_NO_PAD.encode(signing_key.sign(&message_bytes(&payload)).to_bytes()); + assert!( + relay_live_data( + &clients, + &public_key, + connection_id, + payload.clone(), + &signature, + ) + .await + ); + + let Message::Text(message) = mutual_receiver + .recv() + .await + .expect("mutual friend receives") + else { + panic!("expected text live data"); + }; + let ServerMessage::FriendLiveData { + friend_id, + payload: received_payload, + } = serde_json::from_str(&message).expect("decode live data") + else { + panic!("expected friend live data"); + }; + assert_eq!(friend_id, public_key); + assert_eq!(received_payload, payload); + assert!(sender_only_receiver.try_recv().is_err()); + assert!(recipient_only_receiver.try_recv().is_err()); + + assert!( + !relay_live_data( + &clients, + &public_key, + Uuid::new_v4(), + payload.clone(), + &signature, + ) + .await + ); + assert!( + !relay_live_data( + &clients, + &public_key, + connection_id, + "tampered".to_owned(), + &signature, + ) + .await + ); + assert!(mutual_receiver.try_recv().is_err()); + } } diff --git a/src-tauri/src/cursor/mod.rs b/src-tauri/src/cursor/mod.rs index 87e20ab..7e2598c 100644 --- a/src-tauri/src/cursor/mod.rs +++ b/src-tauri/src/cursor/mod.rs @@ -30,6 +30,13 @@ pub struct CursorPositionChanged { pub positions: CursorPositions, } +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct FriendCursorPositionChanged { + pub friend_id: String, + pub positions: CursorPositions, +} + #[derive(Default)] pub struct CursorState(RwLock); @@ -91,9 +98,32 @@ fn update_cursor_position(handle: &AppHandle, positions: CursorPositions) { *guard = positions.clone(); drop(guard); - if let Err(error) = (CursorPositionChanged { positions }).emit(handle) { + if let Err(error) = (CursorPositionChanged { + positions: positions.clone(), + }) + .emit(handle) + { eprintln!("Failed to emit cursor position change: {error}"); } + + handle + .state::() + .send_live_data(crate::live_data::LiveData::Cursor { positions }); +} + +pub(crate) fn emit_friend_position( + handle: &AppHandle, + friend_id: String, + positions: CursorPositions, +) { + if let Err(error) = (FriendCursorPositionChanged { + friend_id, + positions, + }) + .emit(handle) + { + eprintln!("Failed to emit friend cursor position change: {error}"); + } } /// Convert absolute to normalized coordinates (0.12, 0.78), or normalized to absolute (1234, 567) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e68b42e..b7a920b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod cursor; mod db; mod friends; mod keypair; +mod live_data; mod network; mod profile; mod remotes; @@ -73,7 +74,9 @@ fn specta_builder() -> tauri_specta::Builder { profile::ProfileChanged, network::NetworkStatusChanged, cursor::CursorPositionChanged, + cursor::FriendCursorPositionChanged, ufa::ForegroundAppChanged, + ufa::FriendForegroundAppChanged, ]) } diff --git a/src-tauri/src/live_data/mod.rs b/src-tauri/src/live_data/mod.rs new file mode 100644 index 0000000..ed1f212 --- /dev/null +++ b/src-tauri/src/live_data/mod.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +use crate::cursor::CursorPositions; +use crate::ufa::AppMeta; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum LiveData { + Cursor { positions: CursorPositions }, + ForegroundApp { meta: AppMeta }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cursor::{CursorPosition, CursorPositions}; + + #[test] + fn live_data_is_internally_tagged_for_client_side_dispatch() { + let payload = serde_json::to_string(&LiveData::Cursor { + positions: CursorPositions { + raw: CursorPosition { x: 120.0, y: 80.0 }, + mapped: CursorPosition { x: 0.25, y: 0.5 }, + }, + }) + .unwrap(); + + assert_eq!( + payload, + r#"{"type":"cursor","positions":{"raw":{"x":120.0,"y":80.0},"mapped":{"x":0.25,"y":0.5}}}"# + ); + assert!(matches!( + serde_json::from_str(&payload).unwrap(), + LiveData::Cursor { .. } + )); + } +} diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index 1e8c3a8..90a67a6 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -18,6 +18,7 @@ use wyd_common::{ use crate::db::AppDatabase; use crate::friends::{self, FriendsChanged}; use crate::keypair::AppKeypair; +use crate::live_data::LiveData; use crate::remotes::{self, Remote, RemotesChanged}; type Statuses = Arc>>; @@ -62,16 +63,23 @@ pub struct Network { } impl Network { - #[allow(dead_code)] // Ready for the first domain message sender. - pub fn send(&self, remote_id: &str, payload: String) -> Result<(), String> { - let connections = self.connections.lock().map_err(|error| error.to_string())?; - let sender = connections - .get(remote_id) - .map(|connection| &connection.sender) - .ok_or_else(|| "remote is not configured".to_string())?; - sender - .try_send(payload) - .map_err(|error| format!("remote is not ready: {error}")) + pub fn send_live_data(&self, data: LiveData) { + let Ok(payload) = serde_json::to_string(&data) else { + eprintln!("failed to serialize live data"); + return; + }; + let Ok(connections) = self.connections.lock() else { + eprintln!("failed to lock remote connections for live data"); + return; + }; + for connection in connections.values() { + match connection.sender.try_send(payload.clone()) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {} + Err(mpsc::error::TrySendError::Closed(_)) => { + eprintln!("remote {} cannot accept live data", connection.remote.id); + } + } + } } pub fn update_profile(&self, profile: crate::user::User) { @@ -263,6 +271,7 @@ async fn connect( if !matches!(recv(&mut reader).await?, ServerMessage::Registered) { return Err("server rejected registration".into()); } + while outgoing.try_recv().is_ok() {} send(&mut writer, &ClientMessage::SyncFriendProfiles).await?; changed( handle, @@ -315,6 +324,19 @@ async fn connect( eprintln!("failed to synchronize friend profiles: {error}"); } } + ServerMessage::FriendLiveData { friend_id, payload } => { + match serde_json::from_str(&payload) { + Ok(LiveData::Cursor { positions }) => { + crate::cursor::emit_friend_position(handle, friend_id, positions); + } + Ok(LiveData::ForegroundApp { meta }) => { + crate::ufa::emit_friend_app(handle, friend_id, meta); + } + Err(error) => { + eprintln!("failed to decode friend live data: {error}"); + } + } + } _ => {} }, Message::Ping(data) => writer.send(Message::Pong(data)).await?, diff --git a/src-tauri/src/ufa/mod.rs b/src-tauri/src/ufa/mod.rs index 00365c9..0b7c712 100644 --- a/src-tauri/src/ufa/mod.rs +++ b/src-tauri/src/ufa/mod.rs @@ -19,6 +19,13 @@ pub struct ForegroundAppChanged { pub meta: AppMeta, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct FriendForegroundAppChanged { + pub friend_id: String, + pub meta: AppMeta, +} + #[derive(Default)] pub struct ForegroundAppState(RwLock); @@ -67,8 +74,18 @@ pub fn init(handle: &AppHandle) { *current = meta.clone(); drop(current); - if let Err(error) = (ForegroundAppChanged { meta }).emit(&handle) { + if let Err(error) = (ForegroundAppChanged { meta: meta.clone() }).emit(&handle) { eprintln!("Failed to emit foreground app change: {error}"); } + + handle + .state::() + .send_live_data(crate::live_data::LiveData::ForegroundApp { meta }); }); } + +pub(crate) fn emit_friend_app(handle: &AppHandle, friend_id: String, meta: AppMeta) { + if let Err(error) = (FriendForegroundAppChanged { friend_id, meta }).emit(handle) { + eprintln!("Failed to emit friend foreground app change: {error}"); + } +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 2308265..91c997d 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -52,6 +52,8 @@ async listStatuses() : Promise { export const events = __makeEvents__<{ cursorPositionChanged: CursorPositionChanged, foregroundAppChanged: ForegroundAppChanged, +friendCursorPositionChanged: FriendCursorPositionChanged, +friendForegroundAppChanged: FriendForegroundAppChanged, friendsChanged: FriendsChanged, networkStatusChanged: NetworkStatusChanged, profileChanged: ProfileChanged, @@ -59,6 +61,8 @@ remotesChanged: RemotesChanged }>({ cursorPositionChanged: "cursor-position-changed", foregroundAppChanged: "foreground-app-changed", +friendCursorPositionChanged: "friend-cursor-position-changed", +friendForegroundAppChanged: "friend-foreground-app-changed", friendsChanged: "friends-changed", networkStatusChanged: "network-status-changed", profileChanged: "profile-changed", @@ -81,6 +85,8 @@ export type CursorPosition = { x: number; y: number } export type CursorPositionChanged = { positions: CursorPositions } export type CursorPositions = { raw: CursorPosition; mapped: CursorPosition } export type ForegroundAppChanged = { meta: AppMeta } +export type FriendCursorPositionChanged = { friendId: string; positions: CursorPositions } +export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta } export type FriendsChanged = { friends: User[] } export type NetworkStatusChanged = { statuses: ConnectionStatus[] } export type ProfileChanged = { profile: User } diff --git a/src/lib/components/friends-live-data.svelte b/src/lib/components/friends-live-data.svelte new file mode 100644 index 0000000..324e0b8 --- /dev/null +++ b/src/lib/components/friends-live-data.svelte @@ -0,0 +1,469 @@ + + +
+
+
+

Mutual connections

+

Friend signals

+
+ Live +
+ + {#if error} + + {:else if friends.length === 0} +
+ +

Add a friend to watch their live activity arrive here.

+
+ {:else} +
    + {#each friends as friend (friend.id)} + {@const data = liveData.get(friend.id) ?? emptyLiveData()} +
  • +
    + +
    +

    {friend.displayName}

    + {compactId(friend.id)} +
    +
    + +
    +
    + {#if data.foregroundApp} + {#if data.foregroundApp.ico} + + {:else} + + {/if} +
    + Foreground + + {data.foregroundApp.local ?? + data.foregroundApp.unlocal ?? + "Unknown app"} + +
    + {:else} + +
    + Foreground + Waiting for activity +
    + {/if} +
    + +
    +
    + Cursor + {#if data.cursor} + + {Math.round(data.cursor.mapped.x * 100)} · + {Math.round(data.cursor.mapped.y * 100)} + + {:else} + — · — + {/if} +
    +
    + +
    +
    +
    +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e4bbe0b..3d04240 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,6 @@