Some checks failed
API and SemVer surface / api-surface (push) Failing after 1m34s
Native code generation / deterministic (push) Has been cancelled
Concurrency and resource soak audit / soak (push) Has been cancelled
Documentation / documentation (push) Has been cancelled
performance evidence / audit (push) Has been cancelled
First release candidate / non-fuzz-release-gate (push) Has been cancelled
Release platform and feature matrix / audit (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Native release artifact audit / audit (push) Has been cancelled
Imaging and meshing gate / native (push) Failing after 53s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 59s
Skia feature / linux (push) Has been cancelled
1097 lines
39 KiB
Rust
1097 lines
39 KiB
Rust
//! Legacy `VoiceManager` facade backed by the native gateway transport.
|
|
|
|
use crate::*;
|
|
use base64::Engine as _;
|
|
use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser};
|
|
use libremetaverse_types::UUID;
|
|
use libremetaverse_types::compat::{CancellationToken, Subscription};
|
|
use roxmltree::{Document, Node};
|
|
use std::collections::HashMap;
|
|
use std::sync::{
|
|
Arc, Condvar, Mutex, Weak,
|
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
|
};
|
|
use std::time::Duration;
|
|
|
|
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
mutex
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
enum ManagerEvent {
|
|
AccountChannelGetList,
|
|
AccountLogout,
|
|
AuxAudioProperties,
|
|
CaptureDevices,
|
|
ConnectorCreated,
|
|
ConnectorShutdown,
|
|
Login,
|
|
LoginState,
|
|
NewSession,
|
|
ParcelVoiceInfo,
|
|
ParticipantProperties,
|
|
ParticipantState,
|
|
ProvisionAccount,
|
|
RenderDevices,
|
|
SessionConnected,
|
|
SessionCreated,
|
|
SessionState,
|
|
SessionTerminated,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum CapabilityKind {
|
|
Provision,
|
|
Parcel,
|
|
}
|
|
|
|
type Erased = Arc<dyn Fn(&dyn std::any::Any) + Send + Sync>;
|
|
|
|
#[derive(Default)]
|
|
struct ManagerEvents {
|
|
next_id: AtomicU64,
|
|
handlers: Arc<Mutex<HashMap<ManagerEvent, HashMap<u64, Erased>>>>,
|
|
}
|
|
|
|
impl ManagerEvents {
|
|
fn subscribe<T>(&self, key: ManagerEvent, handler: Option<T>) -> Subscription
|
|
where
|
|
T: Clone + Send + Sync + 'static,
|
|
{
|
|
let Some(handler) = handler else {
|
|
return Subscription::detached();
|
|
};
|
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
|
let erased: Erased = Arc::new(move |value| {
|
|
if let Some(invoke) = value.downcast_ref::<Box<dyn Fn(&T) + Send + Sync>>() {
|
|
invoke(&handler);
|
|
}
|
|
});
|
|
lock(&self.handlers)
|
|
.entry(key)
|
|
.or_default()
|
|
.insert(id, erased);
|
|
let handlers = Arc::downgrade(&self.handlers);
|
|
Subscription::new(move || remove(&handlers, key, id))
|
|
}
|
|
|
|
fn emit<T, F>(&self, key: ManagerEvent, invoke: F)
|
|
where
|
|
T: Clone + Send + Sync + 'static,
|
|
F: Fn(&T) + Send + Sync + 'static,
|
|
{
|
|
let handlers: Vec<_> = lock(&self.handlers)
|
|
.get(&key)
|
|
.into_iter()
|
|
.flat_map(|entries| entries.values().cloned())
|
|
.collect();
|
|
let invoke: Box<dyn Fn(&T) + Send + Sync> = Box::new(invoke);
|
|
for handler in handlers {
|
|
handler(&invoke);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remove(
|
|
handlers: &Weak<Mutex<HashMap<ManagerEvent, HashMap<u64, Erased>>>>,
|
|
key: ManagerEvent,
|
|
id: u64,
|
|
) {
|
|
if let Some(handlers) = handlers.upgrade() {
|
|
let mut handlers = lock(&handlers);
|
|
if let Some(event) = handlers.get_mut(&key) {
|
|
event.remove(&id);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ManagerState {
|
|
capture_devices: Vec<String>,
|
|
render_devices: Vec<String>,
|
|
channel_map: HashMap<String, String>,
|
|
connector_handle: String,
|
|
account_handle: String,
|
|
status_code: i32,
|
|
tuning_sound_file: String,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ResponseSignal {
|
|
completed: Mutex<bool>,
|
|
wake: Condvar,
|
|
}
|
|
|
|
impl ResponseSignal {
|
|
fn notify(&self) {
|
|
*lock(&self.completed) = true;
|
|
self.wake.notify_all();
|
|
}
|
|
|
|
fn wait(&self, timeout: Duration) -> bool {
|
|
let completed = lock(&self.completed);
|
|
let (completed, _) = self
|
|
.wake
|
|
.wait_timeout_while(completed, timeout, |value| !*value)
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
*completed
|
|
}
|
|
}
|
|
|
|
struct VoiceManagerInner {
|
|
client: libremetaverse::GridClient,
|
|
gateway: VoiceGateway,
|
|
state: Mutex<ManagerState>,
|
|
events: ManagerEvents,
|
|
signals: Mutex<HashMap<i32, Arc<ResponseSignal>>>,
|
|
subscriptions: Mutex<Vec<Subscription>>,
|
|
disposed: AtomicBool,
|
|
}
|
|
|
|
pub struct VoiceManager {
|
|
pub blocking_timeout: i32,
|
|
pub voice_server: String,
|
|
inner: Arc<VoiceManagerInner>,
|
|
}
|
|
|
|
macro_rules! subscription {
|
|
($name:ident, $key:ident, $type:ty) => {
|
|
#[must_use]
|
|
pub fn $name(&self, handler: Option<$type>) -> Subscription {
|
|
self.inner.events.subscribe(ManagerEvent::$key, handler)
|
|
}
|
|
};
|
|
}
|
|
|
|
impl VoiceManager {
|
|
pub const DAEMON_ARGS: &'static str = " -p tcp -h -c -ll ";
|
|
pub const DAEMON_LOG_LEVEL: i32 = 1;
|
|
pub const DAEMON_PORT: i32 = 44_124;
|
|
pub const REQUEST_TERMINATOR: &'static str = "\n\n\n";
|
|
pub const VOICE_DEBUG_SERVER: &'static str = "bhd.vivox.com";
|
|
pub const VOICE_MAJOR_VERSION: i32 = 1;
|
|
pub const VOICE_RELEASE_SERVER: &'static str = "bhr.vivox.com";
|
|
|
|
pub fn new(client: libremetaverse::GridClient) -> Result<Self, Error> {
|
|
let gateway = VoiceGateway::new(client.clone())?;
|
|
let inner = Arc::new(VoiceManagerInner {
|
|
client,
|
|
gateway,
|
|
state: Mutex::new(ManagerState::default()),
|
|
events: ManagerEvents::default(),
|
|
signals: Mutex::new(HashMap::new()),
|
|
subscriptions: Mutex::new(Vec::new()),
|
|
disposed: AtomicBool::new(false),
|
|
});
|
|
let weak = Arc::downgrade(&inner);
|
|
let subscription = inner.gateway.subscribe_control_line(Arc::new(move |line| {
|
|
if let Some(inner) = weak.upgrade() {
|
|
let facade = VoiceManager {
|
|
blocking_timeout: 30_000,
|
|
voice_server: VoiceManager::VOICE_RELEASE_SERVER.into(),
|
|
inner,
|
|
};
|
|
let _ = facade.process_line(&line);
|
|
}
|
|
}));
|
|
lock(&inner.subscriptions).push(subscription);
|
|
Ok(Self {
|
|
blocking_timeout: 30_000,
|
|
voice_server: Self::VOICE_RELEASE_SERVER.into(),
|
|
inner,
|
|
})
|
|
}
|
|
|
|
subscription!(
|
|
subscribe_on_account_channel_get_list,
|
|
AccountChannelGetList,
|
|
VoiceManagerBasicActionCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_account_logout,
|
|
AccountLogout,
|
|
VoiceManagerBasicActionCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_aux_audio_properties,
|
|
AuxAudioProperties,
|
|
VoiceManagerAuxAudioPropertiesCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_capture_devices,
|
|
CaptureDevices,
|
|
VoiceManagerDevicesCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_connector_created,
|
|
ConnectorCreated,
|
|
VoiceManagerConnectorCreatedCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_connector_initiate_shutdown,
|
|
ConnectorShutdown,
|
|
VoiceManagerBasicActionCallback
|
|
);
|
|
subscription!(subscribe_on_login, Login, VoiceManagerLoginCallback);
|
|
subscription!(
|
|
subscribe_on_login_state_change,
|
|
LoginState,
|
|
VoiceManagerLoginStateChangeCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_new_session,
|
|
NewSession,
|
|
VoiceManagerNewSessionCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_parcel_voice_info,
|
|
ParcelVoiceInfo,
|
|
VoiceManagerParcelVoiceInfoCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_participant_properties,
|
|
ParticipantProperties,
|
|
VoiceManagerParticipantPropertiesCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_participant_state_change,
|
|
ParticipantState,
|
|
VoiceManagerParticipantStateChangeCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_provision_account,
|
|
ProvisionAccount,
|
|
VoiceManagerProvisionAccountCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_render_devices,
|
|
RenderDevices,
|
|
VoiceManagerDevicesCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_session_connected,
|
|
SessionConnected,
|
|
VoiceManagerBasicActionCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_session_created,
|
|
SessionCreated,
|
|
VoiceManagerSessionCreatedCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_session_state_change,
|
|
SessionState,
|
|
VoiceManagerSessionStateChangeCallback
|
|
);
|
|
subscription!(
|
|
subscribe_on_session_terminated,
|
|
SessionTerminated,
|
|
VoiceManagerBasicActionCallback
|
|
);
|
|
|
|
pub fn connect_to_daemon_with_method(&self) -> Result<bool, Error> {
|
|
self.connect_to_daemon_with_string_int32("127.0.0.1".into(), Self::DAEMON_PORT)
|
|
}
|
|
|
|
pub fn connect_to_daemon_with_string_int32(
|
|
&self,
|
|
address: String,
|
|
port: i32,
|
|
) -> Result<bool, Error> {
|
|
self.ensure_active()?;
|
|
self.inner.gateway.connect_to_daemon(address, port)
|
|
}
|
|
|
|
pub fn is_daemon_running(&self) -> Result<bool, Error> {
|
|
self.ensure_active()?;
|
|
Ok(self.inner.gateway.daemon_is_running())
|
|
}
|
|
|
|
/// Native releases do not bundle or spawn the proprietary daemon.
|
|
pub fn start_daemon(&self) -> Result<bool, Error> {
|
|
self.ensure_active()?;
|
|
Ok(false)
|
|
}
|
|
|
|
pub fn stop_daemon(&self) -> Result<(), Error> {
|
|
self.inner.gateway.stop_daemon()
|
|
}
|
|
|
|
pub fn get_channel_map(&self) -> Result<HashMap<String, String>, Error> {
|
|
self.ensure_active()?;
|
|
Ok(lock(&self.inner.state).channel_map.clone())
|
|
}
|
|
|
|
pub fn current_capture_devices(&self) -> Result<Vec<String>, Error> {
|
|
self.ensure_active()?;
|
|
Ok(lock(&self.inner.state).capture_devices.clone())
|
|
}
|
|
|
|
pub fn current_render_devices(&self) -> Result<Vec<String>, Error> {
|
|
self.ensure_active()?;
|
|
Ok(lock(&self.inner.state).render_devices.clone())
|
|
}
|
|
|
|
pub fn voice_account_from_uuid(&self, id: UUID) -> Result<String, Error> {
|
|
Ok(format!(
|
|
"x{}",
|
|
base64::engine::general_purpose::STANDARD
|
|
.encode(id.get_bytes()?)
|
|
.replace('+', "-")
|
|
.replace('/', "_")
|
|
))
|
|
}
|
|
|
|
pub fn uuid_from_voice_account(&self, account_name: String) -> Result<UUID, Error> {
|
|
VoiceParticipant::id_from_name(account_name)
|
|
}
|
|
|
|
pub fn sipuri_from_voice_account(&self, account: String) -> Result<String, Error> {
|
|
if account.len() > 16 * 1024 || account.chars().any(char::is_control) {
|
|
return Err(Error::Argument);
|
|
}
|
|
Ok(format!("sip:{account}@{}", self.voice_server))
|
|
}
|
|
|
|
pub fn request_capture_devices(&self) -> Result<i32, Error> {
|
|
self.inner.gateway.aux_get_capture_devices()
|
|
}
|
|
|
|
pub fn request_render_devices(&self) -> Result<i32, Error> {
|
|
self.inner.gateway.aux_get_render_devices()
|
|
}
|
|
|
|
pub fn request_create_connector_with_method(&self) -> Result<i32, Error> {
|
|
self.request_create_connector_with_string(self.voice_server.clone())
|
|
}
|
|
|
|
pub fn request_create_connector_with_string(&self, voice_server: String) -> Result<i32, Error> {
|
|
let mut logging = VoiceGatewayVoiceLoggingSettings::new()?;
|
|
logging.folder = ".".into();
|
|
logging.file_name_prefix = "vivox-gateway".into();
|
|
let account_server = format!("https://www.{voice_server}/api2/");
|
|
self.inner
|
|
.gateway
|
|
.connector_create("V2 SDK".into(), account_server, 0, u16::MAX, logging)
|
|
}
|
|
|
|
pub fn request_login(
|
|
&self,
|
|
account_name: String,
|
|
password: String,
|
|
conn_handle: String,
|
|
) -> Result<i32, Error> {
|
|
self.inner.gateway.account_login(
|
|
conn_handle,
|
|
account_name,
|
|
password,
|
|
"VerifyAnswer".into(),
|
|
String::new(),
|
|
10,
|
|
false,
|
|
)
|
|
}
|
|
|
|
pub fn request_set_render_device(&self, device_name: String) -> Result<i32, Error> {
|
|
self.inner.gateway.aux_set_render_device(device_name)
|
|
}
|
|
|
|
pub fn request_start_tuning_mode(&self, duration: i32) -> Result<i32, Error> {
|
|
self.inner.gateway.aux_capture_audio_start(duration)
|
|
}
|
|
|
|
pub fn request_stop_tuning_mode(&self) -> Result<i32, Error> {
|
|
self.inner.gateway.aux_capture_audio_stop()
|
|
}
|
|
|
|
pub fn request_set_speaker_volume(&self, volume: i32) -> Result<i32, Error> {
|
|
if !(0..=100).contains(&volume) {
|
|
return Err(Error::Argument);
|
|
}
|
|
self.inner.gateway.aux_set_speaker_level(volume)
|
|
}
|
|
|
|
pub fn request_set_capture_volume(&self, volume: i32) -> Result<i32, Error> {
|
|
if !(0..=100).contains(&volume) {
|
|
return Err(Error::Argument);
|
|
}
|
|
self.inner.gateway.aux_set_mic_level(volume)
|
|
}
|
|
|
|
pub fn request_render_audio_start(&self, file_name: String, loop_: bool) -> Result<i32, Error> {
|
|
lock(&self.inner.state).tuning_sound_file = file_name.clone();
|
|
self.inner.gateway.request_with_string_string(
|
|
"Aux.RenderAudioStart.1".into(),
|
|
Some(format!(
|
|
"{}{}",
|
|
VoiceGateway::make_xml("SoundFilePath".into(), file_name)?,
|
|
VoiceGateway::make_xml("Loop".into(), if loop_ { "1" } else { "0" }.into())?
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub fn request_render_audio_stop(&self) -> Result<i32, Error> {
|
|
let file = lock(&self.inner.state).tuning_sound_file.clone();
|
|
self.inner.gateway.request_with_string_string(
|
|
"Aux.RenderAudioStop.1".into(),
|
|
Some(VoiceGateway::make_xml("SoundFilePath".into(), file)?),
|
|
)
|
|
}
|
|
|
|
pub fn request_provision_account(&self) -> Result<bool, Error> {
|
|
self.request_capability("ProvisionVoiceAccountRequest", CapabilityKind::Provision)
|
|
}
|
|
|
|
pub fn request_parcel_voice_info(&self) -> Result<bool, Error> {
|
|
self.request_capability("ParcelVoiceInfoRequest", CapabilityKind::Parcel)
|
|
}
|
|
|
|
pub fn capture_devices(&self) -> Result<Vec<String>, Error> {
|
|
self.blocking_devices(true)
|
|
}
|
|
|
|
pub fn render_devices(&self) -> Result<Vec<String>, Error> {
|
|
self.blocking_devices(false)
|
|
}
|
|
|
|
pub fn create_connector(&self, status: &mut i32) -> Result<String, Error> {
|
|
let request = self.request_create_connector_with_method()?;
|
|
if request < 0 {
|
|
*status = 0;
|
|
return Ok(String::new());
|
|
}
|
|
let signal = self.signal(request);
|
|
if !signal.wait(self.timeout()) {
|
|
*status = 0;
|
|
return Ok(String::new());
|
|
}
|
|
let state = lock(&self.inner.state);
|
|
*status = state.status_code;
|
|
Ok(if *status == 0 {
|
|
state.connector_handle.clone()
|
|
} else {
|
|
String::new()
|
|
})
|
|
}
|
|
|
|
pub fn login(
|
|
&self,
|
|
account_name: String,
|
|
password: String,
|
|
connector_handle: String,
|
|
status: &mut i32,
|
|
) -> Result<String, Error> {
|
|
let request = self.request_login(account_name, password, connector_handle)?;
|
|
if request < 0 {
|
|
*status = 0;
|
|
return Ok(String::new());
|
|
}
|
|
let signal = self.signal(request);
|
|
if !signal.wait(self.timeout()) {
|
|
*status = 0;
|
|
return Ok(String::new());
|
|
}
|
|
let state = lock(&self.inner.state);
|
|
*status = state.status_code;
|
|
Ok(if *status == 0 {
|
|
state.account_handle.clone()
|
|
} else {
|
|
String::new()
|
|
})
|
|
}
|
|
|
|
pub fn dispose(&self) -> Result<(), Error> {
|
|
if self.inner.disposed.swap(true, Ordering::AcqRel) {
|
|
return Ok(());
|
|
}
|
|
self.inner.gateway.dispose()?;
|
|
lock(&self.inner.state).channel_map.clear();
|
|
lock(&self.inner.state).capture_devices.clear();
|
|
lock(&self.inner.state).render_devices.clear();
|
|
lock(&self.inner.signals).clear();
|
|
lock(&self.inner.subscriptions).clear();
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_active(&self) -> Result<(), Error> {
|
|
if self.inner.disposed.load(Ordering::Acquire) {
|
|
Err(Error::InvalidOperation)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn request_capability(&self, name: &str, kind: CapabilityKind) -> Result<bool, Error> {
|
|
self.ensure_active()?;
|
|
let network = self.inner.client.network();
|
|
if !network.connected() {
|
|
return Ok(false);
|
|
}
|
|
let Some(simulator) = network.current_sim() else {
|
|
return Ok(false);
|
|
};
|
|
let Some(uri) = simulator.native_capability_uri(name)? else {
|
|
return Ok(false);
|
|
};
|
|
let http = self.inner.client.http_caps_client();
|
|
let weak = Arc::downgrade(&self.inner);
|
|
std::thread::Builder::new()
|
|
.name(format!("vivox-{}-cap", name.to_ascii_lowercase()))
|
|
.spawn(move || {
|
|
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
else {
|
|
return;
|
|
};
|
|
let response = runtime.block_on(
|
|
http.post_with_uri_osd_format_osd_cancellation_token_i_progress(
|
|
uri,
|
|
OSDFormat::Xml,
|
|
OSD::Map(HashMap::new()),
|
|
CancellationToken::default(),
|
|
None,
|
|
),
|
|
);
|
|
let Some(inner) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let Ok((status, bytes)) = response else {
|
|
return;
|
|
};
|
|
if !status.is_success_status_code() {
|
|
return;
|
|
}
|
|
let Ok(OSD::Map(map)) = OSDParser::deserialize_with_bytes(bytes) else {
|
|
return;
|
|
};
|
|
match kind {
|
|
CapabilityKind::Provision => {
|
|
let username = map
|
|
.get("username")
|
|
.and_then(|value| value.as_string().ok())
|
|
.unwrap_or_default();
|
|
let password = map
|
|
.get("password")
|
|
.and_then(|value| value.as_string().ok())
|
|
.unwrap_or_default();
|
|
inner
|
|
.events
|
|
.emit::<VoiceManagerProvisionAccountCallback, _>(
|
|
ManagerEvent::ProvisionAccount,
|
|
move |callback| {
|
|
let _ = callback.invoke(username.clone(), password.clone());
|
|
},
|
|
);
|
|
}
|
|
CapabilityKind::Parcel => {
|
|
let region = map
|
|
.get("region_name")
|
|
.and_then(|value| value.as_string().ok())
|
|
.unwrap_or_default();
|
|
let local_id = map
|
|
.get("parcel_local_id")
|
|
.and_then(|value| value.as_integer().ok())
|
|
.unwrap_or_default();
|
|
let channel = match map.get("voice_credentials") {
|
|
Some(OSD::Map(credentials)) => credentials
|
|
.get("channel_uri")
|
|
.and_then(|value| value.as_string().ok())
|
|
.filter(|value| !value.is_empty()),
|
|
_ => None,
|
|
};
|
|
inner.events.emit::<VoiceManagerParcelVoiceInfoCallback, _>(
|
|
ManagerEvent::ParcelVoiceInfo,
|
|
move |callback| {
|
|
let _ = callback.invoke(region.clone(), local_id, channel.clone());
|
|
},
|
|
);
|
|
}
|
|
}
|
|
})
|
|
.map_err(|_| Error::InvalidOperation)?;
|
|
Ok(true)
|
|
}
|
|
|
|
fn timeout(&self) -> Duration {
|
|
Duration::from_millis(u64::try_from(self.blocking_timeout.max(0)).unwrap_or_default())
|
|
}
|
|
|
|
fn signal(&self, cookie: i32) -> Arc<ResponseSignal> {
|
|
lock(&self.inner.signals).entry(cookie).or_default().clone()
|
|
}
|
|
|
|
fn blocking_devices(&self, capture: bool) -> Result<Vec<String>, Error> {
|
|
let request = if capture {
|
|
self.request_capture_devices()?
|
|
} else {
|
|
self.request_render_devices()?
|
|
};
|
|
if request < 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
let signal = self.signal(request);
|
|
if !signal.wait(self.timeout()) {
|
|
return Ok(Vec::new());
|
|
}
|
|
if capture {
|
|
self.current_capture_devices()
|
|
} else {
|
|
self.current_render_devices()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn process_control_line(&self, line: &str) -> Result<(), Error> {
|
|
self.inner.gateway.process_control_line(line)
|
|
}
|
|
|
|
fn process_line(&self, line: &str) -> Result<(), Error> {
|
|
if line.len() > 1024 * 1024 || line.contains("<!DOCTYPE") || line.contains("<!ENTITY") {
|
|
return Err(Error::Argument);
|
|
}
|
|
let document = Document::parse(line).map_err(|_| Error::Argument)?;
|
|
let root = document.root_element();
|
|
let cookie = root
|
|
.descendants()
|
|
.find(|node| node.has_tag_name("Request"))
|
|
.and_then(|node| node.attribute("requestId"))
|
|
.or_else(|| root.attribute("requestId"))
|
|
.and_then(|value| value.parse().ok())
|
|
.unwrap_or(-1);
|
|
let status = value_i32(root, "StatusCode");
|
|
let status_string = value(root, "StatusString");
|
|
if root.has_tag_name("Response") {
|
|
let action = root.attribute("action").unwrap_or_default();
|
|
let mut state = lock(&self.inner.state);
|
|
state.status_code = status;
|
|
match action {
|
|
"Connector.Create.1" => {
|
|
state.connector_handle = value(root, "ConnectorHandle");
|
|
let handle = state.connector_handle.clone();
|
|
drop(state);
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerConnectorCreatedCallback, _>(
|
|
ManagerEvent::ConnectorCreated,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
status,
|
|
status_string.clone(),
|
|
handle.clone(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"Account.Login.1" => {
|
|
state.account_handle = value(root, "AccountHandle");
|
|
let handle = state.account_handle.clone();
|
|
drop(state);
|
|
self.inner.events.emit::<VoiceManagerLoginCallback, _>(
|
|
ManagerEvent::Login,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
status,
|
|
status_string.clone(),
|
|
handle.clone(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"Aux.GetCaptureDevices.1" | "Aux.GetRenderDevices.1" => {
|
|
let capture = action.contains("Capture");
|
|
let devices = values(
|
|
root,
|
|
if capture {
|
|
"CaptureDevice"
|
|
} else {
|
|
"RenderDevice"
|
|
},
|
|
);
|
|
let current = nested(
|
|
root,
|
|
if capture {
|
|
"CurrentCaptureDevice"
|
|
} else {
|
|
"CurrentRenderDevice"
|
|
},
|
|
);
|
|
if capture {
|
|
state.capture_devices = devices;
|
|
} else {
|
|
state.render_devices = devices;
|
|
}
|
|
drop(state);
|
|
let key = if capture {
|
|
ManagerEvent::CaptureDevices
|
|
} else {
|
|
ManagerEvent::RenderDevices
|
|
};
|
|
self.inner.events.emit::<VoiceManagerDevicesCallback, _>(
|
|
key,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
status,
|
|
status_string.clone(),
|
|
current.clone(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"Session.Create.1" => {
|
|
let handle = value(root, "SessionHandle");
|
|
drop(state);
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerSessionCreatedCallback, _>(
|
|
ManagerEvent::SessionCreated,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
status,
|
|
status_string.clone(),
|
|
handle.clone(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"Account.ChannelGetList.1" => {
|
|
state.channel_map = channel_map(root);
|
|
drop(state);
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerBasicActionCallback, _>(
|
|
ManagerEvent::AccountChannelGetList,
|
|
move |callback| {
|
|
let _ = callback.invoke(cookie, status, status_string.clone());
|
|
},
|
|
);
|
|
}
|
|
_ => {
|
|
drop(state);
|
|
if let Some(key) = basic_action(action) {
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerBasicActionCallback, _>(key, move |callback| {
|
|
let _ = callback.invoke(cookie, status, status_string.clone());
|
|
});
|
|
}
|
|
}
|
|
}
|
|
lock(&self.inner.signals)
|
|
.entry(cookie)
|
|
.or_default()
|
|
.clone()
|
|
.notify();
|
|
} else if root.has_tag_name("Event") {
|
|
self.process_event(root, cookie, status, status_string);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn process_event(&self, root: Node<'_, '_>, cookie: i32, status: i32, status_string: String) {
|
|
match root.attribute("type").unwrap_or_default() {
|
|
"LoginStateChangeEvent" | "AccountLoginStateChangeEvent" => {
|
|
let account = value(root, "AccountHandle");
|
|
let state = value_i32(root, "State");
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerLoginStateChangeCallback, _>(
|
|
ManagerEvent::LoginState,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
account.clone(),
|
|
status,
|
|
status_string.clone(),
|
|
state,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"SessionNewEvent" => {
|
|
let account = value(root, "AccountHandle");
|
|
let session = value(root, "SessionHandle");
|
|
let state = value_i32(root, "State");
|
|
let name = value_any(root, &["Name", "ChannelName"]);
|
|
let uri = value_any(root, &["URI", "Uri"]);
|
|
self.inner.events.emit::<VoiceManagerNewSessionCallback, _>(
|
|
ManagerEvent::NewSession,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
account.clone(),
|
|
session.clone(),
|
|
state,
|
|
name.clone(),
|
|
uri.clone(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"SessionStateChangeEvent" => {
|
|
let uri = value_any(root, &["URI", "Uri"]);
|
|
let session = value(root, "SessionHandle");
|
|
let state = value_i32(root, "State");
|
|
let is_channel = value_bool(root, "IsChannel");
|
|
let name = value_any(root, &["Name", "ChannelName"]);
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerSessionStateChangeCallback, _>(
|
|
ManagerEvent::SessionState,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
uri.clone(),
|
|
status,
|
|
status_string.clone(),
|
|
session.clone(),
|
|
state,
|
|
is_channel,
|
|
name.clone(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"ParticipantStateChangeEvent" => {
|
|
let uri = value_any(root, &["ParticipantUri", "URI", "Uri"]);
|
|
let state = value_i32(root, "State");
|
|
let name = value(root, "AccountName");
|
|
let display_name = value(root, "DisplayName");
|
|
let participant_type = value_i32(root, "ParticipantType");
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerParticipantStateChangeCallback, _>(
|
|
ManagerEvent::ParticipantState,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
uri.clone(),
|
|
status,
|
|
status_string.clone(),
|
|
state,
|
|
name.clone(),
|
|
display_name.clone(),
|
|
participant_type,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"ParticipantPropertiesEvent" | "ParticipantUpdatedEvent" => {
|
|
let uri = value_any(root, &["ParticipantUri", "URI", "Uri"]);
|
|
let locally_muted = value_bool(root, "IsLocallyMuted");
|
|
let moderator_muted = value_bool(root, "IsModeratorMuted");
|
|
let speaking = value_bool(root, "IsSpeaking");
|
|
let volume = value_i32(root, "Volume");
|
|
let energy = value_f32(root, "Energy");
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerParticipantPropertiesCallback, _>(
|
|
ManagerEvent::ParticipantProperties,
|
|
move |callback| {
|
|
let _ = callback.invoke(
|
|
cookie,
|
|
uri.clone(),
|
|
status,
|
|
status_string.clone(),
|
|
locally_muted,
|
|
moderator_muted,
|
|
speaking,
|
|
volume,
|
|
energy,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
"AuxAudioPropertiesEvent" => {
|
|
let energy = value_f32(root, "MicEnergy");
|
|
self.inner
|
|
.events
|
|
.emit::<VoiceManagerAuxAudioPropertiesCallback, _>(
|
|
ManagerEvent::AuxAudioProperties,
|
|
move |callback| {
|
|
let _ = callback.invoke(cookie, energy);
|
|
},
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn basic_action(action: &str) -> Option<ManagerEvent> {
|
|
Some(match action {
|
|
"Session.Connect.1" => ManagerEvent::SessionConnected,
|
|
"Session.Terminate.1" => ManagerEvent::SessionTerminated,
|
|
"Account.Logout.1" => ManagerEvent::AccountLogout,
|
|
"Connector.InitiateShutdown.1" => ManagerEvent::ConnectorShutdown,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
fn value_any(root: Node<'_, '_>, names: &[&str]) -> String {
|
|
names
|
|
.iter()
|
|
.map(|name| value(root, name))
|
|
.find(|value| !value.is_empty())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn value_bool(root: Node<'_, '_>, name: &str) -> bool {
|
|
matches!(
|
|
value(root, name).to_ascii_lowercase().as_str(),
|
|
"1" | "true"
|
|
)
|
|
}
|
|
|
|
fn channel_map(root: Node<'_, '_>) -> HashMap<String, String> {
|
|
root.descendants()
|
|
.filter(|node| node.has_tag_name("Channel"))
|
|
.filter_map(|node| {
|
|
let name = value(node, "Name");
|
|
let uri = value_any(node, &["URI", "Uri"]);
|
|
(!name.is_empty() && !uri.is_empty()).then_some((name, uri))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn value(root: Node<'_, '_>, name: &str) -> String {
|
|
root.descendants()
|
|
.find(|node| node.has_tag_name(name))
|
|
.and_then(|node| node.text())
|
|
.unwrap_or_default()
|
|
.to_owned()
|
|
}
|
|
|
|
fn value_i32(root: Node<'_, '_>, name: &str) -> i32 {
|
|
value(root, name).parse().unwrap_or_default()
|
|
}
|
|
|
|
fn value_f32(root: Node<'_, '_>, name: &str) -> f32 {
|
|
value(root, name).parse().unwrap_or_default()
|
|
}
|
|
|
|
fn nested(root: Node<'_, '_>, name: &str) -> String {
|
|
root.descendants()
|
|
.find(|node| node.has_tag_name(name))
|
|
.map(|node| value(node, "Device"))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn values(root: Node<'_, '_>, name: &str) -> Vec<String> {
|
|
root.descendants()
|
|
.filter(|node| node.has_tag_name(name))
|
|
.map(|node| value(node, "Device"))
|
|
.filter(|value| !value.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::mpsc;
|
|
|
|
#[test]
|
|
fn account_and_sip_conversions_round_trip() {
|
|
let client = libremetaverse::GridClient::new().unwrap();
|
|
let manager = VoiceManager::new(client).unwrap();
|
|
let id = UUID::new_with_string("1673cfd3-8229-4445-8d92-ec3570e5e587".into()).unwrap();
|
|
let account = manager.voice_account_from_uuid(id).unwrap();
|
|
assert_eq!(
|
|
manager.uuid_from_voice_account(account.clone()).unwrap(),
|
|
id
|
|
);
|
|
assert_eq!(
|
|
manager
|
|
.sipuri_from_voice_account(account)
|
|
.unwrap()
|
|
.split('@')
|
|
.count(),
|
|
2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn response_parser_updates_device_snapshot_and_invokes_callback() {
|
|
let client = libremetaverse::GridClient::new().unwrap();
|
|
let manager = VoiceManager::new(client).unwrap();
|
|
let observed = Arc::new(Mutex::new(String::new()));
|
|
let output = Arc::clone(&observed);
|
|
let _subscription = manager.subscribe_on_capture_devices(Some(
|
|
VoiceManagerDevicesCallback::from_handler(move |_, _, _, current| {
|
|
*lock(&output) = current;
|
|
Ok(())
|
|
}),
|
|
));
|
|
manager.process_control_line("<Response requestId=\"7\" action=\"Aux.GetCaptureDevices.1\"><ReturnCode>0</ReturnCode><Results><StatusCode>0</StatusCode><StatusString>OK</StatusString><CaptureDevices><CaptureDevice><Device>USB</Device></CaptureDevice></CaptureDevices><CurrentCaptureDevice><Device>USB</Device></CurrentCaptureDevice></Results></Response>").unwrap();
|
|
assert_eq!(manager.current_capture_devices().unwrap(), ["USB"]);
|
|
assert_eq!(*lock(&observed), "USB");
|
|
assert!(manager.signal(7).wait(Duration::ZERO));
|
|
}
|
|
|
|
#[test]
|
|
fn session_and_participant_events_preserve_protocol_payloads() {
|
|
let manager = VoiceManager::new(libremetaverse::GridClient::new().unwrap()).unwrap();
|
|
let (session_sender, session_receiver) = mpsc::channel();
|
|
let _session = manager.subscribe_on_session_state_change(Some(
|
|
VoiceManagerSessionStateChangeCallback::from_handler(
|
|
move |_, uri, status, _, handle, state, channel, name| {
|
|
session_sender
|
|
.send((uri, status, handle, state, channel, name))
|
|
.unwrap();
|
|
Ok(())
|
|
},
|
|
),
|
|
));
|
|
let (participant_sender, participant_receiver) = mpsc::channel();
|
|
let _participant = manager.subscribe_on_participant_properties(Some(
|
|
VoiceManagerParticipantPropertiesCallback::from_handler(
|
|
move |_, uri, _, _, local, moderator, speaking, volume, energy| {
|
|
participant_sender
|
|
.send((uri, local, moderator, speaking, volume, energy))
|
|
.unwrap();
|
|
Ok(())
|
|
},
|
|
),
|
|
));
|
|
|
|
manager.process_control_line("<Event type=\"SessionStateChangeEvent\"><StatusCode>0</StatusCode><StatusString>OK</StatusString><SessionHandle>session-1</SessionHandle><URI>sip:channel@example.test</URI><State>4</State><IsChannel>true</IsChannel><ChannelName>Town square</ChannelName></Event>").unwrap();
|
|
manager.process_control_line("<Event type=\"ParticipantPropertiesEvent\"><ParticipantUri>sip:avatar@example.test</ParticipantUri><IsLocallyMuted>0</IsLocallyMuted><IsModeratorMuted>1</IsModeratorMuted><IsSpeaking>true</IsSpeaking><Volume>61</Volume><Energy>0.5</Energy></Event>").unwrap();
|
|
|
|
assert_eq!(
|
|
session_receiver.recv().unwrap(),
|
|
(
|
|
"sip:channel@example.test".into(),
|
|
0,
|
|
"session-1".into(),
|
|
4,
|
|
true,
|
|
"Town square".into()
|
|
)
|
|
);
|
|
assert_eq!(
|
|
participant_receiver.recv().unwrap(),
|
|
("sip:avatar@example.test".into(), false, true, true, 61, 0.5)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn channel_list_response_updates_name_to_uri_map() {
|
|
let manager = VoiceManager::new(libremetaverse::GridClient::new().unwrap()).unwrap();
|
|
manager.process_control_line("<Response requestId=\"9\" action=\"Account.ChannelGetList.1\"><Results><StatusCode>0</StatusCode><StatusString>OK</StatusString><Channels><Channel><Name>Town square</Name><URI>sip:town@example.test</URI></Channel></Channels></Results></Response>").unwrap();
|
|
assert_eq!(
|
|
manager
|
|
.get_channel_map()
|
|
.unwrap()
|
|
.get("Town square")
|
|
.map(String::as_str),
|
|
Some("sip:town@example.test")
|
|
);
|
|
}
|
|
}
|