minor websocket refactoring

This commit is contained in:
2026-02-06 23:21:58 +08:00
parent 4e2e19c60a
commit 99340d4278
12 changed files with 370 additions and 471 deletions

View File

@@ -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);
if utils::extract_text_value(payload, "initialized").is_ok() {
mark_ws_initialized();
restore_connection_ui();
}
}
// Mark WebSocket as initialized and reset backoff timer
/// 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;
}
}
// Connection restored: close health manager and reopen scene
/// Restore UI after successful connection
fn restore_connection_ui() {
close_health_manager_window();
open_scene_window();
} else {
info!("Received initialized event with empty payload");
}
}
_ => tracing::error!("Received unexpected payload format for initialized"),
}
}

View File

@@ -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;
}

View File

@@ -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());
}
}

View File

@@ -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<T: Serialize + Send + 'static>(
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<T: Serialize + Clone>(event: &str, payload: T) {
if let Err(e) = get_app_handle().emit(event, payload) {
error!("Failed to emit {} event to frontend: {:?}", event, e);
}
}

View File

@@ -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<serde_json::Value>
if let Some(first_value) = values.first() {
let incoming_data: Result<IncomingFriendCursorPayload, _> =
serde_json::from_value(first_value.clone());
match incoming_data {
Ok(friend_data) => {
// We received normalized coordinates (mapped)
if let Ok(friend_data) =
utils::extract_and_parse::<IncomingFriendCursorPayload>(payload, "friend-cursor-position")
{
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(),
user_id: friend_data.user_id,
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);
}
}

View File

@@ -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)

View File

@@ -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<InteractionPayloadDto, _> =
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)
if let Ok(data) =
utils::extract_and_parse::<InteractionPayloadDto>(payload, "interaction-received")
{
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"),
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<InteractionDeliveryFailedDto, _> =
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::<InteractionDeliveryFailedDto>(
payload,
"interaction-delivery-failed",
) {
emitter::emit_to_frontend(WS_EVENT::INTERACTION_DELIVERY_FAILED, data);
}
}

View File

@@ -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};

View File

@@ -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<AppDataRefreshScope>) {
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;
}
});
}

View File

@@ -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,
}

View File

@@ -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<Mutex<Option<JoinHandle<()>>>> =
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 (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;
// Cancel previous pending report
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;
// Schedule new report after 500ms
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;
}
}
}
let _ = emitter::ws_emit(WS_EVENT::CLIENT_REPORT_USER_STATUS, status).await;
});
*debouncer = Some(handle);
}
}

View File

@@ -0,0 +1,70 @@
use rust_socketio::Payload;
use serde::de::DeserializeOwned;
use tracing::{error, info};
/// Result type for payload operations
pub type PayloadResult<T> = Result<T, PayloadError>;
/// 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<serde_json::Value> {
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<T: DeserializeOwned>(
value: serde_json::Value,
event_name: &str,
) -> PayloadResult<T> {
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<T: DeserializeOwned>(
payload: Payload,
event_name: &str,
) -> PayloadResult<T> {
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<String> {
value.get("id").and_then(|v| v.as_str()).map(String::from)
}