fixed ghostty crash & better ws failure recovery
This commit is contained in:
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user