//! `ModifyMaterialParams` capability integration. #![allow( clippy::missing_errors_doc, clippy::must_use_candidate, clippy::option_option )] use crate::assets::AssetMaterial; use crate::{Error, GridClient, MediaEntry, Simulator}; use flate2::Compression; use flate2::read::ZlibDecoder; use flate2::write::ZlibEncoder; use libremetaverse_structured_data::{OSD, OSDParser}; use libremetaverse_types::{UUID, compat::CancellationToken}; use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::Arc; use std::time::{Duration, Instant}; #[derive(Clone, Debug)] pub struct ObjectMediaEventArgs { face_media: Vec, success: bool, version: String, } impl ObjectMediaEventArgs { pub fn new(success: bool, version: String, face_media: Vec) -> Result { if face_media.len() > 45 || version.len() > 16 * 1024 { return Err(Error::Argument); } Ok(Self { face_media, success, version, }) } pub fn face_media(&self) -> Vec { self.face_media.clone() } pub fn set_face_media(&mut self, value: Vec) { self.face_media = value; } pub const fn success(&self) -> bool { self.success } pub fn set_success(&mut self, value: bool) { self.success = value; } pub fn version(&self) -> String { self.version.clone() } pub fn set_version(&mut self, value: String) { self.version = value; } } #[derive(Clone, Debug)] pub struct GLTFMaterialUpdate { asset_id: Option>, object_id: UUID, override_: Option, side: i32, } impl GLTFMaterialUpdate { pub fn new() -> Result { Ok(Self { asset_id: None, object_id: UUID::zero(), override_: None, side: 0, }) } pub fn asset_id(&self) -> Option> { self.asset_id } pub fn set_asset_id(&mut self, value: Option>) { self.asset_id = value; } pub const fn object_id(&self) -> UUID { self.object_id } pub fn set_object_id(&mut self, value: UUID) { self.object_id = value; } pub fn override_(&self) -> Option { self.override_.clone() } pub fn set_override_(&mut self, value: Option) { self.override_ = value; } pub const fn side(&self) -> i32 { self.side } pub fn set_side(&mut self, value: i32) { self.side = value; } } pub struct ObjectManager { pub(crate) inner: Arc, } impl ObjectManager { pub(crate) fn native_new(client: Option>) -> Result { crate::object_manager::ObjectManagerInner::new(client) } fn client(&self) -> Result { self.inner.client() } fn capability( sim: &Simulator, name: &str, ) -> Result, Error> { sim.native_caps() .map(|caps| caps.capability_uri(name.to_owned())) .transpose() .map(Option::flatten) } async fn post_llsd( &self, uri: libremetaverse_types::compat::Uri, body: OSD, cancellation_token: Option, ) -> Result<(bool, Vec), Error> { let payload = OSDParser::serialize_llsd_xml_bytes(body)?; let (response, bytes) = self .client()? .native_http_caps_client() .post_with_uri_string_bytes_cancellation_token_i_progress( uri, "application/llsd+xml".into(), payload, cancellation_token.unwrap_or_default(), None, ) .await?; Ok(((200..300).contains(&response.status_code), bytes)) } pub(crate) async fn native_navigate_object_media( &self, prim_id: UUID, face: i32, new_url: String, sim: Simulator, cancellation_token: Option, ) -> Result<(), Error> { if !(0..45).contains(&face) || new_url.len() > 16 * 1024 { return Err(Error::Argument); } let Some(uri) = Self::capability(&sim, "ObjectMediaNavigate")? else { return Err(Error::InvalidOperation); }; let (success, _) = self .post_llsd( uri, OSD::Map(HashMap::from([ ("current_url".into(), OSD::String(new_url)), ("object_id".into(), OSD::UUID(prim_id)), ("texture_index".into(), OSD::Integer(face)), ])), cancellation_token, ) .await?; if success { Ok(()) } else { Err(Error::HttpRequest) } } pub(crate) async fn native_update_object_media( &self, prim_id: UUID, face_media: Vec, sim: Simulator, cancellation_token: Option, ) -> Result<(), Error> { if face_media.len() > 45 { return Err(Error::Argument); } let Some(uri) = Self::capability(&sim, "ObjectMedia")? else { return Err(Error::InvalidOperation); }; let media = face_media .iter() .map(|entry| { if entry.is_undefined() { Ok(OSD::Undefined) } else { Ok(OSD::Map(entry.get_osd()?.snapshot())) } }) .collect::, Error>>()?; let (success, _) = self .post_llsd( uri, OSD::Map(HashMap::from([ ("object_id".into(), OSD::UUID(prim_id)), ("object_media_data".into(), OSD::Array(media)), ("verb".into(), OSD::String("UPDATE".into())), ])), cancellation_token, ) .await?; if !success { return Err(Error::HttpRequest); } if let Some(local_id) = sim .global_to_local_id .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&prim_id) .copied() { let mut objects = sim .objects_primitives .write() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(prim) = objects.get_mut(&local_id) { prim.face_media = face_media; } } Ok(()) } pub(crate) async fn native_request_object_media( &self, prim_id: UUID, sim: Simulator, cancellation_token: Option, ) -> Result<(bool, String, Option>), Error> { let Some(uri) = Self::capability(&sim, "ObjectMedia")? else { return Ok((false, String::new(), None)); }; let (success, bytes) = self .post_llsd( uri, OSD::Map(HashMap::from([ ("object_id".into(), OSD::UUID(prim_id)), ("verb".into(), OSD::String("GET".into())), ])), cancellation_token, ) .await?; if !success || bytes.is_empty() || bytes.len() > 16 * 1024 * 1024 { return Ok((false, String::new(), None)); } let OSD::Map(map) = OSDParser::deserialize_with_bytes(bytes)? else { return Ok((false, String::new(), None)); }; let version = map .get("object_media_version") .map(OSD::as_string) .transpose()? .unwrap_or_default(); let media = match map.get("object_media_data") { Some(OSD::Array(values)) if values.len() <= 45 => Some( values .iter() .cloned() .map(MediaEntry::from_osd) .collect::, Error>>()?, ), Some(_) => return Err(Error::Argument), None => None, }; if let Some(local_id) = sim .global_to_local_id .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&prim_id) .copied() { let mut objects = sim .objects_primitives .write() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(prim) = objects.get_mut(&local_id) { prim.media_version.clone_from(&version); prim.face_media = media.clone().unwrap_or_default(); } } Ok((true, version, media)) } fn decode_legacy_materials( bytes: Vec, ) -> Result, Error> { if bytes.is_empty() || bytes.len() > 16 * 1024 * 1024 { return Ok(Vec::new()); } let OSD::Map(map) = OSDParser::deserialize_with_bytes(bytes)? else { return Err(Error::Argument); }; let compressed = map.get("Zipped").ok_or(Error::Argument)?.as_binary()?; let mut binary = Vec::new(); ZlibDecoder::new(compressed.as_slice()) .take(16 * 1024 * 1024 + 1) .read_to_end(&mut binary) .map_err(|_| Error::Argument)?; if binary.len() > 16 * 1024 * 1024 { return Err(Error::Argument); } let decoded = OSDParser::deserialize_llsd_binary_with_bytes(binary)?; let values = match decoded { OSD::Array(values) => values, OSD::Map(values) => values .get("material_data") .and_then(|value| match value { OSD::Array(values) => Some(values.clone()), _ => None, }) .unwrap_or_default(), _ => return Err(Error::Argument), }; if values.len() > 65_535 { return Err(Error::Argument); } values .into_iter() .map(|entry| match entry { OSD::Map(map) => crate::materials::LegacyMaterial::new_with_osd_map( libremetaverse_structured_data::OSDMap::new_with_dictionary(map)?, ), _ => Err(Error::Argument), }) .collect() } pub(crate) async fn native_request_materials( &self, sim: Simulator, materials: Option>, cancellation_token: Option, ) -> Result>, Error> { let Some(uri) = Self::capability(&sim, "RenderMaterials")? else { return Ok(Box::new(std::iter::empty())); }; let token = cancellation_token.unwrap_or_default(); let (response, bytes) = if let Some(materials) = materials { if materials.is_empty() || materials.len() > 65_535 { return Err(Error::Argument); } let binary = OSDParser::serialize_llsd_binary_with_osd(OSD::Array( materials.into_iter().map(OSD::UUID).collect(), ))?; let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best()); encoder.write_all(&binary).map_err(|_| Error::Argument)?; let zipped = encoder.finish().map_err(|_| Error::Argument)?; let body = OSD::Map(HashMap::from([("Zipped".into(), OSD::Binary(zipped))])); let payload = OSDParser::serialize_llsd_xml_bytes(body)?; self.client()? .native_http_caps_client() .post_with_uri_string_bytes_cancellation_token_i_progress( uri, "application/llsd+xml".into(), payload, token, None, ) .await? } else { self.client()? .native_http_caps_client() .get(uri, token, None) .await? }; if !(200..300).contains(&response.status_code) { return Ok(Box::new(std::iter::empty())); } Ok(Box::new(Self::decode_legacy_materials(bytes)?.into_iter())) } pub(crate) async fn native_send_material_updates( &self, sim: Simulator, updates: Box>, cancellation_token: Option, ) -> Result { let Some(caps) = sim.native_caps() else { return Ok(false); }; let deadline = Instant::now() + Duration::from_secs(2); let uri = loop { if let Some(uri) = caps.capability_uri("ModifyMaterialParams".into())? { break uri; } if caps.seed_request_finished() || Instant::now() >= deadline { return Ok(false); } std::thread::sleep(Duration::from_millis(1)); }; let mut body = Vec::new(); for update in updates { if update.side < 0 { return Err(Error::Argument); } let mut entry = HashMap::from([ ("object_id".into(), OSD::UUID(update.object_id)), ("side".into(), OSD::Integer(update.side)), ]); if let Some(Some(asset_id)) = update.asset_id { entry.insert("asset_id".into(), OSD::UUID(asset_id)); } if let Some(material) = update.override_ { entry.insert("gltf_json".into(), OSD::String(material.to_json()?)); } else if update.asset_id.flatten().is_none() { entry.insert("gltf_json".into(), OSD::String(String::new())); } body.push(OSD::Map(entry)); } if body.is_empty() { return Err(Error::Argument); } let payload = OSDParser::serialize_llsd_xml_bytes(OSD::Array(body))?; let client = self.client()?; let (response, bytes) = client .native_http_caps_client() .post_with_uri_string_bytes_cancellation_token_i_progress( uri, "application/llsd+xml".into(), payload, cancellation_token.unwrap_or_default(), None, ) .await?; if !(200..300).contains(&response.status_code) { return Ok(false); } let response: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| Error::Argument)?; Ok(response .get("success") .and_then(serde_json::Value::as_bool) .unwrap_or(false)) } pub(crate) async fn native_set_material_override( &self, sim: Simulator, object_id: UUID, side: i32, override_material: AssetMaterial, cancellation_token: Option, ) -> Result { let mut update = GLTFMaterialUpdate::new()?; update.object_id = object_id; update.side = side; update.override_ = Some(override_material); self.native_send_material_updates( sim, Box::new(std::iter::once(update)), cancellation_token, ) .await } pub(crate) async fn native_clear_material_override( &self, sim: Simulator, object_id: UUID, side: i32, cancellation_token: Option, ) -> Result { let mut update = GLTFMaterialUpdate::new()?; update.object_id = object_id; update.side = side; self.native_send_material_updates( sim, Box::new(std::iter::once(update)), cancellation_token, ) .await } pub(crate) async fn native_apply_material( &self, sim: Simulator, object_id: UUID, side: i32, material_asset_id: UUID, override_material: Option, cancellation_token: Option, ) -> Result { let mut update = GLTFMaterialUpdate::new()?; update.object_id = object_id; update.side = side; update.asset_id = Some(Some(material_asset_id)); update.override_ = override_material; self.native_send_material_updates( sim, Box::new(std::iter::once(update)), cancellation_token, ) .await } } #[cfg(test)] mod tests { use super::*; #[test] fn legacy_material_capability_response_is_decoded() { let id = UUID::random().unwrap(); let material = OSD::Map(HashMap::from([ ("ID".into(), OSD::UUID(id)), ( "Material".into(), OSD::Map(HashMap::from([("SpecExp".into(), OSD::Integer(37))])), ), ])); let binary = OSDParser::serialize_llsd_binary_with_osd(OSD::Array(vec![material])).unwrap(); let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best()); encoder.write_all(&binary).unwrap(); let response = OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([( "Zipped".into(), OSD::Binary(encoder.finish().unwrap()), )]))) .unwrap(); let decoded = ObjectManager::decode_legacy_materials(response).unwrap(); assert_eq!(decoded.len(), 1); assert_eq!(decoded[0].id(), id); assert_eq!(decoded[0].specular_exponent(), 37); } #[test] fn legacy_material_capability_rejects_invalid_compression() { let response = OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([( "Zipped".into(), OSD::Binary(vec![0xde, 0xad, 0xbe, 0xef]), )]))) .unwrap(); assert!(ObjectManager::decode_legacy_materials(response).is_err()); } #[test] fn object_media_event_args_preserve_response_values() { let mut media = MediaEntry::new().unwrap(); media.current_url = "https://example.invalid/media".into(); let mut args = ObjectMediaEventArgs::new(true, "x-mv:0000000001".into(), vec![media]).unwrap(); assert!(args.success()); assert_eq!(args.version(), "x-mv:0000000001"); assert_eq!(args.face_media().len(), 1); args.set_success(false); args.set_version("x-mv:0000000002".into()); args.set_face_media(Vec::new()); assert!(!args.success()); assert_eq!(args.version(), "x-mv:0000000002"); assert!(args.face_media().is_empty()); } }