Implement grid and directory discovery (#71)
All checks were successful
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:
2026-08-10 17:45:08 +00:00
parent d537a37172
commit 66fbb87b3f
14 changed files with 3949 additions and 1541 deletions

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

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 {

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

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

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;

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,

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