minor refactoring of app startup sequence & some extra trivial matters

This commit is contained in:
2026-02-03 22:28:05 +08:00
parent e15cf16817
commit f696d5e385
24 changed files with 284 additions and 337 deletions

View File

@@ -1,18 +1,18 @@
use crate::{ use crate::{
lock_r, lock_r,
models::app_data::AppData, models::app_data::AppData,
state::{init_app_data, FDOLL}, state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
}; };
#[tauri::command] #[tauri::command]
pub fn get_app_data() -> Result<AppData, String> { pub fn get_app_data() -> Result<AppData, String> {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
Ok(guard.ui.app_data.clone()) Ok(guard.user_data.clone())
} }
#[tauri::command] #[tauri::command]
pub async fn refresh_app_data() -> Result<AppData, String> { pub async fn refresh_app_data() -> Result<AppData, String> {
init_app_data().await; init_app_data_scoped(AppDataRefreshScope::All).await;
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
Ok(guard.ui.app_data.clone()) Ok(guard.user_data.clone())
} }

View File

@@ -1,7 +1,7 @@
use tauri; use tauri;
use tracing; use tracing;
use crate::init::lifecycle; use crate::{init::lifecycle::construct_user_session, services::scene::close_splash_window};
#[tauri::command] #[tauri::command]
pub async fn logout_and_restart() -> Result<(), String> { pub async fn logout_and_restart() -> Result<(), String> {
@@ -16,11 +16,11 @@ pub fn start_auth_flow() -> Result<(), String> {
crate::services::auth::cancel_auth_flow(); crate::services::auth::cancel_auth_flow();
crate::services::auth::init_auth_code_retrieval(|| { crate::services::auth::init_auth_code_retrieval(|| {
tracing::info!("Authentication successful, creating scene..."); tracing::info!("Authentication successful, constructing user session...");
// Close welcome window if it's still open
crate::services::welcome::close_welcome_window(); crate::services::welcome::close_welcome_window();
tauri::async_runtime::spawn(async { tauri::async_runtime::spawn(async {
lifecycle::handle_authentication_flow().await; construct_user_session().await;
close_splash_window();
}); });
}) })
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())

View File

@@ -8,8 +8,8 @@ pub mod interaction;
pub mod sprite; pub mod sprite;
pub mod user_status; pub mod user_status;
use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL};
use crate::lock_r; use crate::lock_r;
use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL};
use tauri::async_runtime; use tauri::async_runtime;
/// Helper to execute a mutation operation and refresh app data scopes in the background. /// Helper to execute a mutation operation and refresh app data scopes in the background.
@@ -33,18 +33,21 @@ pub async fn refresh_app_data(scopes: &[AppDataRefreshScope]) {
} }
/// Helper to execute a mutation operation with conditional refresh. /// Helper to execute a mutation operation with conditional refresh.
/// ///
/// # Example /// # Example
/// ```ignore /// ```ignore
/// pub async fn delete_doll(id: String) -> Result<(), String> { /// pub async fn delete_doll(id: String) -> Result<(), String> {
/// let result = DollsRemote::new().delete_doll(&id).await.map_err(|e| e.to_string())?; /// let result = DollsRemote::new().delete_doll(&id).await.map_err(|e| e.to_string())?;
/// let is_active = is_active_doll(&id); /// let is_active = is_active_doll(&id);
/// refresh_app_data_conditionally(&[AppDataRefreshScope::Dolls], /// refresh_app_data_conditionally(&[AppDataRefreshScope::Dolls],
/// is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends])); /// is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]));
/// Ok(result) /// Ok(result)
/// } /// }
/// ``` /// ```
pub async fn refresh_app_data_conditionally(base_scopes: &[AppDataRefreshScope], conditional_scopes: Option<&[AppDataRefreshScope]>) { pub async fn refresh_app_data_conditionally(
base_scopes: &[AppDataRefreshScope],
conditional_scopes: Option<&[AppDataRefreshScope]>,
) {
let mut all_scopes = base_scopes.to_vec(); let mut all_scopes = base_scopes.to_vec();
if let Some(extra_scopes) = conditional_scopes { if let Some(extra_scopes) = conditional_scopes {
all_scopes.extend_from_slice(extra_scopes); all_scopes.extend_from_slice(extra_scopes);
@@ -57,8 +60,7 @@ pub async fn refresh_app_data_conditionally(base_scopes: &[AppDataRefreshScope],
pub fn is_active_doll(doll_id: &str) -> bool { pub fn is_active_doll(doll_id: &str) -> bool {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
guard guard
.ui .user_data
.app_data
.user .user
.as_ref() .as_ref()
.and_then(|u| u.active_doll_id.as_ref()) .and_then(|u| u.active_doll_id.as_ref())

View File

@@ -1,59 +1,58 @@
use reqwest::StatusCode; use reqwest::StatusCode;
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{info, warn}; use tracing::warn;
use crate::{ use crate::{
init::startup::{initialize_app_data_and_connections, transition_to_main_interface},
lock_w,
models::health::HealthError, models::health::HealthError,
remotes::health::HealthRemote, remotes::health::HealthRemote,
services::{ services::{
active_app::init_active_app_changes_listener, close_all_windows,
auth::get_tokens, health_manager::open_health_manager_window,
health_manager::show_health_manager_with_error, scene::open_scene_window,
scene::close_splash_window, ws::client::{clear_ws_client, establish_websocket_connection},
welcome::open_welcome_window,
}, },
state::FDOLL, state::{clear_app_data, init_app_data_scoped, AppDataRefreshScope},
system_tray::{init_system_tray, update_system_tray}, system_tray::update_system_tray,
}; };
/// Initializes and starts the core app lifecycle after initial setup. /// Connects the user profile and opens the scene window.
/// pub async fn construct_user_session() {
/// This function handles: connect_user_profile().await;
/// - System tray initialization and storage in app state open_scene_window();
/// - Active app change listener setup update_system_tray(true);
/// - Startup sequence execution with error handling
///
/// # Errors
/// If the startup sequence fails, displays a health manager dialog
/// with the error details.
///
/// # Example
/// ```
/// // Called automatically during app setup in initialize_app_environment()
/// lifecycle::launch_core_services().await;
/// ```
pub async fn launch_core_services() {
let tray = init_system_tray();
{
let mut guard = lock_w!(FDOLL);
guard.tray = Some(tray);
}
// Begin listening for foreground app changes
init_active_app_changes_listener();
if let Err(err) = validate_environment_and_start_app().await {
tracing::warn!("Startup sequence encountered an error: {}", err);
show_health_manager_with_error(Some(err.to_string()));
}
} }
/// Perform checks for environment, network condition /// Disconnects the user profile and closes the scene window.
/// and handle situations where startup would not be appropriate. pub async fn destruct_user_session() {
pub async fn validate_environment_and_start_app() -> Result<(), HealthError> { disconnect_user_profile().await;
close_all_windows();
update_system_tray(false);
}
/// Initializes the user profile and establishes a WebSocket connection.
async fn connect_user_profile() {
init_app_data_scoped(AppDataRefreshScope::All).await;
establish_websocket_connection().await;
}
/// Clears the user profile and WebSocket connection.
async fn disconnect_user_profile() {
clear_app_data();
clear_ws_client().await;
}
/// Destructs the user session and show health manager window
/// with error message, offering troubleshooting options.
pub async fn handle_disasterous_failure(error_message: Option<String>) {
destruct_user_session().await;
open_health_manager_window(error_message);
}
/// Pings the server's health endpoint a maximum of
/// three times with a backoff of 500ms between
/// attempts. Return health error if no success.
pub async fn validate_server_health() -> Result<(), HealthError> {
let health_remote = HealthRemote::try_new()?; let health_remote = HealthRemote::try_new()?;
// simple retry loop to smooth transient network issues // simple retry loop to smooth transient network issues
@@ -63,7 +62,6 @@ pub async fn validate_environment_and_start_app() -> Result<(), HealthError> {
for attempt in 1..=MAX_ATTEMPTS { for attempt in 1..=MAX_ATTEMPTS {
match health_remote.get_health().await { match health_remote.get_health().await {
Ok(_) => { Ok(_) => {
handle_authentication_flow().await;
return Ok(()); return Ok(());
} }
Err(HealthError::NonOkStatus(status)) => { Err(HealthError::NonOkStatus(status)) => {
@@ -87,25 +85,9 @@ pub async fn validate_environment_and_start_app() -> Result<(), HealthError> {
} }
} }
warn!("Server is unavailable!");
Err(HealthError::UnexpectedStatus( Err(HealthError::UnexpectedStatus(
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
)) ))
} }
/// Handles authentication flow: checks for tokens and either restores session or shows welcome.
pub async fn handle_authentication_flow() {
match get_tokens().await {
Some(_tokens) => {
info!("Tokens found in keyring - restoring user session");
let start = initialize_app_data_and_connections().await;
transition_to_main_interface(start).await;
update_system_tray(true);
}
None => {
info!("No active session found - showing welcome first");
open_welcome_window();
close_splash_window();
update_system_tray(false);
}
}
}

View File

@@ -1,3 +1,41 @@
use crate::{
init::{
lifecycle::{construct_user_session, handle_disasterous_failure, validate_server_health},
tracing::init_logging,
},
services::{
active_app::init_foreground_app_change_listener,
auth::get_session_token,
cursor::init_cursor_tracking,
scene::{close_splash_window, open_splash_window},
welcome::open_welcome_window,
},
state::init_app_state,
system_tray::init_system_tray,
};
pub mod lifecycle; pub mod lifecycle;
pub mod startup;
pub mod tracing; pub mod tracing;
/// The very function that handles
/// init and startup of everything.
pub async fn launch_app() {
init_logging();
open_splash_window();
init_app_state();
init_system_tray();
init_cursor_tracking().await;
init_foreground_app_change_listener();
if let Err(err) = validate_server_health().await {
handle_disasterous_failure(Some(err.to_string())).await;
return;
}
match get_session_token().await {
Some(_tokens) => construct_user_session().await,
None => open_welcome_window(),
}
close_splash_window();
}

View File

@@ -1,52 +0,0 @@
use std::time::Duration;
use tokio::time::{sleep, Instant};
use crate::{
services::{
auth::get_access_token,
scene::{close_splash_window, open_scene_window, open_splash_window},
ws::init_ws_client,
},
state::init_app_data,
};
async fn establish_websocket_connection() {
const MAX_ATTEMPTS: u8 = 5;
const BACKOFF: Duration = Duration::from_millis(300);
for _attempt in 1..=MAX_ATTEMPTS {
if get_access_token().await.is_some() {
init_ws_client().await;
return;
}
sleep(BACKOFF).await;
}
}
pub async fn initialize_app_data_and_connections() -> Instant {
open_splash_window();
// Record start time for minimum splash duration
let start = Instant::now();
// Initialize app data first so we only start WebSocket after auth is fully available
init_app_data().await;
// Initialize WebSocket client after we know auth is present
establish_websocket_connection().await;
start
}
pub async fn transition_to_main_interface(start: Instant) {
// Ensure splash stays visible for at least 3 seconds
let elapsed = start.elapsed();
if elapsed < Duration::from_secs(3) {
sleep(Duration::from_secs(3) - elapsed).await;
}
// Close splash and open main scene
close_splash_window();
open_scene_window();
}

View File

@@ -4,7 +4,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use crate::get_app_handle; use crate::get_app_handle;
/// Initialize `tracing_subscriber` for logging to file & console /// Initialize `tracing_subscriber` for logging to file & console
pub fn setup_logging() { pub fn init_logging() {
// Set up file appender // Set up file appender
let app_handle = get_app_handle(); let app_handle = get_app_handle();
let app_log_dir = app_handle let app_log_dir = app_handle

View File

@@ -1,10 +1,6 @@
use crate::{ use crate::services::{
init::tracing::setup_logging, doll_editor::open_doll_editor_window,
services::{ scene::{set_pet_menu_state, set_scene_interactive},
cursor::start_cursor_tracking,
doll_editor::open_doll_editor_window,
scene::{open_splash_window, set_pet_menu_state, set_scene_interactive},
},
}; };
use commands::app::{quit_app, restart_app}; use commands::app::{quit_app, restart_app};
use commands::app_data::{get_app_data, refresh_app_data}; use commands::app_data::{get_app_data, refresh_app_data};
@@ -40,14 +36,6 @@ pub fn get_app_handle<'a>() -> &'a tauri::AppHandle<tauri::Wry> {
.expect("get_app_handle called but app is still not initialized") .expect("get_app_handle called but app is still not initialized")
} }
fn initialize_app_environment() -> Result<(), tauri::Error> {
setup_logging();
open_splash_window();
state::init_fdoll_state();
async_runtime::spawn(async move { init::lifecycle::launch_core_services().await });
Ok(())
}
fn register_app_events(event: tauri::RunEvent) { fn register_app_events(event: tauri::RunEvent) {
if let tauri::RunEvent::ExitRequested { api, code, .. } = event { if let tauri::RunEvent::ExitRequested { api, code, .. } = event {
if code.is_none() { if code.is_none() {
@@ -66,7 +54,6 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
start_cursor_tracking,
get_app_data, get_app_data,
refresh_app_data, refresh_app_data,
list_friends, list_friends,
@@ -102,7 +89,7 @@ pub fn run() {
APP_HANDLE APP_HANDLE
.set(app.handle().to_owned()) .set(app.handle().to_owned())
.expect("Failed to init app handle."); .expect("Failed to init app handle.");
initialize_app_environment().expect("Failed to setup app."); async_runtime::spawn(async move { init::launch_app().await });
Ok(()) Ok(())
}) })
.build(tauri::generate_context!()) .build(tauri::generate_context!())

View File

@@ -43,5 +43,5 @@ pub struct AppData {
pub user: Option<UserProfile>, pub user: Option<UserProfile>,
pub friends: Option<Vec<FriendshipResponseDto>>, pub friends: Option<Vec<FriendshipResponseDto>>,
pub dolls: Option<Vec<DollDto>>, pub dolls: Option<Vec<DollDto>>,
pub scene: SceneData, pub scene: SceneData, // TODO: move this out of app data
} }

View File

@@ -756,8 +756,10 @@ mod windows_impl {
pub static ACTIVE_APP_CHANGED: &str = "active-app-changed"; pub static ACTIVE_APP_CHANGED: &str = "active-app-changed";
/// Initializes the active app change listener and emits events to the Tauri app on changes. /// Initializes the foreground app change listener
pub fn init_active_app_changes_listener() { /// and emits events to the Tauri app on changes.
/// Used for app to emit user foreground app to peers.
pub fn init_foreground_app_change_listener() {
let app_handle = get_app_handle(); let app_handle = get_app_handle();
listen_for_active_app_changes(|app_names: AppMetadata| { listen_for_active_app_changes(|app_names: AppMetadata| {
if let Err(e) = app_handle.emit(ACTIVE_APP_CHANGED, app_names) { if let Err(e) = app_handle.emit(ACTIVE_APP_CHANGED, app_names) {

View File

@@ -114,7 +114,7 @@ fn generate_code_challenge(code_verifier: &str) -> String {
/// Returns the auth pass object, including /// Returns the auth pass object, including
/// access token, refresh token, expire time etc. /// access token, refresh token, expire time etc.
/// Automatically refreshes if expired. /// Automatically refreshes if expired.
pub async fn get_tokens() -> Option<AuthPass> { pub async fn get_session_token() -> Option<AuthPass> {
info!("Retrieving tokens"); info!("Retrieving tokens");
let Some(auth_pass) = ({ lock_r!(FDOLL).auth.auth_pass.clone() }) else { let Some(auth_pass) = ({ lock_r!(FDOLL).auth.auth_pass.clone() }) else {
return None; return None;
@@ -173,7 +173,7 @@ pub async fn get_tokens() -> Option<AuthPass> {
/// Helper function to get the current access token. /// Helper function to get the current access token.
pub async fn get_access_token() -> Option<String> { pub async fn get_access_token() -> Option<String> {
get_tokens().await.map(|pass| pass.access_token) get_session_token().await.map(|pass| pass.access_token)
} }
/// Save auth_pass to secure storage (keyring) and update app state. /// Save auth_pass to secure storage (keyring) and update app state.
@@ -367,7 +367,7 @@ pub fn clear_auth_pass() -> Result<(), OAuthError> {
/// ``` /// ```
pub fn logout() -> Result<(), OAuthError> { pub fn logout() -> Result<(), OAuthError> {
info!("Logging out user"); info!("Logging out user");
lock_w!(FDOLL).auth.auth_pass = None; lock_w!(FDOLL).auth.auth_pass = None;
clear_auth_pass()?; clear_auth_pass()?;
// Clear OAuth flow state as well // Clear OAuth flow state as well
@@ -386,8 +386,16 @@ pub async fn logout_and_restart() -> Result<(), OAuthError> {
let (refresh_token, session_state, base_url) = { let (refresh_token, session_state, base_url) = {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
( (
guard.auth.auth_pass.as_ref().map(|p| p.refresh_token.clone()), guard
guard.auth.auth_pass.as_ref().map(|p| p.session_state.clone()), .auth
.auth_pass
.as_ref()
.map(|p| p.refresh_token.clone()),
guard
.auth
.auth_pass
.as_ref()
.map(|p| p.session_state.clone()),
guard guard
.app_config .app_config
.api_base_url .api_base_url
@@ -676,7 +684,7 @@ where
{ {
let mut guard = lock_w!(FDOLL); let mut guard = lock_w!(FDOLL);
guard.auth.auth_pass = Some(auth_pass.clone()); guard.auth.auth_pass = Some(auth_pass.clone());
guard.auth.oauth_flow = Default::default(); guard.auth.oauth_flow = Default::default();
} }
if let Err(e) = save_auth_pass(&auth_pass) { if let Err(e) = save_auth_pass(&auth_pass) {
error!("Failed to save auth pass: {}", e); error!("Failed to save auth pass: {}", e);
@@ -720,7 +728,8 @@ pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, OAuthError>
( (
guard.app_config.clone(), guard.app_config.clone(),
guard guard
.network.clients .network
.clients
.as_ref() .as_ref()
.expect("clients present") .expect("clients present")
.http_client .http_client

View File

@@ -40,8 +40,8 @@ pub fn get_latest_cursor_position() -> Option<CursorPosition> {
/// Convert absolute screen coordinates to normalized coordinates (0.0 - 1.0) /// Convert absolute screen coordinates to normalized coordinates (0.0 - 1.0)
pub fn absolute_to_normalized(pos: &CursorPosition) -> CursorPosition { pub fn absolute_to_normalized(pos: &CursorPosition) -> CursorPosition {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
let screen_w = guard.ui.app_data.scene.display.screen_width as f64; let screen_w = guard.user_data.scene.display.screen_width as f64;
let screen_h = guard.ui.app_data.scene.display.screen_height as f64; let screen_h = guard.user_data.scene.display.screen_height as f64;
CursorPosition { CursorPosition {
x: (pos.x / screen_w).clamp(0.0, 1.0), x: (pos.x / screen_w).clamp(0.0, 1.0),
@@ -52,8 +52,8 @@ pub fn absolute_to_normalized(pos: &CursorPosition) -> CursorPosition {
/// Convert normalized coordinates to absolute screen coordinates /// Convert normalized coordinates to absolute screen coordinates
pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition { pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
let screen_w = guard.ui.app_data.scene.display.screen_width as f64; let screen_w = guard.user_data.scene.display.screen_width as f64;
let screen_h = guard.ui.app_data.scene.display.screen_height as f64; let screen_h = guard.user_data.scene.display.screen_height as f64;
CursorPosition { CursorPosition {
x: (normalized.x * screen_w).round(), x: (normalized.x * screen_w).round(),
@@ -61,10 +61,9 @@ pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
} }
} }
/// Initialize cursor tracking - can be called multiple times safely from any window /// Initialize cursor tracking. Broadcasts cursor
/// Only the first call will actually start tracking, subsequent calls are no-ops /// position changes via `cursor-position` event.
#[tauri::command] pub async fn init_cursor_tracking() {
pub async fn start_cursor_tracking() -> Result<(), String> {
info!("start_cursor_tracking called"); info!("start_cursor_tracking called");
// Use OnceCell to ensure this only runs once, even if called from multiple windows // Use OnceCell to ensure this only runs once, even if called from multiple windows
@@ -74,17 +73,16 @@ pub async fn start_cursor_tracking() -> Result<(), String> {
info!("First call to start_cursor_tracking - spawning cursor tracking task"); info!("First call to start_cursor_tracking - spawning cursor tracking task");
tauri::async_runtime::spawn(async { tauri::async_runtime::spawn(async {
if let Err(e) = init_cursor_tracking().await { if let Err(e) = init_cursor_tracking_i().await {
error!("Failed to initialize cursor tracking: {}", e); error!("Failed to initialize cursor tracking: {}", e);
} }
}); });
}); });
info!("Cursor tracking initialization registered"); info!("Cursor tracking initialization registered");
Ok(())
} }
async fn init_cursor_tracking() -> Result<(), String> { async fn init_cursor_tracking_i() -> Result<(), String> {
info!("Initializing cursor tracking..."); info!("Initializing cursor tracking...");
// Create a channel to decouple event generation (producer) from processing (consumer). // Create a channel to decouple event generation (producer) from processing (consumer).
@@ -124,7 +122,7 @@ async fn init_cursor_tracking() -> Result<(), String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let scale_factor = { let scale_factor = {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
guard.ui.app_data.scene.display.monitor_scale_factor guard.user_data.scene.display.monitor_scale_factor
}; };
// The producer closure moves `tx` into it. // The producer closure moves `tx` into it.

View File

@@ -1,36 +1,15 @@
use crate::get_app_handle; use crate::get_app_handle;
use crate::{lock_r, state::FDOLL, system_tray::update_system_tray}; use crate::{lock_r, state::FDOLL, system_tray::update_system_tray};
use tauri::{Emitter, Manager}; use tauri::Manager;
use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder, MessageDialogKind};
use tauri_plugin_positioner::WindowExt; use tauri_plugin_positioner::WindowExt;
use tracing::{error, info}; use tracing::{error, info};
pub static HEALTH_MANAGER_WINDOW_LABEL: &str = "health_manager"; pub static HEALTH_MANAGER_WINDOW_LABEL: &str = "health_manager";
pub static HEALTH_MANAGER_EVENT: &str = "health-error";
fn close_window_if_exists(label: &str) {
let app_handle = get_app_handle();
if let Some(window) = app_handle.get_window(label) {
info!("Closing window with label: {}", label);
if let Err(e) = window.close() {
error!("Failed to close {} window: {}", label, e);
} else {
info!("Closed window with label: {}", label);
}
} else {
info!("No window found with label: {}", label);
}
}
/// Closes primary UI windows and shows the health manager with an optional error message. /// Closes primary UI windows and shows the health manager with an optional error message.
pub fn show_health_manager_with_error(error_message: Option<String>) { pub fn open_health_manager_window(error_message: Option<String>) {
let app_handle = get_app_handle(); let app_handle = get_app_handle();
// Ensure other windows are closed before showing health manager
close_window_if_exists(crate::services::scene::SPLASH_WINDOW_LABEL);
close_window_if_exists(crate::services::scene::SCENE_WINDOW_LABEL);
close_window_if_exists(crate::services::app_menu::APP_MENU_WINDOW_LABEL);
update_system_tray(false);
let existing_webview_window = app_handle.get_window(HEALTH_MANAGER_WINDOW_LABEL); let existing_webview_window = app_handle.get_window(HEALTH_MANAGER_WINDOW_LABEL);
@@ -45,12 +24,6 @@ pub fn show_health_manager_with_error(error_message: Option<String>) {
.kind(MessageDialogKind::Error) .kind(MessageDialogKind::Error)
.show(|_| {}); .show(|_| {});
} }
if let Some(message) = error_message {
if let Err(e) = window.emit(HEALTH_MANAGER_EVENT, message.clone()) {
error!("Failed to emit health error event: {}", e);
}
}
return; return;
} }
@@ -58,7 +31,13 @@ pub fn show_health_manager_with_error(error_message: Option<String>) {
let webview_window = match tauri::WebviewWindowBuilder::new( let webview_window = match tauri::WebviewWindowBuilder::new(
app_handle, app_handle,
HEALTH_MANAGER_WINDOW_LABEL, HEALTH_MANAGER_WINDOW_LABEL,
tauri::WebviewUrl::App("/health-manager".into()), tauri::WebviewUrl::App(
format!(
"/health-manager?err={}",
error_message.unwrap_or(String::from("Something went wrong!"))
)
.into(),
),
) )
.title("Health Manager") .title("Health Manager")
.inner_size(420.0, 420.0) .inner_size(420.0, 420.0)
@@ -89,12 +68,6 @@ pub fn show_health_manager_with_error(error_message: Option<String>) {
error!("Failed to move health manager window to center: {}", e); error!("Failed to move health manager window to center: {}", e);
} }
if let Some(message) = error_message {
if let Err(e) = webview_window.emit(HEALTH_MANAGER_EVENT, message.clone()) {
error!("Failed to emit health error event: {}", e);
}
}
if let Err(e) = webview_window.show() { if let Err(e) = webview_window.show() {
error!("Failed to show health manager window: {}", e); error!("Failed to show health manager window: {}", e);
MessageDialogBuilder::new( MessageDialogBuilder::new(
@@ -117,7 +90,7 @@ pub fn close_health_manager_window() {
} else { } else {
info!("Health manager window closed"); info!("Health manager window closed");
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
let is_logged_in = guard.ui.app_data.user.is_some(); let is_logged_in = guard.user_data.user.is_some();
drop(guard); drop(guard);
update_system_tray(is_logged_in); update_system_tray(is_logged_in);
} }

View File

@@ -1,3 +1,7 @@
use tauri::Manager;
use crate::get_app_handle;
pub mod active_app; pub mod active_app;
pub mod app_menu; pub mod app_menu;
pub mod auth; pub mod auth;
@@ -10,3 +14,11 @@ pub mod scene;
pub mod sprite_recolor; pub mod sprite_recolor;
pub mod welcome; pub mod welcome;
pub mod ws; pub mod ws;
pub fn close_all_windows() {
let app_handle = get_app_handle();
let webview_windows = app_handle.webview_windows();
for window in webview_windows {
window.1.close().unwrap();
}
}

View File

@@ -1,15 +1,32 @@
use std::time::Duration;
use rust_socketio::ClientBuilder; use rust_socketio::ClientBuilder;
use tauri::async_runtime; use tauri::async_runtime;
use tokio::time::sleep;
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
lock_r, lock_w, lock_r, lock_w,
services::client_config_manager::AppConfig, services::{auth::get_access_token, client_config_manager::AppConfig},
state::FDOLL, state::FDOLL,
}; };
use super::handlers; use super::handlers;
pub async fn establish_websocket_connection() {
const MAX_ATTEMPTS: u8 = 5;
const BACKOFF: Duration = Duration::from_millis(300);
for _attempt in 1..=MAX_ATTEMPTS {
if get_access_token().await.is_some() {
init_ws_client().await;
return;
}
sleep(BACKOFF).await;
}
}
pub async fn init_ws_client() { pub async fn init_ws_client() {
let app_config = { let app_config = {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
@@ -26,16 +43,19 @@ pub async fn init_ws_client() {
} }
Err(e) => { Err(e) => {
error!("Failed to initialize WebSocket client: {}", e); error!("Failed to initialize WebSocket client: {}", e);
// If we failed because no token, clear the WS client to avoid stale retries clear_ws_client().await;
let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.network.clients.as_mut() {
clients.ws_client = None;
clients.is_ws_initialized = false;
}
} }
} }
} }
pub async fn clear_ws_client() {
let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.network.clients.as_mut() {
clients.ws_client = None;
clients.is_ws_initialized = false;
}
}
pub async fn build_ws_client( pub async fn build_ws_client(
app_config: &AppConfig, app_config: &AppConfig,
) -> Result<rust_socketio::client::Client, String> { ) -> Result<rust_socketio::client::Client, String> {

View File

@@ -3,8 +3,7 @@ use tauri::async_runtime;
use tracing::error; use tracing::error;
use crate::{ use crate::{
lock_r, init::lifecycle::handle_disasterous_failure, lock_r, services::cursor::CursorPosition,
services::{cursor::CursorPosition, health_manager::show_health_manager_with_error},
state::FDOLL, state::FDOLL,
}; };
@@ -41,11 +40,11 @@ pub async fn report_cursor_data(cursor_position: CursorPosition) {
Ok(Ok(_)) => (), Ok(Ok(_)) => (),
Ok(Err(e)) => { Ok(Err(e)) => {
error!("Failed to emit cursor report: {}", e); error!("Failed to emit cursor report: {}", e);
show_health_manager_with_error(Some(format!("WebSocket emit failed: {}", e))); handle_disasterous_failure(Some(format!("WebSocket emit failed: {}", e))).await;
} }
Err(e) => { Err(e) => {
error!("Failed to execute blocking task for cursor report: {}", e); error!("Failed to execute blocking task for cursor report: {}", e);
show_health_manager_with_error(Some(format!("WebSocket task failed: {}", e))); handle_disasterous_failure(Some(format!("WebSocket task failed: {}", e))).await;
} }
} }
} }

View File

@@ -36,8 +36,7 @@ pub fn on_doll_updated(payload: Payload, _socket: RawClient) {
let is_active_doll = if let Some(id) = doll_id { let is_active_doll = if let Some(id) = doll_id {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
guard guard
.ui .user_data
.app_data
.user .user
.as_ref() .as_ref()
.and_then(|u| u.active_doll_id.as_ref()) .and_then(|u| u.active_doll_id.as_ref())
@@ -75,8 +74,7 @@ pub fn on_doll_deleted(payload: Payload, _socket: RawClient) {
let is_active_doll = if let Some(id) = doll_id { let is_active_doll = if let Some(id) = doll_id {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
guard guard
.ui .user_data
.app_data
.user .user
.as_ref() .as_ref()
.and_then(|u| u.active_doll_id.as_ref()) .and_then(|u| u.active_doll_id.as_ref())

View File

@@ -41,7 +41,7 @@ impl WS_EVENT {
pub const CLIENT_SEND_INTERACTION: &str = "client-send-interaction"; pub const CLIENT_SEND_INTERACTION: &str = "client-send-interaction";
} }
mod client; pub mod client;
mod connection; mod connection;
mod cursor; mod cursor;
mod doll; mod doll;
@@ -50,6 +50,5 @@ mod handlers;
mod interaction; mod interaction;
mod user_status; mod user_status;
pub use client::init_ws_client;
pub use cursor::report_cursor_data; pub use cursor::report_cursor_data;
pub use user_status::{report_user_status, UserStatusPayload}; pub use user_status::{report_user_status, UserStatusPayload};

View File

@@ -1,12 +1,12 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rust_socketio::Payload; use rust_socketio::Payload;
use tauri::async_runtime; use tauri::async_runtime::{self};
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::error; use tracing::error;
use crate::{lock_r, services::health_manager::show_health_manager_with_error, state::FDOLL}; use crate::{init::lifecycle::handle_disasterous_failure, lock_r, state::FDOLL};
use super::WS_EVENT; use super::WS_EVENT;
@@ -66,20 +66,16 @@ pub async fn report_user_status(status: UserStatusPayload) {
Ok(Ok(_)) => (), Ok(Ok(_)) => (),
Ok(Err(e)) => { Ok(Err(e)) => {
error!("Failed to emit user status report: {}", e); error!("Failed to emit user status report: {}", e);
show_health_manager_with_error(Some(format!( handle_disasterous_failure(Some(format!("WebSocket emit failed: {}", e)))
"WebSocket emit failed: {}", .await;
e
)));
} }
Err(e) => { Err(e) => {
error!( error!(
"Failed to execute blocking task for user status report: {}", "Failed to execute blocking task for user status report: {}",
e e
); );
show_health_manager_with_error(Some(format!( handle_disasterous_failure(Some(format!("WebSocket task failed: {}", e)))
"WebSocket task failed: {}", .await;
e
)));
} }
} }
} }

View File

@@ -1,5 +1,5 @@
// in app-core/src/state.rs // in app-core/src/state.rs
use crate::lock_w; use crate::{lock_w, models::app_data::AppData};
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
use tauri::tray::TrayIcon; use tauri::tray::TrayIcon;
use tracing::info; use tracing::info;
@@ -17,7 +17,7 @@ pub struct AppState {
pub app_config: crate::services::client_config_manager::AppConfig, pub app_config: crate::services::client_config_manager::AppConfig,
pub network: NetworkState, pub network: NetworkState,
pub auth: AuthState, pub auth: AuthState,
pub ui: UiState, pub user_data: AppData,
pub tray: Option<TrayIcon>, pub tray: Option<TrayIcon>,
} }
@@ -26,15 +26,17 @@ pub struct AppState {
pub static FDOLL: LazyLock<Arc<RwLock<AppState>>> = pub static FDOLL: LazyLock<Arc<RwLock<AppState>>> =
LazyLock::new(|| Arc::new(RwLock::new(AppState::default()))); LazyLock::new(|| Arc::new(RwLock::new(AppState::default())));
pub fn init_fdoll_state() { /// Populate app state with initial
/// values and necesary client instances.
pub fn init_app_state() {
dotenvy::dotenv().ok();
{ {
let mut guard = lock_w!(FDOLL); let mut guard = lock_w!(FDOLL);
dotenvy::dotenv().ok();
guard.app_config = crate::services::client_config_manager::load_app_config(); guard.app_config = crate::services::client_config_manager::load_app_config();
guard.network = init_network_state(); guard.network = init_network_state();
guard.auth = init_auth_state(); guard.auth = init_auth_state();
guard.ui = init_ui_state(); guard.user_data = AppData::default();
} }
update_display_dimensions_for_scene_state();
info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)"); info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)");
} }

View File

@@ -1,36 +1,19 @@
use crate::{ use crate::{
get_app_handle, lock_r, lock_w, get_app_handle, lock_r, lock_w,
models::app_data::AppData,
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote}, remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
state::FDOLL,
}; };
use std::{ use std::{collections::HashSet, sync::LazyLock};
collections::HashSet,
sync::{LazyLock},
};
use tauri::Emitter; use tauri::Emitter;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{info, warn}; use tracing::{info, warn};
pub struct UiState { pub fn update_display_dimensions_for_scene_state() {
pub app_data: AppData,
}
impl Default for UiState {
fn default() -> Self {
Self {
app_data: AppData::default(),
}
}
}
pub fn init_ui_state() -> UiState {
let mut ui_state = UiState::default();
// Initialize screen dimensions
let app_handle = get_app_handle(); let app_handle = get_app_handle();
let mut guard = lock_w!(FDOLL);
// Get primary monitor with retries // Get primary monitor with retries
// Note: This duplicates logic from init_cursor_tracking, but we need it here for global state
let primary_monitor = { let primary_monitor = {
let mut retry_count = 0; let mut retry_count = 0;
let max_retries = 3; let max_retries = 3;
@@ -77,23 +60,21 @@ pub fn init_ui_state() -> UiState {
let logical_monitor_dimensions: tauri::LogicalSize<i32> = let logical_monitor_dimensions: tauri::LogicalSize<i32> =
monitor_dimensions.to_logical(monitor_scale_factor); monitor_dimensions.to_logical(monitor_scale_factor);
ui_state.app_data.scene.display.screen_width = logical_monitor_dimensions.width; guard.user_data.scene.display.screen_width = logical_monitor_dimensions.width;
ui_state.app_data.scene.display.screen_height = logical_monitor_dimensions.height; guard.user_data.scene.display.screen_height = logical_monitor_dimensions.height;
ui_state.app_data.scene.display.monitor_scale_factor = monitor_scale_factor; guard.user_data.scene.display.monitor_scale_factor = monitor_scale_factor;
ui_state.app_data.scene.grid_size = 600; // Hardcoded grid size guard.user_data.scene.grid_size = 600; // Hardcoded grid size
info!( info!(
"Initialized global AppData with screen dimensions: {}x{}, scale: {}, grid: {}", "Initialized global AppData with screen dimensions: {}x{}, scale: {}, grid: {}",
logical_monitor_dimensions.width, logical_monitor_dimensions.width,
logical_monitor_dimensions.height, logical_monitor_dimensions.height,
monitor_scale_factor, monitor_scale_factor,
ui_state.app_data.scene.grid_size guard.user_data.scene.grid_size
); );
} else { } else {
warn!("Could not initialize screen dimensions in global state - no monitor found"); warn!("Could not initialize screen dimensions in global state - no monitor found");
} }
ui_state
} }
/// Defines which parts of AppData should be refreshed from the server /// Defines which parts of AppData should be refreshed from the server
@@ -109,15 +90,6 @@ pub enum AppDataRefreshScope {
Dolls, Dolls,
} }
/// To be called in init state or need to refresh data.
/// Populate user data in app state from the server.
///
/// This is a convenience wrapper that refreshes all data.
/// For more control, use `init_app_data_scoped`.
pub async fn init_app_data() {
init_app_data_scoped(AppDataRefreshScope::All).await;
}
static REFRESH_IN_FLIGHT: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> = static REFRESH_IN_FLIGHT: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
LazyLock::new(|| Mutex::new(HashSet::new())); LazyLock::new(|| Mutex::new(HashSet::new()));
static REFRESH_PENDING: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> = static REFRESH_PENDING: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
@@ -159,7 +131,7 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
match user_remote.get_user(None).await { match user_remote.get_user(None).await {
Ok(user) => { Ok(user) => {
let mut guard = lock_w!(crate::state::FDOLL); let mut guard = lock_w!(crate::state::FDOLL);
guard.ui.app_data.user = Some(user); guard.user_data.user = Some(user);
} }
Err(e) => { Err(e) => {
warn!("Failed to fetch user profile: {}", e); warn!("Failed to fetch user profile: {}", e);
@@ -187,7 +159,7 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
match friend_remote.get_friends().await { match friend_remote.get_friends().await {
Ok(friends) => { Ok(friends) => {
let mut guard = lock_w!(crate::state::FDOLL); let mut guard = lock_w!(crate::state::FDOLL);
guard.ui.app_data.friends = Some(friends); guard.user_data.friends = Some(friends);
} }
Err(e) => { Err(e) => {
warn!("Failed to fetch friends list: {}", e); warn!("Failed to fetch friends list: {}", e);
@@ -212,7 +184,7 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
match dolls_remote.get_dolls().await { match dolls_remote.get_dolls().await {
Ok(dolls) => { Ok(dolls) => {
let mut guard = lock_w!(crate::state::FDOLL); let mut guard = lock_w!(crate::state::FDOLL);
guard.ui.app_data.dolls = Some(dolls); guard.user_data.dolls = Some(dolls);
} }
Err(e) => { Err(e) => {
warn!("Failed to fetch dolls list: {}", e); warn!("Failed to fetch dolls list: {}", e);
@@ -235,7 +207,7 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
// Emit event regardless of partial success, frontend should handle nulls/empty states // Emit event regardless of partial success, frontend should handle nulls/empty states
{ {
let guard = lock_r!(crate::state::FDOLL); // Use read lock to get data let guard = lock_r!(crate::state::FDOLL); // Use read lock to get data
let app_data_clone = guard.ui.app_data.clone(); let app_data_clone = guard.user_data.clone();
drop(guard); // Drop lock before emitting to prevent potential deadlocks drop(guard); // Drop lock before emitting to prevent potential deadlocks
if let Err(e) = get_app_handle().emit("app-data-refreshed", &app_data_clone) { if let Err(e) = get_app_handle().emit("app-data-refreshed", &app_data_clone) {
@@ -280,4 +252,11 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
break; break;
} }
} }
pub fn clear_app_data() {
let mut guard = lock_w!(FDOLL);
guard.user_data.dolls = None;
guard.user_data.user = None;
guard.user_data.friends = None;
}

View File

@@ -1,11 +1,15 @@
use crate::{get_app_handle, lock_r, services::app_menu::open_app_menu_window, state::FDOLL}; use crate::{
get_app_handle, lock_r, lock_w, services::app_menu::open_app_menu_window, state::FDOLL,
};
use tauri::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
tray::TrayIconBuilder, tray::TrayIconBuilder,
}; };
use tracing::error; use tracing::error;
pub fn init_system_tray() -> tauri::tray::TrayIcon { /// Constructs app system tray.
/// Uses Tauri.
pub fn init_system_tray() {
let app = get_app_handle(); let app = get_app_handle();
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap(); let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap();
@@ -17,7 +21,7 @@ pub fn init_system_tray() -> tauri::tray::TrayIcon {
Err(err) => todo!("Handle error: {}", err), Err(err) => todo!("Handle error: {}", err),
}; };
TrayIconBuilder::new() let tray = TrayIconBuilder::new()
.menu(&menu) .menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() { .on_menu_event(|app, event| match event.id.as_ref() {
"quit" => { "quit" => {
@@ -32,9 +36,17 @@ pub fn init_system_tray() -> tauri::tray::TrayIcon {
}) })
.icon(app.default_window_icon().unwrap().clone()) .icon(app.default_window_icon().unwrap().clone())
.build(app) .build(app)
.unwrap_or_else(|err| panic!("Failed to build tray: {}", err)) .unwrap_or_else(|err| panic!("Failed to build tray: {}", err));
{
let mut guard = lock_w!(FDOLL);
guard.tray = Some(tray);
};
update_system_tray(false);
} }
/// Toggle the "Open App Menu" item in the system tray.
/// Used for when user is signed in vs not signed in.
pub fn update_system_tray(is_logged_in: bool) { pub fn update_system_tray(is_logged_in: bool) {
let app = get_app_handle(); let app = get_app_handle();
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);

View File

@@ -50,9 +50,6 @@ export async function initCursorTracking() {
} }
try { try {
// Start tracking
await invoke("start_cursor_tracking");
// Listen to cursor position events (each window subscribes independently) // Listen to cursor position events (each window subscribes independently)
unlistenCursor = await listen<CursorPositions>( unlistenCursor = await listen<CursorPositions>(
"cursor-position", "cursor-position",

View File

@@ -1,22 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { page } from "$app/stores";
let errorMessage = ""; let errorMessage = "";
let unlisten: (() => void) | null = null;
let isRestarting = false; let isRestarting = false;
onMount(async () => { onMount(() => {
unlisten = await listen<string>("health-error", (event) => { errorMessage = $page.url.searchParams.get("err") || "";
errorMessage = event.payload;
});
});
onDestroy(() => {
if (unlisten) {
unlisten();
}
}); });
const tryAgain = async () => { const tryAgain = async () => {
@@ -34,41 +25,44 @@
<div class="size-full p-4"> <div class="size-full p-4">
<div class="flex flex-col gap-4 size-full justify-between"> <div class="flex flex-col gap-4 size-full justify-between">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-4">
<p class="text-md font-light">Something is not right...</p> <div class="flex flex-col gap-2">
<p class="opacity-70 text-3xl font-bold"> <p class="text-md font-light">Something is not right...</p>
Seems like the server is inaccessible. Check your network? <p class="opacity-70 text-3xl font-bold">
</p> Seems like the server is inaccessible. Check your network?
{#if errorMessage} </p>
<p class="text-sm opacity-70 wrap-break-word">{errorMessage}</p> </div>
{/if} {#if errorMessage}
</div> <p class="text-xs opacity-70 wrap-break-word font-mono">
<div class="flex flex-row gap-2"> {errorMessage}
<button </p>
class="btn"
class:btn-disabled={isRestarting}
disabled={isRestarting}
onclick={tryAgain}
>
{#if isRestarting}
Retrying…
{:else}
Try again
{/if} {/if}
</button> </div>
<button <div class="flex flex-row gap-2">
class="btn btn-outline" <button
onclick={async () => { class="btn"
try { class:btn-disabled={isRestarting}
await invoke("open_client_config_manager"); disabled={isRestarting}
} catch (err) { onclick={tryAgain}
errorMessage = `Failed to open config manager: ${err}`; >
} {#if isRestarting}
}} Retrying…
> {:else}
Advanced options Try again
</button> {/if}
</div> </button>
<button
class="btn btn-outline"
onclick={async () => {
try {
await invoke("open_client_config_manager");
} catch (err) {
errorMessage = `Failed to open config manager: ${err}`;
}
}}
>
Advanced options
</button>
</div>
</div> </div>
</div> </div>