abstract ufa to user activity

This commit is contained in:
2026-09-10 17:39:29 +08:00
parent 03c13734eb
commit 0ec9c08449
15 changed files with 497 additions and 254 deletions
+286
View File
@@ -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<String>,
pub details: Option<String>,
pub icon: Option<String>,
}
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<String, Activity>;
type ActivitiesByUser = HashMap<String, Activities>;
#[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<ActivitiesByUser>);
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<Activity>,
) -> Result<Activities, String> {
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(&current)?;
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<Vec<String>, 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<ActivitiesByUser, String> {
self.0
.read()
.map(|activities| activities.clone())
.map_err(|error| error.to_string())
}
pub(crate) fn get(&self, user_id: &str) -> Result<Option<Activities>, 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<String>,
activity: Option<Activity>,
) {
let user_id = handle
.state::<crate::keypair::AppKeypair>()
.public_key()
.to_owned();
let activities =
match handle
.state::<ActivityState>()
.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::<crate::network::Network>()
.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::<ActivityState>()
.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::<ActivityState>().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")
);
}
}
+3 -2
View File
@@ -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<dyn std::error:
keypair::init(handle).await?;
interactions::init(handle);
cursor::init(handle);
activity::init(handle);
network::init(handle).await?;
ufa::init(handle);
puppet::init(handle)?;
@@ -136,8 +138,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
network::NetworkStatusChanged,
network::FriendStatusesChanged,
cursor::CursorPositionChanged,
ufa::ForegroundAppChanged,
ufa::FriendForegroundAppChanged,
activity::ActivitiesChanged,
interactions::FriendInteractionReceived,
puppet::PuppetStatesChanged,
scene::SceneConfigurationChanged,
+10 -10
View File
@@ -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<String, CursorPositions>,
pub foreground_apps: HashMap<String, AppMeta>,
pub activities: HashMap<String, Activities>,
}
#[tauri::command]
@@ -57,8 +57,8 @@ pub struct LiveDataSnapshot {
pub fn list_live_data(handle: AppHandle) -> Result<LiveDataSnapshot, String> {
Ok(LiveDataSnapshot {
cursor_positions: handle.state::<crate::cursor::CursorState>().snapshot()?,
foreground_apps: handle
.state::<crate::ufa::ForegroundAppState>()
activities: handle
.state::<crate::activity::ActivityState>()
.snapshot()?,
})
}
@@ -73,11 +73,11 @@ pub(crate) fn publish_current(handle: &AppHandle) -> Result<(), String> {
if let Some(positions) = handle.state::<crate::cursor::CursorState>().get(&user_id)? {
network.send_live_data(LiveData::Cursor { positions });
}
if let Some(meta) = handle
.state::<crate::ufa::ForegroundAppState>()
if let Some(activities) = handle
.state::<crate::activity::ActivityState>()
.get(&user_id)?
{
network.send_live_data(LiveData::ForegroundApp { meta });
network.send_live_data(LiveData::Activities { activities });
}
Ok(())
}
+6 -6
View File
@@ -85,7 +85,7 @@ pub(super) struct ConnectionInputs {
pub(super) friends: watch::Receiver<Vec<String>>,
pub(super) keypair: AppKeypair,
pub(super) cursor_data: watch::Receiver<Option<String>>,
pub(super) foreground_app_data: watch::Receiver<Option<String>>,
pub(super) activity_data: watch::Receiver<Option<String>>,
pub(super) interactions: mpsc::Receiver<InteractionRequest>,
pub(super) profile_lookups: mpsc::Receiver<ProfileLookupRequest>,
pub(super) skin_lookups: mpsc::Receiver<SkinLookupRequest>,
@@ -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)?
{
+16 -19
View File
@@ -58,7 +58,7 @@ pub struct FriendStatusesChanged {
struct Connection {
remote: Remote,
cursor_sender: watch::Sender<Option<String>>,
foreground_app_sender: watch::Sender<Option<String>>,
activity_sender: watch::Sender<Option<String>>,
interaction_sender: mpsc::Sender<InteractionRequest>,
profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>,
skin_lookup_sender: mpsc::Sender<SkinLookupRequest>,
@@ -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<dyn std::error::Error>>
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::<crate::ufa::ForegroundAppState>()
.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)
{
+19 -110
View File
@@ -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<HashMap<String, AppMeta>>);
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<HashMap<String, AppMeta>, String> {
self.0
.read()
.map(|apps| apps.clone())
.map_err(|error| error.to_string())
}
pub(crate) fn get(&self, user_id: &str) -> Result<Option<AppMeta>, 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::<ForegroundAppState>()
.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::<crate::keypair::AppKeypair>()
.public_key()
.to_owned();
if let Err(error) = handle
.state::<ForegroundAppState>()
.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::<crate::network::Network>()
.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,
}),
);
}
+1 -2
View File
@@ -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<String>,