From 0ec9c08449c2b14b32e8d2c415c9b6cc052225b8 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Thu, 10 Sep 2026 17:39:29 +0800 Subject: [PATCH] abstract ufa to user activity --- src-common/src/lib.rs | 3 +- src-server/src/network/mod.rs | 25 +- src-tauri/src/activity/mod.rs | 286 ++++++++++++++++++ src-tauri/src/lib.rs | 5 +- src-tauri/src/live_data/mod.rs | 20 +- src-tauri/src/network/connection.rs | 12 +- src-tauri/src/network/mod.rs | 35 +-- src-tauri/src/ufa/mod.rs | 129 ++------ src-tauri/src/ufa/types.rs | 3 +- src/lib/bindings.ts | 16 +- src/lib/components/debug/live-metadata.svelte | 35 ++- src/lib/listeners/friend-statuses.ts | 5 +- src/lib/listeners/live-metadata.ts | 99 +++--- src/routes/scene/+page.svelte | 26 +- .../scene/popovers/user-interaction.svelte | 52 ++-- 15 files changed, 497 insertions(+), 254 deletions(-) create mode 100644 src-tauri/src/activity/mod.rs diff --git a/src-common/src/lib.rs b/src-common/src/lib.rs index e708208..fbf13a7 100644 --- a/src-common/src/lib.rs +++ b/src-common/src/lib.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; use specta::Type; -pub const VERSION: u8 = 4; +pub const VERSION: u8 = 5; pub const DEFAULT_SERVER_PORT: u16 = 27_520; +pub const MAX_LIVE_DATA_PAYLOAD_BYTES: usize = 64 * 1024; pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024; pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024; pub const MAX_SKIN_B64_SIZE: usize = 96 * 1024; diff --git a/src-server/src/network/mod.rs b/src-server/src/network/mod.rs index 068c359..e56bebd 100644 --- a/src-server/src/network/mod.rs +++ b/src-server/src/network/mod.rs @@ -10,7 +10,10 @@ use axum::routing::get; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; -use friendolls_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes}; +use friendolls_common::{ + ClientMessage, MAX_LIVE_DATA_PAYLOAD_BYTES, Profile, ServerMessage, message_bytes, + register_bytes, +}; use futures_util::{SinkExt, StreamExt}; use tokio::sync::{Mutex, mpsc}; use uuid::Uuid; @@ -273,6 +276,10 @@ async fn relay_live_data( payload: String, signature: &str, ) -> bool { + if payload.len() > MAX_LIVE_DATA_PAYLOAD_BYTES { + return false; + } + let clients = clients.lock().await; let Some(source) = clients .get(public_key) @@ -628,6 +635,22 @@ mod tests { ) .await ); + let oversized_payload = "x".repeat(MAX_LIVE_DATA_PAYLOAD_BYTES + 1); + let oversized_signature = URL_SAFE_NO_PAD.encode( + signing_key + .sign(&message_bytes(&oversized_payload)) + .to_bytes(), + ); + assert!( + !relay_live_data( + &clients, + &public_key, + connection_id, + oversized_payload, + &oversized_signature, + ) + .await + ); assert!(mutual_receiver.try_recv().is_err()); } diff --git a/src-tauri/src/activity/mod.rs b/src-tauri/src/activity/mod.rs new file mode 100644 index 0000000..74011f2 --- /dev/null +++ b/src-tauri/src/activity/mod.rs @@ -0,0 +1,286 @@ +use std::collections::HashMap; +use std::sync::RwLock; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager}; +use tauri_specta::Event; + +const MAX_ACTIVITY_SOURCES: usize = 8; +const MAX_ACTIVITY_SOURCE_BYTES: usize = 64; +const MAX_ACTIVITY_TEXT_BYTES: usize = 512; +const MAX_ACTIVITY_ICON_BYTES: usize = 50_000; +// Reserve space for the live-data envelope inside the shared transport limit. +const MAX_ACTIVITY_SET_BYTES: usize = friendolls_common::MAX_LIVE_DATA_PAYLOAD_BYTES - 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ActivityKind { + Application, + Listening, + Watching, + Playing, + Custom, +} + +/// Provider-neutral rich presence data for one activity source. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Activity { + pub kind: ActivityKind, + pub name: Option, + pub details: Option, + pub icon: Option, +} + +impl Activity { + fn validate(&self) -> Result<(), String> { + for (label, value) in [("name", &self.name), ("details", &self.details)] { + if value + .as_ref() + .is_some_and(|value| value.len() > MAX_ACTIVITY_TEXT_BYTES) + { + return Err(format!( + "activity {label} exceeds {MAX_ACTIVITY_TEXT_BYTES} bytes" + )); + } + } + if self + .icon + .as_ref() + .is_some_and(|icon| icon.len() > MAX_ACTIVITY_ICON_BYTES) + { + return Err(format!( + "activity icon exceeds {MAX_ACTIVITY_ICON_BYTES} bytes" + )); + } + Ok(()) + } +} + +pub type Activities = HashMap; +type ActivitiesByUser = HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct ActivitiesChanged { + pub user_id: String, + pub activities: Activities, +} + +#[derive(Default)] +pub(crate) struct ActivityState(RwLock); + +fn validate_activities(activities: &Activities) -> Result<(), String> { + if activities.len() > MAX_ACTIVITY_SOURCES { + return Err(format!( + "activity update exceeds {MAX_ACTIVITY_SOURCES} sources" + )); + } + for (source, activity) in activities { + if source.trim().is_empty() || source.len() > MAX_ACTIVITY_SOURCE_BYTES { + return Err(format!( + "activity source must contain 1 to {MAX_ACTIVITY_SOURCE_BYTES} bytes" + )); + } + activity.validate()?; + } + if serde_json::to_vec(activities) + .map_err(|error| error.to_string())? + .len() + > MAX_ACTIVITY_SET_BYTES + { + return Err(format!( + "activity update exceeds {MAX_ACTIVITY_SET_BYTES} serialized bytes" + )); + } + Ok(()) +} + +impl ActivityState { + fn set_source( + &self, + user_id: String, + source: String, + activity: Option, + ) -> Result { + let mut users = self.0.write().map_err(|error| error.to_string())?; + let mut current = users.get(&user_id).cloned().unwrap_or_default(); + if let Some(activity) = activity { + current.insert(source, activity); + } else { + current.remove(&source); + } + validate_activities(¤t)?; + if current.is_empty() { + users.remove(&user_id); + } else { + users.insert(user_id, current.clone()); + } + Ok(current) + } + + fn replace(&self, user_id: String, activities: Activities) -> Result<(), String> { + validate_activities(&activities)?; + let mut users = self.0.write().map_err(|error| error.to_string())?; + if activities.is_empty() { + users.remove(&user_id); + } else { + users.insert(user_id, activities); + } + Ok(()) + } + + fn remove_users(&self, user_ids: &[String]) -> Result, String> { + let mut users = self.0.write().map_err(|error| error.to_string())?; + Ok(user_ids + .iter() + .filter(|user_id| users.remove(*user_id).is_some()) + .cloned() + .collect()) + } + + pub(crate) fn snapshot(&self) -> Result { + self.0 + .read() + .map(|activities| activities.clone()) + .map_err(|error| error.to_string()) + } + + pub(crate) fn get(&self, user_id: &str) -> Result, String> { + self.0 + .read() + .map(|users| users.get(user_id).cloned()) + .map_err(|error| error.to_string()) + } +} + +pub fn init(handle: &AppHandle) { + handle.manage(ActivityState::default()); +} + +fn emit_changed(handle: &AppHandle, user_id: String, activities: Activities) { + if let Err(error) = (ActivitiesChanged { + user_id, + activities, + }) + .emit(handle) + { + eprintln!("failed to emit activity change: {error}"); + } +} + +pub(crate) fn update_local( + handle: &AppHandle, + source: impl Into, + activity: Option, +) { + let user_id = handle + .state::() + .public_key() + .to_owned(); + let activities = + match handle + .state::() + .set_source(user_id.clone(), source.into(), activity) + { + Ok(activities) => activities, + Err(error) => { + eprintln!("failed to cache local activity: {error}"); + return; + } + }; + emit_changed(handle, user_id, activities.clone()); + handle + .state::() + .send_live_data(crate::live_data::LiveData::Activities { activities }); +} + +pub(crate) fn update_friend(handle: &AppHandle, friend_id: String, activities: Activities) { + if let Err(error) = handle + .state::() + .replace(friend_id.clone(), activities.clone()) + { + eprintln!("failed to cache friend activity: {error}"); + return; + } + emit_changed(handle, friend_id, activities); +} + +pub(crate) fn remove_users(handle: &AppHandle, user_ids: &[String]) { + let removed = match handle.state::().remove_users(user_ids) { + Ok(removed) => removed, + Err(error) => { + eprintln!("failed to remove offline activities: {error}"); + return; + } + }; + for user_id in removed { + emit_changed(handle, user_id, Activities::new()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn activity(name: &str) -> Activity { + Activity { + kind: ActivityKind::Application, + name: Some(name.to_owned()), + details: None, + icon: None, + } + } + + #[test] + fn activity_sources_coexist_and_can_be_cleared_independently() { + let state = ActivityState::default(); + state + .set_source( + "user".to_owned(), + "foreground-app".to_owned(), + Some(activity("Editor")), + ) + .unwrap(); + state + .set_source( + "user".to_owned(), + "media".to_owned(), + Some(activity("Song")), + ) + .unwrap(); + + let activities = state.get("user").unwrap().unwrap(); + assert_eq!(activities.len(), 2); + + state + .set_source("user".to_owned(), "media".to_owned(), None) + .unwrap(); + let activities = state.get("user").unwrap().unwrap(); + assert_eq!(activities.len(), 1); + assert_eq!(activities["foreground-app"].name.as_deref(), Some("Editor")); + } + + #[test] + fn rejects_oversized_activity_sets_without_replacing_cached_data() { + let state = ActivityState::default(); + state + .replace( + "user".to_owned(), + HashMap::from([("foreground-app".to_owned(), activity("Editor"))]), + ) + .unwrap(); + + let oversized = (0..=MAX_ACTIVITY_SOURCES) + .map(|index| (format!("source-{index}"), activity("Activity"))) + .collect(); + assert!(state.replace("user".to_owned(), oversized).is_err()); + assert_eq!( + state.get("user").unwrap().unwrap()["foreground-app"] + .name + .as_deref(), + Some("Editor") + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a75f75e..87f5b70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod activity; mod application; mod cursor; mod db; @@ -27,6 +28,7 @@ async fn launch_app(handle: &tauri::AppHandle) -> Result<(), Box tauri_specta::Builder { network::NetworkStatusChanged, network::FriendStatusesChanged, cursor::CursorPositionChanged, - ufa::ForegroundAppChanged, - ufa::FriendForegroundAppChanged, + activity::ActivitiesChanged, interactions::FriendInteractionReceived, puppet::PuppetStatesChanged, scene::SceneConfigurationChanged, diff --git a/src-tauri/src/live_data/mod.rs b/src-tauri/src/live_data/mod.rs index a13ee50..b7caa48 100644 --- a/src-tauri/src/live_data/mod.rs +++ b/src-tauri/src/live_data/mod.rs @@ -5,13 +5,13 @@ use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{AppHandle, Manager}; +use crate::activity::Activities; use crate::cursor::CursorPositions; -use crate::ufa::AppMeta; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum LiveDataKind { Cursor, - ForegroundApp, + Activity, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -33,14 +33,14 @@ impl LiveDataEnvelope { #[serde(tag = "type", rename_all = "camelCase")] pub enum LiveData { Cursor { positions: CursorPositions }, - ForegroundApp { meta: AppMeta }, + Activities { activities: Activities }, } impl LiveData { pub(crate) fn kind(&self) -> LiveDataKind { match self { Self::Cursor { .. } => LiveDataKind::Cursor, - Self::ForegroundApp { .. } => LiveDataKind::ForegroundApp, + Self::Activities { .. } => LiveDataKind::Activity, } } } @@ -49,7 +49,7 @@ impl LiveData { #[serde(rename_all = "camelCase")] pub struct LiveDataSnapshot { pub cursor_positions: HashMap, - pub foreground_apps: HashMap, + pub activities: HashMap, } #[tauri::command] @@ -57,8 +57,8 @@ pub struct LiveDataSnapshot { pub fn list_live_data(handle: AppHandle) -> Result { Ok(LiveDataSnapshot { cursor_positions: handle.state::().snapshot()?, - foreground_apps: handle - .state::() + activities: handle + .state::() .snapshot()?, }) } @@ -73,11 +73,11 @@ pub(crate) fn publish_current(handle: &AppHandle) -> Result<(), String> { if let Some(positions) = handle.state::().get(&user_id)? { network.send_live_data(LiveData::Cursor { positions }); } - if let Some(meta) = handle - .state::() + if let Some(activities) = handle + .state::() .get(&user_id)? { - network.send_live_data(LiveData::ForegroundApp { meta }); + network.send_live_data(LiveData::Activities { activities }); } Ok(()) } diff --git a/src-tauri/src/network/connection.rs b/src-tauri/src/network/connection.rs index a8fe795..ca5a9aa 100644 --- a/src-tauri/src/network/connection.rs +++ b/src-tauri/src/network/connection.rs @@ -85,7 +85,7 @@ pub(super) struct ConnectionInputs { pub(super) friends: watch::Receiver>, pub(super) keypair: AppKeypair, pub(super) cursor_data: watch::Receiver>, - pub(super) foreground_app_data: watch::Receiver>, + pub(super) activity_data: watch::Receiver>, pub(super) interactions: mpsc::Receiver, pub(super) profile_lookups: mpsc::Receiver, pub(super) skin_lookups: mpsc::Receiver, @@ -144,7 +144,7 @@ async fn connect( friends, keypair, cursor_data, - foreground_app_data, + activity_data, interactions: active_outgoing, profile_lookups, skin_lookups, @@ -189,7 +189,7 @@ async fn connect( return Err("server rejected registration".into()); } cursor_data.borrow_and_update(); - foreground_app_data.borrow_and_update(); + activity_data.borrow_and_update(); while let Ok(request) = active_outgoing.try_recv() { let _ = request .response @@ -230,9 +230,9 @@ async fn connect( }).await?; } } - changed = foreground_app_data.changed() => { - changed.map_err(|_| "foreground-app sender closed")?; - let payload = { foreground_app_data.borrow_and_update().clone() }; + changed = activity_data.changed() => { + changed.map_err(|_| "activity sender closed")?; + let payload = { activity_data.borrow_and_update().clone() }; if let Some(payload) = payload && friend_presence.has_online_friends(&remote.id)? { diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index f8844a3..6440756 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -58,7 +58,7 @@ pub struct FriendStatusesChanged { struct Connection { remote: Remote, cursor_sender: watch::Sender>, - foreground_app_sender: watch::Sender>, + activity_sender: watch::Sender>, interaction_sender: mpsc::Sender, profile_lookup_sender: mpsc::Sender, skin_lookup_sender: mpsc::Sender, @@ -85,7 +85,7 @@ pub struct Network { next_generation: AtomicU64, live_session_id: String, next_cursor_sequence: AtomicU64, - next_foreground_app_sequence: AtomicU64, + next_activity_sequence: AtomicU64, received_sequences: SequenceTracker, } @@ -109,9 +109,7 @@ impl Network { }; let sequence = match data.kind() { LiveDataKind::Cursor => self.next_cursor_sequence.fetch_add(1, Ordering::Relaxed), - LiveDataKind::ForegroundApp => self - .next_foreground_app_sequence - .fetch_add(1, Ordering::Relaxed), + LiveDataKind::Activity => self.next_activity_sequence.fetch_add(1, Ordering::Relaxed), }; let envelope = LiveDataEnvelope { session_id: self.live_session_id.clone(), @@ -122,6 +120,10 @@ impl Network { eprintln!("failed to serialize live data"); return; }; + if payload.len() > friendolls_common::MAX_LIVE_DATA_PAYLOAD_BYTES { + eprintln!("live data exceeds the transport limit"); + return; + } let Ok(connections) = self.connections.lock() else { eprintln!("failed to lock remote connections for live data"); return; @@ -134,9 +136,9 @@ impl Network { LiveDataKind::Cursor => { connection.cursor_sender.send_replace(Some(payload.clone())); } - LiveDataKind::ForegroundApp => { + LiveDataKind::Activity => { connection - .foreground_app_sender + .activity_sender .send_replace(Some(payload.clone())); } } @@ -185,8 +187,8 @@ impl Network { LiveData::Cursor { positions } => { crate::cursor::emit_position(handle, friend_id, positions); } - LiveData::ForegroundApp { meta } => { - crate::ufa::emit_friend_app(handle, friend_id, meta); + LiveData::Activities { activities } => { + crate::activity::update_friend(handle, friend_id, activities); } } } @@ -383,7 +385,7 @@ impl Network { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); let (cursor_sender, cursor_receiver) = watch::channel(None); - let (foreground_app_sender, foreground_app_receiver) = watch::channel(None); + let (activity_sender, activity_receiver) = watch::channel(None); let (interaction_sender, interaction_receiver) = mpsc::channel(16); let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16); let (skin_lookup_sender, skin_lookup_receiver) = mpsc::channel(16); @@ -404,7 +406,7 @@ impl Network { friends: self.friends.subscribe(), keypair: self.keypair.clone(), cursor_data: cursor_receiver, - foreground_app_data: foreground_app_receiver, + activity_data: activity_receiver, interactions: interaction_receiver, profile_lookups: profile_lookup_receiver, skin_lookups: skin_lookup_receiver, @@ -415,7 +417,7 @@ impl Network { Connection { remote, cursor_sender, - foreground_app_sender, + activity_sender, interaction_sender, profile_lookup_sender, skin_lookup_sender, @@ -453,7 +455,7 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box> next_generation: AtomicU64::new(1), live_session_id: uuid::Uuid::new_v4().to_string(), next_cursor_sequence: AtomicU64::new(1), - next_foreground_app_sequence: AtomicU64::new(1), + next_activity_sequence: AtomicU64::new(1), received_sequences: SequenceTracker::default(), }; handle.manage(network); @@ -535,12 +537,7 @@ fn apply_friend_presence_change( match result { Ok(Some(change)) => { crate::cursor::remove_positions(handle, &change.went_offline); - if let Err(error) = handle - .state::() - .remove(&change.went_offline) - { - eprintln!("failed to remove offline foreground apps: {error}"); - } + crate::activity::remove_users(handle, &change.went_offline); if change.route_added && let Err(error) = crate::live_data::publish_current(handle) { diff --git a/src-tauri/src/ufa/mod.rs b/src-tauri/src/ufa/mod.rs index 69c7666..0bb46ae 100644 --- a/src-tauri/src/ufa/mod.rs +++ b/src-tauri/src/ufa/mod.rs @@ -2,10 +2,7 @@ UFA: User Focused App */ -use std::collections::HashMap; -use std::sync::RwLock; -use tauri::{AppHandle, Manager}; -use tauri_specta::Event; +use tauri::AppHandle; pub use types::*; mod icon_cache; #[cfg(target_os = "macos")] @@ -14,51 +11,7 @@ mod types; #[cfg(target_os = "windows")] mod windows; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type, Event)] -#[serde(rename_all = "camelCase")] -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(crate) struct ForegroundAppState(RwLock>); - -impl ForegroundAppState { - fn update(&self, user_id: String, meta: AppMeta) -> Result<(), String> { - self.0 - .write() - .map_err(|error| error.to_string())? - .insert(user_id, meta); - Ok(()) - } - - pub(crate) fn remove(&self, user_ids: &[String]) -> Result<(), String> { - let mut apps = self.0.write().map_err(|error| error.to_string())?; - apps.retain(|user_id, _| !user_ids.contains(user_id)); - Ok(()) - } - - pub(crate) fn snapshot(&self) -> Result, String> { - self.0 - .read() - .map(|apps| apps.clone()) - .map_err(|error| error.to_string()) - } - - pub(crate) fn get(&self, user_id: &str) -> Result, String> { - self.0 - .read() - .map(|apps| apps.get(user_id).cloned()) - .map_err(|error| error.to_string()) - } -} +const ACTIVITY_SOURCE: &str = "foreground-app"; fn current_app() -> AppMeta { #[cfg(target_os = "macos")] @@ -104,72 +57,28 @@ where } pub fn init(handle: &AppHandle) { - handle.manage(ForegroundAppState::default()); update_local_app(handle, current_app()); let handle = handle.clone(); init_listener(move |meta: AppMeta| update_local_app(&handle, meta)); } -pub(crate) fn emit_friend_app(handle: &AppHandle, friend_id: String, meta: AppMeta) { - if let Err(error) = handle - .state::() - .update(friend_id.clone(), meta.clone()) - { - eprintln!("Failed to cache friend foreground app: {error}"); - return; - } - if let Err(error) = (FriendForegroundAppChanged { friend_id, meta }).emit(handle) { - eprintln!("Failed to emit friend foreground app change: {error}"); - } -} - fn update_local_app(handle: &AppHandle, meta: AppMeta) { - let user_id = handle - .state::() - .public_key() - .to_owned(); - if let Err(error) = handle - .state::() - .update(user_id, meta.clone()) - { - eprintln!("Failed to cache foreground app: {error}"); - return; - } - 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 }); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn app(name: &str) -> AppMeta { - AppMeta { - local: Some(name.to_owned()), - unlocal: None, - ico: None, - } - } - - #[test] - fn foreground_app_state_snapshots_and_removes_users() { - let state = ForegroundAppState::default(); - state.update("local".to_owned(), app("Local")).unwrap(); - state.update("friend".to_owned(), app("Friend")).unwrap(); - - assert_eq!( - state.get("friend").unwrap().unwrap().local.as_deref(), - Some("Friend") - ); - state.remove(&["friend".to_owned()]).unwrap(); - - let snapshot = state.snapshot().unwrap(); - assert_eq!(snapshot.len(), 1); - assert_eq!(snapshot["local"].local.as_deref(), Some("Local")); - } + let details = meta + .local + .as_ref() + .zip(meta.unlocal.as_ref()) + .filter(|(local, unlocal)| local != unlocal) + .map(|(_, unlocal)| unlocal.clone()); + let name = meta.local.or(meta.unlocal); + crate::activity::update_local( + handle, + ACTIVITY_SOURCE, + Some(crate::activity::Activity { + kind: crate::activity::ActivityKind::Application, + name, + details, + icon: meta.ico, + }), + ); } diff --git a/src-tauri/src/ufa/types.rs b/src-tauri/src/ufa/types.rs index ce09b1b..805c91b 100644 --- a/src-tauri/src/ufa/types.rs +++ b/src-tauri/src/ufa/types.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use specta::Type; pub const ICON_SIZE: u32 = 64; pub const ICON_CACHE_LIMIT: usize = 50; @@ -11,7 +10,7 @@ pub const ICON_CACHE_LIMIT: usize = 50; pub const MAX_ICON_B64_SIZE: usize = 50_000; /// Metadata for the currently active application, including localized and unlocalized names, and an optional base64-encoded icon. -#[derive(Debug, Clone, Serialize, Deserialize, Default, Type)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AppMeta { pub local: Option, diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index f3d735d..66a9d74 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -107,9 +107,8 @@ async updateSceneHitboxes(hitboxes: SceneHitbox[]) : Promise { export const events = __makeEvents__<{ +activitiesChanged: ActivitiesChanged, cursorPositionChanged: CursorPositionChanged, -foregroundAppChanged: ForegroundAppChanged, -friendForegroundAppChanged: FriendForegroundAppChanged, friendInteractionReceived: FriendInteractionReceived, friendStatusesChanged: FriendStatusesChanged, friendsChanged: FriendsChanged, @@ -120,9 +119,8 @@ puppetStatesChanged: PuppetStatesChanged, remotesChanged: RemotesChanged, sceneConfigurationChanged: SceneConfigurationChanged }>({ +activitiesChanged: "activities-changed", cursorPositionChanged: "cursor-position-changed", -foregroundAppChanged: "foreground-app-changed", -friendForegroundAppChanged: "friend-foreground-app-changed", friendInteractionReceived: "friend-interaction-received", friendStatusesChanged: "friend-statuses-changed", friendsChanged: "friends-changed", @@ -140,10 +138,12 @@ sceneConfigurationChanged: "scene-configuration-changed" /** user-defined types **/ +export type ActivitiesChanged = { userId: string; activities: Partial<{ [key in string]: Activity }> } /** - * Metadata for the currently active application, including localized and unlocalized names, and an optional base64-encoded icon. + * Provider-neutral rich presence data for one activity source. */ -export type AppMeta = { local: string | null; unlocal: string | null; ico: string | null } +export type Activity = { kind: ActivityKind; name: string | null; details: string | null; icon: string | null } +export type ActivityKind = "application" | "listening" | "watching" | "playing" | "custom" export type ConnectionState = "connecting" | "connected" | "disconnected" export type ConnectionStatus = { remoteId: string; address: string; name: string | null; state: ConnectionState } export type CursorPosition = { x: number; y: number } @@ -157,18 +157,16 @@ raw: CursorPosition; * Cursor coordinates normalized to the source monitor for scale-independent projection. */ mapped: CursorPosition } -export type ForegroundAppChanged = { meta: AppMeta } /** * A configured public-key relationship with optional cached remote metadata. * The display name remains absent until learned from a signed remote profile. */ export type Friend = { id: string; displayName: string | null; skinHash: string | null } -export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta } export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent } export type FriendStatusesChanged = { friendIds: string[] } export type FriendsChanged = { friends: Friend[] } export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string } -export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> } +export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; activities: Partial<{ [key in string]: Partial<{ [key in string]: Activity }> }> } export type LocaleChanged = { preference: string; locale: string } export type NetworkStatusChanged = { statuses: ConnectionStatus[] } export type ProfileChanged = { profile: User } diff --git a/src/lib/components/debug/live-metadata.svelte b/src/lib/components/debug/live-metadata.svelte index be7a833..fb1ec26 100644 --- a/src/lib/components/debug/live-metadata.svelte +++ b/src/lib/components/debug/live-metadata.svelte @@ -9,7 +9,7 @@ ...new Set([ $liveMetadata.localId, ...Object.keys($liveMetadata.cursorPositions), - ...$liveMetadata.foregroundApps.keys(), + ...$liveMetadata.activities.keys(), ]), ].filter(Boolean); } @@ -29,7 +29,7 @@ {:else} {#each liveUserIds() as userId (userId)} {@const cursor = $liveMetadata.cursorPositions[userId]} - {@const foregroundApp = $liveMetadata.foregroundApps.get(userId)} + {@const activities = $liveMetadata.activities.get(userId)}

{userId === $liveMetadata.localId ? "Local user" : "Remote user"}

ID: {compactId(userId)}

@@ -42,20 +42,25 @@

Waiting for cursor data...

{/if} -

Foreground app

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

Localized name: {foregroundApp.local ?? "Unavailable"}

-

Executable name: {foregroundApp.unlocal ?? "Unavailable"}

+

Activities

+ {#if activities} + {#each activities as [source, activity]} +
+

{source} ({activity.kind})

+ {#if activity.icon} + + {/if} +

Name: {activity.name ?? "Unavailable"}

+

Details: {activity.details ?? "Unavailable"}

+
+ {/each} {:else} -

Waiting for foreground app data...

+

Waiting for activity data...

{/if}
{/each} diff --git a/src/lib/listeners/friend-statuses.ts b/src/lib/listeners/friend-statuses.ts index e901ca3..e3d9f48 100644 --- a/src/lib/listeners/friend-statuses.ts +++ b/src/lib/listeners/friend-statuses.ts @@ -1,15 +1,12 @@ import { writable } from "svelte/store"; import { commands, events } from "$lib/bindings"; -import { retainOnlineForegroundApps } from "./live-metadata"; export const onlineFriendIds = writable>(new Set()); export const friendStatusesListenerError = writable(""); export async function initFriendStatusesListener() { const apply = (friendIds: string[]) => { - const next = new Set(friendIds); - onlineFriendIds.set(next); - retainOnlineForegroundApps(next); + onlineFriendIds.set(new Set(friendIds)); }; const unlisten = await events.friendStatusesChanged.listen((event) => { diff --git a/src/lib/listeners/live-metadata.ts b/src/lib/listeners/live-metadata.ts index e474296..e5f8ef6 100644 --- a/src/lib/listeners/live-metadata.ts +++ b/src/lib/listeners/live-metadata.ts @@ -2,47 +2,48 @@ import { writable } from "svelte/store"; import { commands, events, - type AppMeta, + type Activity, type CursorPositions, } from "$lib/bindings"; export type LiveMetadata = { localId: string; cursorPositions: Partial>; - foregroundApps: Map; + activities: Map>; }; export const liveMetadata = writable({ localId: "", cursorPositions: {}, - foregroundApps: new Map(), + activities: new Map(), }); export const liveMetadataListenerError = writable(""); -export function retainOnlineForegroundApps(onlineFriendIds: Set) { - liveMetadata.update((metadata) => ({ - ...metadata, - foregroundApps: new Map( - [...metadata.foregroundApps].filter( - ([userId]) => - userId === metadata.localId || onlineFriendIds.has(userId), - ), - ), - })); -} - export async function initLiveMetadataListeners() { let localId = ""; let initializing = true; let pendingCursorPositions: LiveMetadata["cursorPositions"] | null = null; - let pendingLocalForegroundApp: AppMeta | null = null; - const pendingFriendForegroundApps = new Map(); + const pendingActivities = new Map>(); - const updateForegroundApp = (userId: string, meta: AppMeta) => { - liveMetadata.update((current) => ({ - ...current, - foregroundApps: new Map(current.foregroundApps).set(userId, meta), - })); + const activityMap = ( + activities: Partial>, + ): Map => + new Map( + Object.entries(activities).filter( + (entry): entry is [string, Activity] => entry[1] !== undefined, + ), + ); + + const replaceActivities = ( + userId: string, + userActivities: Map, + ) => { + liveMetadata.update((current) => { + const activities = new Map(current.activities); + if (userActivities.size > 0) activities.set(userId, userActivities); + else activities.delete(userId); + return { ...current, activities }; + }); }; const applyPendingUpdates = () => { @@ -51,17 +52,13 @@ export async function initLiveMetadataListeners() { liveMetadata.update((current) => ({ ...current, cursorPositions })); pendingCursorPositions = null; } - if (localId && pendingLocalForegroundApp) { - updateForegroundApp(localId, pendingLocalForegroundApp); - pendingLocalForegroundApp = null; - } - pendingFriendForegroundApps.forEach((meta, friendId) => { - updateForegroundApp(friendId, meta); + pendingActivities.forEach((activities, userId) => { + replaceActivities(userId, activities); }); - pendingFriendForegroundApps.clear(); + pendingActivities.clear(); }; - const unlisteners = await Promise.all([ + const subscriptions = await Promise.allSettled([ events.cursorPositionChanged.listen((event) => { if (initializing) { pendingCursorPositions = event.payload.positions; @@ -72,24 +69,24 @@ export async function initLiveMetadataListeners() { cursorPositions: event.payload.positions, })); }), - events.foregroundAppChanged.listen((event) => { - if (!initializing && localId) { - updateForegroundApp(localId, event.payload.meta); - } else { - pendingLocalForegroundApp = event.payload.meta; - } - }), - events.friendForegroundAppChanged.listen((event) => { + events.activitiesChanged.listen((event) => { + const activities = activityMap(event.payload.activities); if (initializing) { - pendingFriendForegroundApps.set( - event.payload.friendId, - event.payload.meta, - ); + pendingActivities.set(event.payload.userId, activities); } else { - updateForegroundApp(event.payload.friendId, event.payload.meta); + replaceActivities(event.payload.userId, activities); } }), ]); + const unlisteners = subscriptions.flatMap((subscription) => + subscription.status === "fulfilled" ? [subscription.value] : [], + ); + for (const subscription of subscriptions) { + if (subscription.status === "rejected") { + unlisteners.forEach((unlisten) => unlisten()); + throw subscription.reason; + } + } try { const [snapshot] = await Promise.all([ @@ -98,14 +95,20 @@ export async function initLiveMetadataListeners() { localId = resolvedLocalId; }), ]); - const foregroundApps = new Map(); - Object.entries(snapshot.foregroundApps).forEach(([userId, meta]) => { - if (meta) foregroundApps.set(userId, meta); - }); + const activities = new Map>(); + Object.entries(snapshot.activities).forEach( + ([userId, sourceActivities]) => { + if (!sourceActivities) return; + const userActivities = activityMap(sourceActivities); + if (userActivities.size > 0) { + activities.set(userId, userActivities); + } + }, + ); liveMetadata.set({ localId, cursorPositions: snapshot.cursorPositions, - foregroundApps, + activities, }); initializing = false; applyPendingUpdates(); diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index a75cdd0..01df4f3 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -15,6 +15,7 @@ import SceneInteractionBubble from "./popovers/interaction-bubble.svelte"; import SceneUserPopoverContent from "./popovers/user-interaction.svelte"; import { messages } from "$lib/i18n"; + import type { Activity } from "$lib/bindings"; let selectedUserId = $state(null); let lockedPopoverUserId = $state(null); @@ -69,6 +70,21 @@ ); } + function orderedActivities( + activities: Map | undefined, + ): Activity[] { + if (!activities) return []; + return [...activities] + .sort(([leftSource], [rightSource]) => + leftSource.localeCompare(rightSource), + ) + .map(([, activity]) => activity); + } + + function firstActivityIcon(activities: Activity[]): string | undefined { + return activities.find((activity) => activity.icon)?.icon ?? undefined; + } + function dismissInteraction(interactionId: string) { incomingInteraction.update((current) => current?.interactionId === interactionId ? null : current, @@ -113,7 +129,9 @@ {#each $puppetStates as puppet, index (puppet.id)} {@const userId = puppet.id} {@const bounds = puppetBoundsById.get(userId)} - {@const foregroundApp = $liveMetadata.foregroundApps.get(userId)} + {@const activities = $liveMetadata.activities.get(userId)} + {@const userActivities = orderedActivities(activities)} + {@const activityIcon = firstActivityIcon(userActivities)} {@const popoverId = `scene-user-${index}`} {@const interaction = $incomingInteraction?.friendId === userId ? $incomingInteraction : null} @@ -137,9 +155,9 @@ /> {/if} - {#if foregroundApp?.ico} + {#if activityIcon} (lockedPopoverUserId = active ? userId : null)} onDismiss={dismissPopover} diff --git a/src/routes/scene/popovers/user-interaction.svelte b/src/routes/scene/popovers/user-interaction.svelte index 3c26bee..626b7c4 100644 --- a/src/routes/scene/popovers/user-interaction.svelte +++ b/src/routes/scene/popovers/user-interaction.svelte @@ -1,7 +1,7 @@