//! Offline marketplace folder classification and capability-backed listing state. #![allow(clippy::missing_errors_doc)] #![allow(clippy::must_use_candidate)] #![allow(clippy::needless_pass_by_value)] use crate::agent_manager::EventRegistry; use crate::client_core::ClientWeakHandle; use crate::marketplace::{ MarketplaceFolderRole, MarketplaceListingStatus, MarketplaceValidationFlags, }; use crate::{Error, GridClient, Inventory, InventoryFolder}; use libremetaverse_structured_data::{OSD, OSDParser}; use libremetaverse_types::compat::{ CancellationToken, EventHandler, ExternalError, HttpResponse, Subscription, Uri, }; use libremetaverse_types::{FolderType, UUID}; use serde_json::{Value, json}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; const JSON_CONTENT_TYPE: &str = "application/json"; fn read(value: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { value .read() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn write(value: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { value .write() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn folder_at(inventory: &Inventory, id: UUID) -> Option { inventory .get_node_or_default(id) .ok() .flatten() .and_then(|node| node.data()) .and_then(|value| value.as_any().downcast_ref::().cloned()) } fn child_folders(inventory: &Inventory, parent: UUID) -> Vec { inventory .get_contents_with_uuid(parent) .unwrap_or_default() .into_iter() .filter_map(|value| value.as_any().downcast_ref::().cloned()) .collect() } /// Pure inventory-tree decisions used by marketplace workflows. pub struct MarketplaceFolderClassifier; impl MarketplaceFolderClassifier { pub fn get_listings_root(inventory: &Inventory) -> Result { Ok(inventory .find_folder_for_type(FolderType::MarketplaceListings) .unwrap_or_else(UUID::zero)) } pub fn get_role( folder_id: UUID, inventory: &Inventory, ) -> Result { let Some(folder) = folder_at(inventory, folder_id) else { return Ok(MarketplaceFolderRole::None); }; if folder.preferred_type() == FolderType::MarketplaceListings { return Ok(MarketplaceFolderRole::ListingsRoot); } let Some(parent) = folder_at(inventory, folder.base.parent_uuid()) else { return Ok(MarketplaceFolderRole::None); }; if parent.preferred_type() == FolderType::MarketplaceListings { return Ok(MarketplaceFolderRole::Listing); } let parent_role = Self::get_role(parent.base.uuid(), inventory)?; if folder.preferred_type() == FolderType::MarketplaceVersion && parent_role == MarketplaceFolderRole::Listing { return Ok(MarketplaceFolderRole::Version); } if folder.preferred_type() == FolderType::MarketplaceStock && parent_role == MarketplaceFolderRole::Version { return Ok(MarketplaceFolderRole::Stock); } if parent_role == MarketplaceFolderRole::Version { return Ok(MarketplaceFolderRole::Content); } Ok(MarketplaceFolderRole::None) } pub fn is_marketplace_folder(folder_id: UUID, inventory: &Inventory) -> Result { Ok(Self::get_role(folder_id, inventory)? != MarketplaceFolderRole::None) } pub fn get_listing_folder(folder_id: UUID, inventory: &Inventory) -> Result { let mut current = folder_id; for _ in 0..512 { match Self::get_role(current, inventory)? { MarketplaceFolderRole::Listing => return Ok(current), MarketplaceFolderRole::ListingsRoot | MarketplaceFolderRole::None => { return Ok(UUID::zero()); } _ => { let Some(folder) = folder_at(inventory, current) else { return Ok(UUID::zero()); }; current = folder.base.parent_uuid(); } } } Ok(UUID::zero()) } pub fn get_version_folder(listing: UUID, inventory: &Inventory) -> Result { if Self::get_role(listing, inventory)? != MarketplaceFolderRole::Listing { return Ok(UUID::zero()); } Ok(child_folders(inventory, listing) .into_iter() .find(|folder| folder.preferred_type() == FolderType::MarketplaceVersion) .map_or_else(UUID::zero, |folder| folder.base.uuid())) } pub fn get_stock_count(version: UUID, inventory: &Inventory) -> Result { if version == UUID::zero() { return Ok(0); } let count = inventory .get_contents_with_uuid(version) .unwrap_or_default() .iter() .filter(|value| value.as_any().downcast_ref::().is_none()) .count(); Ok(i32::try_from(count).unwrap_or(i32::MAX)) } pub fn get_all_listing_folder_ids(inventory: &Inventory) -> Result, Error> { let root = Self::get_listings_root(inventory)?; if root == UUID::zero() { return Ok(Vec::new()); } Ok(child_folders(inventory, root) .into_iter() .map(|folder| folder.base.uuid()) .collect()) } pub fn validate_listing( listing: UUID, inventory: &Inventory, ) -> Result { if Self::get_role(listing, inventory)? != MarketplaceFolderRole::Listing { return Ok(MarketplaceValidationFlags::INVALID_STRUCTURE); } let versions: Vec<_> = child_folders(inventory, listing) .into_iter() .filter(|folder| folder.preferred_type() == FolderType::MarketplaceVersion) .collect(); match versions.as_slice() { [] => Ok(MarketplaceValidationFlags::MISSING_VERSION_FOLDER), [version] if Self::get_stock_count(version.base.uuid(), inventory)? == 0 => { Ok(MarketplaceValidationFlags::EMPTY_LISTING) } [_] => Ok(MarketplaceValidationFlags::VALID), _ => Ok(MarketplaceValidationFlags::MULTIPLE_VERSION_FOLDERS), } } } #[derive(Clone, Debug)] pub struct MarketplaceListing { edit_url: Option, last_sync_utc: SystemTime, listing_folder_uuid: UUID, listing_id: i32, status: MarketplaceListingStatus, stock_count: i32, version_folder_uuid: UUID, } impl MarketplaceListing { pub fn new() -> Result { Ok(Self { edit_url: None, last_sync_utc: UNIX_EPOCH, listing_folder_uuid: UUID::zero(), listing_id: 0, status: MarketplaceListingStatus::Unknown, stock_count: 0, version_folder_uuid: UUID::zero(), }) } pub fn edit_url(&self) -> Option { self.edit_url.clone() } pub fn set_edit_url(&mut self, value: Option) { self.edit_url = value; } pub fn last_sync_utc(&self) -> SystemTime { self.last_sync_utc } pub fn set_last_sync_utc(&mut self, value: SystemTime) { self.last_sync_utc = value; } pub fn listing_folder_uuid(&self) -> UUID { self.listing_folder_uuid } pub fn set_listing_folder_uuid(&mut self, value: UUID) { self.listing_folder_uuid = value; } pub fn listing_id(&self) -> i32 { self.listing_id } pub fn set_listing_id(&mut self, value: i32) { self.listing_id = value; } pub fn status(&self) -> MarketplaceListingStatus { self.status } pub fn set_status(&mut self, value: MarketplaceListingStatus) { self.status = value; } pub fn stock_count(&self) -> i32 { self.stock_count } pub fn set_stock_count(&mut self, value: i32) { self.stock_count = value; } pub fn version_folder_uuid(&self) -> UUID { self.version_folder_uuid } pub fn set_version_folder_uuid(&mut self, value: UUID) { self.version_folder_uuid = value; } } #[derive(Clone)] pub struct MarketplaceErrorEventArgs { message: String, exception: Option>, } impl MarketplaceErrorEventArgs { pub fn new(message: String, exception: Option>) -> Result { Ok(Self { message, exception }) } pub fn message(&self) -> String { self.message.clone() } pub fn exception(&self) -> Option> { self.exception.clone() } } #[derive(Clone)] pub struct MarketplaceListingChangedEventArgs { listing_id: i32, listing: Option>, } impl MarketplaceListingChangedEventArgs { pub fn new(listing_id: i32, listing: Option>) -> Result { Ok(Self { listing_id, listing, }) } pub fn listing_id(&self) -> i32 { self.listing_id } pub fn listing(&self) -> Option> { self.listing.clone() } } #[derive(Clone)] pub struct MarketplaceListingsSyncedEventArgs { listings: Vec, } impl MarketplaceListingsSyncedEventArgs { pub fn new(listings: Vec) -> Result { Ok(Self { listings }) } pub fn listings(&self) -> Vec { self.listings.clone() } } enum MarketplaceClient { Standalone(GridClient), ClientOwned(ClientWeakHandle), } struct MarketplaceState { client: RwLock, by_id: RwLock>, by_folder: RwLock>, errors: EventRegistry, changed: EventRegistry, synced: EventRegistry, live_mutations: AtomicBool, } #[derive(Clone)] pub struct MarketplaceManager(Arc); impl MarketplaceManager { pub fn new(client: GridClient) -> Result { Ok(Self::with_client(MarketplaceClient::Standalone(client))) } fn with_client(client: MarketplaceClient) -> Self { Self(Arc::new(MarketplaceState { client: RwLock::new(client), by_id: RwLock::new(HashMap::new()), by_folder: RwLock::new(HashMap::new()), errors: EventRegistry::default(), changed: EventRegistry::default(), synced: EventRegistry::default(), live_mutations: AtomicBool::new(false), })) } pub(crate) fn native_new_client_owned(client: &GridClient) -> Self { Self::with_client(MarketplaceClient::ClientOwned(client.native_weak_handle())) } pub(crate) fn native_bind_to_client(&self, client: &GridClient) { *write(&self.0.client) = MarketplaceClient::ClientOwned(client.native_weak_handle()); } fn client(&self) -> Result { match &*read(&self.0.client) { MarketplaceClient::Standalone(client) => Ok(client.clone()), MarketplaceClient::ClientOwned(client) => { client.upgrade().ok_or(Error::InvalidOperation) } } } /// Explicitly enables mutation requests to non-test capability hosts. pub fn enable_live_mutations(&self) { self.0.live_mutations.store(true, Ordering::Release); } pub fn subscribe_error( &self, handler: EventHandler, ) -> Subscription { self.0.errors.subscribe(handler) } pub fn subscribe_listing_changed( &self, handler: EventHandler, ) -> Subscription { self.0.changed.subscribe(handler) } pub fn subscribe_listings_synced( &self, handler: EventHandler, ) -> Subscription { self.0.synced.subscribe(handler) } pub fn listings_by_id(&self) -> HashMap { read(&self.0.by_id).clone() } pub fn listings_by_folder(&self) -> HashMap { read(&self.0.by_folder).clone() } pub fn try_get_by_id(&self, id: i32, output: &mut Option) -> bool { *output = read(&self.0.by_id).get(&id).cloned(); output.is_some() } pub fn try_get_by_folder(&self, folder: UUID, output: &mut Option) -> bool { *output = read(&self.0.by_folder).get(&folder).cloned(); output.is_some() } fn emit_error(&self, message: impl Into, error: Option<&Error>) { let exception = error.map(|error| Arc::new(ExternalError(error.to_string()))); self.0.errors.emit(MarketplaceErrorEventArgs { message: message.into(), exception, }); } async fn base_uri(&self, token: &CancellationToken) -> Result { token.throw_if_cancellation_requested()?; let simulator = self .client()? .native_network()? .native_current_sim() .ok_or(Error::InvalidOperation)?; if let Some(caps) = simulator.native_caps() && let Some(uri) = caps.capability_uri("DirectDelivery".into())? { return Ok(uri); } // Seed discovery runs independently of callers. If it has not installed // its map yet, resolve the one required capability through the same // bounded, injected HTTP client instead of racing the background task. let seed = simulator.seed_capability().ok_or(Error::InvalidOperation)?; let simulator_client = simulator.native_data_arc().client.clone(); let (response, body) = simulator_client .native_http_caps_client() .get(seed, token.clone(), None) .await?; if !(200..300).contains(&response.status_code) { return Err(Error::InvalidOperation); } let OSD::Map(map) = OSDParser::deserialize_with_bytes(body)? else { return Err(Error::InvalidOperation); }; map.get("DirectDelivery") .and_then(|value| value.as_uri().ok().flatten()) .ok_or(Error::InvalidOperation) } async fn request( &self, method: &str, path: &str, payload: Vec, token: CancellationToken, mutation: bool, ) -> Result<(HttpResponse, Vec), Error> { let base = self.base_uri(&token).await?; if mutation && !crate::is_offline_fixture_uri(&base) && !self.0.live_mutations.load(Ordering::Acquire) { return Err(Error::InvalidOperation); } let uri = Uri(format!("{}/{}", base.0.trim_end_matches('/'), path)); let client = self.client()?.native_http_caps_client(); match method { "GET" => client.get(uri, token, None).await, "POST" => { client .post_with_uri_string_bytes_cancellation_token_i_progress( uri, JSON_CONTENT_TYPE.into(), payload, token, None, ) .await } "PUT" => { client .put_with_uri_string_bytes_cancellation_token_i_progress( uri, JSON_CONTENT_TYPE.into(), payload, token, None, ) .await } "DELETE" => { client .delete_with_uri_string_bytes_cancellation_token_i_progress( uri, JSON_CONTENT_TYPE.into(), payload, token, None, ) .await } _ => Err(Error::Argument), } } fn parse_listings(bytes: &[u8]) -> Result, Error> { let root: Value = serde_json::from_slice(bytes).map_err(|error| Error::Parse { position: error.column(), context: "marketplace JSON", })?; let array = root .get("listings") .and_then(Value::as_array) .ok_or(Error::Parse { position: 0, context: "marketplace listings array", })?; array.iter().map(Self::parse_listing).collect() } fn parse_listing(value: &Value) -> Result { let mut listing = MarketplaceListing::new()?; listing.listing_id = value .get("id") .and_then(Value::as_i64) .and_then(|id| i32::try_from(id).ok()) .unwrap_or_default(); listing.status = match value.get("is_listed").and_then(Value::as_bool) { Some(true) => MarketplaceListingStatus::Listed, Some(false) => MarketplaceListingStatus::Unlisted, None => MarketplaceListingStatus::Unknown, }; listing.edit_url = value .get("edit_url") .and_then(Value::as_str) .map(str::to_owned); if let Some(info) = value.get("inventory_info") { listing.listing_folder_uuid = parse_uuid(info.get("listing_folder_id")); listing.version_folder_uuid = parse_uuid(info.get("version_folder_id")); listing.stock_count = info .get("count_on_hand") .and_then(Value::as_i64) .and_then(|count| i32::try_from(count).ok()) .unwrap_or_default(); } listing.last_sync_utc = SystemTime::now(); Ok(listing) } fn replace_cache(&self, listings: &[MarketplaceListing]) { let by_id = listings .iter() .cloned() .map(|listing| (listing.listing_id, listing)) .collect(); let by_folder = listings .iter() .filter(|listing| listing.listing_folder_uuid != UUID::zero()) .cloned() .map(|listing| (listing.listing_folder_uuid, listing)) .collect(); *write(&self.0.by_id) = by_id; *write(&self.0.by_folder) = by_folder; } fn cache_listing(&self, listing: MarketplaceListing) { write(&self.0.by_id).insert(listing.listing_id, listing.clone()); if listing.listing_folder_uuid != UUID::zero() { write(&self.0.by_folder).insert(listing.listing_folder_uuid, listing); } } pub async fn fetch_listings(&self, ct: Option) -> Result<(), Error> { let token = ct.unwrap_or_default(); let response = self .request("GET", "listings", Vec::new(), token, false) .await; let (response, body) = match response { Ok(value) => value, Err(Error::Cancelled) => return Err(Error::Cancelled), Err(error) => { self.emit_error("Failed to fetch marketplace listings", Some(&error)); return Ok(()); } }; if !(200..300).contains(&response.status_code) { self.emit_error( format!( "Marketplace listings request returned HTTP {}", response.status_code ), None, ); return Ok(()); } match Self::parse_listings(&body) { Ok(listings) => { self.replace_cache(&listings); self.0 .synced .emit(MarketplaceListingsSyncedEventArgs { listings }); } Err(error) => self.emit_error("Invalid marketplace listings response", Some(&error)), } Ok(()) } pub async fn create_listing( &self, folder: UUID, version: UUID, name: String, count: Option, ct: Option, ) -> Result, Error> { let body = json!({"inventory_info": {"listing_folder_id": folder.to_string(), "version_folder_id": version.to_string(), "count_on_hand": count, "name": name}}); let token = ct.unwrap_or_default(); let result = self .request( "POST", "listings", serde_json::to_vec(&body).map_err(|_| Error::InvalidOperation)?, token, true, ) .await; let Some(listing) = self.handle_listing_response(result, "create marketplace listing")? else { return Ok(None); }; self.cache_listing(listing.clone()); self.0.changed.emit(MarketplaceListingChangedEventArgs { listing_id: listing.listing_id, listing: Some(Arc::new(listing.clone())), }); Ok(Some(listing)) } pub async fn delete_listing( &self, id: i32, ct: Option, ) -> Result { let token = ct.unwrap_or_default(); let result = self .request("DELETE", &format!("listing/{id}"), Vec::new(), token, true) .await; let (response, _) = match result { Ok(value) => value, Err(Error::Cancelled) => return Err(Error::Cancelled), Err(error) => { self.emit_error("Failed to delete marketplace listing", Some(&error)); return Ok(false); } }; if !(200..300).contains(&response.status_code) { self.emit_error( format!("Marketplace delete returned HTTP {}", response.status_code), None, ); return Ok(false); } if let Some(old) = write(&self.0.by_id).remove(&id) { write(&self.0.by_folder).remove(&old.listing_folder_uuid); } self.0.changed.emit(MarketplaceListingChangedEventArgs { listing_id: id, listing: None, }); Ok(true) } pub async fn activate_listing( &self, id: i32, ct: Option, ) -> Result { self.set_listing_active(id, true, ct).await } pub async fn deactivate_listing( &self, id: i32, ct: Option, ) -> Result { self.set_listing_active(id, false, ct).await } async fn set_listing_active( &self, id: i32, active: bool, ct: Option, ) -> Result { let Some(cached) = read(&self.0.by_id).get(&id).cloned() else { return Ok(false); }; let body = json!({"id": id, "is_listed": active, "inventory_info": {"listing_folder_id": cached.listing_folder_uuid.to_string(), "version_folder_id": cached.version_folder_uuid.to_string(), "count_on_hand": cached.stock_count}}); let result = self .request( "PUT", &format!("listing/{id}"), serde_json::to_vec(&body).map_err(|_| Error::InvalidOperation)?, ct.unwrap_or_default(), true, ) .await; let Some(listing) = self.handle_listing_response(result, "update marketplace listing")? else { return Ok(false); }; self.cache_listing(listing.clone()); self.0.changed.emit(MarketplaceListingChangedEventArgs { listing_id: id, listing: Some(Arc::new(listing)), }); Ok(true) } fn handle_listing_response( &self, result: Result<(HttpResponse, Vec), Error>, operation: &str, ) -> Result, Error> { let (response, body) = match result { Ok(value) => value, Err(Error::Cancelled) => return Err(Error::Cancelled), Err(error) => { self.emit_error(format!("Failed to {operation}"), Some(&error)); return Ok(None); } }; if !(200..300).contains(&response.status_code) { self.emit_error( format!( "Marketplace operation returned HTTP {}", response.status_code ), None, ); return Ok(None); } match Self::parse_listings(&body) .and_then(|mut listings| listings.drain(..).next().ok_or(Error::InvalidOperation)) { Ok(listing) => Ok(Some(listing)), Err(error) => { self.emit_error("Invalid marketplace listing response", Some(&error)); Ok(None) } } } } fn parse_uuid(value: Option<&Value>) -> UUID { value .and_then(Value::as_str) .and_then(|value| UUID::new_with_string(value.to_owned()).ok()) .unwrap_or_else(UUID::zero) }