From 1432a8e25e08c0d71c7a8ba9ff2479d90ebb6ca7 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Thu, 15 Jan 2026 16:49:09 +0800 Subject: [PATCH] refactored ws --- src-tauri/src/services/ws.rs | 564 ----------------------- src-tauri/src/services/ws/client.rs | 169 +++++++ src-tauri/src/services/ws/cursor.rs | 52 +++ src-tauri/src/services/ws/doll.rs | 101 ++++ src-tauri/src/services/ws/friend.rs | 191 ++++++++ src-tauri/src/services/ws/interaction.rs | 70 +++ src-tauri/src/services/ws/mod.rs | 49 ++ 7 files changed, 632 insertions(+), 564 deletions(-) delete mode 100644 src-tauri/src/services/ws.rs create mode 100644 src-tauri/src/services/ws/client.rs create mode 100644 src-tauri/src/services/ws/cursor.rs create mode 100644 src-tauri/src/services/ws/doll.rs create mode 100644 src-tauri/src/services/ws/friend.rs create mode 100644 src-tauri/src/services/ws/interaction.rs create mode 100644 src-tauri/src/services/ws/mod.rs diff --git a/src-tauri/src/services/ws.rs b/src-tauri/src/services/ws.rs deleted file mode 100644 index 6892c64..0000000 --- a/src-tauri/src/services/ws.rs +++ /dev/null @@ -1,564 +0,0 @@ -use rust_socketio::{ClientBuilder, Event, Payload, RawClient}; -use serde_json::json; -use tauri::{async_runtime, Emitter}; -use tracing::{error, info}; - -use crate::{ - get_app_handle, lock_r, lock_w, - models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto}, - services::{ - client_config_manager::AppConfig, - cursor::{normalized_to_absolute, CursorPosition, CursorPositions}, - health_manager::{close_health_manager_window, show_health_manager_with_error}, - scene::open_scene_window, - }, - state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}, -}; -use serde::{Deserialize, Serialize}; - -#[allow(non_camel_case_types)] // pretend to be a const like in js -pub struct WS_EVENT; - -#[derive(Debug, Deserialize)] -struct IncomingFriendCursorPayload { - #[serde(rename = "userId")] - user_id: String, - position: CursorPosition, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct OutgoingFriendCursorPayload { - user_id: String, - position: CursorPositions, -} - -impl WS_EVENT { - pub const CURSOR_REPORT_POSITION: &str = "cursor-report-position"; - pub const FRIEND_REQUEST_RECEIVED: &str = "friend-request-received"; - pub const FRIEND_REQUEST_ACCEPTED: &str = "friend-request-accepted"; - pub const FRIEND_REQUEST_DENIED: &str = "friend-request-denied"; - pub const UNFRIENDED: &str = "unfriended"; - pub const FRIEND_CURSOR_POSITION: &str = "friend-cursor-position"; - pub const FRIEND_DISCONNECTED: &str = "friend-disconnected"; - pub const FRIEND_DOLL_CREATED: &str = "friend-doll-created"; - pub const FRIEND_DOLL_UPDATED: &str = "friend-doll-updated"; - pub const FRIEND_DOLL_DELETED: &str = "friend-doll-deleted"; - pub const FRIEND_ACTIVE_DOLL_CHANGED: &str = "friend-active-doll-changed"; - pub const DOLL_CREATED: &str = "doll_created"; - pub const DOLL_UPDATED: &str = "doll_updated"; - pub const DOLL_DELETED: &str = "doll_deleted"; - pub const CLIENT_INITIALIZE: &str = "client-initialize"; - pub const INITIALIZED: &str = "initialized"; - pub const INTERACTION_RECEIVED: &str = "interaction-received"; - pub const INTERACTION_DELIVERY_FAILED: &str = "interaction-delivery-failed"; - pub const CLIENT_SEND_INTERACTION: &str = "client-send-interaction"; -} - -fn emit_initialize(socket: &RawClient) { - if let Err(e) = socket.emit(WS_EVENT::CLIENT_INITIALIZE, json!({})) { - error!("Failed to emit client-initialize: {:?}", e); - } -} - -fn on_connected(_payload: Payload, socket: RawClient) { - info!("WebSocket connected. Sending initialization request."); - emit_initialize(&socket); -} - -fn on_initialized(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received initialized event: {:?}", first_value); - - // Mark WebSocket as initialized and reset backoff timer - let mut guard = lock_w!(FDOLL); - if let Some(clients) = guard.clients.as_mut() { - clients.is_ws_initialized = true; - } - - // Connection restored: close health manager and reopen scene - close_health_manager_window(); - open_scene_window(); - } else { - info!("Received initialized event with empty payload"); - } - } - _ => error!("Received unexpected payload format for initialized"), - } -} - -fn on_friend_request_received(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(str) => { - println!("Received friend request: {:?}", str); - if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_RECEIVED, str) { - error!("Failed to emit friend request received event: {:?}", e); - } - } - _ => error!("Received unexpected payload format for friend request received"), - } -} - -fn on_friend_request_accepted(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(str) => { - println!("Received friend request accepted: {:?}", str); - if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_ACCEPTED, str) { - error!("Failed to emit friend request accepted event: {:?}", e); - } - - // Refresh friends list only (optimized - no need to fetch user profile) - tauri::async_runtime::spawn(async { - init_app_data_scoped(AppDataRefreshScope::Friends).await; - }); - } - _ => error!("Received unexpected payload format for friend request accepted"), - } -} - -fn on_friend_request_denied(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(str) => { - println!("Received friend request denied: {:?}", str); - if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_DENIED, str) { - error!("Failed to emit friend request denied event: {:?}", e); - } - } - _ => error!("Received unexpected payload format for friend request denied"), - } -} - -fn on_unfriended(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(str) => { - println!("Received unfriended: {:?}", str); - if let Err(e) = get_app_handle().emit(WS_EVENT::UNFRIENDED, str) { - error!("Failed to emit unfriended event: {:?}", e); - } - - // Refresh friends list only (optimized - no need to fetch user profile) - tauri::async_runtime::spawn(async { - init_app_data_scoped(AppDataRefreshScope::Friends).await; - }); - } - _ => error!("Received unexpected payload format for unfriended"), - } -} - -fn on_friend_cursor_position(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - // values is Vec - if let Some(first_value) = values.first() { - let incoming_data: Result = - serde_json::from_value(first_value.clone()); - - match incoming_data { - Ok(friend_data) => { - // We received normalized coordinates (mapped) - let mapped_pos = &friend_data.position; - - // Convert normalized coordinates back to absolute screen coordinates (raw) - let raw_pos = normalized_to_absolute(mapped_pos); - - let outgoing_payload = OutgoingFriendCursorPayload { - user_id: friend_data.user_id.clone(), - position: CursorPositions { - raw: raw_pos, - mapped: mapped_pos.clone(), - }, - }; - - if let Err(e) = get_app_handle() - .emit(WS_EVENT::FRIEND_CURSOR_POSITION, outgoing_payload) - { - error!("Failed to emit friend cursor position event: {:?}", e); - } - } - Err(e) => { - error!("Failed to parse friend cursor position data: {}", e); - } - } - } else { - error!("Received empty text payload for friend cursor position"); - } - } - _ => error!("Received unexpected payload format for friend cursor position"), - } -} - -fn on_friend_disconnected(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(str) => { - println!("Received friend disconnected: {:?}", str); - if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_DISCONNECTED, str) { - error!("Failed to emit friend disconnected event: {:?}", e); - } - } - _ => error!("Received unexpected payload format for friend disconnected"), - } -} - -fn on_friend_doll_created(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - // Log raw JSON for now, as requested - if let Some(first_value) = values.first() { - info!("Received friend-doll-created event: {:?}", first_value); - // Future: Trigger re-fetch or emit to frontend - } else { - info!("Received friend-doll-created event with empty payload"); - } - } - _ => error!("Received unexpected payload format for friend-doll-created"), - } -} - -fn on_friend_doll_updated(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received friend-doll-updated event: {:?}", first_value); - } else { - info!("Received friend-doll-updated event with empty payload"); - } - } - _ => error!("Received unexpected payload format for friend-doll-updated"), - } -} - -fn on_friend_doll_deleted(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received friend-doll-deleted event: {:?}", first_value); - } else { - info!("Received friend-doll-deleted event with empty payload"); - } - } - _ => error!("Received unexpected payload format for friend-doll-deleted"), - } -} - -fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!( - "Received friend-active-doll-changed event: {:?}", - first_value - ); - if let Err(e) = - get_app_handle().emit(WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, first_value) - { - error!("Failed to emit friend-active-doll-changed event: {:?}", e); - } - - // Refresh friends list only (optimized - friend's active doll is part of friends data) - // Deduplicate burst events inside init_app_data_scoped. - tauri::async_runtime::spawn(async { - init_app_data_scoped(AppDataRefreshScope::Friends).await; - }); - } else { - info!("Received friend-active-doll-changed event with empty payload"); - } - } - _ => error!("Received unexpected payload format for friend-active-doll-changed"), - } -} - -fn on_doll_created(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received doll.created event: {:?}", first_value); - - // Refresh dolls list - tauri::async_runtime::spawn(async { - init_app_data_scoped(AppDataRefreshScope::Dolls).await; - }); - } else { - info!("Received doll.created event with empty payload"); - } - } - _ => error!("Received unexpected payload format for doll.created"), - } -} - -fn on_doll_updated(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received doll.updated event: {:?}", first_value); - - // Try to extract doll ID to check if it's the active doll - let doll_id = first_value.get("id").and_then(|v| v.as_str()); - - let is_active_doll = if let Some(id) = doll_id { - let guard = lock_r!(FDOLL); - guard - .app_data - .user - .as_ref() - .and_then(|u| u.active_doll_id.as_ref()) - .map(|active_id| active_id == id) - .unwrap_or(false) - } else { - false - }; - - // Refresh dolls + potentially User/Friends if active doll - tauri::async_runtime::spawn(async move { - init_app_data_scoped(AppDataRefreshScope::Dolls).await; - if is_active_doll { - init_app_data_scoped(AppDataRefreshScope::User).await; - init_app_data_scoped(AppDataRefreshScope::Friends).await; - } - }); - } else { - info!("Received doll.updated event with empty payload"); - } - } - _ => error!("Received unexpected payload format for doll.updated"), - } -} - -fn on_doll_deleted(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received doll.deleted event: {:?}", first_value); - - // Try to extract doll ID to check if it was the active doll - let doll_id = first_value.get("id").and_then(|v| v.as_str()); - - let is_active_doll = if let Some(id) = doll_id { - let guard = lock_r!(FDOLL); - guard - .app_data - .user - .as_ref() - .and_then(|u| u.active_doll_id.as_ref()) - .map(|active_id| active_id == id) - .unwrap_or(false) - } else { - false - }; - - // Refresh dolls + User/Friends if the deleted doll was active - tauri::async_runtime::spawn(async move { - init_app_data_scoped(AppDataRefreshScope::Dolls).await; - if is_active_doll { - init_app_data_scoped(AppDataRefreshScope::User).await; - init_app_data_scoped(AppDataRefreshScope::Friends).await; - } - }); - } else { - info!("Received doll.deleted event with empty payload"); - } - } - _ => error!("Received unexpected payload format for doll.deleted"), - } -} - -fn on_interaction_received(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received interaction-received event: {:?}", first_value); - - let interaction_data: Result = - serde_json::from_value(first_value.clone()); - - match interaction_data { - Ok(data) => { - if let Err(e) = get_app_handle().emit(WS_EVENT::INTERACTION_RECEIVED, data) - { - error!("Failed to emit interaction-received event: {:?}", e); - } - } - Err(e) => { - error!("Failed to parse interaction payload: {}", e); - } - } - } else { - info!("Received interaction-received event with empty payload"); - } - } - _ => error!("Received unexpected payload format for interaction-received"), - } -} - -fn on_interaction_delivery_failed(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!( - "Received interaction-delivery-failed event: {:?}", - first_value - ); - - let failure_data: Result = - serde_json::from_value(first_value.clone()); - - match failure_data { - Ok(data) => { - if let Err(e) = - get_app_handle().emit(WS_EVENT::INTERACTION_DELIVERY_FAILED, data) - { - error!("Failed to emit interaction-delivery-failed event: {:?}", e); - } - } - Err(e) => { - error!("Failed to parse interaction failure payload: {}", e); - } - } - } else { - info!("Received interaction-delivery-failed event with empty payload"); - } - } - _ => error!("Received unexpected payload format for interaction-delivery-failed"), - } -} - -pub async fn report_cursor_data(cursor_position: CursorPosition) { - // Only attempt to get clients if lock_r succeeds (it should, but safety first) - // and if clients are actually initialized. - let (client_opt, is_initialized) = { - let guard = lock_r!(FDOLL); - if let Some(clients) = &guard.clients { - ( - clients.ws_client.as_ref().cloned(), - clients.is_ws_initialized, - ) - } else { - (None, false) - } - }; - - if let Some(client) = client_opt { - if !is_initialized { - return; - } - - match async_runtime::spawn_blocking(move || { - client.emit( - WS_EVENT::CURSOR_REPORT_POSITION, - Payload::Text(vec![json!(cursor_position)]), - ) - }) - .await - { - Ok(Ok(_)) => (), - Ok(Err(e)) => { - error!("Failed to emit cursor report: {}", e); - show_health_manager_with_error(Some(format!("WebSocket emit failed: {}", e))); - } - Err(e) => { - error!("Failed to execute blocking task for cursor report: {}", e); - show_health_manager_with_error(Some(format!("WebSocket task failed: {}", e))); - } - } - } -} - -pub async fn init_ws_client() { - let app_config = { - let guard = lock_r!(FDOLL); - guard.app_config.clone() - }; - - match build_ws_client(&app_config).await { - Ok(ws_client) => { - let mut guard = lock_w!(FDOLL); - if let Some(clients) = guard.clients.as_mut() { - clients.ws_client = Some(ws_client); - clients.is_ws_initialized = false; // wait for initialized event - } - } - Err(e) => { - error!("Failed to initialize WebSocket client: {}", e); - // If we failed because no token, clear the WS client to avoid stale retries - let mut guard = lock_w!(FDOLL); - if let Some(clients) = guard.clients.as_mut() { - clients.ws_client = None; - clients.is_ws_initialized = false; - } - } - } -} - -pub async fn build_ws_client( - app_config: &AppConfig, -) -> Result { - // Always fetch a fresh/valid token (refreshing if needed) - let token = match crate::services::auth::get_access_token().await { - Some(t) => t, - None => return Err("No access token available for WebSocket connection".to_string()), - }; - - let api_base_url = app_config - .api_base_url - .clone() - .ok_or("Missing API base URL")?; - - let client_result = async_runtime::spawn_blocking(move || { - ClientBuilder::new(api_base_url) - .namespace("/") - .on( - WS_EVENT::FRIEND_REQUEST_RECEIVED, - on_friend_request_received, - ) - .on( - WS_EVENT::FRIEND_REQUEST_ACCEPTED, - on_friend_request_accepted, - ) - .on(WS_EVENT::FRIEND_REQUEST_DENIED, on_friend_request_denied) - .on(WS_EVENT::UNFRIENDED, on_unfriended) - .on(WS_EVENT::FRIEND_CURSOR_POSITION, on_friend_cursor_position) - .on(WS_EVENT::FRIEND_DISCONNECTED, on_friend_disconnected) - .on(WS_EVENT::FRIEND_DOLL_CREATED, on_friend_doll_created) - .on(WS_EVENT::FRIEND_DOLL_UPDATED, on_friend_doll_updated) - .on(WS_EVENT::FRIEND_DOLL_DELETED, on_friend_doll_deleted) - .on( - WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, - on_friend_active_doll_changed, - ) - .on(WS_EVENT::DOLL_CREATED, on_doll_created) - .on(WS_EVENT::DOLL_UPDATED, on_doll_updated) - .on(WS_EVENT::DOLL_DELETED, on_doll_deleted) - .on(WS_EVENT::INITIALIZED, on_initialized) - .on(WS_EVENT::INTERACTION_RECEIVED, on_interaction_received) - .on( - WS_EVENT::INTERACTION_DELIVERY_FAILED, - on_interaction_delivery_failed, - ) - // rust-socketio fires Event::Connect on initial connect AND reconnects - // so we resend initialization there instead of a dedicated reconnect event. - .on(Event::Connect, on_connected) - .auth(json!({ "token": token })) - .connect() - }) - .await; - - match client_result { - Ok(connect_result) => match connect_result { - Ok(c) => { - info!("WebSocket client connected successfully"); - Ok(c) - } - Err(e) => { - let err_msg = format!("Failed to connect WebSocket: {:?}", e); - error!("{}", err_msg); - Err(err_msg) - } - }, - Err(e) => { - let err_msg = format!("Failed to spawn blocking task: {:?}", e); - error!("{}", err_msg); - Err(err_msg) - } - } -} diff --git a/src-tauri/src/services/ws/client.rs b/src-tauri/src/services/ws/client.rs new file mode 100644 index 0000000..6a99574 --- /dev/null +++ b/src-tauri/src/services/ws/client.rs @@ -0,0 +1,169 @@ +use rust_socketio::{ClientBuilder, Event, Payload, RawClient}; +use serde_json::json; +use tauri::async_runtime; +use tracing::{error, info}; + +use crate::{ + lock_r, lock_w, + services::{ + client_config_manager::AppConfig, health_manager::close_health_manager_window, + scene::open_scene_window, + }, + state::FDOLL, +}; + +use super::WS_EVENT; + +fn emit_initialize(socket: &RawClient) { + if let Err(e) = socket.emit(WS_EVENT::CLIENT_INITIALIZE, json!({})) { + error!("Failed to emit client-initialize: {:?}", e); + } +} + +fn on_connected(_payload: Payload, socket: RawClient) { + info!("WebSocket connected. Sending initialization request."); + emit_initialize(&socket); +} + +fn on_initialized(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received initialized event: {:?}", first_value); + + // Mark WebSocket as initialized and reset backoff timer + let mut guard = lock_w!(FDOLL); + if let Some(clients) = guard.clients.as_mut() { + clients.is_ws_initialized = true; + } + + // Connection restored: close health manager and reopen scene + close_health_manager_window(); + open_scene_window(); + } else { + info!("Received initialized event with empty payload"); + } + } + _ => error!("Received unexpected payload format for initialized"), + } +} + +pub async fn init_ws_client() { + let app_config = { + let guard = lock_r!(FDOLL); + guard.app_config.clone() + }; + + match build_ws_client(&app_config).await { + Ok(ws_client) => { + let mut guard = lock_w!(FDOLL); + if let Some(clients) = guard.clients.as_mut() { + clients.ws_client = Some(ws_client); + clients.is_ws_initialized = false; // wait for initialized event + } + } + Err(e) => { + error!("Failed to initialize WebSocket client: {}", e); + // If we failed because no token, clear the WS client to avoid stale retries + let mut guard = lock_w!(FDOLL); + if let Some(clients) = guard.clients.as_mut() { + clients.ws_client = None; + clients.is_ws_initialized = false; + } + } + } +} + +pub async fn build_ws_client( + app_config: &AppConfig, +) -> Result { + // Always fetch a fresh/valid token (refreshing if needed) + let token = match crate::services::auth::get_access_token().await { + Some(t) => t, + None => return Err("No access token available for WebSocket connection".to_string()), + }; + + let api_base_url = app_config + .api_base_url + .clone() + .ok_or("Missing API base URL")?; + + let client_result = async_runtime::spawn_blocking(move || { + ClientBuilder::new(api_base_url) + .namespace("/") + .on( + WS_EVENT::FRIEND_REQUEST_RECEIVED, + super::friend::on_friend_request_received, + ) + .on( + WS_EVENT::FRIEND_REQUEST_ACCEPTED, + super::friend::on_friend_request_accepted, + ) + .on( + WS_EVENT::FRIEND_REQUEST_DENIED, + super::friend::on_friend_request_denied, + ) + .on(WS_EVENT::UNFRIENDED, super::friend::on_unfriended) + .on( + WS_EVENT::FRIEND_CURSOR_POSITION, + super::friend::on_friend_cursor_position, + ) + .on( + WS_EVENT::FRIEND_DISCONNECTED, + super::friend::on_friend_disconnected, + ) + .on( + WS_EVENT::FRIEND_DOLL_CREATED, + super::friend::on_friend_doll_created, + ) + .on( + WS_EVENT::FRIEND_DOLL_UPDATED, + super::friend::on_friend_doll_updated, + ) + .on( + WS_EVENT::FRIEND_DOLL_DELETED, + super::friend::on_friend_doll_deleted, + ) + .on( + WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, + super::friend::on_friend_active_doll_changed, + ) + .on(WS_EVENT::DOLL_CREATED, super::doll::on_doll_created) + .on(WS_EVENT::DOLL_UPDATED, super::doll::on_doll_updated) + .on(WS_EVENT::DOLL_DELETED, super::doll::on_doll_deleted) + .on(WS_EVENT::INITIALIZED, on_initialized) + .on( + WS_EVENT::INTERACTION_RECEIVED, + super::interaction::on_interaction_received, + ) + .on( + WS_EVENT::INTERACTION_DELIVERY_FAILED, + super::interaction::on_interaction_delivery_failed, + ) + // rust-socketio fires Event::Connect on initial connect AND reconnects + // so we resend initialization there instead of a dedicated reconnect event. + .on(Event::Connect, on_connected) + .auth(json!({ "token": token })) + .connect() + }) + .await; + + match client_result { + Ok(connect_result) => match connect_result { + Ok(c) => { + info!("WebSocket client connected successfully"); + Ok(c) + } + Err(e) => { + let err_msg = format!("Failed to connect WebSocket: {:?}", e); + error!("{}", err_msg); + Err(err_msg) + } + }, + Err(e) => { + let err_msg = format!("Failed to spawn blocking task: {:?}", e); + error!("{}", err_msg); + Err(err_msg) + } + } +} diff --git a/src-tauri/src/services/ws/cursor.rs b/src-tauri/src/services/ws/cursor.rs new file mode 100644 index 0000000..9cf776e --- /dev/null +++ b/src-tauri/src/services/ws/cursor.rs @@ -0,0 +1,52 @@ +use rust_socketio::Payload; +use tauri::async_runtime; +use tracing::error; + +use crate::{ + lock_r, + services::{cursor::CursorPosition, health_manager::show_health_manager_with_error}, + state::FDOLL, +}; + +use super::WS_EVENT; + +pub async fn report_cursor_data(cursor_position: CursorPosition) { + // Only attempt to get clients if lock_r succeeds (it should, but safety first) + // and if clients are actually initialized. + let (client_opt, is_initialized) = { + let guard = lock_r!(FDOLL); + if let Some(clients) = &guard.clients { + ( + clients.ws_client.as_ref().cloned(), + clients.is_ws_initialized, + ) + } else { + (None, false) + } + }; + + if let Some(client) = client_opt { + if !is_initialized { + return; + } + + match async_runtime::spawn_blocking(move || { + client.emit( + WS_EVENT::CURSOR_REPORT_POSITION, + Payload::Text(vec![serde_json::json!(cursor_position)]), + ) + }) + .await + { + Ok(Ok(_)) => (), + Ok(Err(e)) => { + error!("Failed to emit cursor report: {}", e); + show_health_manager_with_error(Some(format!("WebSocket emit failed: {}", e))); + } + Err(e) => { + error!("Failed to execute blocking task for cursor report: {}", e); + show_health_manager_with_error(Some(format!("WebSocket task failed: {}", e))); + } + } + } +} diff --git a/src-tauri/src/services/ws/doll.rs b/src-tauri/src/services/ws/doll.rs new file mode 100644 index 0000000..85b9729 --- /dev/null +++ b/src-tauri/src/services/ws/doll.rs @@ -0,0 +1,101 @@ +use rust_socketio::{Payload, RawClient}; +use tracing::{error, info}; + +use crate::{ + lock_r, + state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}, +}; + +pub fn on_doll_created(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received doll.created event: {:?}", first_value); + + // Refresh dolls list + tauri::async_runtime::spawn(async { + init_app_data_scoped(AppDataRefreshScope::Dolls).await; + }); + } else { + info!("Received doll.created event with empty payload"); + } + } + _ => error!("Received unexpected payload format for doll.created"), + } +} + +pub fn on_doll_updated(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received doll.updated event: {:?}", first_value); + + // Try to extract doll ID to check if it's the active doll + let doll_id = first_value.get("id").and_then(|v| v.as_str()); + + let is_active_doll = if let Some(id) = doll_id { + let guard = lock_r!(FDOLL); + guard + .app_data + .user + .as_ref() + .and_then(|u| u.active_doll_id.as_ref()) + .map(|active_id| active_id == id) + .unwrap_or(false) + } else { + false + }; + + // Refresh dolls + potentially User/Friends if active doll + tauri::async_runtime::spawn(async move { + init_app_data_scoped(AppDataRefreshScope::Dolls).await; + if is_active_doll { + init_app_data_scoped(AppDataRefreshScope::User).await; + init_app_data_scoped(AppDataRefreshScope::Friends).await; + } + }); + } else { + info!("Received doll.updated event with empty payload"); + } + } + _ => error!("Received unexpected payload format for doll.updated"), + } +} + +pub fn on_doll_deleted(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received doll.deleted event: {:?}", first_value); + + // Try to extract doll ID to check if it was the active doll + let doll_id = first_value.get("id").and_then(|v| v.as_str()); + + let is_active_doll = if let Some(id) = doll_id { + let guard = lock_r!(FDOLL); + guard + .app_data + .user + .as_ref() + .and_then(|u| u.active_doll_id.as_ref()) + .map(|active_id| active_id == id) + .unwrap_or(false) + } else { + false + }; + + // Refresh dolls + User/Friends if the deleted doll was active + tauri::async_runtime::spawn(async move { + init_app_data_scoped(AppDataRefreshScope::Dolls).await; + if is_active_doll { + init_app_data_scoped(AppDataRefreshScope::User).await; + init_app_data_scoped(AppDataRefreshScope::Friends).await; + } + }); + } else { + info!("Received doll.deleted event with empty payload"); + } + } + _ => error!("Received unexpected payload format for doll.deleted"), + } +} diff --git a/src-tauri/src/services/ws/friend.rs b/src-tauri/src/services/ws/friend.rs new file mode 100644 index 0000000..a367033 --- /dev/null +++ b/src-tauri/src/services/ws/friend.rs @@ -0,0 +1,191 @@ +use rust_socketio::{Payload, RawClient}; +use tauri::Emitter; +use tracing::{error, info}; + +use crate::{ + get_app_handle, + services::cursor::{normalized_to_absolute, CursorPositions}, + state::{init_app_data_scoped, AppDataRefreshScope}, +}; + +use super::{IncomingFriendCursorPayload, OutgoingFriendCursorPayload, WS_EVENT}; + +pub fn on_friend_request_received(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(str) => { + println!("Received friend request: {:?}", str); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_RECEIVED, str) { + error!("Failed to emit friend request received event: {:?}", e); + } + } + _ => error!("Received unexpected payload format for friend request received"), + } +} + +pub fn on_friend_request_accepted(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(str) => { + println!("Received friend request accepted: {:?}", str); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_ACCEPTED, str) { + error!("Failed to emit friend request accepted event: {:?}", e); + } + + // Refresh friends list only (optimized - no need to fetch user profile) + tauri::async_runtime::spawn(async { + init_app_data_scoped(AppDataRefreshScope::Friends).await; + }); + } + _ => error!("Received unexpected payload format for friend request accepted"), + } +} + +pub fn on_friend_request_denied(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(str) => { + println!("Received friend request denied: {:?}", str); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_DENIED, str) { + error!("Failed to emit friend request denied event: {:?}", e); + } + } + _ => error!("Received unexpected payload format for friend request denied"), + } +} + +pub fn on_unfriended(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(str) => { + println!("Received unfriended: {:?}", str); + if let Err(e) = get_app_handle().emit(WS_EVENT::UNFRIENDED, str) { + error!("Failed to emit unfriended event: {:?}", e); + } + + // Refresh friends list only (optimized - no need to fetch user profile) + tauri::async_runtime::spawn(async { + init_app_data_scoped(AppDataRefreshScope::Friends).await; + }); + } + _ => error!("Received unexpected payload format for unfriended"), + } +} + +pub fn on_friend_cursor_position(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + // values is Vec + if let Some(first_value) = values.first() { + let incoming_data: Result = + serde_json::from_value(first_value.clone()); + + match incoming_data { + Ok(friend_data) => { + // We received normalized coordinates (mapped) + let mapped_pos = &friend_data.position; + + // Convert normalized coordinates back to absolute screen coordinates (raw) + let raw_pos = normalized_to_absolute(mapped_pos); + + let outgoing_payload = OutgoingFriendCursorPayload { + user_id: friend_data.user_id.clone(), + position: CursorPositions { + raw: raw_pos, + mapped: mapped_pos.clone(), + }, + }; + + if let Err(e) = get_app_handle() + .emit(WS_EVENT::FRIEND_CURSOR_POSITION, outgoing_payload) + { + error!("Failed to emit friend cursor position event: {:?}", e); + } + } + Err(e) => { + error!("Failed to parse friend cursor position data: {}", e); + } + } + } else { + error!("Received empty text payload for friend cursor position"); + } + } + _ => error!("Received unexpected payload format for friend cursor position"), + } +} + +pub fn on_friend_disconnected(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(str) => { + println!("Received friend disconnected: {:?}", str); + if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_DISCONNECTED, str) { + error!("Failed to emit friend disconnected event: {:?}", e); + } + } + _ => error!("Received unexpected payload format for friend disconnected"), + } +} + +pub fn on_friend_doll_created(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + // Log raw JSON for now, as requested + if let Some(first_value) = values.first() { + info!("Received friend-doll-created event: {:?}", first_value); + // Future: Trigger re-fetch or emit to frontend + } else { + info!("Received friend-doll-created event with empty payload"); + } + } + _ => error!("Received unexpected payload format for friend-doll-created"), + } +} + +pub fn on_friend_doll_updated(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received friend-doll-updated event: {:?}", first_value); + } else { + info!("Received friend-doll-updated event with empty payload"); + } + } + _ => error!("Received unexpected payload format for friend-doll-updated"), + } +} + +pub fn on_friend_doll_deleted(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received friend-doll-deleted event: {:?}", first_value); + } else { + info!("Received friend-doll-deleted event with empty payload"); + } + } + _ => error!("Received unexpected payload format for friend-doll-deleted"), + } +} + +pub fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!( + "Received friend-active-doll-changed event: {:?}", + first_value + ); + if let Err(e) = + get_app_handle().emit(WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, first_value) + { + error!("Failed to emit friend-active-doll-changed event: {:?}", e); + } + + // Refresh friends list only (optimized - friend's active doll is part of friends data) + // Deduplicate burst events inside init_app_data_scoped. + tauri::async_runtime::spawn(async { + init_app_data_scoped(AppDataRefreshScope::Friends).await; + }); + } else { + info!("Received friend-active-doll-changed event with empty payload"); + } + } + _ => error!("Received unexpected payload format for friend-active-doll-changed"), + } +} diff --git a/src-tauri/src/services/ws/interaction.rs b/src-tauri/src/services/ws/interaction.rs new file mode 100644 index 0000000..fac57e9 --- /dev/null +++ b/src-tauri/src/services/ws/interaction.rs @@ -0,0 +1,70 @@ +use rust_socketio::{Payload, RawClient}; +use tauri::Emitter; +use tracing::{error, info}; + +use crate::{ + get_app_handle, + models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto}, +}; + +use super::WS_EVENT; + +pub fn on_interaction_received(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!("Received interaction-received event: {:?}", first_value); + + let interaction_data: Result = + serde_json::from_value(first_value.clone()); + + match interaction_data { + Ok(data) => { + if let Err(e) = get_app_handle().emit(WS_EVENT::INTERACTION_RECEIVED, data) + { + error!("Failed to emit interaction-received event: {:?}", e); + } + } + Err(e) => { + error!("Failed to parse interaction payload: {}", e); + } + } + } else { + info!("Received interaction-received event with empty payload"); + } + } + _ => error!("Received unexpected payload format for interaction-received"), + } +} + +pub fn on_interaction_delivery_failed(payload: Payload, _socket: RawClient) { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + info!( + "Received interaction-delivery-failed event: {:?}", + first_value + ); + + let failure_data: Result = + serde_json::from_value(first_value.clone()); + + match failure_data { + Ok(data) => { + if let Err(e) = + get_app_handle().emit(WS_EVENT::INTERACTION_DELIVERY_FAILED, data) + { + error!("Failed to emit interaction-delivery-failed event: {:?}", e); + } + } + Err(e) => { + error!("Failed to parse interaction failure payload: {}", e); + } + } + } else { + info!("Received interaction-delivery-failed event with empty payload"); + } + } + _ => error!("Received unexpected payload format for interaction-delivery-failed"), + } +} diff --git a/src-tauri/src/services/ws/mod.rs b/src-tauri/src/services/ws/mod.rs new file mode 100644 index 0000000..65eeac2 --- /dev/null +++ b/src-tauri/src/services/ws/mod.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; + +#[allow(non_camel_case_types)] // pretend to be a const like in js +pub struct WS_EVENT; + +#[derive(Debug, Deserialize)] +pub struct IncomingFriendCursorPayload { + #[serde(rename = "userId")] + user_id: String, + position: crate::services::cursor::CursorPosition, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutgoingFriendCursorPayload { + user_id: String, + position: crate::services::cursor::CursorPositions, +} + +impl WS_EVENT { + pub const CURSOR_REPORT_POSITION: &str = "cursor-report-position"; + pub const FRIEND_REQUEST_RECEIVED: &str = "friend-request-received"; + pub const FRIEND_REQUEST_ACCEPTED: &str = "friend-request-accepted"; + pub const FRIEND_REQUEST_DENIED: &str = "friend-request-denied"; + pub const UNFRIENDED: &str = "unfriended"; + pub const FRIEND_CURSOR_POSITION: &str = "friend-cursor-position"; + pub const FRIEND_DISCONNECTED: &str = "friend-disconnected"; + pub const FRIEND_DOLL_CREATED: &str = "friend-doll-created"; + pub const FRIEND_DOLL_UPDATED: &str = "friend-doll-updated"; + pub const FRIEND_DOLL_DELETED: &str = "friend-doll-deleted"; + pub const FRIEND_ACTIVE_DOLL_CHANGED: &str = "friend-active-doll-changed"; + pub const DOLL_CREATED: &str = "doll_created"; + pub const DOLL_UPDATED: &str = "doll_updated"; + pub const DOLL_DELETED: &str = "doll_deleted"; + pub const CLIENT_INITIALIZE: &str = "client-initialize"; + pub const INITIALIZED: &str = "initialized"; + pub const INTERACTION_RECEIVED: &str = "interaction-received"; + pub const INTERACTION_DELIVERY_FAILED: &str = "interaction-delivery-failed"; + pub const CLIENT_SEND_INTERACTION: &str = "client-send-interaction"; +} + +mod client; +mod cursor; +mod doll; +mod friend; +mod interaction; + +pub use client::init_ws_client; +pub use cursor::report_cursor_data;