fixed ghostty crash & better ws failure recovery

This commit is contained in:
2026-02-07 00:49:17 +08:00
parent 99340d4278
commit e0c2bc3144
12 changed files with 225 additions and 67 deletions

View File

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

View File

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

View File

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

View File

@@ -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<String> {
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::<AnyObject>()
];
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<String> {
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)

View File

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

View File

@@ -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<F>(callback: F)
@@ -434,6 +434,24 @@ fn get_active_app_icon_b64(exe_path: &str) -> Option<String> {
}
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)

View File

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

View File

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

View File

@@ -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<T: Serialize + Send + 'static>(
/// Acquire WebSocket client and initialization state from app state
fn get_ws_state() -> (Option<rust_socketio::client::Client>, 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<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 (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<T: Serialize + Send + 'static>(
.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<T: Serialize + Send + 'static>(
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<T: Serialize + Send + 'static>(
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)
}
}

View File

@@ -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<Mutex<Option<JoinHandle<()>>>> =
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);

View File

@@ -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<T> = Result<T, PayloadError>;
@@ -10,7 +10,7 @@ pub type PayloadResult<T> = Result<T, PayloadError>;
pub enum PayloadError {
InvalidFormat,
EmptyPayload,
ParseError(String),
ParseError(()),
}
/// Extract the first value from a Text payload
@@ -37,7 +37,7 @@ pub fn parse_payload<T: DeserializeOwned>(
) -> PayloadResult<T> {
serde_json::from_value(value).map_err(|e| {
error!("Failed to parse {} payload: {}", event_name, e);
PayloadError::ParseError(e.to_string())
PayloadError::ParseError(())
})
}