//! Transactional first-login outfit copy and application. use crate::appearance::{CurrentOutfitFolder, InitialOutfitInitialOutfitPhase as Phase}; use crate::{ Error, GridClient, InventoryBase, InventoryFolder, InventoryNode, InventorySortOrder, LoginStatus, }; use libremetaverse_types::compat::{ CancellationToken, CancellationTokenSource, IProgress, Subscription, }; use libremetaverse_types::{FolderType, UUID}; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; const MAX_FOLDERS: usize = 256; #[derive(Clone, Debug)] pub struct InitialOutfitProgress { current_item_name: String, items_copied: i32, message: String, phase: Phase, total_items: i32, } impl InitialOutfitProgress { #[allow(clippy::unnecessary_wraps)] // The generated constructor uses the crate Result model. pub(crate) fn native_new() -> Result { Ok(Self { current_item_name: String::new(), items_copied: 0, message: String::new(), phase: Phase::Counting, total_items: 0, }) } pub(crate) fn native_current_item_name(&self) -> String { self.current_item_name.clone() } pub(crate) fn native_set_current_item_name(&mut self, value: String) { self.current_item_name = value; } pub(crate) const fn native_items_copied(&self) -> i32 { self.items_copied } pub(crate) fn native_set_items_copied(&mut self, value: i32) { self.items_copied = value; } pub(crate) fn native_message(&self) -> String { self.message.clone() } pub(crate) fn native_set_message(&mut self, value: String) { self.message = value; } pub(crate) const fn native_phase(&self) -> Phase { self.phase } pub(crate) fn native_set_phase(&mut self, value: Phase) { self.phase = value; } pub(crate) const fn native_total_items(&self) -> i32 { self.total_items } pub(crate) fn native_set_total_items(&mut self, value: i32) { self.total_items = value; } } #[derive(Clone)] pub struct InitialOutfit { client: GridClient, cof: CurrentOutfitFolder, } struct FolderPlan { source: InventoryFolder, children: Vec, items: Vec, } impl InitialOutfit { #[allow(clippy::unnecessary_wraps)] // The generated constructor uses the crate Result model. pub(crate) fn native_new(client: GridClient, cof: CurrentOutfitFolder) -> Result { Ok(Self { client, cof }) } #[allow(clippy::needless_pass_by_value)] // Mirrors the public String signature. pub(crate) fn native_find_node_by_name( root: InventoryNode, name: String, ) -> Result, Error> { if name.is_empty() { return Ok(None); } let mut queue = VecDeque::from([root]); let mut visited = HashSet::new(); let mut traversed = 0_usize; while let Some(node) = queue.pop_front() { traversed += 1; if traversed > MAX_FOLDERS { return Err(Error::InvalidOperation); } let identity = node.data().map(|value| value.inventory_base().uuid()); if let Some(identity) = identity && !visited.insert(identity) { continue; } if visited.len() > MAX_FOLDERS { return Err(Error::InvalidOperation); } if node .data() .is_some_and(|value| value.inventory_base().name() == name) { return Ok(Some(node)); } queue.extend(node.nodes().values()); } Ok(None) } #[allow(clippy::unused_async)] // Preserves the Task-returning compatibility API. pub(crate) async fn native_create_folder( &self, parent: UUID, name: String, folder_type: FolderType, cancellation_token: Option, ) -> Result { let token = cancellation_token.unwrap_or_default(); token.throw_if_cancellation_requested()?; let id = self .client .native_inventory()? .native_create_folder(parent, name, folder_type)?; token.throw_if_cancellation_requested()?; if id == UUID::zero() { Err(Error::InvalidOperation) } else { Ok(id) } } pub(crate) async fn native_check_system_folders( &self, cancellation_token: Option, ) -> Result<(), Error> { let token = cancellation_token.unwrap_or_default(); token.throw_if_cancellation_requested()?; let inventory = self.client.native_inventory()?; let store = inventory.native_store().ok_or(Error::InvalidOperation)?; let root = store .root_folder() .ok_or(Error::InvalidOperation)? .base .uuid(); for (folder_type, name) in [ (FolderType::Clothing, "Clothing"), (FolderType::Trash, "Trash"), ] { token.throw_if_cancellation_requested()?; if inventory.native_find_folder_for_type(folder_type) == root { self.native_create_folder(root, name.to_owned(), folder_type, Some(token.clone())) .await?; } } Ok(()) } async fn plan_copy( &self, root: InventoryFolder, token: &CancellationToken, ) -> Result<(Vec, i32), Error> { let inventory = self.client.native_inventory()?; let mut queue = VecDeque::from([root]); let mut visited = HashSet::new(); let mut plans = Vec::new(); let mut total = 0_i32; while let Some(folder) = queue.pop_front() { token.throw_if_cancellation_requested()?; if !visited.insert(folder.base.uuid()) { continue; } if visited.len() > MAX_FOLDERS { return Err(Error::InvalidOperation); } let contents = inventory .native_folder_contents( folder.base.uuid(), folder.base.owner_id(), true, true, InventorySortOrder::BY_NAME, Some(token.clone()), false, ) .await?; let mut children = Vec::new(); let mut items = Vec::new(); for value in contents { if let Some(child) = value.as_any().downcast_ref::() { children.push(child.clone()); queue.push_back(child.clone()); } else { total = total.checked_add(1).ok_or(Error::Argument)?; items.push(value.inventory_base().clone()); } } plans.push(FolderPlan { source: folder, children, items, }); } Ok((plans, total)) } pub(crate) async fn native_copy_folder( &self, folder: InventoryFolder, destination: UUID, cancellation_token: Option, progress: Option>>, ) -> Result { self.native_copy_folder_impl(folder, destination, cancellation_token, progress, true) .await .map(|(folder, _)| folder) } async fn native_copy_folder_impl( &self, folder: InventoryFolder, destination: UUID, cancellation_token: Option, progress: Option>>, report_complete: bool, ) -> Result<(UUID, i32), Error> { let token = cancellation_token.unwrap_or_default(); let (plans, total) = self.plan_copy(folder.clone(), &token).await?; report( progress.as_deref(), Phase::Counting, total, 0, "", "Inventory counted", )?; let root_id = self .native_create_folder( destination, folder.base.name(), folder.preferred_type(), Some(token.clone()), ) .await?; let result = self .execute_copy(&plans, root_id, total, &token, progress.as_deref()) .await; if let Err(error) = result { self.cleanup_folder(root_id).await; return Err(error); } if report_complete { report( progress.as_deref(), Phase::Complete, total, total, "", "Folder copy complete", )?; } Ok((root_id, total)) } async fn cleanup_folder(&self, root_id: UUID) { // Compensation deliberately ignores the cancelled caller token. let Ok(inventory) = self.client.native_inventory() else { return; }; let _ = inventory .native_remove_inventory_objects(Vec::new(), vec![root_id], None) .await; if let Some(store) = inventory.native_store() && let Ok(Some(folder)) = store.get_value_or_default_with_uuid_a7c63fbe::(root_id) { let _ = store.remove_node_for(&folder); } } async fn execute_copy( &self, plans: &[FolderPlan], root_id: UUID, total: i32, token: &CancellationToken, progress: Option<&dyn IProgress>, ) -> Result<(), Error> { let inventory = self.client.native_inventory()?; let root_source = plans.first().ok_or(Error::Argument)?.source.base.uuid(); let mut destinations = HashMap::from([(root_source, root_id)]); let mut copied = 0_i32; for plan in plans { token.throw_if_cancellation_requested()?; let destination = *destinations .get(&plan.source.base.uuid()) .ok_or(Error::InvalidOperation)?; for child in &plan.children { let id = self .native_create_folder( destination, child.base.name(), child.preferred_type(), Some(token.clone()), ) .await?; destinations.insert(child.base.uuid(), id); } for item in &plan.items { token.throw_if_cancellation_requested()?; report( progress, Phase::Copying, total, copied, &item.name(), "Copying inventory item", )?; let copied_item = inventory .native_copy_item( item.uuid(), destination, item.name(), item.owner_id(), Some(token.clone()), ) .await?; if copied_item.is_none() { return Err(Error::InvalidOperation); } copied += 1; report( progress, Phase::Copying, total, copied, &item.name(), "Inventory item copied", )?; } } Ok(()) } pub(crate) async fn native_set_initial_outfit( &self, outfit: String, cancellation_token: Option, progress: Option>>, ) -> Result<(), Error> { if outfit.is_empty() { return Err(Error::Argument); } let token = cancellation_token.unwrap_or_default(); token.throw_if_cancellation_requested()?; let inventory = self.client.native_inventory()?; let store = inventory.native_store().ok_or(Error::InvalidOperation)?; let node = Self::native_find_node_by_name(store.library_root_node(), outfit)? .ok_or(Error::InvalidOperation)?; let folder = node .data() .and_then(|value| value.as_any().downcast_ref::().cloned()) .ok_or(Error::InvalidOperation)?; self.native_check_system_folders(Some(token.clone())) .await?; let clothing = inventory.native_find_folder_for_type(FolderType::Clothing); let wrapper = progress.map(Arc::>::from); let (copied, total) = self .native_copy_folder_impl( folder, clothing, Some(token.clone()), wrapper .as_ref() .map(|sink| Box::new(SharedProgress(Arc::clone(sink))) as Box<_>), false, ) .await?; report( wrapper.as_deref(), Phase::Applying, total, total, "", "Applying initial outfit", )?; let applied = async { token.throw_if_cancellation_requested()?; if self .cof .native_replace_outfit(copied, Some(token.clone())) .await? { Ok(()) } else { Err(Error::InvalidOperation) } } .await; if let Err(error) = applied { self.cleanup_folder(copied).await; return Err(error); } report( wrapper.as_deref(), Phase::Complete, total, total, "", "Initial outfit applied", ) } } #[allow(clippy::unnecessary_wraps)] // Keeps progress reporting composable with fallible stages. fn report( sink: Option<&dyn IProgress>, phase: Phase, total: i32, copied: i32, current: &str, message: &str, ) -> Result<(), Error> { if let Some(sink) = sink { sink.report(InitialOutfitProgress { current_item_name: current.to_owned(), items_copied: copied, message: message.to_owned(), phase, total_items: total, }); } Ok(()) } struct SharedProgress(Arc>); impl IProgress for SharedProgress { fn report(&self, value: InitialOutfitProgress) { self.0.report(value); } } pub struct InitialOutfitHandler { cancellation: CancellationTokenSource, subscription: Mutex>, } impl InitialOutfitHandler { pub(crate) fn native_new( client: GridClient, cof: CurrentOutfitFolder, progress: Option>>, ) -> Result { let cancellation = CancellationTokenSource::new(); let token = cancellation.token(); let started = Arc::new(AtomicBool::new(false)); let callback_started = Arc::clone(&started); let progress = progress.map(Arc::>::from); let network = client.native_network()?; let subscription = network.native_subscribe_login_progress(Arc::new(move |event| { if event.status() != LoginStatus::Success { return; } let Some(login) = client .native_network() .ok() .and_then(|network| network.login_response_data) else { return; }; if !login.first_login() || login.initial_outfit().is_empty() { return; } if callback_started .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return; } let client = client.clone(); let cof = cof.clone(); let outfit = login.initial_outfit(); let token = token.clone(); let progress = progress.as_ref().map(Arc::clone); let _ = std::thread::Builder::new() .name("initial-outfit".to_owned()) .spawn(move || { let Ok(runtime) = tokio::runtime::Builder::new_current_thread() .enable_all() .build() else { return; }; let mut client = client; let _ = runtime.block_on(async move { let _ = client .native_self() .native_set_agent_access_async("A".to_owned(), Some(token.clone())) .await; let initial = InitialOutfit { client, cof }; initial .native_set_initial_outfit( outfit, Some(token), progress.map(|sink| Box::new(SharedProgress(sink)) as Box<_>), ) .await }); }); })); Ok(Self { cancellation, subscription: Mutex::new(Some(subscription)), }) } #[allow(clippy::unnecessary_wraps)] // IDisposable is represented with the crate Result model. pub(crate) fn native_dispose(&self) -> Result<(), Error> { self.cancellation.cancel(); self.subscription .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .take(); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn progress_properties_round_trip_without_shared_mutability() { let mut progress = InitialOutfitProgress::native_new().unwrap(); progress.native_set_phase(Phase::Copying); progress.native_set_total_items(7); progress.native_set_items_copied(3); progress.native_set_current_item_name("shirt".to_owned()); progress.native_set_message("copying".to_owned()); assert_eq!(progress.native_phase(), Phase::Copying); assert_eq!(progress.native_total_items(), 7); assert_eq!(progress.native_items_copied(), 3); assert_eq!(progress.native_current_item_name(), "shirt"); assert_eq!(progress.native_message(), "copying"); } #[test] fn find_node_by_name_walks_children_and_is_bounded() { let mut root_data = InventoryFolder::new(UUID::random().unwrap()).unwrap(); root_data.base.set_name("Library".to_owned()); let root = InventoryNode::new_with_inventory_base(&root_data).unwrap(); let mut child_data = InventoryFolder::new(UUID::random().unwrap()).unwrap(); child_data.base.set_name("Starter Outfit".to_owned()); let child = InventoryNode::new_with_inventory_base(&child_data).unwrap(); root.nodes().add(child_data.base.uuid(), child).unwrap(); let found = InitialOutfit::native_find_node_by_name(root, "Starter Outfit".to_owned()) .unwrap() .unwrap(); assert_eq!( found.data().unwrap().inventory_base().name(), "Starter Outfit" ); } #[tokio::test] async fn cancelled_create_does_not_touch_inventory_or_network() { let client = GridClient::new().unwrap(); let cof = CurrentOutfitFolder::native_new(Some(Arc::new(client.clone()))).unwrap(); let initial = InitialOutfit::native_new(client, cof).unwrap(); let source = CancellationTokenSource::new(); source.cancel(); let result = initial .native_create_folder( UUID::zero(), "Clothing".to_owned(), FolderType::Clothing, Some(source.token()), ) .await; assert_eq!(result, Err(Error::Cancelled)); } #[test] fn handler_dispose_is_idempotent_and_cancels_work() { let handler = InitialOutfitHandler { cancellation: CancellationTokenSource::new(), subscription: Mutex::new(None), }; handler.native_dispose().unwrap(); handler.native_dispose().unwrap(); assert!(handler.cancellation.token().is_cancellation_requested()); } }