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
+2 -1
View File
@@ -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;
+24 -1
View File
@@ -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());
} }
+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 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,
+10 -10
View File
@@ -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(())
} }
+6 -6
View File
@@ -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)?
{ {
+16 -19
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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 }
+20 -15
View File
@@ -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 -4
View File
@@ -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) => {
+51 -48
View File
@@ -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();
+22 -4
View File
@@ -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}