state management refactoring

This commit is contained in:
2026-01-21 20:28:31 +08:00
parent 7f37a3f8c2
commit 8173f10937
18 changed files with 255 additions and 186 deletions

View File

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

View File

@@ -51,6 +51,7 @@ pub async fn update_doll(id: String, dto: UpdateDollDto) -> Result<DollDto, Stri
let is_active_doll = {
let guard = lock_r!(FDOLL);
guard
.ui
.app_data
.user
.as_ref()
@@ -82,6 +83,7 @@ pub async fn delete_doll(id: String) -> Result<(), String> {
let is_active_doll = {
let guard = lock_r!(FDOLL);
guard
.ui
.app_data
.user
.as_ref()

View File

@@ -18,7 +18,7 @@ impl DollsRemote {
.expect("App configuration error")
.clone(),
client: guard
.clients
.network.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -18,7 +18,7 @@ impl FriendRemote {
.expect("App configuration error")
.clone(),
client: guard
.clients
.network.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -18,7 +18,7 @@ impl HealthRemote {
.ok_or(HealthError::ConfigMissing("api_base_url"))?;
let client = guard
.clients
.network.clients
.as_ref()
.map(|c| c.http_client.clone())
.ok_or(HealthError::ConfigMissing("http_client"))?;

View File

@@ -20,7 +20,7 @@ impl SessionRemote {
.expect("App configuration error")
.clone(),
client: guard
.clients
.network.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -18,7 +18,7 @@ impl UserRemote {
.expect("App configuration error")
.clone(),
client: guard
.clients
.network.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -25,10 +25,10 @@ const SERVICE_NAME: &str = "friendolls";
pub fn cancel_auth_flow() {
let mut guard = lock_w!(FDOLL);
if let Some(token) = guard.oauth_flow.cancel_token.take() {
if let Some(token) = guard.auth.oauth_flow.cancel_token.take() {
token.cancel();
}
guard.oauth_flow = Default::default();
guard.auth.oauth_flow = Default::default();
}
/// Errors that can occur during OAuth authentication flow.
@@ -116,13 +116,13 @@ fn generate_code_challenge(code_verifier: &str) -> String {
/// Automatically refreshes if expired.
pub async fn get_tokens() -> Option<AuthPass> {
info!("Retrieving tokens");
let Some(auth_pass) = ({ lock_r!(FDOLL).auth_pass.clone() }) else {
let Some(auth_pass) = ({ lock_r!(FDOLL).auth.auth_pass.clone() }) else {
return None;
};
let Some(issued_at) = auth_pass.issued_at else {
warn!("Auth pass missing issued_at timestamp, clearing");
lock_w!(FDOLL).auth_pass = None;
lock_w!(FDOLL).auth.auth_pass = None;
return None;
};
@@ -137,7 +137,7 @@ pub async fn get_tokens() -> Option<AuthPass> {
if refresh_expired {
info!("Refresh token expired, clearing auth state");
lock_w!(FDOLL).auth_pass = None;
lock_w!(FDOLL).auth.auth_pass = None;
if let Err(e) = clear_auth_pass() {
error!("Failed to clear expired auth pass: {}", e);
}
@@ -148,7 +148,7 @@ pub async fn get_tokens() -> Option<AuthPass> {
let _guard = REFRESH_LOCK.lock().await;
// Double-check after acquiring lock
let auth_pass = lock_r!(FDOLL).auth_pass.clone()?;
let auth_pass = lock_r!(FDOLL).auth.auth_pass.clone()?;
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let expired = current_time - auth_pass.issued_at? >= auth_pass.expires_in;
@@ -162,7 +162,7 @@ pub async fn get_tokens() -> Option<AuthPass> {
Ok(new_pass) => Some(new_pass),
Err(e) => {
error!("Failed to refresh token: {}", e);
lock_w!(FDOLL).auth_pass = None;
lock_w!(FDOLL).auth.auth_pass = None;
if let Err(e) = clear_auth_pass() {
error!("Failed to clear auth pass after refresh failure: {}", e);
}
@@ -367,11 +367,11 @@ pub fn clear_auth_pass() -> Result<(), OAuthError> {
/// ```
pub fn logout() -> Result<(), OAuthError> {
info!("Logging out user");
lock_w!(FDOLL).auth_pass = None;
lock_w!(FDOLL).auth.auth_pass = None;
clear_auth_pass()?;
// Clear OAuth flow state as well
lock_w!(FDOLL).oauth_flow = Default::default();
lock_w!(FDOLL).auth.oauth_flow = Default::default();
// TODO: Call OAuth provider's revocation endpoint
// This would require adding a revoke_token() function that calls:
@@ -386,8 +386,8 @@ pub async fn logout_and_restart() -> Result<(), OAuthError> {
let (refresh_token, session_state, base_url) = {
let guard = lock_r!(FDOLL);
(
guard.auth_pass.as_ref().map(|p| p.refresh_token.clone()),
guard.auth_pass.as_ref().map(|p| p.session_state.clone()),
guard.auth.auth_pass.as_ref().map(|p| p.refresh_token.clone()),
guard.auth.auth_pass.as_ref().map(|p| p.session_state.clone()),
guard
.app_config
.api_base_url
@@ -460,7 +460,7 @@ pub async fn exchange_code_for_auth_pass(
) -> Result<AuthPass, OAuthError> {
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
let clients = guard.clients.as_ref();
let clients = guard.network.clients.as_ref();
if clients.is_none() {
error!("Clients not initialized yet!");
return Err(OAuthError::InvalidConfig);
@@ -581,10 +581,10 @@ where
{
let mut guard = lock_w!(FDOLL);
guard.oauth_flow.state = Some(state.clone());
guard.oauth_flow.code_verifier = Some(code_verifier.clone());
guard.oauth_flow.initiated_at = Some(current_time);
guard.oauth_flow.cancel_token = Some(cancel_token.clone());
guard.auth.oauth_flow.state = Some(state.clone());
guard.auth.oauth_flow.code_verifier = Some(code_verifier.clone());
guard.auth.oauth_flow.initiated_at = Some(current_time);
guard.auth.oauth_flow.cancel_token = Some(cancel_token.clone());
}
let mut url = match url::Url::parse(&format!("{}/auth", &app_config.auth.auth_url)) {
@@ -655,8 +655,8 @@ where
let (stored_state, stored_verifier) = {
let guard = lock_r!(FDOLL);
(
guard.oauth_flow.state.clone(),
guard.oauth_flow.code_verifier.clone(),
guard.auth.oauth_flow.state.clone(),
guard.auth.oauth_flow.code_verifier.clone(),
)
};
@@ -675,8 +675,8 @@ where
Ok(auth_pass) => {
{
let mut guard = lock_w!(FDOLL);
guard.auth_pass = Some(auth_pass.clone());
guard.oauth_flow = Default::default();
guard.auth.auth_pass = Some(auth_pass.clone());
guard.auth.oauth_flow = Default::default();
}
if let Err(e) = save_auth_pass(&auth_pass) {
error!("Failed to save auth pass: {}", e);
@@ -720,7 +720,7 @@ pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, OAuthError>
(
guard.app_config.clone(),
guard
.clients
.network.clients
.as_ref()
.expect("clients present")
.http_client
@@ -765,7 +765,7 @@ pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, OAuthError>
);
// Update state and storage
lock_w!(FDOLL).auth_pass = Some(auth_pass.clone());
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
if let Err(e) = save_auth_pass(&auth_pass) {
error!("Failed to save refreshed auth pass: {}", e);
} else {

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)
pub fn absolute_to_normalized(pos: &CursorPosition) -> CursorPosition {
let guard = lock_r!(FDOLL);
let screen_w = guard.app_data.scene.display.screen_width as f64;
let screen_h = guard.app_data.scene.display.screen_height as f64;
let screen_w = guard.ui.app_data.scene.display.screen_width as f64;
let screen_h = guard.ui.app_data.scene.display.screen_height as f64;
CursorPosition {
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
pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
let guard = lock_r!(FDOLL);
let screen_w = guard.app_data.scene.display.screen_width as f64;
let screen_h = guard.app_data.scene.display.screen_height as f64;
let screen_w = guard.ui.app_data.scene.display.screen_width as f64;
let screen_h = guard.ui.app_data.scene.display.screen_height as f64;
CursorPosition {
x: (normalized.x * screen_w).round(),
@@ -124,7 +124,7 @@ async fn init_cursor_tracking() -> Result<(), String> {
#[cfg(target_os = "windows")]
let scale_factor = {
let guard = lock_r!(FDOLL);
guard.app_data.scene.display.monitor_scale_factor
guard.ui.app_data.scene.display.monitor_scale_factor
};
// The producer closure moves `tx` into it.

View File

@@ -117,7 +117,7 @@ pub fn close_health_manager_window() {
} else {
info!("Health manager window closed");
let guard = lock_r!(FDOLL);
let is_logged_in = guard.app_data.user.is_some();
let is_logged_in = guard.ui.app_data.user.is_some();
drop(guard);
update_system_tray(is_logged_in);
}

View File

@@ -9,7 +9,7 @@ pub async fn send_interaction(dto: SendInteractionDto) -> Result<(), String> {
// Check if WS is initialized
let client = {
let guard = lock_r!(FDOLL);
if let Some(clients) = &guard.clients {
if let Some(clients) = &guard.network.clients {
if clients.is_ws_initialized {
clients.ws_client.clone()
} else {

View File

@@ -33,7 +33,7 @@ fn on_initialized(payload: Payload, _socket: RawClient) {
// Mark WebSocket as initialized and reset backoff timer
let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.clients.as_mut() {
if let Some(clients) = guard.network.clients.as_mut() {
clients.is_ws_initialized = true;
}
@@ -57,7 +57,7 @@ pub async fn init_ws_client() {
match build_ws_client(&app_config).await {
Ok(ws_client) => {
let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.clients.as_mut() {
if let Some(clients) = guard.network.clients.as_mut() {
clients.ws_client = Some(ws_client);
clients.is_ws_initialized = false; // wait for initialized event
}
@@ -66,7 +66,7 @@ pub async fn init_ws_client() {
error!("Failed to initialize WebSocket client: {}", e);
// If we failed because no token, clear the WS client to avoid stale retries
let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.clients.as_mut() {
if let Some(clients) = guard.network.clients.as_mut() {
clients.ws_client = None;
clients.is_ws_initialized = false;
}

View File

@@ -15,7 +15,7 @@ pub async fn report_cursor_data(cursor_position: CursorPosition) {
// and if clients are actually initialized.
let (client_opt, is_initialized) = {
let guard = lock_r!(FDOLL);
if let Some(clients) = &guard.clients {
if let Some(clients) = &guard.network.clients {
(
clients.ws_client.as_ref().cloned(),
clients.is_ws_initialized,

View File

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

View File

@@ -0,0 +1,40 @@
use crate::services::auth::{load_auth_pass, AuthPass};
use tracing::{info, warn};
#[derive(Default, Clone)]
pub struct OAuthFlowTracker {
pub state: Option<String>,
pub code_verifier: Option<String>,
pub initiated_at: Option<u64>,
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
}
pub struct AuthState {
pub auth_pass: Option<AuthPass>,
pub oauth_flow: OAuthFlowTracker,
}
impl Default for AuthState {
fn default() -> Self {
Self {
auth_pass: None,
oauth_flow: OAuthFlowTracker::default(),
}
}
}
pub fn init_auth_state() -> AuthState {
let auth_pass = match load_auth_pass() {
Ok(pass) => pass,
Err(e) => {
warn!("Failed to load auth pass from keyring: {e}");
None
}
};
info!("Loaded auth pass");
AuthState {
auth_pass,
oauth_flow: OAuthFlowTracker::default(),
}
}

View File

@@ -0,0 +1,44 @@
// in app-core/src/state.rs
use crate::{lock_w};
use std::{
sync::{Arc, LazyLock, RwLock},
};
use tauri::tray::TrayIcon;
use tracing::info;
mod network;
mod auth;
mod ui;
pub use network::*;
pub use auth::*;
pub use ui::*;
#[derive(Default)]
pub struct AppState {
pub app_config: crate::services::client_config_manager::AppConfig,
pub network: NetworkState,
pub auth: AuthState,
pub ui: UiState,
pub tracing_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
pub tray: Option<TrayIcon>,
}
// Global application state
// Read / write this state via the `lock_r!` / `lock_w!` macros from `fdoll-core::utilities`
pub static FDOLL: LazyLock<Arc<RwLock<AppState>>> =
LazyLock::new(|| Arc::new(RwLock::new(AppState::default())));
pub fn init_fdoll_state(tracing_guard: Option<tracing_appender::non_blocking::WorkerGuard>) {
{
let mut guard = lock_w!(FDOLL);
dotenvy::dotenv().ok();
guard.tracing_guard = tracing_guard;
guard.app_config = crate::services::client_config_manager::load_app_config();
guard.network = init_network_state();
guard.auth = init_auth_state();
guard.ui = init_ui_state();
}
info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)");
}

View File

@@ -0,0 +1,35 @@
#[derive(Clone)]
pub struct Clients {
pub http_client: reqwest::Client,
pub ws_client: Option<rust_socketio::client::Client>,
pub is_ws_initialized: bool,
}
pub struct NetworkState {
pub clients: Option<Clients>,
}
impl Default for NetworkState {
fn default() -> Self {
Self { clients: None }
}
}
pub fn init_network_state() -> NetworkState {
let http_client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.user_agent("friendolls-desktop/0.1.0")
.build()
.expect("Client should build");
NetworkState {
clients: Some(Clients {
http_client,
ws_client: None,
is_ws_initialized: false,
}),
}
}

View File

@@ -1,83 +1,30 @@
// in app-core/src/state.rs
use crate::{
get_app_handle, lock_r, lock_w,
models::app_data::AppData,
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::{
auth::{load_auth_pass, AuthPass},
client_config_manager::{load_app_config, AppConfig},
},
};
use std::{
collections::HashSet,
sync::{Arc, LazyLock, RwLock},
sync::{LazyLock},
};
use tauri::Emitter;
use tokio::sync::Mutex;
use tracing::{info, warn};
#[derive(Default, Clone)]
pub struct OAuthFlowTracker {
pub state: Option<String>,
pub code_verifier: Option<String>,
pub initiated_at: Option<u64>,
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
}
pub struct Clients {
pub http_client: reqwest::Client,
pub ws_client: Option<rust_socketio::client::Client>,
pub is_ws_initialized: bool,
}
#[derive(Default)]
pub struct AppState {
pub app_config: AppConfig,
pub clients: Option<Clients>,
pub auth_pass: Option<AuthPass>,
pub oauth_flow: OAuthFlowTracker,
pub tracing_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
pub tray: Option<tauri::tray::TrayIcon>,
// exposed to the frontend
pub struct UiState {
pub app_data: AppData,
}
// Global application state
// Read / write this state via the `lock_r!` / `lock_w!` macros from `fdoll-core::utilities`
pub static FDOLL: LazyLock<Arc<RwLock<AppState>>> =
LazyLock::new(|| Arc::new(RwLock::new(AppState::default())));
pub fn init_fdoll_state(tracing_guard: Option<tracing_appender::non_blocking::WorkerGuard>) {
{
let mut guard = lock_w!(FDOLL);
dotenvy::dotenv().ok();
guard.tracing_guard = tracing_guard;
guard.app_config = load_app_config();
guard.auth_pass = match load_auth_pass() {
Ok(pass) => pass,
Err(e) => {
warn!("Failed to load auth pass from keyring: {e}");
None
impl Default for UiState {
fn default() -> Self {
Self {
app_data: AppData::default(),
}
};
info!("Loaded auth pass");
}
}
// Initialize HTTP client immediately (non-blocking)
let http_client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.user_agent("friendolls-desktop/0.1.0")
.build()
.expect("Client should build");
// Store HTTP client immediately - WebSocket client will be added later
guard.clients = Some(Clients {
http_client,
ws_client: None,
is_ws_initialized: false,
});
info!("Initialized HTTP client");
pub fn init_ui_state() -> UiState {
let mut ui_state = UiState::default();
// Initialize screen dimensions
let app_handle = get_app_handle();
@@ -130,24 +77,23 @@ pub fn init_fdoll_state(tracing_guard: Option<tracing_appender::non_blocking::Wo
let logical_monitor_dimensions: tauri::LogicalSize<i32> =
monitor_dimensions.to_logical(monitor_scale_factor);
guard.app_data.scene.display.screen_width = logical_monitor_dimensions.width;
guard.app_data.scene.display.screen_height = logical_monitor_dimensions.height;
guard.app_data.scene.display.monitor_scale_factor = monitor_scale_factor;
guard.app_data.scene.grid_size = 600; // Hardcoded grid size
ui_state.app_data.scene.display.screen_width = logical_monitor_dimensions.width;
ui_state.app_data.scene.display.screen_height = logical_monitor_dimensions.height;
ui_state.app_data.scene.display.monitor_scale_factor = monitor_scale_factor;
ui_state.app_data.scene.grid_size = 600; // Hardcoded grid size
info!(
"Initialized global AppData with screen dimensions: {}x{}, scale: {}, grid: {}",
logical_monitor_dimensions.width,
logical_monitor_dimensions.height,
monitor_scale_factor,
guard.app_data.scene.grid_size
ui_state.app_data.scene.grid_size
);
} else {
warn!("Could not initialize screen dimensions in global state - no monitor found");
}
}
info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)");
ui_state
}
/// Defines which parts of AppData should be refreshed from the server
@@ -172,6 +118,11 @@ pub async fn init_app_data() {
init_app_data_scoped(AppDataRefreshScope::All).await;
}
static REFRESH_IN_FLIGHT: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
static REFRESH_PENDING: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
/// Populate specific parts of app data from the server based on the scope.
///
/// # Arguments
@@ -185,11 +136,6 @@ pub async fn init_app_data() {
/// // Refresh only user profile when updating user settings
/// init_app_data_scoped(AppDataRefreshScope::User).await;
/// ```
static REFRESH_IN_FLIGHT: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
static REFRESH_PENDING: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
loop {
// Deduplicate concurrent refreshes for the same scope
@@ -212,8 +158,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
if matches!(scope, AppDataRefreshScope::All | AppDataRefreshScope::User) {
match user_remote.get_user(None).await {
Ok(user) => {
let mut guard = lock_w!(FDOLL);
guard.app_data.user = Some(user);
let mut guard = lock_w!(crate::state::FDOLL);
guard.ui.app_data.user = Some(user);
}
Err(e) => {
warn!("Failed to fetch user profile: {}", e);
@@ -240,8 +186,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
) {
match friend_remote.get_friends().await {
Ok(friends) => {
let mut guard = lock_w!(FDOLL);
guard.app_data.friends = Some(friends);
let mut guard = lock_w!(crate::state::FDOLL);
guard.ui.app_data.friends = Some(friends);
}
Err(e) => {
warn!("Failed to fetch friends list: {}", e);
@@ -265,8 +211,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
if matches!(scope, AppDataRefreshScope::All | AppDataRefreshScope::Dolls) {
match dolls_remote.get_dolls().await {
Ok(dolls) => {
let mut guard = lock_w!(FDOLL);
guard.app_data.dolls = Some(dolls);
let mut guard = lock_w!(crate::state::FDOLL);
guard.ui.app_data.dolls = Some(dolls);
}
Err(e) => {
warn!("Failed to fetch dolls list: {}", e);
@@ -288,8 +234,8 @@ pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
// Emit event regardless of partial success, frontend should handle nulls/empty states
{
let guard = lock_r!(FDOLL); // Use read lock to get data
let app_data_clone = guard.app_data.clone();
let guard = lock_r!(crate::state::FDOLL); // Use read lock to get data
let app_data_clone = guard.ui.app_data.clone();
drop(guard); // Drop lock before emitting to prevent potential deadlocks
if let Err(e) = get_app_handle().emit("app-data-refreshed", &app_data_clone) {