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::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] #[tauri::command]
pub fn quit_app() -> Result<(), String> { pub fn quit_app() -> Result<(), String> {
@@ -12,3 +15,24 @@ pub fn restart_app() {
let app_handle = get_app_handle(); let app_handle = get_app_handle();
app_handle.restart(); 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. /// Connects the user profile and opens the scene window.
pub async fn construct_user_session() { pub async fn construct_user_session() {
connect_user_profile().await; connect_user_profile().await;
close_all_windows();
open_scene_window(); open_scene_window();
update_system_tray(true); update_system_tray(true);
} }

View File

@@ -2,7 +2,7 @@ use crate::services::{
doll_editor::open_doll_editor_window, doll_editor::open_doll_editor_window,
scene::{set_pet_menu_state, set_scene_interactive}, 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::app_data::{get_app_data, refresh_app_data};
use commands::auth::{logout_and_restart, start_auth_flow}; use commands::auth::{logout_and_restart, start_auth_flow};
use commands::config::{get_client_config, open_client_config_manager, save_client_config}; use commands::config::{get_client_config, open_client_config_manager, save_client_config};
@@ -74,6 +74,7 @@ pub fn run() {
recolor_gif_base64, recolor_gif_base64,
quit_app, quit_app,
restart_app, restart_app,
retry_connection,
get_client_config, get_client_config,
save_client_config, save_client_config,
open_client_config_manager, open_client_config_manager,

View File

@@ -7,7 +7,7 @@ use objc2::{class, msg_send, sel};
use objc2_foundation::NSString; use objc2_foundation::NSString;
use std::ffi::CStr; use std::ffi::CStr;
use std::sync::{Mutex, Once}; use std::sync::{Mutex, Once};
use tracing::warn; use tracing::{info, warn};
#[allow(unused_imports)] // for framework linking, not referenced in code #[allow(unused_imports)] // for framework linking, not referenced in code
use objc2_app_kit::NSWorkspace; use objc2_app_kit::NSWorkspace;
@@ -163,34 +163,73 @@ fn get_active_app_icon_b64(front_app: *mut AnyObject) -> Option<String> {
return None; return None;
} }
let _: () = msg_send![icon, setSize: objc2_foundation::NSSize::new(ICON_SIZE, ICON_SIZE)]; // Render icon at exact pixel dimensions to avoid multi-representation bloat.
// NSImage.setSize only changes display size, not pixel data. On Retina Macs,
let tiff: *mut AnyObject = msg_send![icon, TIFFRepresentation]; // TIFFRepresentation can include 2x/3x representations (512x512+), producing
if tiff.is_null() { // oversized base64 strings that crash WebSocket payloads.
warn!( // Instead: create a new bitmap at exactly ICON_SIZE x ICON_SIZE pixels and
"Failed to fetch icon for {}: TIFFRepresentation null", // draw the icon into it, then export that single representation as PNG.
path_str 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; return None;
} }
let rep: *mut AnyObject = msg_send![class!(NSBitmapImageRep), imageRepWithData: tiff]; // Save/restore graphics context to draw icon into our bitmap
if rep.is_null() { let _: () = msg_send![class!(NSGraphicsContext), saveGraphicsState];
warn!( let ctx: *mut AnyObject = msg_send![
"Failed to fetch icon for {}: imageRepWithData null", class!(NSGraphicsContext),
path_str graphicsContextWithBitmapImageRep: bitmap
); ];
if ctx.is_null() {
let _: () = msg_send![class!(NSGraphicsContext), restoreGraphicsState];
warn!("Failed to create graphics context for {}", path_str);
return None; 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![ let png_data: *mut AnyObject = msg_send![
rep, bitmap,
representationUsingType: 4u64, representationUsingType: 4u64,
properties: std::ptr::null::<AnyObject>() properties: std::ptr::null::<AnyObject>()
]; ];
if png_data.is_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; return None;
} }
@@ -201,8 +240,27 @@ fn get_active_app_icon_b64(front_app: *mut AnyObject) -> Option<String> {
return None; return None;
} }
let slice = slice::from_raw_parts(bytes, len); let data_slice = slice::from_raw_parts(bytes, len);
let encoded = STANDARD.encode(slice); 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()); put_cached_icon(&path_str, encoded.clone());
Some(encoded) Some(encoded)

View File

@@ -4,6 +4,12 @@ use ts_rs::TS;
pub const ICON_SIZE: u32 = 64; pub const ICON_SIZE: u32 = 64;
pub const ICON_CACHE_LIMIT: usize = 50; 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. /// Metadata for the currently active application, including localized and unlocalized names, and an optional base64-encoded icon.
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@@ -4,7 +4,7 @@ use std::iter;
use std::os::windows::ffi::OsStringExt; use std::os::windows::ffi::OsStringExt;
use std::path::Path; use std::path::Path;
use std::ptr; use std::ptr;
use tracing::warn; use tracing::{info, warn};
use windows::core::PCWSTR; use windows::core::PCWSTR;
use windows::Win32::Foundation::HWND; use windows::Win32::Foundation::HWND;
use windows::Win32::Globalization::GetUserDefaultLangID; 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::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK};
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
DispatchMessageW, GetForegroundWindow, GetMessageW, EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT, DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowTextW, GetWindowThreadProcessId,
GetWindowTextW, GetWindowThreadProcessId, EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT,
}; };
pub fn listen_for_active_app_changes<F>(callback: F) 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); 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()); put_cached_icon(exe_path, encoded.clone());
Some(encoded) Some(encoded)

View File

@@ -2,8 +2,10 @@ use rust_socketio::{Payload, RawClient};
use tracing::info; use tracing::info;
use crate::{ use crate::{
lock_w, services::health_manager::close_health_manager_window, lock_w,
services::scene::open_scene_window, state::FDOLL, 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}; use super::{types::WS_EVENT, utils};
@@ -24,17 +26,29 @@ pub fn on_connected(_payload: Payload, socket: RawClient) {
/// Handler for initialized event /// Handler for initialized event
pub fn on_initialized(payload: Payload, _socket: RawClient) { pub fn on_initialized(payload: Payload, _socket: RawClient) {
if utils::extract_text_value(payload, "initialized").is_ok() { if utils::extract_text_value(payload, "initialized").is_ok() {
mark_ws_initialized(); let needs_data_refresh = check_and_mark_initialized();
restore_connection_ui(); 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 /// Mark WebSocket as initialized and check if app data needs refreshing
fn mark_ws_initialized() { ///
/// 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); let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.network.clients.as_mut() { if let Some(clients) = guard.network.clients.as_mut() {
clients.is_ws_initialized = true; 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 /// Restore UI after successful connection

View File

@@ -3,6 +3,9 @@ use crate::services::cursor::CursorPosition;
use super::{emitter, types::WS_EVENT}; use super::{emitter, types::WS_EVENT};
/// Report cursor position to WebSocket server /// 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) { 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,19 +1,12 @@
use rust_socketio::Payload; use rust_socketio::Payload;
use serde::Serialize; use serde::Serialize;
use tauri::{async_runtime, Emitter}; 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}; use crate::{get_app_handle, init::lifecycle::handle_disasterous_failure, lock_r, state::FDOLL};
/// Emit data to WebSocket server /// Acquire WebSocket client and initialization state from app state
/// fn get_ws_state() -> (Option<rust_socketio::client::Client>, bool) {
/// 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); let guard = lock_r!(FDOLL);
if let Some(clients) = &guard.network.clients { if let Some(clients) = &guard.network.clients {
( (
@@ -23,7 +16,14 @@ pub async fn ws_emit<T: Serialize + Send + 'static>(
} else { } else {
(None, false) (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) = get_ws_state();
let Some(client) = client_opt else { let Some(client) = client_opt else {
return Ok(()); // Client not available, silent skip return Ok(()); // Client not available, silent skip
@@ -42,16 +42,43 @@ pub async fn ws_emit<T: Serialize + Send + 'static>(
.await .await
{ {
Ok(Ok(_)) => Ok(()), Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => { Ok(Err(e)) => Err(format!("WebSocket emit failed: {}", e)),
let err_msg = format!("WebSocket emit failed: {}", e); Err(e) => Err(format!("WebSocket task failed: {}", e)),
error!("{}", err_msg); }
}
/// 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; handle_disasterous_failure(Some(err_msg.clone())).await;
Err(err_msg) 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) Err(err_msg)
} }
} }

View File

@@ -3,6 +3,7 @@ use serde::Serialize;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::Duration; use tokio::time::Duration;
use tracing::warn;
use crate::services::active_app::AppMetadata; use crate::services::active_app::AppMetadata;
@@ -21,6 +22,9 @@ static USER_STATUS_REPORT_DEBOUNCE: Lazy<Mutex<Option<JoinHandle<()>>>> =
Lazy::new(|| Mutex::new(None)); Lazy::new(|| Mutex::new(None));
/// Report user status to WebSocket server with debouncing /// 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) { pub async fn report_user_status(status: UserStatusPayload) {
let mut debouncer = USER_STATUS_REPORT_DEBOUNCE.lock().await; 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 // Schedule new report after 500ms
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await; 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); *debouncer = Some(handle);

View File

@@ -1,6 +1,6 @@
use rust_socketio::Payload; use rust_socketio::Payload;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use tracing::{error, info}; use tracing::error;
/// Result type for payload operations /// Result type for payload operations
pub type PayloadResult<T> = Result<T, PayloadError>; pub type PayloadResult<T> = Result<T, PayloadError>;
@@ -10,7 +10,7 @@ pub type PayloadResult<T> = Result<T, PayloadError>;
pub enum PayloadError { pub enum PayloadError {
InvalidFormat, InvalidFormat,
EmptyPayload, EmptyPayload,
ParseError(String), ParseError(()),
} }
/// Extract the first value from a Text payload /// Extract the first value from a Text payload
@@ -37,7 +37,7 @@ pub fn parse_payload<T: DeserializeOwned>(
) -> PayloadResult<T> { ) -> PayloadResult<T> {
serde_json::from_value(value).map_err(|e| { serde_json::from_value(value).map_err(|e| {
error!("Failed to parse {} payload: {}", event_name, e); error!("Failed to parse {} payload: {}", event_name, e);
PayloadError::ParseError(e.to_string()) PayloadError::ParseError(())
}) })
} }

View File

@@ -4,21 +4,21 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
let errorMessage = ""; let errorMessage = "";
let isRestarting = false; let isRetrying = false;
onMount(() => { onMount(() => {
errorMessage = $page.url.searchParams.get("err") || ""; errorMessage = $page.url.searchParams.get("err") || "";
}); });
const tryAgain = async () => { const tryAgain = async () => {
if (isRestarting) return; if (isRetrying) return;
isRestarting = true; isRetrying = true;
errorMessage = ""; errorMessage = "";
try { try {
await invoke("restart_app"); await invoke("retry_connection");
} catch (err) { } catch (err) {
errorMessage = `Restart failed: ${err}`; errorMessage = `${err}`;
isRestarting = false; isRetrying = false;
} }
}; };
</script> </script>
@@ -41,11 +41,11 @@
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<button <button
class="btn" class="btn"
class:btn-disabled={isRestarting} class:btn-disabled={isRetrying}
disabled={isRestarting} disabled={isRetrying}
onclick={tryAgain} onclick={tryAgain}
> >
{#if isRestarting} {#if isRetrying}
Retrying… Retrying…
{:else} {:else}
Try again Try again