diff --git a/src-tauri/src/services/ws/connection.rs b/src-tauri/src/services/ws/connection.rs index 4ff315d..a203849 100644 --- a/src-tauri/src/services/ws/connection.rs +++ b/src-tauri/src/services/ws/connection.rs @@ -2,44 +2,43 @@ use rust_socketio::{Payload, RawClient}; use tracing::info; use crate::{ - lock_w, - services::health_manager::close_health_manager_window, - services::scene::open_scene_window, - state::FDOLL, + lock_w, services::health_manager::close_health_manager_window, + services::scene::open_scene_window, state::FDOLL, }; -use super::WS_EVENT; +use super::{types::WS_EVENT, utils}; +/// Emit initialization request to WebSocket server fn emit_initialize(socket: &RawClient) { if let Err(e) = socket.emit(WS_EVENT::CLIENT_INITIALIZE, serde_json::json!({})) { tracing::error!("Failed to emit client-initialize: {:?}", e); } } +/// Handler for WebSocket connection event pub fn on_connected(_payload: Payload, socket: RawClient) { info!("WebSocket connected. Sending initialization request."); emit_initialize(&socket); } +/// Handler for initialized event pub 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.network.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"); - } - } - _ => tracing::error!("Received unexpected payload format for initialized"), + if utils::extract_text_value(payload, "initialized").is_ok() { + mark_ws_initialized(); + restore_connection_ui(); } -} \ No newline at end of file +} + +/// Mark WebSocket as initialized in app state +fn mark_ws_initialized() { + let mut guard = lock_w!(FDOLL); + if let Some(clients) = guard.network.clients.as_mut() { + clients.is_ws_initialized = true; + } +} + +/// Restore UI after successful connection +fn restore_connection_ui() { + close_health_manager_window(); + open_scene_window(); +} diff --git a/src-tauri/src/services/ws/cursor.rs b/src-tauri/src/services/ws/cursor.rs index 053679f..7c2bc56 100644 --- a/src-tauri/src/services/ws/cursor.rs +++ b/src-tauri/src/services/ws/cursor.rs @@ -1,51 +1,8 @@ -use rust_socketio::Payload; -use tauri::async_runtime; -use tracing::error; +use crate::services::cursor::CursorPosition; -use crate::{ - init::lifecycle::handle_disasterous_failure, lock_r, services::cursor::CursorPosition, - state::FDOLL, -}; - -use super::WS_EVENT; +use super::{emitter, types::WS_EVENT}; +/// Report cursor position to WebSocket server 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.network.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); - handle_disasterous_failure(Some(format!("WebSocket emit failed: {}", e))).await; - } - Err(e) => { - error!("Failed to execute blocking task for cursor report: {}", e); - handle_disasterous_failure(Some(format!("WebSocket task failed: {}", e))).await; - } - } - } + let _ = emitter::ws_emit(WS_EVENT::CURSOR_REPORT_POSITION, cursor_position).await; } diff --git a/src-tauri/src/services/ws/doll.rs b/src-tauri/src/services/ws/doll.rs index cfff7e7..5f9d30c 100644 --- a/src-tauri/src/services/ws/doll.rs +++ b/src-tauri/src/services/ws/doll.rs @@ -1,101 +1,26 @@ use rust_socketio::{Payload, RawClient}; -use tracing::{error, info}; -use crate::{ - lock_r, - state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}, -}; +use super::{refresh, utils}; +/// Handler for doll.created event 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"), + if utils::extract_text_value(payload, "doll.created").is_ok() { + refresh::refresh_app_data(crate::state::AppDataRefreshScope::Dolls); } } +/// Handler for doll.updated event 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 - .user_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"), + if let Ok(value) = utils::extract_text_value(payload, "doll.updated") { + let doll_id = utils::extract_doll_id(&value); + refresh::refresh_with_active_doll_check(doll_id.as_deref()); } } +/// Handler for doll.deleted event 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 - .user_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"), + if let Ok(value) = utils::extract_text_value(payload, "doll.deleted") { + let doll_id = utils::extract_doll_id(&value); + refresh::refresh_with_active_doll_check(doll_id.as_deref()); } } diff --git a/src-tauri/src/services/ws/emitter.rs b/src-tauri/src/services/ws/emitter.rs new file mode 100644 index 0000000..0d014ed --- /dev/null +++ b/src-tauri/src/services/ws/emitter.rs @@ -0,0 +1,67 @@ +use rust_socketio::Payload; +use serde::Serialize; +use tauri::{async_runtime, Emitter}; +use tracing::error; + +use crate::{get_app_handle, init::lifecycle::handle_disasterous_failure, lock_r, state::FDOLL}; + +/// Emit data to WebSocket server +/// +/// Handles client acquisition, initialization checks, blocking emit, and error handling. +/// Returns Ok(()) on success, Err with message on failure. +pub async fn ws_emit( + event: &'static str, + payload: T, +) -> Result<(), String> { + let (client_opt, is_initialized) = { + let guard = lock_r!(FDOLL); + if let Some(clients) = &guard.network.clients { + ( + clients.ws_client.as_ref().cloned(), + clients.is_ws_initialized, + ) + } else { + (None, false) + } + }; + + let Some(client) = client_opt else { + return Ok(()); // Client not available, silent skip + }; + + if !is_initialized { + return Ok(()); // Not initialized yet, silent skip + } + + let payload_value = serde_json::to_value(&payload) + .map_err(|e| format!("Failed to serialize payload: {}", e))?; + + match async_runtime::spawn_blocking(move || { + client.emit(event, Payload::Text(vec![payload_value])) + }) + .await + { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => { + let err_msg = format!("WebSocket emit failed: {}", e); + error!("{}", err_msg); + handle_disasterous_failure(Some(err_msg.clone())).await; + Err(err_msg) + } + Err(e) => { + let err_msg = format!("WebSocket task failed: {}", e); + error!("Failed to execute blocking task for {}: {}", event, e); + handle_disasterous_failure(Some(err_msg.clone())).await; + Err(err_msg) + } + } +} + +/// Emit event to frontend (Tauri window) +/// +/// Handles error logging consistently. +pub fn emit_to_frontend(event: &str, payload: T) { + if let Err(e) = get_app_handle().emit(event, payload) { + error!("Failed to emit {} event to frontend: {:?}", event, e); + } +} diff --git a/src-tauri/src/services/ws/friend.rs b/src-tauri/src/services/ws/friend.rs index 80efa74..f20f318 100644 --- a/src-tauri/src/services/ws/friend.rs +++ b/src-tauri/src/services/ws/friend.rs @@ -1,191 +1,106 @@ use rust_socketio::{Payload, RawClient}; -use tauri::Emitter; -use tracing::{error, info}; +use tracing::info; -use crate::{ - get_app_handle, - services::cursor::{normalized_to_absolute, CursorPositions}, - state::{init_app_data_scoped, AppDataRefreshScope}, +use crate::services::cursor::{normalized_to_absolute, CursorPositions}; +use crate::state::AppDataRefreshScope; + +use super::{ + emitter, refresh, + types::{IncomingFriendCursorPayload, OutgoingFriendCursorPayload, WS_EVENT}, + utils, }; -use super::{IncomingFriendCursorPayload, OutgoingFriendCursorPayload, WS_EVENT}; - +/// Handler for friend-request-received 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"), + if let Ok(value) = utils::extract_text_value(payload, "friend-request-received") { + emitter::emit_to_frontend(WS_EVENT::FRIEND_REQUEST_RECEIVED, value); } } +/// Handler for friend-request-accepted event 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"), + if let Ok(value) = utils::extract_text_value(payload, "friend-request-accepted") { + emitter::emit_to_frontend(WS_EVENT::FRIEND_REQUEST_ACCEPTED, value); + refresh::refresh_app_data(AppDataRefreshScope::Friends); } } +/// Handler for friend-request-denied event 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"), + if let Ok(value) = utils::extract_text_value(payload, "friend-request-denied") { + emitter::emit_to_frontend(WS_EVENT::FRIEND_REQUEST_DENIED, value); } } +/// Handler for unfriended event 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"), + if let Ok(value) = utils::extract_text_value(payload, "unfriended") { + emitter::emit_to_frontend(WS_EVENT::UNFRIENDED, value); + refresh::refresh_app_data(AppDataRefreshScope::Friends); } } +/// Handler for friend-cursor-position event 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()); + if let Ok(friend_data) = + utils::extract_and_parse::(payload, "friend-cursor-position") + { + let mapped_pos = &friend_data.position; + let raw_pos = normalized_to_absolute(mapped_pos); - match incoming_data { - Ok(friend_data) => { - // We received normalized coordinates (mapped) - let mapped_pos = &friend_data.position; + let outgoing_payload = OutgoingFriendCursorPayload { + user_id: friend_data.user_id, + position: CursorPositions { + raw: raw_pos, + mapped: mapped_pos.clone(), + }, + }; - // 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"), + emitter::emit_to_frontend(WS_EVENT::FRIEND_CURSOR_POSITION, outgoing_payload); } } +/// Handler for friend-disconnected event 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"), + if let Ok(value) = utils::extract_text_value(payload, "friend-disconnected") { + emitter::emit_to_frontend(WS_EVENT::FRIEND_DISCONNECTED, value); } } +/// Handler for friend-doll-created event pub fn on_friend_doll_created(payload: Payload, _socket: RawClient) { - handle_friend_doll_change(WS_EVENT::FRIEND_DOLL_CREATED, payload); + handle_friend_doll_change("friend-doll-created", payload); } +/// Handler for friend-doll-updated event pub fn on_friend_doll_updated(payload: Payload, _socket: RawClient) { - handle_friend_doll_change(WS_EVENT::FRIEND_DOLL_UPDATED, payload); + handle_friend_doll_change("friend-doll-updated", payload); } +/// Handler for friend-doll-deleted event pub fn on_friend_doll_deleted(payload: Payload, _socket: RawClient) { - handle_friend_doll_change(WS_EVENT::FRIEND_DOLL_DELETED, payload); + handle_friend_doll_change("friend-doll-deleted", payload); } +/// Common handler for friend doll change events fn handle_friend_doll_change(event_name: &str, payload: Payload) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - info!("Received {} event: {:?}", event_name, first_value); - // Future: Trigger re-fetch or emit to frontend - } else { - info!("Received {} event with empty payload", event_name); - } - } - _ => error!("Received unexpected payload format for {}", event_name), + if let Ok(value) = utils::extract_text_value(payload, event_name) { + info!("Friend doll changed: {:?}", value); + // Future: Could emit to frontend or trigger specific actions } } +/// Handler for friend-active-doll-changed event 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"), + if let Ok(value) = utils::extract_text_value(payload, "friend-active-doll-changed") { + emitter::emit_to_frontend(WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, value); + refresh::refresh_app_data(AppDataRefreshScope::Friends); } } +/// Handler for friend-user-status event pub fn on_friend_user_status(payload: Payload, _socket: RawClient) { - match payload { - Payload::Text(values) => { - if let Some(first_value) = values.first() { - if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_USER_STATUS, first_value) { - error!("Failed to emit friend-user-status event: {:?}", e); - } - } else { - info!("Received friend-user-status event with empty payload"); - } - } - _ => error!("Received unexpected payload format for friend-user-status"), + if let Ok(value) = utils::extract_text_value(payload, "friend-user-status") { + emitter::emit_to_frontend(WS_EVENT::FRIEND_USER_STATUS, value); } } diff --git a/src-tauri/src/services/ws/handlers.rs b/src-tauri/src/services/ws/handlers.rs index 24337d0..0cc407e 100644 --- a/src-tauri/src/services/ws/handlers.rs +++ b/src-tauri/src/services/ws/handlers.rs @@ -1,6 +1,6 @@ use rust_socketio::{ClientBuilder, Event}; -use crate::services::ws::WS_EVENT; +use super::types::WS_EVENT; pub fn register_event_handlers(builder: ClientBuilder) -> ClientBuilder { builder @@ -41,7 +41,10 @@ pub fn register_event_handlers(builder: ClientBuilder) -> ClientBuilder { WS_EVENT::FRIEND_ACTIVE_DOLL_CHANGED, super::friend::on_friend_active_doll_changed, ) - .on(WS_EVENT::FRIEND_USER_STATUS, super::friend::on_friend_user_status) + .on( + WS_EVENT::FRIEND_USER_STATUS, + super::friend::on_friend_user_status, + ) .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) @@ -57,4 +60,4 @@ pub fn register_event_handlers(builder: ClientBuilder) -> ClientBuilder { // rust-socketio fires Event::Connect on initial connect AND reconnects // so we resend initialization there instead of a dedicated reconnect event. .on(Event::Connect, super::connection::on_connected) -} \ No newline at end of file +} diff --git a/src-tauri/src/services/ws/interaction.rs b/src-tauri/src/services/ws/interaction.rs index fac57e9..620c0c5 100644 --- a/src-tauri/src/services/ws/interaction.rs +++ b/src-tauri/src/services/ws/interaction.rs @@ -1,70 +1,24 @@ use rust_socketio::{Payload, RawClient}; -use tauri::Emitter; -use tracing::{error, info}; -use crate::{ - get_app_handle, - models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto}, -}; +use crate::models::interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto}; -use super::WS_EVENT; +use super::{emitter, types::WS_EVENT, utils}; +/// Handler for interaction-received 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"), + if let Ok(data) = + utils::extract_and_parse::(payload, "interaction-received") + { + emitter::emit_to_frontend(WS_EVENT::INTERACTION_RECEIVED, data); } } +/// Handler for interaction-delivery-failed event 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"), + if let Ok(data) = utils::extract_and_parse::( + payload, + "interaction-delivery-failed", + ) { + emitter::emit_to_frontend(WS_EVENT::INTERACTION_DELIVERY_FAILED, data); } } diff --git a/src-tauri/src/services/ws/mod.rs b/src-tauri/src/services/ws/mod.rs index a5a0273..f299475 100644 --- a/src-tauri/src/services/ws/mod.rs +++ b/src-tauri/src/services/ws/mod.rs @@ -1,54 +1,32 @@ -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 FRIEND_USER_STATUS: &str = "friend-user-status"; - pub const CLIENT_REPORT_USER_STATUS: &str = "client-report-user-status"; - 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"; -} - -pub mod client; +/// WebSocket module for real-time communication +/// +/// Organized into focused submodules: +/// - types: Event constants and payload structures +/// - utils: Common payload handling and parsing utilities +/// - emitter: WebSocket and frontend event emission +/// - refresh: Data refresh orchestration +/// - handlers: Event handler registration +/// - connection: Connection lifecycle handlers +/// - doll: Doll-related event handlers +/// - friend: Friend-related event handlers +/// - interaction: Interaction event handlers +/// - cursor: Cursor position reporting +/// - user_status: User status reporting mod connection; mod cursor; mod doll; +mod emitter; mod friend; mod handlers; mod interaction; +mod refresh; +mod types; mod user_status; +mod utils; +pub mod client; + +// Re-export public API pub use cursor::report_cursor_data; +pub use types::WS_EVENT; pub use user_status::{report_user_status, UserStatusPayload}; diff --git a/src-tauri/src/services/ws/refresh.rs b/src-tauri/src/services/ws/refresh.rs new file mode 100644 index 0000000..c2840e3 --- /dev/null +++ b/src-tauri/src/services/ws/refresh.rs @@ -0,0 +1,33 @@ +use tauri::async_runtime; + +use crate::state::{init_app_data_scoped, AppDataRefreshScope}; + +/// Refresh app data with the given scope +pub fn refresh_app_data(scope: AppDataRefreshScope) { + async_runtime::spawn(async move { + init_app_data_scoped(scope).await; + }); +} + +/// Refresh multiple scopes sequentially +#[allow(dead_code)] +pub fn refresh_app_data_multi(scopes: Vec) { + async_runtime::spawn(async move { + for scope in scopes { + init_app_data_scoped(scope).await; + } + }); +} + +/// Refresh dolls and optionally user/friends if doll was active +pub fn refresh_with_active_doll_check(doll_id: Option<&str>) { + let is_active = doll_id.map(super::utils::is_active_doll).unwrap_or(false); + + async_runtime::spawn(async move { + init_app_data_scoped(AppDataRefreshScope::Dolls).await; + if is_active { + init_app_data_scoped(AppDataRefreshScope::User).await; + init_app_data_scoped(AppDataRefreshScope::Friends).await; + } + }); +} diff --git a/src-tauri/src/services/ws/types.rs b/src-tauri/src/services/ws/types.rs new file mode 100644 index 0000000..f2de5e5 --- /dev/null +++ b/src-tauri/src/services/ws/types.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +/// WebSocket event constants +#[allow(non_camel_case_types)] +pub struct WS_EVENT; + +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 FRIEND_USER_STATUS: &str = "friend-user-status"; + pub const CLIENT_REPORT_USER_STATUS: &str = "client-report-user-status"; + 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"; +} + +/// Incoming friend cursor position from WebSocket +#[derive(Debug, Deserialize)] +pub struct IncomingFriendCursorPayload { + #[serde(rename = "userId")] + pub user_id: String, + pub position: crate::services::cursor::CursorPosition, +} + +/// Outgoing friend cursor position to frontend +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutgoingFriendCursorPayload { + pub user_id: String, + pub position: crate::services::cursor::CursorPositions, +} diff --git a/src-tauri/src/services/ws/user_status.rs b/src-tauri/src/services/ws/user_status.rs index 840d8c7..b0b9d73 100644 --- a/src-tauri/src/services/ws/user_status.rs +++ b/src-tauri/src/services/ws/user_status.rs @@ -1,86 +1,39 @@ use once_cell::sync::Lazy; -use rust_socketio::Payload; -use tauri::async_runtime::{self}; +use serde::Serialize; use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio::time::Duration; -use tracing::error; -use crate::{init::lifecycle::handle_disasterous_failure, lock_r, state::FDOLL}; use crate::services::active_app::AppMetadata; -use super::WS_EVENT; +use super::{emitter, types::WS_EVENT}; -#[derive(Clone, serde::Serialize)] +/// User status payload sent to WebSocket server +#[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UserStatusPayload { pub app_metadata: AppMetadata, pub state: String, } +/// Debouncer for user status reports static USER_STATUS_REPORT_DEBOUNCE: Lazy>>> = Lazy::new(|| Mutex::new(None)); +/// Report user status to WebSocket server with debouncing pub async fn report_user_status(status: UserStatusPayload) { - let payload_value = match serde_json::to_value(&status) { - Ok(val) => val, - Err(e) => { - error!("Failed to serialize user status payload: {}", e); - return; - } - }; + let mut debouncer = USER_STATUS_REPORT_DEBOUNCE.lock().await; - let (client_opt, is_initialized) = { - let guard = lock_r!(FDOLL); - if let Some(clients) = &guard.network.clients { - ( - clients.ws_client.as_ref().cloned(), - clients.is_ws_initialized, - ) - } else { - (None, false) - } - }; - - { - let mut debouncer = USER_STATUS_REPORT_DEBOUNCE.lock().await; - if let Some(handle) = debouncer.take() { - handle.abort(); - } - let payload_value_clone = payload_value.clone(); - let client_opt_clone = client_opt.clone(); - let is_initialized_clone = is_initialized; - let handle = tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(500)).await; - if let Some(client) = client_opt_clone { - if !is_initialized_clone { - return; - } - match async_runtime::spawn_blocking(move || { - client.emit( - WS_EVENT::CLIENT_REPORT_USER_STATUS, - Payload::Text(vec![payload_value_clone]), - ) - }) - .await - { - Ok(Ok(_)) => (), - Ok(Err(e)) => { - error!("Failed to emit user status report: {}", e); - handle_disasterous_failure(Some(format!("WebSocket emit failed: {}", e))) - .await; - } - Err(e) => { - error!( - "Failed to execute blocking task for user status report: {}", - e - ); - handle_disasterous_failure(Some(format!("WebSocket task failed: {}", e))) - .await; - } - } - } - }); - *debouncer = Some(handle); + // Cancel previous pending report + if let Some(handle) = debouncer.take() { + handle.abort(); } + + // Schedule new report after 500ms + let handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + let _ = emitter::ws_emit(WS_EVENT::CLIENT_REPORT_USER_STATUS, status).await; + }); + + *debouncer = Some(handle); } diff --git a/src-tauri/src/services/ws/utils.rs b/src-tauri/src/services/ws/utils.rs new file mode 100644 index 0000000..df43c6c --- /dev/null +++ b/src-tauri/src/services/ws/utils.rs @@ -0,0 +1,70 @@ +use rust_socketio::Payload; +use serde::de::DeserializeOwned; +use tracing::{error, info}; + +/// Result type for payload operations +pub type PayloadResult = Result; + +/// Errors that can occur during payload handling +#[derive(Debug)] +pub enum PayloadError { + InvalidFormat, + EmptyPayload, + ParseError(String), +} + +/// Extract the first value from a Text payload +pub fn extract_text_value(payload: Payload, event_name: &str) -> PayloadResult { + match payload { + Payload::Text(values) => { + if let Some(first_value) = values.first() { + Ok(first_value.clone()) + } else { + Err(PayloadError::EmptyPayload) + } + } + _ => { + error!("Received unexpected payload format for {}", event_name); + Err(PayloadError::InvalidFormat) + } + } +} + +/// Parse payload value into a specific type +pub fn parse_payload( + value: serde_json::Value, + event_name: &str, +) -> PayloadResult { + serde_json::from_value(value).map_err(|e| { + error!("Failed to parse {} payload: {}", event_name, e); + PayloadError::ParseError(e.to_string()) + }) +} + +/// Extract and parse payload in one step +pub fn extract_and_parse( + payload: Payload, + event_name: &str, +) -> PayloadResult { + let value = extract_text_value(payload, event_name)?; + parse_payload(value, event_name) +} + +/// Check if a doll ID matches the current user's active doll +pub fn is_active_doll(doll_id: &str) -> bool { + use crate::{lock_r, state::FDOLL}; + + let guard = lock_r!(FDOLL); + guard + .user_data + .user + .as_ref() + .and_then(|u| u.active_doll_id.as_ref()) + .map(|active_id| active_id == doll_id) + .unwrap_or(false) +} + +/// Extract doll ID from a JSON value +pub fn extract_doll_id(value: &serde_json::Value) -> Option { + value.get("id").and_then(|v| v.as_str()).map(String::from) +}