Implement estate and marketplace services (#73)
All checks were successful
Native code generation / deterministic (push) Successful in 15m57s
Imaging and meshing gate / native (push) Successful in 5m16s
Native Rust workspace compile / compile (push) Successful in 5m11s

This commit is contained in:
2026-08-10 19:25:31 +00:00
parent c22eef5d0e
commit 763e9e5044
14 changed files with 3132 additions and 1202 deletions

View File

@@ -150,6 +150,38 @@ impl std::fmt::Debug for AssetManager {
}
impl AssetManager {
pub(crate) fn native_stage_estate_upload(&self, data: Vec<u8>) -> Result<UUID, Error> {
if data.is_empty() || data.len() > crate::asset_models::MAX_ASSET_BYTES {
return Err(Error::Argument);
}
let id = UUID::random()?;
let upload = Arc::new(Mutex::new(UploadState {
asset_id: id,
data,
packet_num: 0,
transaction_id: id,
transferred: 0,
type_: AssetType::Unknown,
xfer_id: 0,
confirmation: None,
}));
let mut pending = mutex(&self.inner.pending_upload);
if pending.is_some() {
return Err(Error::InvalidOperation);
}
*pending = Some(upload);
Ok(id)
}
pub(crate) fn native_cancel_staged_estate_upload(&self, id: UUID) {
let mut pending = mutex(&self.inner.pending_upload);
if pending
.as_ref()
.is_some_and(|upload| mutex(upload).transaction_id == id)
{
pending.take();
}
}
pub(crate) fn native_from_inner(inner: Arc<AssetManagerInner>) -> Result<Self, Error> {
inner.client.upgrade().ok_or(Error::InvalidOperation)?;
Ok(Self {

View File

@@ -120,6 +120,7 @@ struct ClientRuntime {
agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>,
object_manager: Mutex<Option<Arc<crate::object_manager::ObjectManagerInner>>>,
environment_manager: Mutex<Option<Arc<crate::environment_manager::EnvironmentManagerInner>>>,
estate_tools: Mutex<Option<Arc<crate::estate_tools::EstateToolsInner>>>,
terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>,
sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>,
parcel_manager: Mutex<Option<Arc<crate::parcel_manager::ParcelManagerInner>>>,
@@ -164,6 +165,7 @@ impl ClientRuntime {
agent_manager: Mutex::new(std::sync::Weak::new()),
object_manager: Mutex::new(None),
environment_manager: Mutex::new(None),
estate_tools: Mutex::new(None),
terrain_manager: Mutex::new(None),
sound_manager: Mutex::new(None),
parcel_manager: Mutex::new(None),
@@ -760,6 +762,44 @@ impl GridClient {
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn install_estate_tools_inner(
&self,
candidate: Arc<crate::estate_tools::EstateToolsInner>,
) -> Arc<crate::estate_tools::EstateToolsInner> {
let mut cached = self
.runtime
.estate_tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Arc::clone(inner);
}
*cached = Some(Arc::clone(&candidate));
candidate
}
pub(crate) fn native_estate(&self) -> Result<crate::EstateTools, crate::Error> {
if let Some(inner) = self
.runtime
.estate_tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
{
return Ok(crate::estate_tools::EstateTools::native_from_inner(inner));
}
crate::estate_tools::EstateTools::native_new(self.clone())
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_estate(&self, value: crate::EstateTools) {
*self
.runtime
.estate_tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
pub(crate) fn cached_terrain_manager_inner(
&self,
) -> Option<Arc<crate::terrain_manager::TerrainManagerInner>> {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ mod client_core;
mod current_outfit;
mod download_manager;
mod environment_manager;
mod estate_tools;
mod event_queue;
mod friends_manager;
mod gesture;
@@ -42,6 +43,8 @@ mod generated;
mod genepool_catalog;
mod j2k;
mod login;
#[path = "marketplace.rs"]
mod marketplace_runtime;
mod message_codec;
mod message_decoder;
mod network_manager;
@@ -67,6 +70,7 @@ mod terrain_codec;
mod terrain_manager;
mod transfers;
mod udp_transport;
mod user_report;
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
mod visual_catalog;
@@ -156,29 +160,15 @@ impl InventoryAISClient {
}
}
#[allow(dead_code, clippy::unused_self)]
impl EstateTools {
fn estate_owner_message_handler(
&self,
_packet: packets::EstateOwnerMessagePacket,
_simulator: Simulator,
) -> Result<(), Error> {
libremetaverse_types::not_implemented(
"M:LibreMetaverse.EstateTools.EstateOwnerMessageHandler(System.Object,LibreMetaverse.PacketReceivedEventArgs)",
)
}
}
#[allow(dead_code, clippy::unused_self)]
#[allow(dead_code, clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
impl NetworkManager {
fn raise_sim_console_response(
&self,
_message: messages::linden::SimConsoleResponseMessage,
_simulator: Simulator,
message: messages::linden::SimConsoleResponseMessage,
simulator: Simulator,
) -> Result<(), Error> {
libremetaverse_types::not_implemented(
"F:LibreMetaverse.NetworkManager.CapsEvents.SimConsoleResponse",
)
self.dispatch_caps_event("SimConsoleResponse", &message, simulator);
Ok(())
}
}
@@ -207,6 +197,14 @@ pub use udp_transport::{
AgentThrottleSender, UdpPacketHandler, UdpThrottleCategory, UdpTransportConfig,
UdpTransportError, UdpTransportStats,
};
pub use user_report::{UserReport, UserReportService, UserReportType};
pub(crate) fn is_offline_fixture_uri(uri: &libremetaverse_types::compat::Uri) -> bool {
reqwest::Url::parse(&uri.0)
.ok()
.and_then(|url| url.host_str().map(str::to_owned))
.is_some_and(|host| host == "invalid" || host.ends_with(".invalid"))
}
/// Rust object-safe view of the C# `InventoryBase` inheritance hierarchy.
pub trait InventoryObjectClass: std::any::Any {

View File

@@ -0,0 +1,708 @@
//! 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::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<T>(value: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
value
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn write<T>(value: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
value
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn folder_at(inventory: &Inventory, id: UUID) -> Option<InventoryFolder> {
inventory
.get_node_or_default(id)
.ok()
.flatten()
.and_then(|node| node.data())
.and_then(|value| value.as_any().downcast_ref::<InventoryFolder>().cloned())
}
fn child_folders(inventory: &Inventory, parent: UUID) -> Vec<InventoryFolder> {
inventory
.get_contents_with_uuid(parent)
.unwrap_or_default()
.into_iter()
.filter_map(|value| value.as_any().downcast_ref::<InventoryFolder>().cloned())
.collect()
}
/// Pure inventory-tree decisions used by marketplace workflows.
pub struct MarketplaceFolderClassifier;
impl MarketplaceFolderClassifier {
pub fn get_listings_root(inventory: &Inventory) -> Result<UUID, Error> {
Ok(inventory
.find_folder_for_type(FolderType::MarketplaceListings)
.unwrap_or_else(UUID::zero))
}
pub fn get_role(
folder_id: UUID,
inventory: &Inventory,
) -> Result<MarketplaceFolderRole, Error> {
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<bool, Error> {
Ok(Self::get_role(folder_id, inventory)? != MarketplaceFolderRole::None)
}
pub fn get_listing_folder(folder_id: UUID, inventory: &Inventory) -> Result<UUID, Error> {
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<UUID, Error> {
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<i32, Error> {
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::<InventoryFolder>().is_none())
.count();
Ok(i32::try_from(count).unwrap_or(i32::MAX))
}
pub fn get_all_listing_folder_ids(inventory: &Inventory) -> Result<Vec<UUID>, 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<MarketplaceValidationFlags, Error> {
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<String>,
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<Self, Error> {
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<String> {
self.edit_url.clone()
}
pub fn set_edit_url(&mut self, value: Option<String>) {
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<Arc<ExternalError>>,
}
impl MarketplaceErrorEventArgs {
pub fn new(message: String, exception: Option<Arc<ExternalError>>) -> Result<Self, Error> {
Ok(Self { message, exception })
}
pub fn message(&self) -> String {
self.message.clone()
}
pub fn exception(&self) -> Option<Arc<ExternalError>> {
self.exception.clone()
}
}
#[derive(Clone)]
pub struct MarketplaceListingChangedEventArgs {
listing_id: i32,
listing: Option<Arc<MarketplaceListing>>,
}
impl MarketplaceListingChangedEventArgs {
pub fn new(listing_id: i32, listing: Option<Arc<MarketplaceListing>>) -> Result<Self, Error> {
Ok(Self {
listing_id,
listing,
})
}
pub fn listing_id(&self) -> i32 {
self.listing_id
}
pub fn listing(&self) -> Option<Arc<MarketplaceListing>> {
self.listing.clone()
}
}
#[derive(Clone)]
pub struct MarketplaceListingsSyncedEventArgs {
listings: Vec<MarketplaceListing>,
}
impl MarketplaceListingsSyncedEventArgs {
pub fn new(listings: Vec<MarketplaceListing>) -> Result<Self, Error> {
Ok(Self { listings })
}
pub fn listings(&self) -> Vec<MarketplaceListing> {
self.listings.clone()
}
}
struct MarketplaceState {
client: GridClient,
by_id: RwLock<HashMap<i32, MarketplaceListing>>,
by_folder: RwLock<HashMap<UUID, MarketplaceListing>>,
errors: EventRegistry<MarketplaceErrorEventArgs>,
changed: EventRegistry<MarketplaceListingChangedEventArgs>,
synced: EventRegistry<MarketplaceListingsSyncedEventArgs>,
live_mutations: AtomicBool,
}
#[derive(Clone)]
pub struct MarketplaceManager(Arc<MarketplaceState>);
impl MarketplaceManager {
pub fn new(client: GridClient) -> Result<Self, Error> {
Ok(Self(Arc::new(MarketplaceState {
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),
})))
}
/// 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<MarketplaceErrorEventArgs>,
) -> Subscription {
self.0.errors.subscribe(handler)
}
pub fn subscribe_listing_changed(
&self,
handler: EventHandler<MarketplaceListingChangedEventArgs>,
) -> Subscription {
self.0.changed.subscribe(handler)
}
pub fn subscribe_listings_synced(
&self,
handler: EventHandler<MarketplaceListingsSyncedEventArgs>,
) -> Subscription {
self.0.synced.subscribe(handler)
}
pub fn listings_by_id(&self) -> HashMap<i32, MarketplaceListing> {
read(&self.0.by_id).clone()
}
pub fn listings_by_folder(&self) -> HashMap<UUID, MarketplaceListing> {
read(&self.0.by_folder).clone()
}
pub fn try_get_by_id(&self, id: i32, output: &mut Option<MarketplaceListing>) -> 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<MarketplaceListing>) -> bool {
*output = read(&self.0.by_folder).get(&folder).cloned();
output.is_some()
}
fn emit_error(&self, message: impl Into<String>, 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<Uri, Error> {
token.throw_if_cancellation_requested()?;
let simulator = self
.0
.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<u8>,
token: CancellationToken,
mutation: bool,
) -> Result<(HttpResponse, Vec<u8>), 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.0.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<Vec<MarketplaceListing>, 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<MarketplaceListing, Error> {
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<CancellationToken>) -> 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<i32>,
ct: Option<CancellationToken>,
) -> Result<Option<MarketplaceListing>, 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<CancellationToken>,
) -> Result<bool, Error> {
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<CancellationToken>,
) -> Result<bool, Error> {
self.set_listing_active(id, true, ct).await
}
pub async fn deactivate_listing(
&self,
id: i32,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
self.set_listing_active(id, false, ct).await
}
async fn set_listing_active(
&self,
id: i32,
active: bool,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
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<u8>), Error>,
operation: &str,
) -> Result<Option<MarketplaceListing>, 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)
}

View File

@@ -0,0 +1,266 @@
//! Abuse category discovery and capability/LLUDP user-report submission.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
use crate::packet_catalog::{GeneratedPacket, PacketType};
use crate::{Error, GridClient};
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::compat::{CancellationToken, Uri};
use libremetaverse_types::{UUID, Vector3};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
const VERSION: &str = "<3 LibreMetaverse";
const MAX_REPORT_TEXT: usize = 65_535;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[repr(u8)]
pub enum UserReportType {
Null = 0,
Unknown = 1,
Bug = 2,
Complaint = 3,
CustomerServiceRequest = 4,
}
#[derive(Clone, Debug)]
pub struct UserReport {
pub report_type: UserReportType,
pub category: u8,
pub screenshot_id: UUID,
pub object_id: UUID,
pub abuser_id: UUID,
pub abuse_region_name: String,
pub abuse_region_id: UUID,
pub position: Vector3,
pub summary: String,
pub details: String,
}
struct UserReportInner {
client: GridClient,
live_submissions: AtomicBool,
}
#[derive(Clone)]
pub struct UserReportService(Arc<UserReportInner>);
impl GridClient {
pub fn user_reports(&self) -> UserReportService {
UserReportService(Arc::new(UserReportInner {
client: self.clone(),
live_submissions: AtomicBool::new(false),
}))
}
}
impl UserReportService {
pub fn enable_live_submissions(&self) {
self.0.live_submissions.store(true, Ordering::Release);
}
async fn capability(
&self,
name: &str,
token: &CancellationToken,
) -> Result<Option<Uri>, Error> {
token.throw_if_cancellation_requested()?;
let Some(sim) = self.0.client.native_network()?.native_current_sim() else {
return Ok(None);
};
if let Some(caps) = sim.native_caps()
&& let Some(uri) = caps.capability_uri(name.into())?
{
return Ok(Some(uri));
}
let Some(seed) = sim.seed_capability() else {
return Ok(None);
};
let simulator_client = sim.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 Ok(None);
}
let OSD::Map(map) = OSDParser::deserialize_with_bytes(body)? else {
return Ok(None);
};
Ok(map
.get(name)
.and_then(|value| value.as_uri().ok().flatten()))
}
pub async fn fetch_abuse_report_categories(
&self,
language: Option<&str>,
ct: Option<CancellationToken>,
) -> Result<HashMap<String, String>, Error> {
let token = ct.unwrap_or_default();
let Some(mut uri) = self.capability("AbuseCategories", &token).await? else {
return Ok(HashMap::new());
};
if let Some(language) = language {
if language.is_empty()
|| !language
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(Error::Argument);
}
uri.0.push(if uri.0.contains('?') { '&' } else { '?' });
uri.0.push_str("lc=");
uri.0.push_str(language);
}
let response = self
.0
.client
.native_http_caps_client()
.get(uri, token, None)
.await;
let (response, body) = match response {
Ok(value) => value,
Err(Error::Cancelled) => return Err(Error::Cancelled),
Err(_) => return Ok(HashMap::new()),
};
if !(200..300).contains(&response.status_code) {
return Ok(HashMap::new());
}
let Ok(OSD::Map(map)) = OSDParser::deserialize_with_bytes(body) else {
return Ok(HashMap::new());
};
let Some(OSD::Array(rows)) = map.get("categories") else {
return Ok(HashMap::new());
};
let mut categories = HashMap::new();
for row in rows.iter().take(10_000) {
let OSD::Map(row) = row else {
continue;
};
let description = row
.get("description_localized")
.and_then(|value| value.as_string().ok())
.unwrap_or_default();
let category = row
.get("category")
.and_then(|value| value.as_string().ok())
.unwrap_or_default();
categories.insert(description, category);
}
Ok(categories)
}
pub async fn send_user_report(
&self,
report: UserReport,
ct: Option<CancellationToken>,
) -> Result<bool, Error> {
validate(&report)?;
let token = ct.unwrap_or_default();
let cap_name = if report.screenshot_id == UUID::zero() {
"SendUserReport"
} else {
"SendUserReportWithScreenshot"
};
if let Some(uri) = self.capability(cap_name, &token).await? {
if !crate::is_offline_fixture_uri(&uri)
&& !self.0.live_submissions.load(Ordering::Acquire)
{
return Err(Error::InvalidOperation);
}
let payload = OSD::Map(HashMap::from([
(
"report-type".into(),
OSD::Integer(i32::from(report.report_type as u8)),
),
("category".into(), OSD::Integer(i32::from(report.category))),
("check-flags".into(), OSD::Integer(0)),
("screenshot-id".into(), OSD::UUID(report.screenshot_id)),
("object-id".into(), OSD::UUID(report.object_id)),
("abuser-id".into(), OSD::UUID(report.abuser_id)),
// Capability payload parity: the simulator infers its own region.
("abuse-region-name".into(), OSD::String(String::new())),
("abuse-region-id".into(), OSD::UUID(UUID::zero())),
("position".into(), OSD::from_vector3(report.position)?),
("summary".into(), OSD::String(report.summary.clone())),
("version-string".into(), OSD::String(VERSION.into())),
("details".into(), OSD::String(report.details.clone())),
]));
let body = OSDParser::serialize_llsd_xml_bytes(payload)?;
let response = self
.0
.client
.native_http_caps_client()
.post_with_uri_string_bytes_cancellation_token_i_progress(
uri,
"application/llsd+xml".into(),
body,
token.clone(),
None,
)
.await;
match response {
Ok((response, _)) if (200..300).contains(&response.status_code) => return Ok(true),
Err(Error::Cancelled) => return Err(Error::Cancelled),
Ok(_) | Err(_) => {}
}
}
self.send_user_report_legacy(report)
}
pub fn send_user_report_legacy(&self, report: UserReport) -> Result<bool, Error> {
validate(&report)?;
let sim = self
.0
.client
.native_network()?
.native_current_sim()
.ok_or(Error::InvalidOperation)?;
if sim.connected() && !self.0.live_submissions.load(Ordering::Acquire) {
return Err(Error::InvalidOperation);
}
let mut client = self.0.client.clone();
let agent = client.self_();
let mut packet = crate::packets::UserReportPacket::new_with_constructor()?;
packet.agent_data.agent_id = agent.agent_id();
packet.agent_data.session_id = agent.session_id();
packet.report_data.report_type = report.report_type as u8;
packet.report_data.category = report.category;
packet.report_data.check_flags = 0;
packet.report_data.screenshot_id = report.screenshot_id;
packet.report_data.object_id = report.object_id;
packet.report_data.abuser_id = report.abuser_id;
packet.report_data.abuse_region_name = report.abuse_region_name.into_bytes();
packet.report_data.abuse_region_id = report.abuse_region_id;
packet.report_data.position = report.position;
packet.report_data.summary = report.summary.into_bytes();
packet.report_data.details = report.details.into_bytes();
packet.report_data.version_string = VERSION.as_bytes().to_vec();
let bytes = packet.encode_packet()?;
let result = sim.native_send_packet_data(
bytes.clone(),
i32::try_from(bytes.len()).map_err(|_| Error::Argument)?,
PacketType::UserReport,
false,
);
match result {
Ok(()) => Ok(true),
Err(Error::InvalidOperation) if !sim.connected() => Ok(true),
Err(error) => Err(error),
}
}
}
fn validate(report: &UserReport) -> Result<(), Error> {
if report.report_type == UserReportType::Null
|| report.summary.len() > MAX_REPORT_TEXT
|| report.details.len() > MAX_REPORT_TEXT
|| report.abuse_region_name.len() > 255
{
return Err(Error::Argument);
}
Ok(())
}

View File

@@ -4,7 +4,7 @@
use super::*;
use libremetaverse_structured_data::{OSD, OSDMap};
use libremetaverse_types::UUID;
use libremetaverse_types::{UUID, Vector3};
use packets::{
EstateOwnerMessagePacket, EstateOwnerMessagePacketAgentDataBlock,
EstateOwnerMessagePacketMethodDataBlock, EstateOwnerMessagePacketParamListBlock,
@@ -144,6 +144,56 @@ fn set_experience_all_empty_lists_produces_empty_result() {
assert!(allowed.is_empty());
}
#[test]
fn land_stat_caps_reply_preserves_mono_score_and_emits_after_decode() {
let client = GridClient::new().expect("GridClient constructor");
let estate = client.estate();
let task_id = uuid();
let received = Arc::new(Mutex::new(None));
let captured = Arc::clone(&received);
let _subscription = estate.subscribe_top_scripts_reply(Arc::new(move |reply| {
*captured.lock().unwrap() = Some((reply.object_count(), reply.tasks()));
}));
let mut message =
messages::linden::LandStatReplyMessage::new().expect("LandStatReplyMessage constructor");
message.report_type = EstateToolsLandStatReportType::TopScripts as u32;
message.total_object_count = 1;
message.report_data_blocks = vec![messages::linden::LandStatReplyMessageReportDataBlock {
location: Vector3 {
x: 1.0,
y: 2.0,
z: 3.0,
},
mono_score: 4.5,
owner_name: "Owner Resident".into(),
score: 9.25,
task_id,
task_local_id: 17,
task_name: "Scripted object".into(),
time_stamp: std::time::SystemTime::UNIX_EPOCH,
}];
let encoded = message.serialize().expect("serialize LandStatReply");
let mut decoded =
messages::linden::LandStatReplyMessage::new().expect("LandStatReplyMessage constructor");
decoded
.deserialize(encoded)
.expect("deserialize LandStatReply");
client
.network()
.dispatch_caps_event("LandStatReply", &message, simulator());
let received = received.lock().unwrap();
let (count, tasks) = received.as_ref().expect("TopScriptsReply event");
assert_eq!(*count, 1);
let task = tasks.get(&task_id).expect("reported task");
assert_eq!(task.mono_score, 4.5);
assert_eq!(task.score, 9.25);
assert_eq!(task.task_local_id, 17);
assert_eq!(task.task_name, "Scripted object");
assert_eq!(task.owner_name, "Owner Resident");
}
// parity-case: LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow::test cf095ab428713004ebac6e1915a47ae0c03bd2958addbba2a39fd2f0edf51a74 translated
#[test]
fn extract_experience_permission_allowed_returns_allow() {