Implement appearance baking pipeline (#66)
This commit is contained in:
607
crates/libremetaverse/src/initial_outfit.rs
Normal file
607
crates/libremetaverse/src/initial_outfit.rs
Normal file
@@ -0,0 +1,607 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user