diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 30a5c61..b730b12 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -1,4 +1,7 @@ use crate::get_app_handle; +use crate::init::lifecycle::{construct_user_session, validate_server_health}; +use crate::services::auth::get_session_token; +use tracing::info; #[tauri::command] pub fn quit_app() -> Result<(), String> { @@ -12,3 +15,24 @@ pub fn restart_app() { let app_handle = get_app_handle(); app_handle.restart(); } + +/// Attempt to re-establish the user session without restarting the app. +/// +/// Validates server health, checks for a valid session token, +/// then reconstructs the user session (re-fetches app data + WebSocket). +#[tauri::command] +pub async fn retry_connection() -> Result<(), String> { + info!("Retrying connection..."); + + validate_server_health() + .await + .map_err(|e| format!("Server health check failed: {}", e))?; + + if get_session_token().await.is_none() { + return Err("No valid session token. Please restart and log in again.".to_string()); + } + + construct_user_session().await; + info!("Connection retry succeeded"); + Ok(()) +} diff --git a/src-tauri/src/init/lifecycle.rs b/src-tauri/src/init/lifecycle.rs index 78f1925..cbc1963 100644 --- a/src-tauri/src/init/lifecycle.rs +++ b/src-tauri/src/init/lifecycle.rs @@ -19,6 +19,7 @@ use crate::{ /// Connects the user profile and opens the scene window. pub async fn construct_user_session() { connect_user_profile().await; + close_all_windows(); open_scene_window(); update_system_tray(true); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 004d6fa..4007851 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ use crate::services::{ doll_editor::open_doll_editor_window, scene::{set_pet_menu_state, set_scene_interactive}, }; -use commands::app::{quit_app, restart_app}; +use commands::app::{quit_app, restart_app, retry_connection}; use commands::app_data::{get_app_data, refresh_app_data}; use commands::auth::{logout_and_restart, start_auth_flow}; use commands::config::{get_client_config, open_client_config_manager, save_client_config}; @@ -74,6 +74,7 @@ pub fn run() { recolor_gif_base64, quit_app, restart_app, + retry_connection, get_client_config, save_client_config, open_client_config_manager, diff --git a/src-tauri/src/services/active_app/macos.rs b/src-tauri/src/services/active_app/macos.rs index c1bec82..ecea310 100644 --- a/src-tauri/src/services/active_app/macos.rs +++ b/src-tauri/src/services/active_app/macos.rs @@ -7,7 +7,7 @@ use objc2::{class, msg_send, sel}; use objc2_foundation::NSString; use std::ffi::CStr; use std::sync::{Mutex, Once}; -use tracing::warn; +use tracing::{info, warn}; #[allow(unused_imports)] // for framework linking, not referenced in code use objc2_app_kit::NSWorkspace; @@ -163,34 +163,73 @@ fn get_active_app_icon_b64(front_app: *mut AnyObject) -> Option { return None; } - let _: () = msg_send![icon, setSize: objc2_foundation::NSSize::new(ICON_SIZE, ICON_SIZE)]; - - let tiff: *mut AnyObject = msg_send![icon, TIFFRepresentation]; - if tiff.is_null() { - warn!( - "Failed to fetch icon for {}: TIFFRepresentation null", - path_str - ); + // Render icon at exact pixel dimensions to avoid multi-representation bloat. + // NSImage.setSize only changes display size, not pixel data. On Retina Macs, + // TIFFRepresentation can include 2x/3x representations (512x512+), producing + // oversized base64 strings that crash WebSocket payloads. + // Instead: create a new bitmap at exactly ICON_SIZE x ICON_SIZE pixels and + // draw the icon into it, then export that single representation as PNG. + let size = ICON_SIZE as u64; + let bitmap: *mut AnyObject = msg_send![class!(NSBitmapImageRep), alloc]; + let bitmap: *mut AnyObject = msg_send![ + bitmap, + initWithBitmapDataPlanes: std::ptr::null::<*mut u8>(), + pixelsWide: size, + pixelsHigh: size, + bitsPerSample: 8u64, + samplesPerPixel: 4u64, + hasAlpha: true, + isPlanar: false, + colorSpaceName: &*NSString::from_str("NSCalibratedRGBColorSpace"), + bytesPerRow: 0u64, + bitsPerPixel: 0u64 + ]; + if bitmap.is_null() { + warn!("Failed to create bitmap rep for {}", path_str); return None; } - let rep: *mut AnyObject = msg_send![class!(NSBitmapImageRep), imageRepWithData: tiff]; - if rep.is_null() { - warn!( - "Failed to fetch icon for {}: imageRepWithData null", - path_str - ); + // Save/restore graphics context to draw icon into our bitmap + let _: () = msg_send![class!(NSGraphicsContext), saveGraphicsState]; + let ctx: *mut AnyObject = msg_send![ + class!(NSGraphicsContext), + graphicsContextWithBitmapImageRep: bitmap + ]; + if ctx.is_null() { + let _: () = msg_send![class!(NSGraphicsContext), restoreGraphicsState]; + warn!("Failed to create graphics context for {}", path_str); return None; } + let _: () = msg_send![class!(NSGraphicsContext), setCurrentContext: ctx]; - // 4 = NSBitmapImageFileTypePNG + // Draw the icon into the bitmap at exact pixel size + let draw_rect = objc2_foundation::NSRect::new( + objc2_foundation::NSPoint::new(0.0, 0.0), + objc2_foundation::NSSize::new(ICON_SIZE, ICON_SIZE), + ); + let from_rect = objc2_foundation::NSRect::new( + objc2_foundation::NSPoint::new(0.0, 0.0), + objc2_foundation::NSSize::new(0.0, 0.0), // zero = use full source + ); + // 2 = NSCompositingOperationSourceOver + let _: () = msg_send![ + icon, + drawInRect: draw_rect, + fromRect: from_rect, + operation: 2u64, + fraction: 1.0f64 + ]; + + let _: () = msg_send![class!(NSGraphicsContext), restoreGraphicsState]; + + // Export bitmap as PNG (4 = NSBitmapImageFileTypePNG) let png_data: *mut AnyObject = msg_send![ - rep, + bitmap, representationUsingType: 4u64, properties: std::ptr::null::() ]; if png_data.is_null() { - warn!("Failed to fetch icon for {}: PNG data null", path_str); + warn!("Failed to export icon as PNG for {}", path_str); return None; } @@ -201,8 +240,27 @@ fn get_active_app_icon_b64(front_app: *mut AnyObject) -> Option { return None; } - let slice = slice::from_raw_parts(bytes, len); - let encoded = STANDARD.encode(slice); + let data_slice = slice::from_raw_parts(bytes, len); + let encoded = STANDARD.encode(data_slice); + + info!( + "App icon captured: {}, PNG bytes: {}, base64 size: {} bytes", + path_str, + len, + encoded.len() + ); + + // Cap icon size to prevent oversized WebSocket payloads + if encoded.len() > super::types::MAX_ICON_B64_SIZE { + warn!( + "Icon for {} exceeds size limit ({} > {} bytes), skipping", + path_str, + encoded.len(), + super::types::MAX_ICON_B64_SIZE + ); + return None; + } + put_cached_icon(&path_str, encoded.clone()); Some(encoded) diff --git a/src-tauri/src/services/active_app/types.rs b/src-tauri/src/services/active_app/types.rs index 79d049e..ea8fe1c 100644 --- a/src-tauri/src/services/active_app/types.rs +++ b/src-tauri/src/services/active_app/types.rs @@ -4,6 +4,12 @@ use ts_rs::TS; pub const ICON_SIZE: u32 = 64; pub const ICON_CACHE_LIMIT: usize = 50; +/// Maximum base64-encoded icon size in bytes (~50KB). +/// A 64x64 RGBA PNG should be well under this. Anything larger +/// indicates multi-representation or uncompressed data that +/// could crash WebSocket payloads. +pub const MAX_ICON_B64_SIZE: usize = 50_000; + /// Metadata for the currently active application, including localized and unlocalized names, and an optional base64-encoded icon. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/services/active_app/windows.rs b/src-tauri/src/services/active_app/windows.rs index 09feca3..9939dd4 100644 --- a/src-tauri/src/services/active_app/windows.rs +++ b/src-tauri/src/services/active_app/windows.rs @@ -4,7 +4,7 @@ use std::iter; use std::os::windows::ffi::OsStringExt; use std::path::Path; use std::ptr; -use tracing::warn; +use tracing::{info, warn}; use windows::core::PCWSTR; use windows::Win32::Foundation::HWND; use windows::Win32::Globalization::GetUserDefaultLangID; @@ -12,8 +12,8 @@ use windows::Win32::System::ProcessStatus::GetModuleFileNameExW; use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::WindowsAndMessaging::{ - DispatchMessageW, GetForegroundWindow, GetMessageW, EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT, - GetWindowTextW, GetWindowThreadProcessId, + DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowTextW, GetWindowThreadProcessId, + EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT, }; pub fn listen_for_active_app_changes(callback: F) @@ -434,6 +434,24 @@ fn get_active_app_icon_b64(exe_path: &str) -> Option { } let encoded = STANDARD.encode(&png_buffer); + + info!( + "App icon captured: {}, PNG bytes: {}, base64 size: {} bytes", + exe_path, + png_buffer.len(), + encoded.len() + ); + + if encoded.len() > super::types::MAX_ICON_B64_SIZE { + warn!( + "Icon for {} exceeds size limit ({} > {} bytes), skipping", + exe_path, + encoded.len(), + super::types::MAX_ICON_B64_SIZE + ); + return None; + } + put_cached_icon(exe_path, encoded.clone()); Some(encoded) diff --git a/src-tauri/src/services/ws/connection.rs b/src-tauri/src/services/ws/connection.rs index a203849..ab77947 100644 --- a/src-tauri/src/services/ws/connection.rs +++ b/src-tauri/src/services/ws/connection.rs @@ -2,8 +2,10 @@ 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::{init_app_data_scoped, AppDataRefreshScope, FDOLL}, }; use super::{types::WS_EVENT, utils}; @@ -24,17 +26,29 @@ pub fn on_connected(_payload: Payload, socket: RawClient) { /// Handler for initialized event pub fn on_initialized(payload: Payload, _socket: RawClient) { if utils::extract_text_value(payload, "initialized").is_ok() { - mark_ws_initialized(); + let needs_data_refresh = check_and_mark_initialized(); restore_connection_ui(); + + if needs_data_refresh { + info!("Reconnection detected: refreshing app data"); + tauri::async_runtime::spawn(async { + init_app_data_scoped(AppDataRefreshScope::All).await; + }); + } } } -/// Mark WebSocket as initialized in app state -fn mark_ws_initialized() { +/// Mark WebSocket as initialized and check if app data needs refreshing +/// +/// Returns true if user data is missing (indicating a reconnection +/// after session teardown where app data was cleared). +fn check_and_mark_initialized() -> bool { let mut guard = lock_w!(FDOLL); if let Some(clients) = guard.network.clients.as_mut() { clients.is_ws_initialized = true; } + // If user data is gone, we need to re-fetch everything + guard.user_data.user.is_none() } /// Restore UI after successful connection diff --git a/src-tauri/src/services/ws/cursor.rs b/src-tauri/src/services/ws/cursor.rs index 7c2bc56..9ca3b68 100644 --- a/src-tauri/src/services/ws/cursor.rs +++ b/src-tauri/src/services/ws/cursor.rs @@ -3,6 +3,9 @@ use crate::services::cursor::CursorPosition; use super::{emitter, types::WS_EVENT}; /// Report cursor position to WebSocket server +/// +/// Uses soft emit since cursor telemetry is non-critical +/// and should not tear down the session on failure. pub async fn report_cursor_data(cursor_position: CursorPosition) { - let _ = emitter::ws_emit(WS_EVENT::CURSOR_REPORT_POSITION, cursor_position).await; + let _ = emitter::ws_emit_soft(WS_EVENT::CURSOR_REPORT_POSITION, cursor_position).await; } diff --git a/src-tauri/src/services/ws/emitter.rs b/src-tauri/src/services/ws/emitter.rs index 0d014ed..a4dff77 100644 --- a/src-tauri/src/services/ws/emitter.rs +++ b/src-tauri/src/services/ws/emitter.rs @@ -1,29 +1,29 @@ use rust_socketio::Payload; use serde::Serialize; use tauri::{async_runtime, Emitter}; -use tracing::error; +use tracing::{error, warn}; 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( +/// Acquire WebSocket client and initialization state from app state +fn get_ws_state() -> (Option, bool) { + 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) + } +} + +/// Serialize and emit a payload via the WebSocket client (blocking) +async fn do_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 (client_opt, is_initialized) = get_ws_state(); let Some(client) = client_opt else { return Ok(()); // Client not available, silent skip @@ -42,16 +42,43 @@ pub async fn ws_emit( .await { Ok(Ok(_)) => Ok(()), - Ok(Err(e)) => { - let err_msg = format!("WebSocket emit failed: {}", e); - error!("{}", err_msg); + Ok(Err(e)) => Err(format!("WebSocket emit failed: {}", e)), + Err(e) => Err(format!("WebSocket task failed: {}", e)), + } +} + +/// Emit critical data to WebSocket server +/// +/// On failure, triggers disaster recovery (session teardown + health manager). +/// Use for essential operations where connection loss is unrecoverable. +#[allow(dead_code)] +pub async fn ws_emit( + event: &'static str, + payload: T, +) -> Result<(), String> { + match do_emit(event, payload).await { + Ok(_) => Ok(()), + Err(err_msg) => { + error!("[critical] {}", 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; + } +} + +/// Emit non-critical data to WebSocket server +/// +/// On failure, logs a warning but does NOT trigger disaster recovery. +/// Use for telemetry, status updates, and other non-essential operations +/// where a failure should not tear down the user session. +pub async fn ws_emit_soft( + event: &'static str, + payload: T, +) -> Result<(), String> { + match do_emit(event, payload).await { + Ok(_) => Ok(()), + Err(err_msg) => { + warn!("[non-critical] {}", err_msg); Err(err_msg) } } diff --git a/src-tauri/src/services/ws/user_status.rs b/src-tauri/src/services/ws/user_status.rs index b0b9d73..2cd08f9 100644 --- a/src-tauri/src/services/ws/user_status.rs +++ b/src-tauri/src/services/ws/user_status.rs @@ -3,6 +3,7 @@ use serde::Serialize; use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio::time::Duration; +use tracing::warn; use crate::services::active_app::AppMetadata; @@ -21,6 +22,9 @@ static USER_STATUS_REPORT_DEBOUNCE: Lazy>>> = Lazy::new(|| Mutex::new(None)); /// Report user status to WebSocket server with debouncing +/// +/// Uses soft emit to avoid triggering disaster recovery on failure, +/// since user status is non-critical telemetry. pub async fn report_user_status(status: UserStatusPayload) { let mut debouncer = USER_STATUS_REPORT_DEBOUNCE.lock().await; @@ -32,7 +36,9 @@ pub async fn report_user_status(status: UserStatusPayload) { // 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; + if let Err(e) = emitter::ws_emit_soft(WS_EVENT::CLIENT_REPORT_USER_STATUS, status).await { + warn!("User status report failed: {}", e); + } }); *debouncer = Some(handle); diff --git a/src-tauri/src/services/ws/utils.rs b/src-tauri/src/services/ws/utils.rs index df43c6c..80706d0 100644 --- a/src-tauri/src/services/ws/utils.rs +++ b/src-tauri/src/services/ws/utils.rs @@ -1,6 +1,6 @@ use rust_socketio::Payload; use serde::de::DeserializeOwned; -use tracing::{error, info}; +use tracing::error; /// Result type for payload operations pub type PayloadResult = Result; @@ -10,7 +10,7 @@ pub type PayloadResult = Result; pub enum PayloadError { InvalidFormat, EmptyPayload, - ParseError(String), + ParseError(()), } /// Extract the first value from a Text payload @@ -37,7 +37,7 @@ pub fn parse_payload( ) -> PayloadResult { serde_json::from_value(value).map_err(|e| { error!("Failed to parse {} payload: {}", event_name, e); - PayloadError::ParseError(e.to_string()) + PayloadError::ParseError(()) }) } diff --git a/src/routes/health-manager/+page.svelte b/src/routes/health-manager/+page.svelte index eb5610a..991263a 100644 --- a/src/routes/health-manager/+page.svelte +++ b/src/routes/health-manager/+page.svelte @@ -4,21 +4,21 @@ import { page } from "$app/stores"; let errorMessage = ""; - let isRestarting = false; + let isRetrying = false; onMount(() => { errorMessage = $page.url.searchParams.get("err") || ""; }); const tryAgain = async () => { - if (isRestarting) return; - isRestarting = true; + if (isRetrying) return; + isRetrying = true; errorMessage = ""; try { - await invoke("restart_app"); + await invoke("retry_connection"); } catch (err) { - errorMessage = `Restart failed: ${err}`; - isRestarting = false; + errorMessage = `${err}`; + isRetrying = false; } }; @@ -41,11 +41,11 @@