Files
MetaCrate/crates/libremetaverse/src/initial_outfit.rs
Chili Palmer b1f60db3d6
Some checks failed
Native code generation / deterministic (push) Failing after 2m18s
Imaging and meshing gate / native (push) Failing after 1m28s
Native Rust workspace compile / compile (push) Failing after 58s
Implement appearance baking pipeline (#66)
2026-08-10 10:33:29 +00:00

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());
}
}