diff --git a/src-tauri/src/cursor/mod.rs b/src-tauri/src/cursor/mod.rs index 2d5cfa8..86e07d3 100644 --- a/src-tauri/src/cursor/mod.rs +++ b/src-tauri/src/cursor/mod.rs @@ -1,4 +1,4 @@ -use device_query::{DeviceEvents, DeviceEventsHandler}; +use device_query::{DeviceEvents, DeviceEventsHandler, DeviceQuery, DeviceState}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use specta::Type; @@ -63,6 +63,38 @@ impl CursorState { .map(|positions| positions.clone()) .map_err(|error| error.to_string()) } + + pub(crate) fn get(&self, user_id: &str) -> Result, String> { + self.0 + .read() + .map(|positions| positions.get(user_id).cloned()) + .map_err(|error| error.to_string()) + } +} + +fn current_position(handle: &AppHandle) -> Result { + let monitor = handle + .primary_monitor() + .map_err(|error| error.to_string())? + .ok_or("Primary monitor is unavailable")?; + let position = DeviceState::new().get_mouse().coords; + + #[cfg(target_os = "windows")] + let raw = CursorPosition { + x: position.0 as f64, + y: position.1 as f64, + }; + + #[cfg(not(target_os = "windows"))] + let raw = CursorPosition { + x: position.0 as f64 * monitor.scale_factor(), + y: position.1 as f64 * monitor.scale_factor(), + }; + + Ok(CursorPositions { + mapped: transform_cursor_pos(&raw, true, &monitor), + raw, + }) } // Was private, but for some reason LSP @@ -95,6 +127,11 @@ pub fn init(app: &AppHandle) { return; } + match current_position(app) { + Ok(positions) => update_cursor_position(app, positions), + Err(error) => eprintln!("Failed to resolve current cursor position: {error}"), + } + let (stop_tx, stop_rx) = watch::channel(false); println!("Spawning cursor tracking task"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5dd31c..cb5c092 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,10 +23,10 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box> scene_configuration::init(handle).await?; keypair::init(handle).await?; app.manage(interactions::InteractionState::default()); - network::init(handle).await?; app.manage(ufa::ForegroundAppState::default()); app.manage(cursor::CursorState::default()); app.manage(puppet::PuppetStateStore::default()); + network::init(handle).await?; ufa::init(handle); cursor::init(handle); puppet::init(handle)?; @@ -79,6 +79,7 @@ fn specta_builder() -> tauri_specta::Builder { network::list_statuses, network::list_friend_statuses, network::resolve_friend_display_name, + live_data::list_live_data, skins::resolve_skin, images::pick_and_send_image, images::send_image_bytes, diff --git a/src-tauri/src/live_data/mod.rs b/src-tauri/src/live_data/mod.rs index ed1f212..609828f 100644 --- a/src-tauri/src/live_data/mod.rs +++ b/src-tauri/src/live_data/mod.rs @@ -1,4 +1,8 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager}; use crate::cursor::CursorPositions; use crate::ufa::AppMeta; @@ -10,6 +14,43 @@ pub enum LiveData { ForegroundApp { meta: AppMeta }, } +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct LiveDataSnapshot { + pub cursor_positions: HashMap, + pub foreground_apps: HashMap, +} + +#[tauri::command] +#[specta::specta] +pub fn list_live_data(handle: AppHandle) -> Result { + Ok(LiveDataSnapshot { + cursor_positions: handle.state::().snapshot()?, + foreground_apps: handle + .state::() + .snapshot()?, + }) +} + +pub(crate) fn publish_current(handle: &AppHandle) -> Result<(), String> { + let user_id = handle + .state::() + .public_key() + .to_owned(); + let network = handle.state::(); + + if let Some(positions) = handle.state::().get(&user_id)? { + network.send_live_data(LiveData::Cursor { positions }); + } + if let Some(meta) = handle + .state::() + .get(&user_id)? + { + network.send_live_data(LiveData::ForegroundApp { meta }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index 4c30d06..c202ebe 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -261,12 +261,13 @@ impl Network { .filter(|(id, connection)| desired.get(*id) != Some(&connection.remote)) .map(|(id, _)| id.clone()) .collect(); + let mut presence_changes = Vec::new(); for id in stale { if let Some(connection) = connections.remove(&id) { connection.task.abort(); remove_status(&self.statuses, &id, connection.generation); - apply_friend_presence_change(handle, self.friend_presence.remove(&id)); + presence_changes.push(self.friend_presence.remove(&id)); } } @@ -317,6 +318,9 @@ impl Network { } drop(connections); + for change in presence_changes { + apply_friend_presence_change(handle, change); + } emit_statuses(handle, &self.statuses) } } @@ -337,8 +341,10 @@ pub async fn init(handle: &AppHandle) -> Result<(), Box> keypair, next_generation: AtomicU64::new(1), }; - network.sync_remotes(handle, remotes::all(&database).await?)?; handle.manage(network); + handle + .state::() + .sync_remotes(handle, remotes::all(&database).await?)?; let listener_handle = handle.clone(); RemotesChanged::listen(handle, move |event| { @@ -411,6 +417,17 @@ fn apply_friend_presence_change( match result { Ok(Some(change)) => { crate::cursor::remove_positions(handle, &change.went_offline); + if let Err(error) = handle + .state::() + .remove(&change.went_offline) + { + eprintln!("failed to remove offline foreground apps: {error}"); + } + if !change.came_online.is_empty() + && let Err(error) = crate::live_data::publish_current(handle) + { + eprintln!("failed to publish current live data: {error}"); + } if let Err(error) = (FriendStatusesChanged { friend_ids: change.online, }) diff --git a/src-tauri/src/network/presence.rs b/src-tauri/src/network/presence.rs index a74c6d1..7e5a2f3 100644 --- a/src-tauri/src/network/presence.rs +++ b/src-tauri/src/network/presence.rs @@ -4,6 +4,7 @@ use std::sync::Mutex; #[derive(Debug, PartialEq, Eq)] pub(super) struct Change { pub(super) online: Vec, + pub(super) came_online: Vec, pub(super) went_offline: Vec, } @@ -88,11 +89,14 @@ fn diff(before: HashSet, by_remote: &HashMap>) - return None; } let mut went_offline = before.difference(&after).cloned().collect::>(); + let mut came_online = after.difference(&before).cloned().collect::>(); let mut online = after.into_iter().collect::>(); + came_online.sort_unstable(); went_offline.sort_unstable(); online.sort_unstable(); Some(Change { online, + came_online, went_offline, }) } @@ -110,6 +114,7 @@ mod tests { .unwrap(), Some(Change { online: vec!["friend".to_owned()], + came_online: vec!["friend".to_owned()], went_offline: Vec::new(), }) ); @@ -124,6 +129,7 @@ mod tests { presence.remove("remote-b").unwrap(), Some(Change { online: Vec::new(), + came_online: Vec::new(), went_offline: vec!["friend".to_owned()], }) ); @@ -136,14 +142,12 @@ mod tests { .replace("remote", vec!["kept".to_owned(), "removed".to_owned()]) .unwrap(); - assert_eq!( - presence - .update("remote", "added".to_owned(), true) - .unwrap() - .expect("friend came online") - .online, - ["added", "kept", "removed"] - ); + let change = presence + .update("remote", "added".to_owned(), true) + .unwrap() + .expect("friend came online"); + assert_eq!(change.online, ["added", "kept", "removed"]); + assert_eq!(change.came_online, ["added"]); assert_eq!( presence .update("remote", "added".to_owned(), false) diff --git a/src-tauri/src/ufa/mod.rs b/src-tauri/src/ufa/mod.rs index 0b7c712..2e384bd 100644 --- a/src-tauri/src/ufa/mod.rs +++ b/src-tauri/src/ufa/mod.rs @@ -2,6 +2,7 @@ UFA: User Focused App */ +use std::collections::HashMap; use std::sync::RwLock; use tauri::{AppHandle, Manager}; use tauri_specta::Event; @@ -27,7 +28,47 @@ pub struct FriendForegroundAppChanged { } #[derive(Default)] -pub struct ForegroundAppState(RwLock); +pub struct ForegroundAppState(RwLock>); + +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, String> { + self.0 + .read() + .map(|apps| apps.clone()) + .map_err(|error| error.to_string()) + } + + pub(crate) fn get(&self, user_id: &str) -> Result, String> { + self.0 + .read() + .map(|apps| apps.get(user_id).cloned()) + .map_err(|error| error.to_string()) + } +} + +fn current_app() -> AppMeta { + #[cfg(target_os = "macos")] + let meta = macos::get_active_app_metadata_macos(); + #[cfg(target_os = "windows")] + let meta = windows::get_active_app_metadata_windows(None); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let meta = AppMeta::default(); + meta +} /// Listens for changes in the active (foreground) application and calls the provided callback with metadata. /// The implementation varies by platform: macOS uses NSWorkspace notifications, Windows uses WinEventHook. @@ -66,26 +107,71 @@ where /// and emits events to the Tauri app on changes. /// Used for app to emit user foreground app to peers. pub fn init(handle: &AppHandle) { + update_local_app(handle, current_app()); let handle = handle.clone(); - init_listener(move |meta: AppMeta| { - let state = handle.state::(); - let mut current = state.0.write().expect("Foreground App lock failed"); - *current = meta.clone(); - drop(current); - - if let Err(error) = (ForegroundAppChanged { meta: meta.clone() }).emit(&handle) { - eprintln!("Failed to emit foreground app change: {error}"); - } - - handle - .state::() - .send_live_data(crate::live_data::LiveData::ForegroundApp { 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::() + .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::() + .public_key() + .to_owned(); + if let Err(error) = handle + .state::() + .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::() + .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")); + } +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 105a1ae..01ea008 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -50,6 +50,9 @@ async listFriendStatuses() : Promise { async resolveFriendDisplayName(userId: string) : Promise { return await TAURI_INVOKE("resolve_friend_display_name", { userId }); }, +async listLiveData() : Promise { + return await TAURI_INVOKE("list_live_data"); +}, async resolveSkin(userId: string, skinHash: string) : Promise { return await TAURI_INVOKE("resolve_skin", { userId, skinHash }); }, @@ -142,6 +145,7 @@ export type FriendInteractionReceived = { interactionId: string; friendId: strin 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 NetworkStatusChanged = { statuses: ConnectionStatus[] } export type ProfileChanged = { profile: User } export type PuppetMovementMode = "free" | "bottom" diff --git a/src/lib/listeners/friend-statuses.ts b/src/lib/listeners/friend-statuses.ts index 4fb29d1..e901ca3 100644 --- a/src/lib/listeners/friend-statuses.ts +++ b/src/lib/listeners/friend-statuses.ts @@ -1,20 +1,15 @@ import { writable } from "svelte/store"; import { commands, events } from "$lib/bindings"; -import { removeFriendForegroundApps } from "./live-metadata"; +import { retainOnlineForegroundApps } from "./live-metadata"; export const onlineFriendIds = writable>(new Set()); export const friendStatusesListenerError = writable(""); export async function initFriendStatusesListener() { - let current = new Set(); - const apply = (friendIds: string[]) => { const next = new Set(friendIds); - const wentOffline = [...current].filter((friendId) => !next.has(friendId)); - current = next; onlineFriendIds.set(next); - - removeFriendForegroundApps(wentOffline); + retainOnlineForegroundApps(next); }; const unlisten = await events.friendStatusesChanged.listen((event) => { diff --git a/src/lib/listeners/index.ts b/src/lib/listeners/index.ts index 777e619..fdce4f6 100644 --- a/src/lib/listeners/index.ts +++ b/src/lib/listeners/index.ts @@ -10,17 +10,29 @@ import { initSceneConfigurationListener } from "./scene-configuration"; type Unlisten = () => void; +async function initPresenceListeners(): Promise { + const unlistenLiveMetadata = await initLiveMetadataListeners(); + try { + const unlistenFriendStatuses = await initFriendStatusesListener(); + return () => { + unlistenFriendStatuses(); + unlistenLiveMetadata(); + }; + } catch (error) { + unlistenLiveMetadata(); + throw error; + } +} + export function initAppListeners(): Unlisten { let disposed = false; let unlisteners: Unlisten[] = []; - Promise.allSettled([ initFriendsListener(), initRemotesListener(), initProfileListener(), initConnectionStatusesListener(), - initFriendStatusesListener(), - initLiveMetadataListeners(), + initPresenceListeners(), initInteractionListener(), initPuppetStatesListener(), initSceneConfigurationListener(), diff --git a/src/lib/listeners/live-metadata.ts b/src/lib/listeners/live-metadata.ts index 3d51657..e474296 100644 --- a/src/lib/listeners/live-metadata.ts +++ b/src/lib/listeners/live-metadata.ts @@ -19,20 +19,24 @@ export const liveMetadata = writable({ }); export const liveMetadataListenerError = writable(""); -export function removeFriendForegroundApps(friendIds: string[]) { - if (friendIds.length === 0) return; - const removed = new Set(friendIds); +export function retainOnlineForegroundApps(onlineFriendIds: Set) { liveMetadata.update((metadata) => ({ ...metadata, foregroundApps: new Map( - [...metadata.foregroundApps].filter(([userId]) => !removed.has(userId)), + [...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(); const updateForegroundApp = (userId: string, meta: AppMeta) => { liveMetadata.update((current) => ({ @@ -41,34 +45,76 @@ export async function initLiveMetadataListeners() { })); }; + const applyPendingUpdates = () => { + const cursorPositions = pendingCursorPositions; + if (cursorPositions) { + liveMetadata.update((current) => ({ ...current, cursorPositions })); + pendingCursorPositions = null; + } + if (localId && pendingLocalForegroundApp) { + updateForegroundApp(localId, pendingLocalForegroundApp); + pendingLocalForegroundApp = null; + } + pendingFriendForegroundApps.forEach((meta, friendId) => { + updateForegroundApp(friendId, meta); + }); + pendingFriendForegroundApps.clear(); + }; + const unlisteners = await Promise.all([ events.cursorPositionChanged.listen((event) => { + if (initializing) { + pendingCursorPositions = event.payload.positions; + return; + } liveMetadata.update((current) => ({ ...current, cursorPositions: event.payload.positions, })); }), events.foregroundAppChanged.listen((event) => { - if (localId) { + if (!initializing && localId) { updateForegroundApp(localId, event.payload.meta); } else { pendingLocalForegroundApp = event.payload.meta; } }), events.friendForegroundAppChanged.listen((event) => { - updateForegroundApp(event.payload.friendId, event.payload.meta); + if (initializing) { + pendingFriendForegroundApps.set( + event.payload.friendId, + event.payload.meta, + ); + } else { + updateForegroundApp(event.payload.friendId, event.payload.meta); + } }), ]); try { - localId = await commands.getPublicKey(); - liveMetadata.update((current) => ({ ...current, localId })); - - if (pendingLocalForegroundApp) { - updateForegroundApp(localId, pendingLocalForegroundApp); - pendingLocalForegroundApp = null; - } + const [snapshot] = await Promise.all([ + commands.listLiveData(), + commands.getPublicKey().then((resolvedLocalId) => { + localId = resolvedLocalId; + }), + ]); + const foregroundApps = new Map(); + Object.entries(snapshot.foregroundApps).forEach(([userId, meta]) => { + if (meta) foregroundApps.set(userId, meta); + }); + liveMetadata.set({ + localId, + cursorPositions: snapshot.cursorPositions, + foregroundApps, + }); + initializing = false; + applyPendingUpdates(); } catch (error) { + initializing = false; + if (localId) { + liveMetadata.update((current) => ({ ...current, localId })); + } + applyPendingUpdates(); liveMetadataListenerError.set(String(error)); }