trivial enhancements to state management solution

This commit is contained in:
2025-12-24 16:25:48 +08:00
parent 83f145f490
commit 42f798c8b7
11 changed files with 400 additions and 178 deletions

View File

@@ -6,12 +6,11 @@ use crate::{
UserBasicDto, UserBasicDto,
}, },
remotes::user::UserRemote, remotes::user::UserRemote,
services::cursor::start_cursor_tracking, services::{cursor::start_cursor_tracking, doll_editor::open_doll_editor_window},
services::doll_editor::open_doll_editor_window, state::{init_app_data, init_app_data_scoped, AppDataRefreshScope, FDOLL},
state::{init_app_data, FDOLL},
}; };
use tauri::async_runtime; use tauri::async_runtime;
use tauri::{Emitter, Manager}; use tauri::Manager;
use tracing_subscriber::{self, util::SubscriberInitExt}; use tracing_subscriber::{self, util::SubscriberInitExt};
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new(); static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
@@ -204,12 +203,11 @@ async fn create_doll(dto: CreateDollDto) -> Result<DollDto, String> {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Emit event locally so the app knows about the update immediately // Refresh dolls list in background (deduped inside init_app_data_scoped)
// The websocket event will also come in, but this makes the UI snappy async_runtime::spawn(async {
if let Err(e) = get_app_handle().emit("doll_created", &result) { init_app_data_scoped(AppDataRefreshScope::Dolls).await;
tracing::error!("Failed to emit local doll.created event: {}", e); });
}
Ok(result) Ok(result)
} }
@@ -220,10 +218,26 @@ async fn update_doll(id: String, dto: UpdateDollDto) -> Result<DollDto, String>
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Emit event locally // Check if this was the active doll (after update completes to avoid stale reads)
if let Err(e) = get_app_handle().emit("doll_updated", &result) { let is_active_doll = {
tracing::error!("Failed to emit local doll.updated event: {}", e); let guard = lock_r!(FDOLL);
} guard
.app_data
.user
.as_ref()
.and_then(|u| u.active_doll_id.as_ref())
.map(|active_id| active_id == &id)
.unwrap_or(false)
};
// Refresh dolls list + User/Friends if this was the active doll
async_runtime::spawn(async move {
init_app_data_scoped(AppDataRefreshScope::Dolls).await;
if is_active_doll {
init_app_data_scoped(AppDataRefreshScope::User).await;
init_app_data_scoped(AppDataRefreshScope::Friends).await;
}
});
Ok(result) Ok(result)
} }
@@ -235,20 +249,26 @@ async fn delete_doll(id: String) -> Result<(), String> {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Emit event locally // Check if this was the active doll (after delete completes to avoid stale reads)
// We need to send something that matches the structure expected by the frontend let is_active_doll = {
// The WS event sends the full object, but for deletion usually ID is enough or the object itself let guard = lock_r!(FDOLL);
// Let's send a dummy object with just the ID if we can, or just the ID if the frontend handles it. guard
// Looking at WS code: on_doll_deleted emits the payload it gets. .app_data
// Ideally we'd return the deleted doll from the backend but delete usually returns empty. .user
// Let's just emit the ID wrapped in an object or similar if needed. .as_ref()
// Actually, let's check what the frontend expects. .and_then(|u| u.active_doll_id.as_ref())
// src/routes/app-menu/tabs/your-dolls/index.svelte just listens and calls refreshDolls(), .map(|active_id| active_id == &id)
// it doesn't use the payload. So any payload is fine. .unwrap_or(false)
};
if let Err(e) = get_app_handle().emit("doll_deleted", ()) {
tracing::error!("Failed to emit local doll.deleted event: {}", e); // Refresh dolls list + User/Friends if the deleted doll was active
} async_runtime::spawn(async move {
init_app_data_scoped(AppDataRefreshScope::Dolls).await;
if is_active_doll {
init_app_data_scoped(AppDataRefreshScope::User).await;
init_app_data_scoped(AppDataRefreshScope::Friends).await;
}
});
Ok(()) Ok(())
} }
@@ -258,7 +278,16 @@ async fn set_active_doll(doll_id: String) -> Result<(), String> {
UserRemote::new() UserRemote::new()
.set_active_doll(&doll_id) .set_active_doll(&doll_id)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
// Refresh User (for active_doll_id) + Friends (so friends see your active doll)
// We don't need to refresh Dolls since the doll itself hasn't changed
async_runtime::spawn(async {
init_app_data_scoped(AppDataRefreshScope::User).await;
init_app_data_scoped(AppDataRefreshScope::Friends).await;
});
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -266,7 +295,15 @@ async fn remove_active_doll() -> Result<(), String> {
UserRemote::new() UserRemote::new()
.remove_active_doll() .remove_active_doll()
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
// Refresh User (for active_doll_id) + Friends (so friends see your doll is gone)
async_runtime::spawn(async {
init_app_data_scoped(AppDataRefreshScope::User).await;
init_app_data_scoped(AppDataRefreshScope::Friends).await;
});
Ok(())
} }
#[tauri::command] #[tauri::command]

View File

@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use crate::remotes::{friends::FriendshipResponseDto, user::UserProfile}; use crate::remotes::{dolls::DollDto, friends::FriendshipResponseDto, user::UserProfile};
#[derive(Serialize, Deserialize, Clone, Debug, TS)] #[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)] #[ts(export)]
@@ -42,5 +42,6 @@ impl Default for SceneData {
pub struct AppData { 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 scene: SceneData, pub scene: SceneData,
} }

View File

@@ -83,32 +83,44 @@ impl DollsRemote {
pub async fn get_dolls(&self) -> Result<Vec<DollDto>, RemoteError> { pub async fn get_dolls(&self) -> Result<Vec<DollDto>, RemoteError> {
let url = format!("{}/dolls/me", self.base_url); let url = format!("{}/dolls/me", self.base_url);
tracing::info!("DollsRemote::get_dolls - Sending GET request to URL: {}", url); tracing::info!(
"DollsRemote::get_dolls - Sending GET request to URL: {}",
url
);
let resp = with_auth(self.client.get(url)).await.send().await?; let resp = with_auth(self.client.get(url)).await.send().await?;
let resp = resp.error_for_status().map_err(|e| { let resp = resp.error_for_status().map_err(|e| {
tracing::error!("DollsRemote::get_dolls - HTTP error: {}", e); tracing::error!("DollsRemote::get_dolls - HTTP error: {}", e);
e e
})?; })?;
let text = resp.text().await.map_err(|e| { let text = resp.text().await.map_err(|e| {
tracing::error!("DollsRemote::get_dolls - Failed to read response text: {}", e); tracing::error!(
"DollsRemote::get_dolls - Failed to read response text: {}",
e
);
e e
})?; })?;
let dolls: Vec<DollDto> = serde_json::from_str(&text).map_err(|e| { let dolls: Vec<DollDto> = serde_json::from_str(&text).map_err(|e| {
tracing::error!("DollsRemote::get_dolls - Failed to parse JSON: {}", e); tracing::error!("DollsRemote::get_dolls - Failed to parse JSON: {}", e);
e e
})?; })?;
tracing::info!("DollsRemote::get_dolls - Successfully parsed {} dolls", dolls.len()); tracing::info!(
"DollsRemote::get_dolls - Successfully parsed {} dolls",
dolls.len()
);
Ok(dolls) Ok(dolls)
} }
pub async fn get_doll(&self, id: &str) -> Result<DollDto, RemoteError> { pub async fn get_doll(&self, id: &str) -> Result<DollDto, RemoteError> {
let url = format!("{}/dolls/{}", self.base_url, id); let url = format!("{}/dolls/{}", self.base_url, id);
tracing::info!("DollsRemote::get_doll - Sending GET request to URL: {}", url); tracing::info!(
"DollsRemote::get_doll - Sending GET request to URL: {}",
url
);
let resp = with_auth(self.client.get(url)).await.send().await?; let resp = with_auth(self.client.get(url)).await.send().await?;
let resp = resp.error_for_status().map_err(|e| { let resp = resp.error_for_status().map_err(|e| {
@@ -117,7 +129,10 @@ impl DollsRemote {
})?; })?;
let text = resp.text().await.map_err(|e| { let text = resp.text().await.map_err(|e| {
tracing::error!("DollsRemote::get_doll - Failed to read response text: {}", e); tracing::error!(
"DollsRemote::get_doll - Failed to read response text: {}",
e
);
e e
})?; })?;
@@ -125,14 +140,17 @@ impl DollsRemote {
tracing::error!("DollsRemote::get_doll - Failed to parse JSON: {}", e); tracing::error!("DollsRemote::get_doll - Failed to parse JSON: {}", e);
e e
})?; })?;
Ok(doll) Ok(doll)
} }
pub async fn create_doll(&self, dto: CreateDollDto) -> Result<DollDto, RemoteError> { pub async fn create_doll(&self, dto: CreateDollDto) -> Result<DollDto, RemoteError> {
let url = format!("{}/dolls", self.base_url); let url = format!("{}/dolls", self.base_url);
tracing::info!("DollsRemote::create_doll - Sending POST request to URL: {}", url); tracing::info!(
"DollsRemote::create_doll - Sending POST request to URL: {}",
url
);
let resp = with_auth(self.client.post(url)) let resp = with_auth(self.client.post(url))
.await .await
.json(&dto) .json(&dto)
@@ -145,7 +163,10 @@ impl DollsRemote {
})?; })?;
let text = resp.text().await.map_err(|e| { let text = resp.text().await.map_err(|e| {
tracing::error!("DollsRemote::create_doll - Failed to read response text: {}", e); tracing::error!(
"DollsRemote::create_doll - Failed to read response text: {}",
e
);
e e
})?; })?;
@@ -153,14 +174,17 @@ impl DollsRemote {
tracing::error!("DollsRemote::create_doll - Failed to parse JSON: {}", e); tracing::error!("DollsRemote::create_doll - Failed to parse JSON: {}", e);
e e
})?; })?;
Ok(doll) Ok(doll)
} }
pub async fn update_doll(&self, id: &str, dto: UpdateDollDto) -> Result<DollDto, RemoteError> { pub async fn update_doll(&self, id: &str, dto: UpdateDollDto) -> Result<DollDto, RemoteError> {
let url = format!("{}/dolls/{}", self.base_url, id); let url = format!("{}/dolls/{}", self.base_url, id);
tracing::info!("DollsRemote::update_doll - Sending PATCH request to URL: {}", url); tracing::info!(
"DollsRemote::update_doll - Sending PATCH request to URL: {}",
url
);
let resp = with_auth(self.client.patch(url)) let resp = with_auth(self.client.patch(url))
.await .await
.json(&dto) .json(&dto)
@@ -173,7 +197,10 @@ impl DollsRemote {
})?; })?;
let text = resp.text().await.map_err(|e| { let text = resp.text().await.map_err(|e| {
tracing::error!("DollsRemote::update_doll - Failed to read response text: {}", e); tracing::error!(
"DollsRemote::update_doll - Failed to read response text: {}",
e
);
e e
})?; })?;
@@ -181,16 +208,19 @@ impl DollsRemote {
tracing::error!("DollsRemote::update_doll - Failed to parse JSON: {}", e); tracing::error!("DollsRemote::update_doll - Failed to parse JSON: {}", e);
e e
})?; })?;
Ok(doll) Ok(doll)
} }
pub async fn delete_doll(&self, id: &str) -> Result<(), RemoteError> { pub async fn delete_doll(&self, id: &str) -> Result<(), RemoteError> {
let url = format!("{}/dolls/{}", self.base_url, id); let url = format!("{}/dolls/{}", self.base_url, id);
tracing::info!("DollsRemote::delete_doll - Sending DELETE request to URL: {}", url); tracing::info!(
"DollsRemote::delete_doll - Sending DELETE request to URL: {}",
url
);
let resp = with_auth(self.client.delete(url)).await.send().await?; let resp = with_auth(self.client.delete(url)).await.send().await?;
resp.error_for_status().map_err(|e| { resp.error_for_status().map_err(|e| {
tracing::error!("DollsRemote::delete_doll - HTTP error: {}", e); tracing::error!("DollsRemote::delete_doll - HTTP error: {}", e);
e e

View File

@@ -1,3 +1,3 @@
pub mod dolls;
pub mod friends; pub mod friends;
pub mod user; pub mod user;
pub mod dolls;

View File

@@ -633,6 +633,15 @@ where
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);
} }
// Immediately refresh app data now that auth is available
tauri::async_runtime::spawn(async {
crate::state::init_app_data_scoped(
crate::state::AppDataRefreshScope::All,
)
.await;
});
on_success(); on_success();
} }
Err(e) => error!("Token exchange failed: {}", e), Err(e) => error!("Token exchange failed: {}", e),

View File

@@ -135,11 +135,8 @@ async fn init_cursor_tracking() -> Result<(), String> {
}; };
let mapped = absolute_to_normalized(&raw); let mapped = absolute_to_normalized(&raw);
let positions = CursorPositions { let positions = CursorPositions { raw, mapped };
raw,
mapped,
};
// Send to consumer channel (non-blocking) // Send to consumer channel (non-blocking)
if let Err(e) = tx.try_send(positions) { if let Err(e) = tx.try_send(positions) {

View File

@@ -122,7 +122,7 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
// macOS Specific: Focus Trap Listener ID // macOS Specific: Focus Trap Listener ID
// We need to capture this to unlisten later. // We need to capture this to unlisten later.
let mut parent_focus_listener_id: Option<u32> = None; let mut parent_focus_listener_id: Option<u32> = None;
if let Some(parent) = app_handle.get_webview_window(APP_MENU_WINDOW_LABEL) { if let Some(parent) = app_handle.get_webview_window(APP_MENU_WINDOW_LABEL) {

View File

@@ -7,7 +7,7 @@ use crate::{
get_app_handle, lock_r, lock_w, get_app_handle, lock_r, lock_w,
models::app_config::AppConfig, models::app_config::AppConfig,
services::cursor::{normalized_to_absolute, CursorPosition, CursorPositions}, services::cursor::{normalized_to_absolute, CursorPosition, CursorPositions},
state::FDOLL, state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -59,7 +59,7 @@ fn on_initialized(payload: Payload, _socket: RawClient) {
Payload::Text(values) => { Payload::Text(values) => {
if let Some(first_value) = values.first() { if let Some(first_value) = values.first() {
info!("Received initialized event: {:?}", first_value); info!("Received initialized event: {:?}", first_value);
// Mark WebSocket as initialized // Mark WebSocket as initialized
let mut guard = lock_w!(FDOLL); let mut guard = lock_w!(FDOLL);
if let Some(clients) = guard.clients.as_mut() { if let Some(clients) = guard.clients.as_mut() {
@@ -93,6 +93,11 @@ fn on_friend_request_accepted(payload: Payload, _socket: RawClient) {
if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_ACCEPTED, str) { if let Err(e) = get_app_handle().emit(WS_EVENT::FRIEND_REQUEST_ACCEPTED, str) {
error!("Failed to emit friend request accepted event: {:?}", e); error!("Failed to emit friend request accepted event: {:?}", e);
} }
// Refresh friends list only (optimized - no need to fetch user profile)
tauri::async_runtime::spawn(async {
init_app_data_scoped(AppDataRefreshScope::Friends).await;
});
} }
_ => error!("Received unexpected payload format for friend request accepted"), _ => error!("Received unexpected payload format for friend request accepted"),
} }
@@ -117,6 +122,11 @@ fn on_unfriended(payload: Payload, _socket: RawClient) {
if let Err(e) = get_app_handle().emit(WS_EVENT::UNFRIENDED, str) { if let Err(e) = get_app_handle().emit(WS_EVENT::UNFRIENDED, str) {
error!("Failed to emit unfriended event: {:?}", e); error!("Failed to emit unfriended event: {:?}", e);
} }
// Refresh friends list only (optimized - no need to fetch user profile)
tauri::async_runtime::spawn(async {
init_app_data_scoped(AppDataRefreshScope::Friends).await;
});
} }
_ => error!("Received unexpected payload format for unfriended"), _ => error!("Received unexpected payload format for unfriended"),
} }
@@ -232,6 +242,12 @@ fn on_friend_active_doll_changed(payload: Payload, _socket: RawClient) {
} else { } else {
info!("Emitted friend-active-doll-changed to frontend"); info!("Emitted friend-active-doll-changed to frontend");
} }
// Refresh friends list only (optimized - friend's active doll is part of friends data)
// Deduplicate burst events inside init_app_data_scoped.
tauri::async_runtime::spawn(async {
init_app_data_scoped(AppDataRefreshScope::Friends).await;
});
} else { } else {
info!("Received friend-active-doll-changed event with empty payload"); info!("Received friend-active-doll-changed event with empty payload");
} }
@@ -245,9 +261,11 @@ fn on_doll_created(payload: Payload, _socket: RawClient) {
Payload::Text(values) => { Payload::Text(values) => {
if let Some(first_value) = values.first() { if let Some(first_value) = values.first() {
info!("Received doll.created event: {:?}", first_value); info!("Received doll.created event: {:?}", first_value);
if let Err(e) = get_app_handle().emit(WS_EVENT::DOLL_CREATED, first_value) {
error!("Failed to emit doll.created event: {:?}", e); // Refresh dolls list
} tauri::async_runtime::spawn(async {
init_app_data_scoped(AppDataRefreshScope::Dolls).await;
});
} else { } else {
info!("Received doll.created event with empty payload"); info!("Received doll.created event with empty payload");
} }
@@ -261,9 +279,31 @@ fn on_doll_updated(payload: Payload, _socket: RawClient) {
Payload::Text(values) => { Payload::Text(values) => {
if let Some(first_value) = values.first() { if let Some(first_value) = values.first() {
info!("Received doll.updated event: {:?}", first_value); info!("Received doll.updated event: {:?}", first_value);
if let Err(e) = get_app_handle().emit(WS_EVENT::DOLL_UPDATED, first_value) {
error!("Failed to emit doll.updated event: {:?}", e); // Try to extract doll ID to check if it's the active doll
} let doll_id = first_value.get("id").and_then(|v| v.as_str());
let is_active_doll = if let Some(id) = doll_id {
let guard = lock_r!(FDOLL);
guard
.app_data
.user
.as_ref()
.and_then(|u| u.active_doll_id.as_ref())
.map(|active_id| active_id == id)
.unwrap_or(false)
} else {
false
};
// Refresh dolls + potentially User/Friends if active doll
tauri::async_runtime::spawn(async move {
init_app_data_scoped(AppDataRefreshScope::Dolls).await;
if is_active_doll {
init_app_data_scoped(AppDataRefreshScope::User).await;
init_app_data_scoped(AppDataRefreshScope::Friends).await;
}
});
} else { } else {
info!("Received doll.updated event with empty payload"); info!("Received doll.updated event with empty payload");
} }
@@ -277,9 +317,31 @@ fn on_doll_deleted(payload: Payload, _socket: RawClient) {
Payload::Text(values) => { Payload::Text(values) => {
if let Some(first_value) = values.first() { if let Some(first_value) = values.first() {
info!("Received doll.deleted event: {:?}", first_value); info!("Received doll.deleted event: {:?}", first_value);
if let Err(e) = get_app_handle().emit(WS_EVENT::DOLL_DELETED, first_value) {
error!("Failed to emit doll.deleted event: {:?}", e); // Try to extract doll ID to check if it was the active doll
} let doll_id = first_value.get("id").and_then(|v| v.as_str());
let is_active_doll = if let Some(id) = doll_id {
let guard = lock_r!(FDOLL);
guard
.app_data
.user
.as_ref()
.and_then(|u| u.active_doll_id.as_ref())
.map(|active_id| active_id == id)
.unwrap_or(false)
} else {
false
};
// Refresh dolls + User/Friends if the deleted doll was active
tauri::async_runtime::spawn(async move {
init_app_data_scoped(AppDataRefreshScope::Dolls).await;
if is_active_doll {
init_app_data_scoped(AppDataRefreshScope::User).await;
init_app_data_scoped(AppDataRefreshScope::Friends).await;
}
});
} else { } else {
info!("Received doll.deleted event with empty payload"); info!("Received doll.deleted event with empty payload");
} }

View File

@@ -5,15 +5,16 @@ use crate::{
app_config::{AppConfig, AuthConfig}, app_config::{AppConfig, AuthConfig},
app_data::AppData, app_data::AppData,
}, },
remotes::{friends::FriendRemote, user::UserRemote}, remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::auth::{load_auth_pass, AuthPass}, services::auth::{load_auth_pass, AuthPass},
}; };
use serde_json::json;
use std::{ use std::{
collections::HashSet,
env, env,
sync::{Arc, LazyLock, RwLock}, sync::{Arc, LazyLock, RwLock},
}; };
use tauri::Emitter; use tauri::Emitter;
use tokio::sync::Mutex;
use tracing::{info, warn}; use tracing::{info, warn};
#[derive(Default, Clone)] #[derive(Default, Clone)]
@@ -154,57 +155,188 @@ pub fn init_fdoll_state(tracing_guard: Option<tracing_appender::non_blocking::Wo
info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)"); info!("Initialized FDOLL state (WebSocket client & user data initializing asynchronously)");
} }
/// Defines which parts of AppData should be refreshed from the server
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AppDataRefreshScope {
/// Refresh all data (user profile + friends list + dolls list)
All,
/// Refresh only user profile
User,
/// Refresh only friends list
Friends,
/// Refresh only dolls list
Dolls,
}
/// To be called in init state or need to refresh data. /// To be called in init state or need to refresh data.
/// Populate user data in app state from the server. /// 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() { pub async fn init_app_data() {
let user_remote = UserRemote::new(); init_app_data_scoped(AppDataRefreshScope::All).await;
let friend_remote = FriendRemote::new(); }
// Fetch user profile /// Populate specific parts of app data from the server based on the scope.
match user_remote.get_user(None).await { ///
Ok(user) => { /// # Arguments
let mut guard = lock_w!(FDOLL); /// * `scope` - Determines which data to refresh (All, User, or Friends)
guard.app_data.user = Some(user); ///
} /// # Examples
Err(e) => { /// ```
warn!("Failed to fetch user profile: {}", e); /// // Refresh only friends list when a friend request is accepted
use tauri_plugin_dialog::MessageDialogBuilder; /// init_app_data_scoped(AppDataRefreshScope::Friends).await;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; ///
/// // 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()));
let handle = get_app_handle(); pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
MessageDialogBuilder::new( loop {
handle.dialog().clone(), // Deduplicate concurrent refreshes for the same scope
"Network Error", {
"Failed to fetch user profile. You may be offline.", let mut in_flight = REFRESH_IN_FLIGHT.lock().await;
) if in_flight.contains(&scope) {
.kind(MessageDialogKind::Error) let mut pending = REFRESH_PENDING.lock().await;
.show(|_| {}); pending.insert(scope);
// We continue execution to see if other parts (like friends) can be loaded or if we just stay in a partial state. return;
// Alternatively, return early here. }
in_flight.insert(scope);
} }
}
// Fetch friends list let result: Result<(), ()> = async {
match friend_remote.get_friends().await { let user_remote = UserRemote::new();
Ok(friends) => { let friend_remote = FriendRemote::new();
let mut guard = lock_w!(FDOLL); let dolls_remote = DollsRemote::new();
guard.app_data.friends = Some(friends);
}
Err(e) => {
warn!("Failed to fetch friends list: {}", e);
// Optionally show another dialog or just log it.
// Showing too many dialogs on startup is annoying, so we might skip this one if user profile failed too.
}
}
// Emit event regardless of partial success, frontend should handle nulls/empty states // Fetch user profile if needed
{ if matches!(scope, AppDataRefreshScope::All | AppDataRefreshScope::User) {
let guard = lock_r!(FDOLL); // Use read lock to get data match user_remote.get_user(None).await {
let app_data_clone = guard.app_data.clone(); Ok(user) => {
drop(guard); // Drop lock before emitting to prevent potential deadlocks let mut guard = lock_w!(FDOLL);
guard.app_data.user = Some(user);
}
Err(e) => {
warn!("Failed to fetch user profile: {}", e);
use tauri_plugin_dialog::MessageDialogBuilder;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
if let Err(e) = get_app_handle().emit("app-data-refreshed", json!(app_data_clone)) { let handle = get_app_handle();
warn!("Failed to emit app-data-refreshed event: {}", e); MessageDialogBuilder::new(
handle.dialog().clone(),
"Network Error",
"Failed to fetch user profile. You may be offline.",
)
.kind(MessageDialogKind::Error)
.show(|_| {});
return Err(());
}
}
}
// Fetch friends list if needed
if matches!(
scope,
AppDataRefreshScope::All | AppDataRefreshScope::Friends
) {
match friend_remote.get_friends().await {
Ok(friends) => {
let mut guard = lock_w!(FDOLL);
guard.app_data.friends = Some(friends);
}
Err(e) => {
warn!("Failed to fetch friends list: {}", e);
use tauri_plugin_dialog::MessageDialogBuilder;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
let handle = get_app_handle();
MessageDialogBuilder::new(
handle.dialog().clone(),
"Network Error",
"Failed to fetch friends list. You may be offline.",
)
.kind(MessageDialogKind::Error)
.show(|_| {});
return Err(());
}
}
}
// Fetch dolls list if needed
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);
}
Err(e) => {
warn!("Failed to fetch dolls list: {}", e);
use tauri_plugin_dialog::MessageDialogBuilder;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
let handle = get_app_handle();
MessageDialogBuilder::new(
handle.dialog().clone(),
"Network Error",
"Failed to fetch dolls list. You may be offline.",
)
.kind(MessageDialogKind::Error)
.show(|_| {});
return Err(());
}
}
}
// 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();
drop(guard); // Drop lock before emitting to prevent potential deadlocks
if let Err(e) = get_app_handle().emit("app-data-refreshed", &app_data_clone) {
warn!("Failed to emit app-data-refreshed event: {}", e);
use tauri_plugin_dialog::MessageDialogBuilder;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
let handle = get_app_handle();
MessageDialogBuilder::new(
handle.dialog().clone(),
"Sync Error",
"Could not broadcast refreshed data to the UI. Some data may be stale.",
)
.kind(MessageDialogKind::Error)
.show(|_| {});
}
}
Ok(())
} }
.await;
// Clear in-flight marker even on early exit
{
let mut in_flight = REFRESH_IN_FLIGHT.lock().await;
in_flight.remove(&scope);
}
// If a refresh was queued while this one was running, run again
let rerun = {
let mut pending = REFRESH_PENDING.lock().await;
pending.remove(&scope)
};
if rerun {
continue;
}
if result.is_err() {
return;
}
break;
} }
} }

View File

@@ -1,72 +1,19 @@
<script lang="ts"> <script lang="ts">
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 { appData } from "../../../../events/app-data";
import type { DollDto } from "../../../../types/bindings/DollDto"; import type { DollDto } from "../../../../types/bindings/DollDto";
import type { UserProfile } from "../../../../types/bindings/UserProfile"; import type { UserProfile } from "../../../../types/bindings/UserProfile";
import type { AppData } from "../../../../types/bindings/AppData";
import DollsList from "./dolls-list.svelte"; import DollsList from "./dolls-list.svelte";
let dolls: DollDto[] = [];
let user: UserProfile | null = null;
let loading = false; let loading = false;
let error: string | null = null; let error: string | null = null;
let user: UserProfile | null = null;
let initialLoading = true;
// We still keep the focus listener as a fallback, but the websocket events should handle most updates // Reactive - automatically updates when appData changes
onMount(() => { $: dolls = $appData?.dolls ?? [];
refreshDolls(); $: user = $appData?.user ?? null;
$: initialLoading = $appData === null;
// Set up listeners
const unlistenCreated = listen("doll_created", (event) => {
console.log("Received doll_created event", event);
refreshDolls();
});
const unlistenUpdated = listen("doll_updated", (event) => {
console.log("Received doll_updated event", event);
refreshDolls();
});
const unlistenDeleted = listen("doll_deleted", (event) => {
console.log("Received doll_deleted event", event);
refreshDolls();
});
return async () => {
(await unlistenCreated)();
(await unlistenUpdated)();
(await unlistenDeleted)();
};
});
let isRefreshing = false;
let refreshQueued = false;
async function refreshDolls() {
if (isRefreshing) {
refreshQueued = true;
return;
}
isRefreshing = true;
loading = true;
try {
do {
refreshQueued = false;
try {
dolls = await invoke("get_dolls");
const appData: AppData = await invoke("refresh_app_data");
user = appData.user;
} catch (e) {
error = (e as Error)?.message ?? String(e);
}
} while (refreshQueued);
} finally {
loading = false;
isRefreshing = false;
}
}
async function openCreateModal() { async function openCreateModal() {
await invoke("open_doll_editor_window", { dollId: null }); await invoke("open_doll_editor_window", { dollId: null });
@@ -78,19 +25,25 @@
async function handleSetActiveDoll(dollId: string) { async function handleSetActiveDoll(dollId: string) {
try { try {
loading = true;
await invoke("set_active_doll", { dollId }); await invoke("set_active_doll", { dollId });
await refreshDolls(); // No manual refresh needed - backend will refresh and emit app-data-refreshed
} catch (e) { } catch (e) {
error = (e as Error)?.message ?? String(e); error = (e as Error)?.message ?? String(e);
} finally {
loading = false;
} }
} }
async function handleRemoveActiveDoll() { async function handleRemoveActiveDoll() {
try { try {
loading = true;
await invoke("remove_active_doll"); await invoke("remove_active_doll");
await refreshDolls(); // No manual refresh needed - backend will refresh and emit app-data-refreshed
} catch (e) { } catch (e) {
error = (e as Error)?.message ?? String(e); error = (e as Error)?.message ?? String(e);
} finally {
loading = false;
} }
} }
</script> </script>
@@ -106,7 +59,7 @@
<DollsList <DollsList
{dolls} {dolls}
{user} {user}
{loading} loading={loading || initialLoading}
{error} {error}
onEditDoll={openEditModal} onEditDoll={openEditModal}
onSetActiveDoll={handleSetActiveDoll} onSetActiveDoll={handleSetActiveDoll}

View File

@@ -1,6 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DollDto } from "./DollDto";
import type { FriendshipResponseDto } from "./FriendshipResponseDto"; import type { FriendshipResponseDto } from "./FriendshipResponseDto";
import type { SceneData } from "./SceneData"; import type { SceneData } from "./SceneData";
import type { UserProfile } from "./UserProfile"; import type { UserProfile } from "./UserProfile";
export type AppData = { user: UserProfile | null, friends: Array<FriendshipResponseDto> | null, scene: SceneData, }; export type AppData = { user: UserProfile | null, friends: Array<FriendshipResponseDto> | null, dolls: Array<DollDto> | null, scene: SceneData, };