608 lines
20 KiB
Rust
608 lines
20 KiB
Rust
//! 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<Self, Error> {
|
|
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<InventoryFolder>,
|
|
items: Vec<InventoryBase>,
|
|
}
|
|
|
|
impl InitialOutfit {
|
|
#[allow(clippy::unnecessary_wraps)] // The generated constructor uses the crate Result model.
|
|
pub(crate) fn native_new(client: GridClient, cof: CurrentOutfitFolder) -> Result<Self, Error> {
|
|
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<Option<InventoryNode>, 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<CancellationToken>,
|
|
) -> Result<UUID, Error> {
|
|
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<CancellationToken>,
|
|
) -> 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<FolderPlan>, 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::<InventoryFolder>() {
|
|
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<CancellationToken>,
|
|
progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
|
|
) -> Result<UUID, Error> {
|
|
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<CancellationToken>,
|
|
progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
|
|
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::<InventoryFolder>(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<InitialOutfitProgress>>,
|
|
) -> 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<CancellationToken>,
|
|
progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
|
|
) -> 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::<InventoryFolder>().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::<dyn IProgress<InitialOutfitProgress>>::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<InitialOutfitProgress>>,
|
|
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<dyn IProgress<InitialOutfitProgress>>);
|
|
impl IProgress<InitialOutfitProgress> for SharedProgress {
|
|
fn report(&self, value: InitialOutfitProgress) {
|
|
self.0.report(value);
|
|
}
|
|
}
|
|
|
|
pub struct InitialOutfitHandler {
|
|
cancellation: CancellationTokenSource,
|
|
subscription: Mutex<Option<Subscription>>,
|
|
}
|
|
|
|
impl InitialOutfitHandler {
|
|
pub(crate) fn native_new(
|
|
client: GridClient,
|
|
cof: CurrentOutfitFolder,
|
|
progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
|
|
) -> Result<Self, Error> {
|
|
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::<dyn IProgress<InitialOutfitProgress>>::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());
|
|
}
|
|
}
|