abstract ufa to user activity
This commit is contained in:
@@ -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(¤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<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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
{
|
||||
|
||||
@@ -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
@@ -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,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>,
|
||||
|
||||
Reference in New Issue
Block a user