fixed ghostty crash & better ws failure recovery
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
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>(
|
||||
event: &'static str,
|
||||
payload: T,
|
||||
) -> Result<(), String> {
|
||||
let (client_opt, is_initialized) = {
|
||||
/// 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 {
|
||||
(
|
||||
@@ -23,7 +16,14 @@ pub async fn ws_emit<T: Serialize + Send + 'static>(
|
||||
} 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) = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -41,11 +41,11 @@
|
||||
<div class="flex flex-row gap-2">
|
||||
<button
|
||||
class="btn"
|
||||
class:btn-disabled={isRestarting}
|
||||
disabled={isRestarting}
|
||||
class:btn-disabled={isRetrying}
|
||||
disabled={isRetrying}
|
||||
onclick={tryAgain}
|
||||
>
|
||||
{#if isRestarting}
|
||||
{#if isRetrying}
|
||||
Retrying…
|
||||
{:else}
|
||||
Try again
|
||||
|
||||
Reference in New Issue
Block a user