abstract ufa to user activity
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
|
|
||||||
pub const VERSION: u8 = 4;
|
pub const VERSION: u8 = 5;
|
||||||
pub const DEFAULT_SERVER_PORT: u16 = 27_520;
|
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_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
|
||||||
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
|
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
|
||||||
pub const MAX_SKIN_B64_SIZE: usize = 96 * 1024;
|
pub const MAX_SKIN_B64_SIZE: usize = 96 * 1024;
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ use axum::routing::get;
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
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 futures_util::{SinkExt, StreamExt};
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -273,6 +276,10 @@ async fn relay_live_data(
|
|||||||
payload: String,
|
payload: String,
|
||||||
signature: &str,
|
signature: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
if payload.len() > MAX_LIVE_DATA_PAYLOAD_BYTES {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let clients = clients.lock().await;
|
let clients = clients.lock().await;
|
||||||
let Some(source) = clients
|
let Some(source) = clients
|
||||||
.get(public_key)
|
.get(public_key)
|
||||||
@@ -628,6 +635,22 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.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());
|
assert!(mutual_receiver.try_recv().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 application;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
mod db;
|
mod db;
|
||||||
@@ -27,6 +28,7 @@ async fn launch_app(handle: &tauri::AppHandle) -> Result<(), Box<dyn std::error:
|
|||||||
keypair::init(handle).await?;
|
keypair::init(handle).await?;
|
||||||
interactions::init(handle);
|
interactions::init(handle);
|
||||||
cursor::init(handle);
|
cursor::init(handle);
|
||||||
|
activity::init(handle);
|
||||||
network::init(handle).await?;
|
network::init(handle).await?;
|
||||||
ufa::init(handle);
|
ufa::init(handle);
|
||||||
puppet::init(handle)?;
|
puppet::init(handle)?;
|
||||||
@@ -136,8 +138,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
|||||||
network::NetworkStatusChanged,
|
network::NetworkStatusChanged,
|
||||||
network::FriendStatusesChanged,
|
network::FriendStatusesChanged,
|
||||||
cursor::CursorPositionChanged,
|
cursor::CursorPositionChanged,
|
||||||
ufa::ForegroundAppChanged,
|
activity::ActivitiesChanged,
|
||||||
ufa::FriendForegroundAppChanged,
|
|
||||||
interactions::FriendInteractionReceived,
|
interactions::FriendInteractionReceived,
|
||||||
puppet::PuppetStatesChanged,
|
puppet::PuppetStatesChanged,
|
||||||
scene::SceneConfigurationChanged,
|
scene::SceneConfigurationChanged,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::activity::Activities;
|
||||||
use crate::cursor::CursorPositions;
|
use crate::cursor::CursorPositions;
|
||||||
use crate::ufa::AppMeta;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub(crate) enum LiveDataKind {
|
pub(crate) enum LiveDataKind {
|
||||||
Cursor,
|
Cursor,
|
||||||
ForegroundApp,
|
Activity,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -33,14 +33,14 @@ impl LiveDataEnvelope {
|
|||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
pub enum LiveData {
|
pub enum LiveData {
|
||||||
Cursor { positions: CursorPositions },
|
Cursor { positions: CursorPositions },
|
||||||
ForegroundApp { meta: AppMeta },
|
Activities { activities: Activities },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveData {
|
impl LiveData {
|
||||||
pub(crate) fn kind(&self) -> LiveDataKind {
|
pub(crate) fn kind(&self) -> LiveDataKind {
|
||||||
match self {
|
match self {
|
||||||
Self::Cursor { .. } => LiveDataKind::Cursor,
|
Self::Cursor { .. } => LiveDataKind::Cursor,
|
||||||
Self::ForegroundApp { .. } => LiveDataKind::ForegroundApp,
|
Self::Activities { .. } => LiveDataKind::Activity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ impl LiveData {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LiveDataSnapshot {
|
pub struct LiveDataSnapshot {
|
||||||
pub cursor_positions: HashMap<String, CursorPositions>,
|
pub cursor_positions: HashMap<String, CursorPositions>,
|
||||||
pub foreground_apps: HashMap<String, AppMeta>,
|
pub activities: HashMap<String, Activities>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -57,8 +57,8 @@ pub struct LiveDataSnapshot {
|
|||||||
pub fn list_live_data(handle: AppHandle) -> Result<LiveDataSnapshot, String> {
|
pub fn list_live_data(handle: AppHandle) -> Result<LiveDataSnapshot, String> {
|
||||||
Ok(LiveDataSnapshot {
|
Ok(LiveDataSnapshot {
|
||||||
cursor_positions: handle.state::<crate::cursor::CursorState>().snapshot()?,
|
cursor_positions: handle.state::<crate::cursor::CursorState>().snapshot()?,
|
||||||
foreground_apps: handle
|
activities: handle
|
||||||
.state::<crate::ufa::ForegroundAppState>()
|
.state::<crate::activity::ActivityState>()
|
||||||
.snapshot()?,
|
.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)? {
|
if let Some(positions) = handle.state::<crate::cursor::CursorState>().get(&user_id)? {
|
||||||
network.send_live_data(LiveData::Cursor { positions });
|
network.send_live_data(LiveData::Cursor { positions });
|
||||||
}
|
}
|
||||||
if let Some(meta) = handle
|
if let Some(activities) = handle
|
||||||
.state::<crate::ufa::ForegroundAppState>()
|
.state::<crate::activity::ActivityState>()
|
||||||
.get(&user_id)?
|
.get(&user_id)?
|
||||||
{
|
{
|
||||||
network.send_live_data(LiveData::ForegroundApp { meta });
|
network.send_live_data(LiveData::Activities { activities });
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ pub(super) struct ConnectionInputs {
|
|||||||
pub(super) friends: watch::Receiver<Vec<String>>,
|
pub(super) friends: watch::Receiver<Vec<String>>,
|
||||||
pub(super) keypair: AppKeypair,
|
pub(super) keypair: AppKeypair,
|
||||||
pub(super) cursor_data: watch::Receiver<Option<String>>,
|
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) interactions: mpsc::Receiver<InteractionRequest>,
|
||||||
pub(super) profile_lookups: mpsc::Receiver<ProfileLookupRequest>,
|
pub(super) profile_lookups: mpsc::Receiver<ProfileLookupRequest>,
|
||||||
pub(super) skin_lookups: mpsc::Receiver<SkinLookupRequest>,
|
pub(super) skin_lookups: mpsc::Receiver<SkinLookupRequest>,
|
||||||
@@ -144,7 +144,7 @@ async fn connect(
|
|||||||
friends,
|
friends,
|
||||||
keypair,
|
keypair,
|
||||||
cursor_data,
|
cursor_data,
|
||||||
foreground_app_data,
|
activity_data,
|
||||||
interactions: active_outgoing,
|
interactions: active_outgoing,
|
||||||
profile_lookups,
|
profile_lookups,
|
||||||
skin_lookups,
|
skin_lookups,
|
||||||
@@ -189,7 +189,7 @@ async fn connect(
|
|||||||
return Err("server rejected registration".into());
|
return Err("server rejected registration".into());
|
||||||
}
|
}
|
||||||
cursor_data.borrow_and_update();
|
cursor_data.borrow_and_update();
|
||||||
foreground_app_data.borrow_and_update();
|
activity_data.borrow_and_update();
|
||||||
while let Ok(request) = active_outgoing.try_recv() {
|
while let Ok(request) = active_outgoing.try_recv() {
|
||||||
let _ = request
|
let _ = request
|
||||||
.response
|
.response
|
||||||
@@ -230,9 +230,9 @@ async fn connect(
|
|||||||
}).await?;
|
}).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changed = foreground_app_data.changed() => {
|
changed = activity_data.changed() => {
|
||||||
changed.map_err(|_| "foreground-app sender closed")?;
|
changed.map_err(|_| "activity sender closed")?;
|
||||||
let payload = { foreground_app_data.borrow_and_update().clone() };
|
let payload = { activity_data.borrow_and_update().clone() };
|
||||||
if let Some(payload) = payload
|
if let Some(payload) = payload
|
||||||
&& friend_presence.has_online_friends(&remote.id)?
|
&& friend_presence.has_online_friends(&remote.id)?
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ pub struct FriendStatusesChanged {
|
|||||||
struct Connection {
|
struct Connection {
|
||||||
remote: Remote,
|
remote: Remote,
|
||||||
cursor_sender: watch::Sender<Option<String>>,
|
cursor_sender: watch::Sender<Option<String>>,
|
||||||
foreground_app_sender: watch::Sender<Option<String>>,
|
activity_sender: watch::Sender<Option<String>>,
|
||||||
interaction_sender: mpsc::Sender<InteractionRequest>,
|
interaction_sender: mpsc::Sender<InteractionRequest>,
|
||||||
profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>,
|
profile_lookup_sender: mpsc::Sender<ProfileLookupRequest>,
|
||||||
skin_lookup_sender: mpsc::Sender<SkinLookupRequest>,
|
skin_lookup_sender: mpsc::Sender<SkinLookupRequest>,
|
||||||
@@ -85,7 +85,7 @@ pub struct Network {
|
|||||||
next_generation: AtomicU64,
|
next_generation: AtomicU64,
|
||||||
live_session_id: String,
|
live_session_id: String,
|
||||||
next_cursor_sequence: AtomicU64,
|
next_cursor_sequence: AtomicU64,
|
||||||
next_foreground_app_sequence: AtomicU64,
|
next_activity_sequence: AtomicU64,
|
||||||
received_sequences: SequenceTracker,
|
received_sequences: SequenceTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +109,7 @@ impl Network {
|
|||||||
};
|
};
|
||||||
let sequence = match data.kind() {
|
let sequence = match data.kind() {
|
||||||
LiveDataKind::Cursor => self.next_cursor_sequence.fetch_add(1, Ordering::Relaxed),
|
LiveDataKind::Cursor => self.next_cursor_sequence.fetch_add(1, Ordering::Relaxed),
|
||||||
LiveDataKind::ForegroundApp => self
|
LiveDataKind::Activity => self.next_activity_sequence.fetch_add(1, Ordering::Relaxed),
|
||||||
.next_foreground_app_sequence
|
|
||||||
.fetch_add(1, Ordering::Relaxed),
|
|
||||||
};
|
};
|
||||||
let envelope = LiveDataEnvelope {
|
let envelope = LiveDataEnvelope {
|
||||||
session_id: self.live_session_id.clone(),
|
session_id: self.live_session_id.clone(),
|
||||||
@@ -122,6 +120,10 @@ impl Network {
|
|||||||
eprintln!("failed to serialize live data");
|
eprintln!("failed to serialize live data");
|
||||||
return;
|
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 {
|
let Ok(connections) = self.connections.lock() else {
|
||||||
eprintln!("failed to lock remote connections for live data");
|
eprintln!("failed to lock remote connections for live data");
|
||||||
return;
|
return;
|
||||||
@@ -134,9 +136,9 @@ impl Network {
|
|||||||
LiveDataKind::Cursor => {
|
LiveDataKind::Cursor => {
|
||||||
connection.cursor_sender.send_replace(Some(payload.clone()));
|
connection.cursor_sender.send_replace(Some(payload.clone()));
|
||||||
}
|
}
|
||||||
LiveDataKind::ForegroundApp => {
|
LiveDataKind::Activity => {
|
||||||
connection
|
connection
|
||||||
.foreground_app_sender
|
.activity_sender
|
||||||
.send_replace(Some(payload.clone()));
|
.send_replace(Some(payload.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,8 +187,8 @@ impl Network {
|
|||||||
LiveData::Cursor { positions } => {
|
LiveData::Cursor { positions } => {
|
||||||
crate::cursor::emit_position(handle, friend_id, positions);
|
crate::cursor::emit_position(handle, friend_id, positions);
|
||||||
}
|
}
|
||||||
LiveData::ForegroundApp { meta } => {
|
LiveData::Activities { activities } => {
|
||||||
crate::ufa::emit_friend_app(handle, friend_id, meta);
|
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 generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||||
let (cursor_sender, cursor_receiver) = watch::channel(None);
|
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 (interaction_sender, interaction_receiver) = mpsc::channel(16);
|
||||||
let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16);
|
let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16);
|
||||||
let (skin_lookup_sender, skin_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(),
|
friends: self.friends.subscribe(),
|
||||||
keypair: self.keypair.clone(),
|
keypair: self.keypair.clone(),
|
||||||
cursor_data: cursor_receiver,
|
cursor_data: cursor_receiver,
|
||||||
foreground_app_data: foreground_app_receiver,
|
activity_data: activity_receiver,
|
||||||
interactions: interaction_receiver,
|
interactions: interaction_receiver,
|
||||||
profile_lookups: profile_lookup_receiver,
|
profile_lookups: profile_lookup_receiver,
|
||||||
skin_lookups: skin_lookup_receiver,
|
skin_lookups: skin_lookup_receiver,
|
||||||
@@ -415,7 +417,7 @@ impl Network {
|
|||||||
Connection {
|
Connection {
|
||||||
remote,
|
remote,
|
||||||
cursor_sender,
|
cursor_sender,
|
||||||
foreground_app_sender,
|
activity_sender,
|
||||||
interaction_sender,
|
interaction_sender,
|
||||||
profile_lookup_sender,
|
profile_lookup_sender,
|
||||||
skin_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),
|
next_generation: AtomicU64::new(1),
|
||||||
live_session_id: uuid::Uuid::new_v4().to_string(),
|
live_session_id: uuid::Uuid::new_v4().to_string(),
|
||||||
next_cursor_sequence: AtomicU64::new(1),
|
next_cursor_sequence: AtomicU64::new(1),
|
||||||
next_foreground_app_sequence: AtomicU64::new(1),
|
next_activity_sequence: AtomicU64::new(1),
|
||||||
received_sequences: SequenceTracker::default(),
|
received_sequences: SequenceTracker::default(),
|
||||||
};
|
};
|
||||||
handle.manage(network);
|
handle.manage(network);
|
||||||
@@ -535,12 +537,7 @@ fn apply_friend_presence_change(
|
|||||||
match result {
|
match result {
|
||||||
Ok(Some(change)) => {
|
Ok(Some(change)) => {
|
||||||
crate::cursor::remove_positions(handle, &change.went_offline);
|
crate::cursor::remove_positions(handle, &change.went_offline);
|
||||||
if let Err(error) = handle
|
crate::activity::remove_users(handle, &change.went_offline);
|
||||||
.state::<crate::ufa::ForegroundAppState>()
|
|
||||||
.remove(&change.went_offline)
|
|
||||||
{
|
|
||||||
eprintln!("failed to remove offline foreground apps: {error}");
|
|
||||||
}
|
|
||||||
if change.route_added
|
if change.route_added
|
||||||
&& let Err(error) = crate::live_data::publish_current(handle)
|
&& let Err(error) = crate::live_data::publish_current(handle)
|
||||||
{
|
{
|
||||||
|
|||||||
+19
-110
@@ -2,10 +2,7 @@
|
|||||||
UFA: User Focused App
|
UFA: User Focused App
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use tauri::AppHandle;
|
||||||
use std::sync::RwLock;
|
|
||||||
use tauri::{AppHandle, Manager};
|
|
||||||
use tauri_specta::Event;
|
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
mod icon_cache;
|
mod icon_cache;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -14,51 +11,7 @@ mod types;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod windows;
|
mod windows;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type, Event)]
|
const ACTIVITY_SOURCE: &str = "foreground-app";
|
||||||
#[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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_app() -> AppMeta {
|
fn current_app() -> AppMeta {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -104,72 +57,28 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(handle: &AppHandle) {
|
pub fn init(handle: &AppHandle) {
|
||||||
handle.manage(ForegroundAppState::default());
|
|
||||||
update_local_app(handle, current_app());
|
update_local_app(handle, current_app());
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
|
|
||||||
init_listener(move |meta: AppMeta| update_local_app(&handle, meta));
|
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) {
|
fn update_local_app(handle: &AppHandle, meta: AppMeta) {
|
||||||
let user_id = handle
|
let details = meta
|
||||||
.state::<crate::keypair::AppKeypair>()
|
.local
|
||||||
.public_key()
|
.as_ref()
|
||||||
.to_owned();
|
.zip(meta.unlocal.as_ref())
|
||||||
if let Err(error) = handle
|
.filter(|(local, unlocal)| local != unlocal)
|
||||||
.state::<ForegroundAppState>()
|
.map(|(_, unlocal)| unlocal.clone());
|
||||||
.update(user_id, meta.clone())
|
let name = meta.local.or(meta.unlocal);
|
||||||
{
|
crate::activity::update_local(
|
||||||
eprintln!("Failed to cache foreground app: {error}");
|
handle,
|
||||||
return;
|
ACTIVITY_SOURCE,
|
||||||
}
|
Some(crate::activity::Activity {
|
||||||
if let Err(error) = (ForegroundAppChanged { meta: meta.clone() }).emit(handle) {
|
kind: crate::activity::ActivityKind::Application,
|
||||||
eprintln!("Failed to emit foreground app change: {error}");
|
name,
|
||||||
}
|
details,
|
||||||
handle
|
icon: meta.ico,
|
||||||
.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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
|
||||||
|
|
||||||
pub const ICON_SIZE: u32 = 64;
|
pub const ICON_SIZE: u32 = 64;
|
||||||
pub const ICON_CACHE_LIMIT: usize = 50;
|
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;
|
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.
|
/// 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")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AppMeta {
|
pub struct AppMeta {
|
||||||
pub local: Option<String>,
|
pub local: Option<String>,
|
||||||
|
|||||||
+7
-9
@@ -107,9 +107,8 @@ async updateSceneHitboxes(hitboxes: SceneHitbox[]) : Promise<null> {
|
|||||||
|
|
||||||
|
|
||||||
export const events = __makeEvents__<{
|
export const events = __makeEvents__<{
|
||||||
|
activitiesChanged: ActivitiesChanged,
|
||||||
cursorPositionChanged: CursorPositionChanged,
|
cursorPositionChanged: CursorPositionChanged,
|
||||||
foregroundAppChanged: ForegroundAppChanged,
|
|
||||||
friendForegroundAppChanged: FriendForegroundAppChanged,
|
|
||||||
friendInteractionReceived: FriendInteractionReceived,
|
friendInteractionReceived: FriendInteractionReceived,
|
||||||
friendStatusesChanged: FriendStatusesChanged,
|
friendStatusesChanged: FriendStatusesChanged,
|
||||||
friendsChanged: FriendsChanged,
|
friendsChanged: FriendsChanged,
|
||||||
@@ -120,9 +119,8 @@ puppetStatesChanged: PuppetStatesChanged,
|
|||||||
remotesChanged: RemotesChanged,
|
remotesChanged: RemotesChanged,
|
||||||
sceneConfigurationChanged: SceneConfigurationChanged
|
sceneConfigurationChanged: SceneConfigurationChanged
|
||||||
}>({
|
}>({
|
||||||
|
activitiesChanged: "activities-changed",
|
||||||
cursorPositionChanged: "cursor-position-changed",
|
cursorPositionChanged: "cursor-position-changed",
|
||||||
foregroundAppChanged: "foreground-app-changed",
|
|
||||||
friendForegroundAppChanged: "friend-foreground-app-changed",
|
|
||||||
friendInteractionReceived: "friend-interaction-received",
|
friendInteractionReceived: "friend-interaction-received",
|
||||||
friendStatusesChanged: "friend-statuses-changed",
|
friendStatusesChanged: "friend-statuses-changed",
|
||||||
friendsChanged: "friends-changed",
|
friendsChanged: "friends-changed",
|
||||||
@@ -140,10 +138,12 @@ sceneConfigurationChanged: "scene-configuration-changed"
|
|||||||
|
|
||||||
/** user-defined types **/
|
/** 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 ConnectionState = "connecting" | "connected" | "disconnected"
|
||||||
export type ConnectionStatus = { remoteId: string; address: string; name: string | null; state: ConnectionState }
|
export type ConnectionStatus = { remoteId: string; address: string; name: string | null; state: ConnectionState }
|
||||||
export type CursorPosition = { x: number; y: number }
|
export type CursorPosition = { x: number; y: number }
|
||||||
@@ -157,18 +157,16 @@ raw: CursorPosition;
|
|||||||
* Cursor coordinates normalized to the source monitor for scale-independent projection.
|
* Cursor coordinates normalized to the source monitor for scale-independent projection.
|
||||||
*/
|
*/
|
||||||
mapped: CursorPosition }
|
mapped: CursorPosition }
|
||||||
export type ForegroundAppChanged = { meta: AppMeta }
|
|
||||||
/**
|
/**
|
||||||
* A configured public-key relationship with optional cached remote metadata.
|
* A configured public-key relationship with optional cached remote metadata.
|
||||||
* The display name remains absent until learned from a signed remote profile.
|
* 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 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 FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent }
|
||||||
export type FriendStatusesChanged = { friendIds: string[] }
|
export type FriendStatusesChanged = { friendIds: string[] }
|
||||||
export type FriendsChanged = { friends: Friend[] }
|
export type FriendsChanged = { friends: Friend[] }
|
||||||
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
|
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 LocaleChanged = { preference: string; locale: string }
|
||||||
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||||
export type ProfileChanged = { profile: User }
|
export type ProfileChanged = { profile: User }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
...new Set([
|
...new Set([
|
||||||
$liveMetadata.localId,
|
$liveMetadata.localId,
|
||||||
...Object.keys($liveMetadata.cursorPositions),
|
...Object.keys($liveMetadata.cursorPositions),
|
||||||
...$liveMetadata.foregroundApps.keys(),
|
...$liveMetadata.activities.keys(),
|
||||||
]),
|
]),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each liveUserIds() as userId (userId)}
|
{#each liveUserIds() as userId (userId)}
|
||||||
{@const cursor = $liveMetadata.cursorPositions[userId]}
|
{@const cursor = $liveMetadata.cursorPositions[userId]}
|
||||||
{@const foregroundApp = $liveMetadata.foregroundApps.get(userId)}
|
{@const activities = $liveMetadata.activities.get(userId)}
|
||||||
<article>
|
<article>
|
||||||
<h2>{userId === $liveMetadata.localId ? "Local user" : "Remote user"}</h2>
|
<h2>{userId === $liveMetadata.localId ? "Local user" : "Remote user"}</h2>
|
||||||
<p title={userId}>ID: {compactId(userId)}</p>
|
<p title={userId}>ID: {compactId(userId)}</p>
|
||||||
@@ -42,20 +42,25 @@
|
|||||||
<p>Waiting for cursor data...</p>
|
<p>Waiting for cursor data...</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h3>Foreground app</h3>
|
<h3>Activities</h3>
|
||||||
{#if foregroundApp}
|
{#if activities}
|
||||||
{#if foregroundApp.ico}
|
{#each activities as [source, activity]}
|
||||||
<img
|
<section>
|
||||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
<h4>{source} ({activity.kind})</h4>
|
||||||
alt=""
|
{#if activity.icon}
|
||||||
width="64"
|
<img
|
||||||
height="64"
|
src={`data:image/png;base64,${activity.icon}`}
|
||||||
/>
|
alt=""
|
||||||
{/if}
|
width="64"
|
||||||
<p>Localized name: {foregroundApp.local ?? "Unavailable"}</p>
|
height="64"
|
||||||
<p>Executable name: {foregroundApp.unlocal ?? "Unavailable"}</p>
|
/>
|
||||||
|
{/if}
|
||||||
|
<p>Name: {activity.name ?? "Unavailable"}</p>
|
||||||
|
<p>Details: {activity.details ?? "Unavailable"}</p>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<p>Waiting for foreground app data...</p>
|
<p>Waiting for activity data...</p>
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import { commands, events } from "$lib/bindings";
|
import { commands, events } from "$lib/bindings";
|
||||||
import { retainOnlineForegroundApps } from "./live-metadata";
|
|
||||||
|
|
||||||
export const onlineFriendIds = writable<Set<string>>(new Set());
|
export const onlineFriendIds = writable<Set<string>>(new Set());
|
||||||
export const friendStatusesListenerError = writable("");
|
export const friendStatusesListenerError = writable("");
|
||||||
|
|
||||||
export async function initFriendStatusesListener() {
|
export async function initFriendStatusesListener() {
|
||||||
const apply = (friendIds: string[]) => {
|
const apply = (friendIds: string[]) => {
|
||||||
const next = new Set(friendIds);
|
onlineFriendIds.set(new Set(friendIds));
|
||||||
onlineFriendIds.set(next);
|
|
||||||
retainOnlineForegroundApps(next);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlisten = await events.friendStatusesChanged.listen((event) => {
|
const unlisten = await events.friendStatusesChanged.listen((event) => {
|
||||||
|
|||||||
@@ -2,47 +2,48 @@ import { writable } from "svelte/store";
|
|||||||
import {
|
import {
|
||||||
commands,
|
commands,
|
||||||
events,
|
events,
|
||||||
type AppMeta,
|
type Activity,
|
||||||
type CursorPositions,
|
type CursorPositions,
|
||||||
} from "$lib/bindings";
|
} from "$lib/bindings";
|
||||||
|
|
||||||
export type LiveMetadata = {
|
export type LiveMetadata = {
|
||||||
localId: string;
|
localId: string;
|
||||||
cursorPositions: Partial<Record<string, CursorPositions>>;
|
cursorPositions: Partial<Record<string, CursorPositions>>;
|
||||||
foregroundApps: Map<string, AppMeta>;
|
activities: Map<string, Map<string, Activity>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const liveMetadata = writable<LiveMetadata>({
|
export const liveMetadata = writable<LiveMetadata>({
|
||||||
localId: "",
|
localId: "",
|
||||||
cursorPositions: {},
|
cursorPositions: {},
|
||||||
foregroundApps: new Map(),
|
activities: new Map(),
|
||||||
});
|
});
|
||||||
export const liveMetadataListenerError = writable("");
|
export const liveMetadataListenerError = writable("");
|
||||||
|
|
||||||
export function retainOnlineForegroundApps(onlineFriendIds: Set<string>) {
|
|
||||||
liveMetadata.update((metadata) => ({
|
|
||||||
...metadata,
|
|
||||||
foregroundApps: new Map(
|
|
||||||
[...metadata.foregroundApps].filter(
|
|
||||||
([userId]) =>
|
|
||||||
userId === metadata.localId || onlineFriendIds.has(userId),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initLiveMetadataListeners() {
|
export async function initLiveMetadataListeners() {
|
||||||
let localId = "";
|
let localId = "";
|
||||||
let initializing = true;
|
let initializing = true;
|
||||||
let pendingCursorPositions: LiveMetadata["cursorPositions"] | null = null;
|
let pendingCursorPositions: LiveMetadata["cursorPositions"] | null = null;
|
||||||
let pendingLocalForegroundApp: AppMeta | null = null;
|
const pendingActivities = new Map<string, Map<string, Activity>>();
|
||||||
const pendingFriendForegroundApps = new Map<string, AppMeta>();
|
|
||||||
|
|
||||||
const updateForegroundApp = (userId: string, meta: AppMeta) => {
|
const activityMap = (
|
||||||
liveMetadata.update((current) => ({
|
activities: Partial<Record<string, Activity>>,
|
||||||
...current,
|
): Map<string, Activity> =>
|
||||||
foregroundApps: new Map(current.foregroundApps).set(userId, meta),
|
new Map(
|
||||||
}));
|
Object.entries(activities).filter(
|
||||||
|
(entry): entry is [string, Activity] => entry[1] !== undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const replaceActivities = (
|
||||||
|
userId: string,
|
||||||
|
userActivities: Map<string, Activity>,
|
||||||
|
) => {
|
||||||
|
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 = () => {
|
const applyPendingUpdates = () => {
|
||||||
@@ -51,17 +52,13 @@ export async function initLiveMetadataListeners() {
|
|||||||
liveMetadata.update((current) => ({ ...current, cursorPositions }));
|
liveMetadata.update((current) => ({ ...current, cursorPositions }));
|
||||||
pendingCursorPositions = null;
|
pendingCursorPositions = null;
|
||||||
}
|
}
|
||||||
if (localId && pendingLocalForegroundApp) {
|
pendingActivities.forEach((activities, userId) => {
|
||||||
updateForegroundApp(localId, pendingLocalForegroundApp);
|
replaceActivities(userId, activities);
|
||||||
pendingLocalForegroundApp = null;
|
|
||||||
}
|
|
||||||
pendingFriendForegroundApps.forEach((meta, friendId) => {
|
|
||||||
updateForegroundApp(friendId, meta);
|
|
||||||
});
|
});
|
||||||
pendingFriendForegroundApps.clear();
|
pendingActivities.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlisteners = await Promise.all([
|
const subscriptions = await Promise.allSettled([
|
||||||
events.cursorPositionChanged.listen((event) => {
|
events.cursorPositionChanged.listen((event) => {
|
||||||
if (initializing) {
|
if (initializing) {
|
||||||
pendingCursorPositions = event.payload.positions;
|
pendingCursorPositions = event.payload.positions;
|
||||||
@@ -72,24 +69,24 @@ export async function initLiveMetadataListeners() {
|
|||||||
cursorPositions: event.payload.positions,
|
cursorPositions: event.payload.positions,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
events.foregroundAppChanged.listen((event) => {
|
events.activitiesChanged.listen((event) => {
|
||||||
if (!initializing && localId) {
|
const activities = activityMap(event.payload.activities);
|
||||||
updateForegroundApp(localId, event.payload.meta);
|
|
||||||
} else {
|
|
||||||
pendingLocalForegroundApp = event.payload.meta;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
events.friendForegroundAppChanged.listen((event) => {
|
|
||||||
if (initializing) {
|
if (initializing) {
|
||||||
pendingFriendForegroundApps.set(
|
pendingActivities.set(event.payload.userId, activities);
|
||||||
event.payload.friendId,
|
|
||||||
event.payload.meta,
|
|
||||||
);
|
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
const [snapshot] = await Promise.all([
|
const [snapshot] = await Promise.all([
|
||||||
@@ -98,14 +95,20 @@ export async function initLiveMetadataListeners() {
|
|||||||
localId = resolvedLocalId;
|
localId = resolvedLocalId;
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const foregroundApps = new Map<string, AppMeta>();
|
const activities = new Map<string, Map<string, Activity>>();
|
||||||
Object.entries(snapshot.foregroundApps).forEach(([userId, meta]) => {
|
Object.entries(snapshot.activities).forEach(
|
||||||
if (meta) foregroundApps.set(userId, meta);
|
([userId, sourceActivities]) => {
|
||||||
});
|
if (!sourceActivities) return;
|
||||||
|
const userActivities = activityMap(sourceActivities);
|
||||||
|
if (userActivities.size > 0) {
|
||||||
|
activities.set(userId, userActivities);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
liveMetadata.set({
|
liveMetadata.set({
|
||||||
localId,
|
localId,
|
||||||
cursorPositions: snapshot.cursorPositions,
|
cursorPositions: snapshot.cursorPositions,
|
||||||
foregroundApps,
|
activities,
|
||||||
});
|
});
|
||||||
initializing = false;
|
initializing = false;
|
||||||
applyPendingUpdates();
|
applyPendingUpdates();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import SceneInteractionBubble from "./popovers/interaction-bubble.svelte";
|
import SceneInteractionBubble from "./popovers/interaction-bubble.svelte";
|
||||||
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
|
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
|
||||||
import { messages } from "$lib/i18n";
|
import { messages } from "$lib/i18n";
|
||||||
|
import type { Activity } from "$lib/bindings";
|
||||||
|
|
||||||
let selectedUserId = $state<string | null>(null);
|
let selectedUserId = $state<string | null>(null);
|
||||||
let lockedPopoverUserId = $state<string | null>(null);
|
let lockedPopoverUserId = $state<string | null>(null);
|
||||||
@@ -69,6 +70,21 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderedActivities(
|
||||||
|
activities: Map<string, Activity> | 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) {
|
function dismissInteraction(interactionId: string) {
|
||||||
incomingInteraction.update((current) =>
|
incomingInteraction.update((current) =>
|
||||||
current?.interactionId === interactionId ? null : current,
|
current?.interactionId === interactionId ? null : current,
|
||||||
@@ -113,7 +129,9 @@
|
|||||||
{#each $puppetStates as puppet, index (puppet.id)}
|
{#each $puppetStates as puppet, index (puppet.id)}
|
||||||
{@const userId = puppet.id}
|
{@const userId = puppet.id}
|
||||||
{@const bounds = puppetBoundsById.get(userId)}
|
{@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 popoverId = `scene-user-${index}`}
|
||||||
{@const interaction =
|
{@const interaction =
|
||||||
$incomingInteraction?.friendId === userId ? $incomingInteraction : null}
|
$incomingInteraction?.friendId === userId ? $incomingInteraction : null}
|
||||||
@@ -137,9 +155,9 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if foregroundApp?.ico}
|
{#if activityIcon}
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
src={`data:image/png;base64,${activityIcon}`}
|
||||||
alt=""
|
alt=""
|
||||||
class="pointer-events-none absolute -top-5 left-1/2 size-4 -translate-x-1/2 object-contain"
|
class="pointer-events-none absolute -top-5 left-1/2 size-4 -translate-x-1/2 object-contain"
|
||||||
style:opacity={$sceneConfiguration.puppetOpacity}
|
style:opacity={$sceneConfiguration.puppetOpacity}
|
||||||
@@ -168,7 +186,7 @@
|
|||||||
titleId={`${popoverId}-title`}
|
titleId={`${popoverId}-title`}
|
||||||
{userId}
|
{userId}
|
||||||
isLocal={userId === $liveMetadata.localId}
|
isLocal={userId === $liveMetadata.localId}
|
||||||
{foregroundApp}
|
activities={userActivities}
|
||||||
onModeChange={(active) =>
|
onModeChange={(active) =>
|
||||||
(lockedPopoverUserId = active ? userId : null)}
|
(lockedPopoverUserId = active ? userId : null)}
|
||||||
onDismiss={dismissPopover}
|
onDismiss={dismissPopover}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
commands,
|
commands,
|
||||||
type AppMeta,
|
type Activity,
|
||||||
type InteractionContent,
|
type InteractionContent,
|
||||||
} from "$lib/bindings";
|
} from "$lib/bindings";
|
||||||
import { friendName, friends } from "$lib/listeners/friends";
|
import { friendName, friends } from "$lib/listeners/friends";
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
titleId: string;
|
titleId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
isLocal: boolean;
|
isLocal: boolean;
|
||||||
foregroundApp?: AppMeta;
|
activities: Activity[];
|
||||||
onModeChange: (active: boolean) => void;
|
onModeChange: (active: boolean) => void;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
onSent: () => void;
|
onSent: () => void;
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
titleId,
|
titleId,
|
||||||
userId,
|
userId,
|
||||||
isLocal,
|
isLocal,
|
||||||
foregroundApp,
|
activities,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
onSent,
|
onSent,
|
||||||
@@ -125,27 +125,33 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-xs text-base-content/50">{$messages.scene_currently_enjoying()}</h3>
|
<h3 class="text-xs text-base-content/50">{$messages.scene_currently_enjoying()}</h3>
|
||||||
<div class="mt-1 flex min-w-0 items-center gap-2">
|
{#if activities.length > 0}
|
||||||
{#if foregroundApp?.ico}
|
<div class="mt-1 grid gap-2">
|
||||||
<img
|
{#each activities as activity}
|
||||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
alt=""
|
{#if activity.icon}
|
||||||
class="size-6 shrink-0 object-contain"
|
<img
|
||||||
/>
|
src={`data:image/png;base64,${activity.icon}`}
|
||||||
{/if}
|
alt=""
|
||||||
<div class="min-w-0">
|
class="size-6 shrink-0 object-contain"
|
||||||
<p class="truncate text-sm">
|
/>
|
||||||
{foregroundApp?.local ??
|
{/if}
|
||||||
foregroundApp?.unlocal ??
|
<div class="min-w-0">
|
||||||
$messages.scene_waiting_data()}
|
<p class="truncate text-sm">
|
||||||
</p>
|
{activity.name ?? $messages.scene_waiting_data()}
|
||||||
{#if foregroundApp?.local && foregroundApp.unlocal}
|
</p>
|
||||||
<p class="truncate text-xs text-base-content/50">
|
{#if activity.details}
|
||||||
{foregroundApp.unlocal}
|
<p class="truncate text-xs text-base-content/50">
|
||||||
</p>
|
{activity.details}
|
||||||
{/if}
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<p class="mt-1 text-sm">{$messages.scene_waiting_data()}</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if !isLocal}
|
{#if !isLocal}
|
||||||
|
|||||||
Reference in New Issue
Block a user