Implement grid and directory discovery (#71)
Native code generation / deterministic (push) Successful in 15m16s
Imaging and meshing gate / native (push) Successful in 5m9s
JPEG 2000 feature / linux (push) Successful in 2m47s
Native Rust workspace compile / compile (push) Successful in 5m14s
Skia feature / linux (push) Successful in 31m28s

This commit is contained in:
hugo committed 2026-08-10 17:45:08 +00:00
1 parent d537a37172
commit 66fbb87b3f
14 files changed
+3949 -1541

No files matched your search

+147 -1
View File
@@ -1003,7 +1003,153 @@ pub struct Span<T>(pub PhantomData<fn(T)>);
pub struct RandomSource;
pub struct IAsyncEnumerable<T>(pub PhantomData<fn() -> T>);
struct AsyncEnumerableState<T> {
values: std::collections::VecDeque<T>,
closed: bool,
waiters: Vec<Waker>,
cancellation: Option<CancellationToken>,
}
/// A cloneable, producer-driven equivalent of `IAsyncEnumerable<T>`.
///
/// Native managers keep a producer clone and publish correlated protocol
/// results. Consumers call [`IAsyncEnumerable::next`] until it returns `None`.
pub struct IAsyncEnumerable<T>(Arc<Mutex<AsyncEnumerableState<T>>>);
impl<T> Clone for IAsyncEnumerable<T> {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
impl<T> Default for IAsyncEnumerable<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> IAsyncEnumerable<T> {
#[must_use]
pub fn new() -> Self {
Self(Arc::new(Mutex::new(AsyncEnumerableState {
values: std::collections::VecDeque::new(),
closed: false,
waiters: Vec::new(),
cancellation: None,
})))
}
#[must_use]
pub fn with_cancellation(cancellation: CancellationToken) -> Self {
let value = Self::new();
value
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.cancellation = Some(cancellation);
value
}
/// Publishes one item and wakes pending consumers.
pub fn push(&self, value: T) {
let waiters = {
let mut state = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.cancellation
.as_ref()
.is_some_and(CancellationToken::is_cancellation_requested)
{
state.closed = true;
}
if state.closed {
return;
}
state.values.push_back(value);
std::mem::take(&mut state.waiters)
};
for waiter in waiters {
waiter.wake();
}
}
/// Completes the sequence after already queued items are consumed.
pub fn close(&self) {
let waiters = {
let mut state = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.closed = true;
std::mem::take(&mut state.waiters)
};
for waiter in waiters {
waiter.wake();
}
}
#[must_use]
pub fn next(&self) -> AsyncEnumerableNext<T> {
let cancellation = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.cancellation
.clone()
.map(|token| token.cancelled());
AsyncEnumerableNext {
state: Arc::clone(&self.0),
registered: false,
cancellation,
}
}
}
pub struct AsyncEnumerableNext<T> {
state: Arc<Mutex<AsyncEnumerableState<T>>>,
registered: bool,
cancellation: Option<CancellationFuture>,
}
impl<T> Future for AsyncEnumerableNext<T> {
type Output = Option<T>;
fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
let should_register = !self.registered;
if let Some(cancelled) = self.cancellation.as_mut()
&& Pin::new(cancelled).poll(context).is_ready()
{
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.closed = true;
}
let outcome = {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(value) = state.values.pop_front() {
Some(Poll::Ready(Some(value)))
} else if state.closed {
Some(Poll::Ready(None))
} else {
if should_register {
state.waiters.push(context.waker().clone());
}
None
}
};
if let Some(outcome) = outcome {
outcome
} else {
self.registered = true;
Poll::Pending
}
}
}
pub struct ImmutableDictionary<TKey, TValue>(pub PhantomData<fn(TKey, TValue)>);
+42 -2
View File
@@ -375,7 +375,10 @@ async fn run_seed_requests(weak: Weak<CapsInner>, cancellation: CancellationToke
.await;
match response {
Ok((response, data)) if response.is_success_status_code() => {
if install_seed_response(&inner, &simulator, data).is_ok() {
if install_seed_response(&inner, &simulator, data, cancellation.clone())
.await
.is_ok()
{
return;
}
}
@@ -392,10 +395,11 @@ async fn run_seed_requests(weak: Weak<CapsInner>, cancellation: CancellationToke
}
}
fn install_seed_response(
async fn install_seed_response(
inner: &Arc<CapsInner>,
simulator: &Simulator,
data: Vec<u8>,
cancellation: CancellationToken,
) -> Result<(), Error> {
let OSD::Map(map) = OSDParser::deserialize_with_bytes(data)? else {
return Err(Error::Parse {
@@ -439,6 +443,24 @@ fn install_seed_response(
queue.start()?;
*mutex(&inner.event_queue) = Some(queue);
}
// SimulatorFeatures is a GET capability, not merely an event-queue
// message. Load its initial snapshot before announcing that capability
// discovery is complete, while still allowing grids that omit or reject
// the optional endpoint to finish seed discovery normally.
let simulator_features_uri = read(&inner.caps).get("SimulatorFeatures").cloned();
if let Some(uri) = simulator_features_uri
&& let Ok((response, bytes)) = simulator
.client
.native_http_caps_client()
.get(uri, cancellation.clone(), None)
.await
&& response.is_success_status_code()
&& bytes.len() <= 8 * 1024 * 1024
{
let _ = simulator.features.set_features(None, Some(bytes), None);
}
cancellation.throw_if_cancellation_requested()?;
inner.emit_capabilities_received(simulator.clone());
Ok(())
}
@@ -635,6 +657,10 @@ mod tests {
"GetTexture".to_owned(),
OSD::Uri(Uri("https://asset.example.test/texture".to_owned())),
),
(
"SimulatorFeatures".to_owned(),
OSD::Uri(Uri("https://caps.example.test/features".to_owned())),
),
(
"InvalidScheme".to_owned(),
OSD::Uri(Uri("file:///private/capability".to_owned())),
@@ -654,12 +680,22 @@ mod tests {
let release = Arc::clone(&release);
async move {
let is_seed = request.uri.0.contains("/seed");
let is_features = request.uri.0.contains("/features");
lock(&recorded).push(request);
if is_seed {
while !release.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(1)).await;
}
response(200, seed_body())
} else if is_features {
response(
200,
OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([(
"MeshRezEnabled".to_owned(),
OSD::Boolean(true),
)])))
.unwrap(),
)
} else {
response(404, Vec::new())
}
@@ -708,6 +744,10 @@ mod tests {
None
);
assert!(caps.event_queue().is_some());
assert_eq!(
simulator.features.get("MeshRezEnabled".to_owned()).unwrap(),
Some(OSD::Boolean(true))
);
let deadline = Instant::now() + Duration::from_secs(2);
while lock(&requests).len() < 2 && Instant::now() < deadline {
+133
View File
@@ -123,6 +123,9 @@ struct ClientRuntime {
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>>>,
grid_manager: Mutex<Option<Arc<crate::grid_manager::GridManagerInner>>>,
directory_manager: Mutex<Option<Arc<crate::directory_manager::DirectoryManagerInner>>>,
interest_list_manager: Mutex<Option<Arc<crate::interest_list::InterestListManagerInner>>>,
appearance_manager: Mutex<Option<Arc<crate::appearance_manager::AppearanceManagerInner>>>,
avatar_manager: Mutex<Option<Arc<crate::avatar_manager::AvatarManagerInner>>>,
animesh_manager: Mutex<Option<Arc<crate::animesh_runtime::AnimeshManagerInner>>>,
@@ -162,6 +165,9 @@ impl ClientRuntime {
terrain_manager: Mutex::new(None),
sound_manager: Mutex::new(None),
parcel_manager: Mutex::new(None),
grid_manager: Mutex::new(None),
directory_manager: Mutex::new(None),
interest_list_manager: Mutex::new(None),
appearance_manager: Mutex::new(None),
avatar_manager: Mutex::new(None),
animesh_manager: Mutex::new(None),
@@ -252,6 +258,18 @@ impl ClientRuntime {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.grid_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.directory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.interest_list_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.http_caps_client
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -845,6 +863,121 @@ impl GridClient {
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn cached_grid_manager_inner(
&self,
) -> Option<Arc<crate::grid_manager::GridManagerInner>> {
self.runtime
.grid_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn install_grid_manager_inner(
&self,
candidate: Arc<crate::grid_manager::GridManagerInner>,
) -> Arc<crate::grid_manager::GridManagerInner> {
let mut cached = self
.runtime
.grid_manager
.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_grid(&self) -> Result<crate::GridManager, crate::Error> {
if let Some(inner) = self.cached_grid_manager_inner() {
Ok(crate::GridManager { inner })
} else {
crate::grid_manager::GridManager::native_new(Arc::new(self.clone()))
}
}
pub(crate) fn native_set_grid(&self, value: crate::GridManager) {
*self
.runtime
.grid_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn cached_directory_manager_inner(
&self,
) -> Option<Arc<crate::directory_manager::DirectoryManagerInner>> {
self.runtime
.directory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn install_directory_manager_inner(
&self,
candidate: Arc<crate::directory_manager::DirectoryManagerInner>,
) -> Arc<crate::directory_manager::DirectoryManagerInner> {
let mut cached = self
.runtime
.directory_manager
.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_directory(&self) -> Result<crate::DirectoryManager, crate::Error> {
if let Some(inner) = self.cached_directory_manager_inner() {
Ok(crate::DirectoryManager { inner })
} else {
crate::directory_manager::DirectoryManager::native_new(Arc::new(self.clone()))
}
}
pub(crate) fn native_set_directory(&self, value: crate::DirectoryManager) {
*self
.runtime
.directory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn cached_interest_list_manager_inner(
&self,
) -> Option<Arc<crate::interest_list::InterestListManagerInner>> {
self.runtime
.interest_list_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn install_interest_list_manager_inner(
&self,
candidate: Arc<crate::interest_list::InterestListManagerInner>,
) -> Arc<crate::interest_list::InterestListManagerInner> {
let mut cached = self
.runtime
.interest_list_manager
.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_interest_list(&self) -> Result<crate::InterestListManager, crate::Error> {
if let Some(inner) = self.cached_interest_list_manager_inner() {
Ok(crate::InterestListManager { inner })
} else {
crate::interest_list::InterestListManager::native_new(Arc::new(self.clone()))
}
}
pub(crate) fn native_set_interest_list(&self, value: crate::InterestListManager) {
*self
.runtime
.interest_list_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn native_set_caps_rate_limiter(
&mut self,
value: crate::caps_http::CapsRateLimiter,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+237
View File
@@ -0,0 +1,237 @@
//! Capability-backed interest-list mode control.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::needless_pass_by_value)]
#![allow(clippy::unnecessary_wraps)] // Fixed mapped constructor is fallible.
use crate::{Error, GridClient, Simulator};
use libremetaverse_structured_data::{OSD, OSDMap, OSDParser};
use libremetaverse_types::compat::CancellationToken;
use std::sync::{Arc, RwLock};
#[derive(Clone, Debug)]
pub struct InterestListMessage {
mode: String,
interest_list_mode: crate::messages::linden::InterestListMode,
}
impl InterestListMessage {
pub fn new() -> Result<Self, Error> {
Ok(Self {
mode: "default".into(),
interest_list_mode: crate::messages::linden::InterestListMode::Default,
})
}
pub fn deserialize(&mut self, map: OSDMap) -> Result<(), Error> {
let mode = map
.get("mode")
.map(|value| value.as_string())
.transpose()?
.unwrap_or_else(|| "default".into());
self.set_mode(mode);
Ok(())
}
pub fn serialize(&self) -> Result<OSDMap, Error> {
OSDMap::new_with_dictionary(std::collections::HashMap::from([(
"mode".into(),
OSD::String(self.mode.clone()),
)]))
}
pub fn interest_list_mode(&self) -> crate::messages::linden::InterestListMode {
self.interest_list_mode
}
pub fn set_interest_list_mode(&mut self, value: crate::messages::linden::InterestListMode) {
self.interest_list_mode = value;
self.mode = mode_name(value).into();
}
pub fn mode(&self) -> String {
self.mode.clone()
}
pub fn set_mode(&mut self, value: String) {
self.interest_list_mode = if matches!(
value.to_ascii_lowercase().as_str(),
"360" | "panoramic360" | "panoramic_360"
) {
crate::messages::linden::InterestListMode::Panoramic360
} else {
crate::messages::linden::InterestListMode::Default
};
self.mode = mode_name(self.interest_list_mode).into();
}
}
impl crate::interfaces::IMessage for InterestListMessage {
fn deserialize(&mut self, map: OSDMap) -> Result<(), Error> {
InterestListMessage::deserialize(self, map)
}
fn serialize(&self) -> Result<OSDMap, Error> {
InterestListMessage::serialize(self)
}
}
fn mode_name(mode: crate::messages::linden::InterestListMode) -> &'static str {
match mode {
crate::messages::linden::InterestListMode::Default => "default",
crate::messages::linden::InterestListMode::Panoramic360 => "360",
}
}
pub(crate) struct InterestListManagerInner {
client: Arc<GridClient>,
current_mode: RwLock<crate::messages::linden::InterestListMode>,
}
#[derive(Clone)]
pub struct InterestListManager {
pub(crate) inner: Arc<InterestListManagerInner>,
}
impl InterestListManager {
pub fn new(client: GridClient) -> Result<Self, Error> {
Self::native_new(Arc::new(client))
}
pub(crate) fn native_new(client: Arc<GridClient>) -> Result<Self, Error> {
if let Some(inner) = client.cached_interest_list_manager_inner() {
return Ok(Self { inner });
}
let inner = Arc::new(InterestListManagerInner {
client: Arc::clone(&client),
current_mode: RwLock::new(crate::messages::linden::InterestListMode::Default),
});
Ok(Self {
inner: client.install_interest_list_manager_inner(inner),
})
}
pub fn dispose(&self) -> Result<(), Error> {
Ok(())
}
pub fn current_mode(&self) -> crate::messages::linden::InterestListMode {
*self
.inner
.current_mode
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub async fn reset(&self, cancellation_token: Option<CancellationToken>) -> Result<(), Error> {
self.set_mode(
crate::messages::linden::InterestListMode::Default,
cancellation_token,
)
.await
}
pub async fn set_mode(
&self,
mode: crate::messages::linden::InterestListMode,
cancellation_token: Option<CancellationToken>,
) -> Result<(), Error> {
let token = cancellation_token.unwrap_or_else(|| self.inner.client.cancellation_token());
token.throw_if_cancellation_requested()?;
let simulations = self.inner.client.network().simulators.snapshot();
let mut accepted = false;
for simulator in simulations {
if self
.set_mode_on_sim(simulator, mode, Some(token.clone()))
.await?
{
accepted = true;
}
}
if accepted || self.inner.client.network().simulators.is_empty() {
*self
.inner
.current_mode
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = mode;
}
Ok(())
}
pub async fn reset_on_sim(
&self,
simulator: Simulator,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
self.set_mode_on_sim(
simulator,
crate::messages::linden::InterestListMode::Default,
cancellation_token,
)
.await
}
pub async fn set_mode_on_sim(
&self,
simulator: Simulator,
mode: crate::messages::linden::InterestListMode,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let token = cancellation_token.unwrap_or_else(|| self.inner.client.cancellation_token());
token.throw_if_cancellation_requested()?;
let Some(caps) = simulator.native_caps() else {
return Ok(false);
};
let Some(uri) = caps.capability_uri("InterestList".into())? else {
return Ok(false);
};
let mut message = InterestListMessage::new()?;
message.set_interest_list_mode(mode);
let body = OSDParser::serialize_llsd_xml_bytes(OSD::Map(message.serialize()?.snapshot()))?;
let (response, bytes) = self
.inner
.client
.native_http_caps_client()
.post_with_uri_string_bytes_cancellation_token_i_progress(
uri,
"application/llsd+xml".into(),
body,
token.clone(),
None,
)
.await?;
token.throw_if_cancellation_requested()?;
if bytes.len() > 1024 * 1024 {
return Err(Error::Argument);
}
let ok = (200..300).contains(&response.status_code);
if ok {
*self
.inner
.current_mode
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = mode;
}
Ok(ok)
}
}
#[cfg(test)]
mod tests {
use super::*;
use libremetaverse_types::compat::CancellationTokenSource;
#[test]
fn message_round_trip() {
let mut value = InterestListMessage::new().unwrap();
value.set_interest_list_mode(crate::messages::linden::InterestListMode::Panoramic360);
let mut copy = InterestListMessage::new().unwrap();
copy.deserialize(value.serialize().unwrap()).unwrap();
assert_eq!(copy.mode(), "360");
assert_eq!(
copy.interest_list_mode(),
crate::messages::linden::InterestListMode::Panoramic360
);
}
#[tokio::test]
async fn cancelled_mode_change_does_not_commit() {
let client = GridClient::new().unwrap();
let manager = InterestListManager::new(client).unwrap();
let source = CancellationTokenSource::new();
source.cancel();
assert_eq!(
manager
.set_mode(
crate::messages::linden::InterestListMode::Panoramic360,
Some(source.token())
)
.await,
Err(Error::Cancelled)
);
assert_eq!(
manager.current_mode(),
crate::messages::linden::InterestListMode::Default
);
}
}
+5
View File
@@ -48,10 +48,15 @@ mod object_material;
mod object_model;
#[rustfmt::skip]
pub mod packet_catalog;
mod directory_manager;
mod grid_manager;
pub use grid_manager::MapItemData;
mod interest_list;
mod packet_wire;
mod parcel_manager;
mod sim_stats;
mod skeleton;
mod slurl;
mod sound_manager;
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
mod skeleton_catalog;
+1 -1
View File
@@ -1135,7 +1135,7 @@ impl Simulator {
client,
colo_location: String::new(),
data_pool: None,
features: SimulatorFeatures,
features: SimulatorFeatures::default(),
flags: RegionFlags(0),
global_to_local_id: RwLock::new(HashMap::new()),
handle,
+430
View File
@@ -0,0 +1,430 @@
//! Second Life location and application URI parsing and formatting.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::needless_pass_by_value)]
use crate::{Error, SlappCommand, ViewerUriType};
use libremetaverse_types::UUID;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write;
const DEFAULT_X: i32 = 128;
const DEFAULT_Y: i32 = 128;
const DEFAULT_Z: i32 = 0;
const MAX_URI_BYTES: usize = 1024 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SlurlParser {
command: SlappCommand,
command_path: String,
query_parameters: HashMap<String, String>,
sim: String,
uri_type: ViewerUriType,
x: i32,
y: i32,
z: i32,
}
fn command_from_str(value: &str) -> SlappCommand {
match value.to_ascii_lowercase().as_str() {
"agent" => SlappCommand::Agent,
"appearance" => SlappCommand::Appearance,
"balance" => SlappCommand::Balance,
"chat" => SlappCommand::Chat,
"classified" => SlappCommand::Classified,
"event" => SlappCommand::Event,
"experience" => SlappCommand::Experience,
"group" => SlappCommand::Group,
"help" => SlappCommand::Help,
"inventory" => SlappCommand::Inventory,
"keybinding" => SlappCommand::Keybinding,
"login" => SlappCommand::Login,
"maptrackavatar" => SlappCommand::MapTrackAvatar,
"objectim" => SlappCommand::ObjectIm,
"openfloater" => SlappCommand::OpenFloater,
"parcel" => SlappCommand::Parcel,
"region" => SlappCommand::Region,
"search" => SlappCommand::Search,
"sharewithavatar" => SlappCommand::ShareWithAvatar,
"teleport" => SlappCommand::Teleport,
"voicecallavatar" => SlappCommand::VoiceCallAvatar,
"wear_folder" => SlappCommand::WearFolder,
"worldmap" => SlappCommand::WorldMap,
_ => SlappCommand::Unknown,
}
}
fn command_name(command: SlappCommand) -> &'static str {
match command {
SlappCommand::Agent => "agent",
SlappCommand::Appearance => "appearance",
SlappCommand::Balance => "balance",
SlappCommand::Chat => "chat",
SlappCommand::Classified => "classified",
SlappCommand::Event => "event",
SlappCommand::Experience => "experience",
SlappCommand::Group => "group",
SlappCommand::Help => "help",
SlappCommand::Inventory => "inventory",
SlappCommand::Keybinding => "keybinding",
SlappCommand::Login => "login",
SlappCommand::MapTrackAvatar => "maptrackavatar",
SlappCommand::ObjectIm => "objectim",
SlappCommand::OpenFloater => "openfloater",
SlappCommand::Parcel => "parcel",
SlappCommand::Region => "region",
SlappCommand::Search => "search",
SlappCommand::ShareWithAvatar => "sharewithavatar",
SlappCommand::Teleport => "teleport",
SlappCommand::VoiceCallAvatar => "voicecallavatar",
SlappCommand::WearFolder => "wear_folder",
SlappCommand::WorldMap => "worldmap",
SlappCommand::Unknown => "unknown",
}
}
fn encode(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
result.push(char::from(byte));
} else {
result.push('%');
let _ = write!(result, "{byte:02X}");
}
}
result
}
fn decode(value: &str) -> Result<String, Error> {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let text = std::str::from_utf8(&bytes[index + 1..index + 3])
.map_err(|_| Error::Argument)?;
output.push(u8::from_str_radix(text, 16).map_err(|_| Error::Argument)?);
index += 3;
}
b'+' => {
output.push(b' ');
index += 1;
}
byte => {
output.push(byte);
index += 1;
}
}
}
String::from_utf8(output).map_err(|_| Error::Argument)
}
fn parse_query(value: Option<&str>) -> Result<HashMap<String, String>, Error> {
let mut result = HashMap::new();
let Some(value) = value else {
return Ok(result);
};
for pair in value.split('&').filter(|pair| !pair.is_empty()).take(4096) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
result.insert(decode(key)?, decode(value)?);
}
Ok(result)
}
fn parse_coordinate(value: Option<&&str>, default: i32) -> Result<i32, Error> {
match value.filter(|value| !value.is_empty()) {
Some(value) => value.parse().map_err(|_| Error::Argument),
None => Ok(default),
}
}
impl SlurlParser {
pub fn new(location: Option<String>) -> Result<Self, Error> {
let location = location.ok_or(Error::ArgumentNull)?;
if location.is_empty() || location.len() > MAX_URI_BYTES {
return Err(Error::Argument);
}
if let Some(application) = location.strip_prefix("secondlife:///app/") {
return Self::parse_application(application);
}
let raw = if let Some(value) = location.strip_prefix("secondlife://") {
value
} else if let Some(value) = location
.strip_prefix("http://maps.secondlife.com/secondlife/")
.or_else(|| location.strip_prefix("https://maps.secondlife.com/secondlife/"))
{
value
} else if let Some(value) = location.strip_prefix("uri:") {
let values: Vec<_> = value.split('&').collect();
return Self::from_location_parts(&values);
} else {
location.as_str()
};
let values: Vec<_> = raw.trim_matches('/').split('/').collect();
Self::from_location_parts(&values)
}
fn from_location_parts(values: &[&str]) -> Result<Self, Error> {
let Some(region) = values.first().filter(|value| !value.is_empty()) else {
return Err(Error::Argument);
};
Ok(Self {
command: SlappCommand::Unknown,
command_path: String::new(),
query_parameters: HashMap::new(),
sim: decode(region)?,
uri_type: ViewerUriType::Location,
x: parse_coordinate(values.get(1), DEFAULT_X)?,
y: parse_coordinate(values.get(2), DEFAULT_Y)?,
z: parse_coordinate(values.get(3), DEFAULT_Z)?,
})
}
fn parse_application(application: &str) -> Result<Self, Error> {
let (path, query) = application
.split_once('?')
.map_or((application, None), |(path, query)| (path, Some(query)));
let command_path = path.trim_start_matches('/').to_owned();
let parts: Vec<_> = command_path.split('/').collect();
let command = command_from_str(parts.first().copied().unwrap_or_default());
let application_location =
if matches!(command, SlappCommand::Teleport | SlappCommand::WorldMap) {
Some(Self::from_location_parts(
parts.get(1..).unwrap_or_default(),
)?)
} else {
None
};
let mut result = Self {
command,
command_path,
query_parameters: parse_query(query)?,
sim: String::new(),
uri_type: ViewerUriType::Application,
x: DEFAULT_X,
y: DEFAULT_Y,
z: DEFAULT_Z,
};
if let Some(location) = application_location {
result.sim = location.sim;
result.x = location.x;
result.y = location.y;
result.z = location.z;
}
Ok(result)
}
pub fn get_agent_url(agent_id: UUID, action: Option<String>) -> Result<String, Error> {
Self::get_slapp_url(
SlappCommand::Agent,
Some(format!(
"{agent_id}/{}",
encode(action.as_deref().unwrap_or("about"))
)),
None,
)
}
pub fn get_group_url(group_id: UUID, action: Option<String>) -> Result<String, Error> {
Self::get_slapp_url(
SlappCommand::Group,
Some(format!(
"{group_id}/{}",
encode(action.as_deref().unwrap_or("about"))
)),
None,
)
}
pub fn get_login_url(
last_name: Option<String>,
session_id: Option<String>,
location: Option<String>,
) -> Result<String, Error> {
let mut query = HashMap::new();
if let Some(value) = last_name {
query.insert("last".into(), value);
}
if let Some(value) = session_id {
query.insert("session".into(), value);
}
if let Some(value) = location {
query.insert("location".into(), value);
}
Self::get_slapp_url(
SlappCommand::Login,
None,
(!query.is_empty()).then_some(query),
)
}
pub fn get_object_im_url(
object_id: UUID,
object_name: String,
owner_id: UUID,
group_owned: bool,
slurl: String,
) -> Result<String, Error> {
let mut query = HashMap::from([
("name".into(), object_name),
("owner".into(), owner_id.to_string()),
("slurl".into(), slurl),
]);
if group_owned {
query.insert("groupowned".into(), "true".into());
}
Self::get_slapp_url(
SlappCommand::ObjectIm,
Some(object_id.to_string()),
Some(query),
)
}
pub fn get_raw_location(&self) -> Result<String, Error> {
if !self.is_location() {
return Err(Error::InvalidOperation);
}
Ok(format!("{}/{}/{}/{}", self.sim, self.x, self.y, self.z))
}
pub fn get_search_url(category: String, search_term: String) -> Result<String, Error> {
Self::get_slapp_url(
SlappCommand::Search,
Some(format!("{}/{}", encode(&category), encode(&search_term))),
None,
)
}
pub fn get_slapp_url(
command: SlappCommand,
path: Option<String>,
query_params: Option<HashMap<String, String>>,
) -> Result<String, Error> {
if command == SlappCommand::Unknown {
return Err(Error::Argument);
}
let mut result = format!("secondlife:///app/{}", command_name(command));
if let Some(path) = path.filter(|path| !path.is_empty()) {
result.push('/');
result.push_str(path.trim_start_matches('/'));
}
if let Some(query) = query_params.filter(|query| !query.is_empty()) {
let ordered: BTreeMap<_, _> = query.into_iter().collect();
result.push('?');
for (index, (key, value)) in ordered.iter().enumerate() {
if index != 0 {
result.push('&');
}
result.push_str(&encode(key));
result.push('=');
result.push_str(&encode(value));
}
}
if result.len() > MAX_URI_BYTES {
return Err(Error::Argument);
}
Ok(result)
}
pub fn get_slurl(&self) -> Result<String, Error> {
Ok(format!(
"secondlife://{}/{}/{}/{}/",
encode(&self.sim),
self.x,
self.y,
self.z
))
}
pub fn get_start_location_uri(&self) -> Result<String, Error> {
Ok(format!("uri:{}&{}&{}&{}", self.sim, self.x, self.y, self.z))
}
pub fn get_teleport_url(
region: String,
x: Option<i32>,
y: Option<i32>,
z: Option<i32>,
) -> Result<String, Error> {
Self::location_application(SlappCommand::Teleport, region, x, y, z)
}
pub fn get_world_map_url(
region: String,
x: Option<i32>,
y: Option<i32>,
z: Option<i32>,
) -> Result<String, Error> {
Self::location_application(SlappCommand::WorldMap, region, x, y, z)
}
fn location_application(
command: SlappCommand,
region: String,
x: Option<i32>,
y: Option<i32>,
z: Option<i32>,
) -> Result<String, Error> {
if region.is_empty() {
return Err(Error::Argument);
}
Self::get_slapp_url(
command,
Some(format!(
"{}/{}/{}/{}",
encode(&region),
x.unwrap_or(DEFAULT_X),
y.unwrap_or(DEFAULT_Y),
z.unwrap_or(DEFAULT_Z)
)),
None,
)
}
pub fn command(&self) -> SlappCommand {
self.command
}
pub fn set_command(&mut self, value: SlappCommand) {
self.command = value;
}
pub fn command_path(&self) -> String {
self.command_path.clone()
}
pub fn set_command_path(&mut self, value: String) {
self.command_path = value;
}
pub fn is_application(&self) -> bool {
self.uri_type == ViewerUriType::Application
}
pub fn is_location(&self) -> bool {
self.uri_type == ViewerUriType::Location
}
pub fn query_parameters(&self) -> HashMap<String, String> {
self.query_parameters.clone()
}
pub fn set_query_parameters(&mut self, value: HashMap<String, String>) {
self.query_parameters = value;
}
pub fn sim(&self) -> String {
self.sim.clone()
}
pub fn set_sim(&mut self, value: String) {
self.sim = value;
}
pub fn uri_type(&self) -> ViewerUriType {
self.uri_type
}
pub fn set_uri_type(&mut self, value: ViewerUriType) {
self.uri_type = value;
}
pub fn x(&self) -> i32 {
self.x
}
pub fn set_x(&mut self, value: i32) {
self.x = value;
}
pub fn y(&self) -> i32 {
self.y
}
pub fn set_y(&mut self, value: i32) {
self.y = value;
}
pub fn z(&self) -> i32 {
self.z
}
pub fn set_z(&mut self, value: i32) {
self.z = value;
}
}