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