resolve live data on initial load

This commit is contained in:
2026-08-31 01:15:32 +08:00
parent c68f8f0bf7
commit 25eb21755f
10 changed files with 293 additions and 50 deletions
+38 -1
View File
@@ -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<Option<CursorPositions>, String> {
self.0
.read()
.map(|positions| positions.get(user_id).cloned())
.map_err(|error| error.to_string())
}
}
fn current_position(handle: &AppHandle) -> Result<CursorPositions, String> {
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");
+2 -1
View File
@@ -23,10 +23,10 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
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<tauri::Wry> {
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,
+41
View File
@@ -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<String, CursorPositions>,
pub foreground_apps: HashMap<String, AppMeta>,
}
#[tauri::command]
#[specta::specta]
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>()
.snapshot()?,
})
}
pub(crate) fn publish_current(handle: &AppHandle) -> Result<(), String> {
let user_id = handle
.state::<crate::keypair::AppKeypair>()
.public_key()
.to_owned();
let network = handle.state::<crate::network::Network>();
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>()
.get(&user_id)?
{
network.send_live_data(LiveData::ForegroundApp { meta });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+19 -2
View File
@@ -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<dyn std::error::Error>>
keypair,
next_generation: AtomicU64::new(1),
};
network.sync_remotes(handle, remotes::all(&database).await?)?;
handle.manage(network);
handle
.state::<Network>()
.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::<crate::ufa::ForegroundAppState>()
.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,
})
+12 -8
View File
@@ -4,6 +4,7 @@ use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Change {
pub(super) online: Vec<String>,
pub(super) came_online: Vec<String>,
pub(super) went_offline: Vec<String>,
}
@@ -88,11 +89,14 @@ fn diff(before: HashSet<String>, by_remote: &HashMap<String, HashSet<String>>) -
return None;
}
let mut went_offline = before.difference(&after).cloned().collect::<Vec<_>>();
let mut came_online = after.difference(&before).cloned().collect::<Vec<_>>();
let mut online = after.into_iter().collect::<Vec<_>>();
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)
+101 -15
View File
@@ -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<AppMeta>);
pub 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 {
#[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::<ForegroundAppState>();
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::<crate::network::Network>()
.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::<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"));
}
}
+4
View File
@@ -50,6 +50,9 @@ async listFriendStatuses() : Promise<string[]> {
async resolveFriendDisplayName(userId: string) : Promise<string | null> {
return await TAURI_INVOKE("resolve_friend_display_name", { userId });
},
async listLiveData() : Promise<LiveDataSnapshot> {
return await TAURI_INVOKE("list_live_data");
},
async resolveSkin(userId: string, skinHash: string) : Promise<string | null> {
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"
+2 -7
View File
@@ -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<Set<string>>(new Set());
export const friendStatusesListenerError = writable("");
export async function initFriendStatusesListener() {
let current = new Set<string>();
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) => {
+15 -3
View File
@@ -10,17 +10,29 @@ import { initSceneConfigurationListener } from "./scene-configuration";
type Unlisten = () => void;
async function initPresenceListeners(): Promise<Unlisten> {
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(),
+59 -13
View File
@@ -19,20 +19,24 @@ export const liveMetadata = writable<LiveMetadata>({
});
export const liveMetadataListenerError = writable("");
export function removeFriendForegroundApps(friendIds: string[]) {
if (friendIds.length === 0) return;
const removed = new Set(friendIds);
export function retainOnlineForegroundApps(onlineFriendIds: Set<string>) {
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<string, AppMeta>();
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<string, AppMeta>();
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));
}