Implement native WebRtcTest validation (#96)
Some checks failed
Native code generation / deterministic (push) Failing after 2m5s
Imaging and meshing gate / native (push) Failing after 5m37s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 17m32s
Skia feature / linux (push) Successful in 31m32s

This commit is contained in:
2026-08-11 15:46:30 +00:00
parent da4afec708
commit 66f10baa1b
14 changed files with 4280 additions and 17 deletions

View File

@@ -13,6 +13,7 @@ libremetaverse-imaging = { path = "../crates/libremetaverse-imaging", features =
libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia", features = ["skia"] }
libremetaverse-structured-data = { path = "../crates/libremetaverse-structured-data" }
libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" }
libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" }
regex = "1.12"
roxmltree = "0.21.1"
tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
@@ -20,6 +21,10 @@ tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "r
[lints]
workspace = true
[features]
default = []
real-audio = ["libremetaverse-voice-webrtc/real-audio"]
[[bin]]
name = "irc-gateway"
path = "src/bin/irc_gateway.rs"

View File

@@ -15,7 +15,43 @@ here so a source entry is never mistaken for a completed port.
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
| `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 |
| `vivox-test` | VivoxTest | Implemented with gated live validation and a scripted fake TCP/control service |
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
| `webrtc-test` | WebRtcTest | Implemented with gated live WebRTC and a deterministic secure loopback peer |
## WebRtcTest
`webrtc-test` uses the native `libremetaverse-voice-webrtc` adapter. Its
`str0m` peer performs ICE, DTLS, SRTP, ordered SCTP `SLData`, SDP offer/answer,
Opus RTP, peer mute/gain/position messages, WAV microphone playback, diagnostics,
and joined teardown. It does not invoke the CLR or upstream C# program.
The hardware-independent gate is:
```sh
webrtc-test --fake --timeout-seconds 10
```
This creates two native peers on IPv4 loopback, exchanges an actual encrypted
WebRTC session, sends and decodes native-libopus audio, validates peer maps and
controls, and proves both peer/audio task counts return to zero. `--list-devices`
always reports the virtual endpoints. A build with `--features real-audio`
also reports CPAL endpoints and `--input-device`/`--output-device` opens the
selected hardware streams; capture and playback are converted to/from 48 kHz
mono without changing the WebRTC protocol.
Live login requires `--allow-live-login --confirm-live-login LOGIN`. It only
checks the redacted voice capabilities unless `--allow-session-audio` is also
present. It reads `GRID_USER="First Last"`, `GRID_PASSWORD`, and
`GRID_LOGIN_URL` from the environment or workspace `.env`; positional values
and `GRID_FIRST_NAME`/`GRID_LAST_NAME` remain supported. See `docs/webrtc.md`
for native prerequisites and the full command/gate matrix.
Run the isolated checks with:
```sh
cargo test -p libremetaverse-voice-webrtc --lib native::tests
cargo test -p libremetaverse-programs --test webrtc_test_cli
cargo test --manifest-path tests/compat/Cargo.toml --test webrtc_protocol_semantics
```
## VivoxTest

View File

@@ -1,3 +1,3 @@
fn main() -> std::process::ExitCode {
libremetaverse_programs::pending_program("WebRtcTest")
libremetaverse_programs::webrtc_test::main_entry()
}

View File

@@ -9,5 +9,6 @@ pub mod prim_inspector;
pub mod simple_bot;
pub mod test_client;
pub mod vivox_test;
pub mod webrtc_test;
pub use libremetaverse::shim::pending_program;

801
programs/src/webrtc_test.rs Normal file
View File

@@ -0,0 +1,801 @@
//! Credential-safe native port of the `WebRtcTest` validation program.
#![allow(clippy::cast_possible_truncation)] // The bounded test tone fits exactly in i16.
#![allow(clippy::cast_precision_loss)] // Tone synthesis intentionally uses f32 sample positions.
#![allow(clippy::struct_excessive_bools)] // Independent CLI safety gates are clearer as flags.
use clap::Parser;
use libremetaverse::types::compat::{CancellationToken, Uri};
use libremetaverse::{GridClient, NetworkManager, Simulator};
use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser};
use libremetaverse_voice_webrtc::{
CloseRequest, LoopbackSignaling, ProvisionRequest, ProvisionResponse, SignalingCompleteRequest,
VoiceEvent, VoiceSecret, VoiceSessionConfig, VoiceSignaling, WebRtcError, WebRtcVoiceSession,
audio_devices, encode_pcm_48k_mono,
};
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::io::{self, BufRead, IsTerminal, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncBufReadExt as _;
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_INPUT: u8 = 3;
pub const EXIT_SERVICE: u8 = 4;
const LIVE_CONFIRMATION: &str = "LOGIN";
const MAX_CAPABILITY_BYTES: usize = 1024 * 1024;
#[derive(Parser)]
#[command(
name = "webrtc-test",
version,
about = "Validate native LibreMetaverse WebRTC voice, Opus audio, and SLData behavior",
arg_required_else_help = true,
after_help = "CI/offline: --fake runs a real in-process ICE/DTLS/SRTP/SCTP peer with virtual 48 kHz audio. Live grid login requires --allow-live-login --confirm-live-login LOGIN; joining voice additionally requires --allow-session-audio. Live credentials use GRID_USER, GRID_PASSWORD, and GRID_LOGIN_URL from the environment or workspace .env. Real hardware enumeration requires building with --features real-audio and remains opt-in through --input-device/--output-device. Native libopus development files are required. Prefer GRID_PASSWORD over a positional password."
)]
struct Cli {
/// Avatar first name; `GRID_FIRST_NAME` is the fallback.
#[arg(value_name = "FIRSTNAME")]
first_name: Option<String>,
/// Avatar last name; `GRID_LAST_NAME` is the fallback.
#[arg(value_name = "LASTNAME")]
last_name: Option<String>,
/// Avatar password; prefer `GRID_PASSWORD` to avoid process-list exposure.
#[arg(value_name = "PASSWORD")]
password: Option<String>,
/// Run the full deterministic loopback signaling, peer, media, and data-channel flow.
#[arg(long)]
fake: bool,
/// List selectable virtual and compiled-in real audio endpoints.
#[arg(long)]
list_devices: bool,
/// Explicitly permit a live grid login.
#[arg(long)]
allow_live_login: bool,
/// Required literal confirmation for live login.
#[arg(long, value_name = "LOGIN")]
confirm_live_login: Option<String>,
/// Join live parcel voice and transmit/receive media.
#[arg(long, requires = "allow_live_login")]
allow_session_audio: bool,
/// Override the grid login endpoint; `GRID_LOGIN_URL` is the fallback.
#[arg(long, value_name = "URL")]
login_uri: Option<String>,
/// Concrete local interface address advertised as the host ICE candidate.
#[arg(long, value_name = "IP")]
bind_ip: Option<IpAddr>,
/// Optional parcel local ID included in WebRTC provisioning.
#[arg(long, value_name = "ID")]
parcel_local_id: Option<i32>,
/// Selected capture endpoint ID.
#[arg(long, default_value = "virtual:microphone")]
input_device: String,
/// Selected playback endpoint ID.
#[arg(long, default_value = "virtual:speaker")]
output_device: String,
/// WAV file to resample, Opus-encode, and send as microphone input.
#[arg(long, value_name = "FILE")]
wav: Option<PathBuf>,
/// Read interactive commands from a file instead of stdin.
#[arg(long, value_name = "FILE")]
commands: Option<PathBuf>,
/// Maximum duration for login, capabilities, connection, and teardown.
#[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
timeout_seconds: u64,
}
#[derive(Debug)]
enum ProgramError {
Usage(&'static str),
Input(String),
Grid(&'static str),
Voice(WebRtcError),
Io(io::Error),
Timeout(&'static str),
}
impl ProgramError {
const fn exit_code(&self) -> u8 {
match self {
Self::Usage(_) => EXIT_USAGE,
Self::Input(_) | Self::Io(_) => EXIT_INPUT,
Self::Grid(_) | Self::Voice(_) | Self::Timeout(_) => EXIT_SERVICE,
}
}
}
impl fmt::Display for ProgramError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage(message) | Self::Grid(message) => formatter.write_str(message),
Self::Input(message) => write!(formatter, "invalid input: {message}"),
Self::Voice(error) => error.fmt(formatter),
Self::Io(error) => error.fmt(formatter),
Self::Timeout(action) => write!(formatter, "{action} timed out"),
}
}
}
impl From<WebRtcError> for ProgramError {
fn from(error: WebRtcError) -> Self {
Self::Voice(error)
}
}
impl From<io::Error> for ProgramError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
/// Runs the `WebRtcTest` command.
#[must_use]
pub fn main_entry() -> ExitCode {
let cli = Cli::parse();
let Ok(runtime) = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
else {
eprintln!("webrtc-test: could not initialize the async runtime");
return ExitCode::from(EXIT_SERVICE);
};
match runtime.block_on(run(cli)) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("webrtc-test: {error}");
ExitCode::from(error.exit_code())
}
}
}
async fn run(cli: Cli) -> Result<(), ProgramError> {
if cli.list_devices {
if cli.fake
|| cli.first_name.is_some()
|| cli.last_name.is_some()
|| cli.password.is_some()
|| cli.allow_live_login
{
return Err(ProgramError::Usage(
"--list-devices cannot be combined with fake or live mode",
));
}
print_devices()?;
return Ok(());
}
if cli.fake {
if cli.first_name.is_some()
|| cli.last_name.is_some()
|| cli.password.is_some()
|| cli.allow_live_login
|| cli.confirm_live_login.is_some()
|| cli.allow_session_audio
|| cli.login_uri.is_some()
|| cli.commands.is_some()
{
return Err(ProgramError::Usage(
"credentials and live/interactive options cannot be combined with --fake",
));
}
return run_fake(Duration::from_secs(cli.timeout_seconds)).await;
}
run_live(cli).await
}
fn print_devices() -> Result<(), ProgramError> {
for device in audio_devices()? {
println!(
"{}\t{}\t{}{}",
if device.input { "input" } else { "output" },
device.id,
device.name,
if device.is_default { " [default]" } else { "" }
);
}
Ok(())
}
async fn run_fake(timeout: Duration) -> Result<(), ProgramError> {
let peer =
libremetaverse::types::UUID::new_with_string("11111111-2222-4333-8444-555555555555".into())
.map_err(|_| ProgramError::Grid("could not build deterministic peer ID"))?;
let signaling = LoopbackSignaling::new(peer);
let mut session = WebRtcVoiceSession::connect(
signaling.clone(),
VoiceSessionConfig {
timeout,
..VoiceSessionConfig::default()
},
)
.await?;
let mut saw_connected = false;
let mut saw_channel = false;
let mut saw_audio_state = false;
let mut saw_position = false;
let mut saw_mute = false;
let mut saw_gain = false;
tokio::time::timeout(timeout, async {
while !(saw_connected
&& saw_channel
&& saw_audio_state
&& saw_position
&& saw_mute
&& saw_gain)
{
match session.next_event().await {
Some(VoiceEvent::Connected) => saw_connected = true,
Some(VoiceEvent::DataChannelReady) => saw_channel = true,
Some(VoiceEvent::PeerAudio(id, _)) if id == peer => saw_audio_state = true,
Some(VoiceEvent::PeerPosition(id, _)) if id == peer => saw_position = true,
Some(VoiceEvent::MuteMap(map)) if map.contains_key(&peer) => saw_mute = true,
Some(VoiceEvent::GainMap(map)) if map.contains_key(&peer) => saw_gain = true,
Some(VoiceEvent::Diagnostic(message)) => {
return Err(ProgramError::Input(message));
}
None | Some(VoiceEvent::Closed) => {
return Err(ProgramError::Grid("fake WebRTC peer closed prematurely"));
}
_ => {}
}
}
Ok(())
})
.await
.map_err(|_| ProgramError::Timeout("fake WebRTC connection"))??;
session.set_peer_mute(peer, true).await?;
session.set_peer_gain(peer, 500).await?;
session
.send_position(
[1.0, 2.0, 3.0],
[0.0, 0.0, 0.0, 1.0],
[4.0, 5.0, 6.0],
[0.0, 0.0, 0.0, 1.0],
)
.await?;
let pcm: Vec<i16> = (0..4 * 960)
.map(|index| {
((index as f32 / 48_000.0 * 440.0 * std::f32::consts::TAU).sin() * 8_000.0) as i16
})
.collect();
session
.play_frames(encode_pcm_48k_mono(&pcm)?, false)
.await?;
tokio::time::timeout(timeout, async {
while session.snapshot().received_audio_frames < 4 {
if session.next_event().await.is_none() {
return Err(ProgramError::Grid("fake audio echo stopped prematurely"));
}
}
Ok(())
})
.await
.map_err(|_| ProgramError::Timeout("virtual audio echo"))??;
session.shutdown().await?;
signaling.wait_closed(timeout).await?;
let snapshot = session.snapshot();
let sent = signaling.received_messages();
if snapshot.active_tasks != 0
|| signaling.active_tasks() != 0
|| !sent.iter().any(|value| value.contains("\"m\""))
|| !sent
.iter()
.any(|value| value.contains("\"ug\"") && value.contains("220"))
|| !sent.iter().any(|value| value.contains("\"sp\""))
|| !sent.iter().any(|value| value.contains("\"pong\""))
{
return Err(ProgramError::Grid(
"fake peer did not observe every required SLData action or clean teardown",
));
}
println!(
"Fake WebRTC validation complete; ice=connected dtls=connected srtp=opus sctp=SLData peer-events=4 sent-audio={} received-audio={} active-peer-tasks=0 active-audio-tasks=0",
snapshot.sent_audio_frames, snapshot.received_audio_frames
);
Ok(())
}
async fn run_live(mut cli: Cli) -> Result<(), ProgramError> {
if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) {
return Err(ProgramError::Usage(
"live mode requires --allow-live-login --confirm-live-login LOGIN",
));
}
let (first_name, last_name) = grid_names(cli.first_name.take(), cli.last_name.take())?;
let password = required(cli.password.take(), "GRID_PASSWORD", "PASSWORD is required")?;
let timeout = Duration::from_secs(cli.timeout_seconds);
let client =
GridClient::new().map_err(|_| ProgramError::Grid("could not construct GridClient"))?;
let network = client.network();
let mut login = network
.default_login_params(
first_name,
last_name,
password,
"WebRtcTest".into(),
env!("CARGO_PKG_VERSION").into(),
)
.map_err(|_| ProgramError::Grid("could not build grid login parameters"))?;
login.login_location = "WebRTC Voice 1/128/128/50".into();
if let Some(uri) = cli
.login_uri
.take()
.or_else(|| credential("GRID_LOGIN_URL"))
{
login.uri = uri;
}
println!("Logging into the grid with redacted credentials...");
let logged_in = tokio::time::timeout(
timeout,
network.login_with_login_params_cancellation_token(login, None),
)
.await
.map_err(|_| ProgramError::Timeout("grid login"))?
.unwrap_or(false);
if !logged_in {
return Err(ProgramError::Grid("grid login was rejected"));
}
let operation = async {
let simulator = wait_for_event_queue(&network, timeout).await?;
let provision_uri = capability(&simulator, "ProvisionVoiceAccountRequest")?;
let signaling_uri = capability(&simulator, "VoiceSignalingRequest")?;
println!("WebRTC voice capabilities are present; capability URLs are redacted.");
if !cli.allow_session_audio {
println!("Live voice not joined; pass --allow-session-audio to create a peer and transmit/receive audio.");
return Ok(());
}
let bind_ip = cli.bind_ip.unwrap_or_else(discover_bind_ip);
let signaling: Arc<dyn VoiceSignaling> = Arc::new(GridCapabilitySignaling {
client: client.clone(),
provision_uri,
signaling_uri,
timeout,
});
let mut session = WebRtcVoiceSession::connect(
signaling,
VoiceSessionConfig {
bind_ip,
parcel_local_id: cli.parcel_local_id,
timeout,
input_device: cli.input_device,
output_device: cli.output_device,
},
)
.await?;
let session_result = async {
wait_ready(&mut session, timeout).await?;
println!("WebRTC voice connected; ICE/DTLS/SRTP and ordered SLData are ready.");
if let Some(wav) = cli.wav.as_deref() {
session.play_wav(wav, true).await?;
println!("WAV microphone playback started (48 kHz mono Opus).");
}
run_commands(&mut session, cli.commands.as_deref()).await
}
.await;
let shutdown_result = session.shutdown().await;
if session.snapshot().active_tasks != 0 {
return Err(ProgramError::Grid("peer/audio tasks remained after shutdown"));
}
session_result?;
shutdown_result?;
println!("WebRTC voice disconnected; peer, audio, and signaling resources closed.");
Ok(())
}
.await;
let _ = network.logout_with_method();
operation
}
fn required(
value: Option<String>,
environment: &str,
message: &'static str,
) -> Result<String, ProgramError> {
value
.or_else(|| credential(environment))
.ok_or(ProgramError::Usage(message))
}
fn credential(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
let dotenv = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env");
let contents = std::fs::read_to_string(dotenv).ok()?;
contents.lines().find_map(|line| {
let line = line.trim().strip_prefix("export ").unwrap_or(line.trim());
let (key, value) = line.split_once('=')?;
(key.trim() == name)
.then(|| value.trim().trim_matches(['\'', '"']).to_owned())
.filter(|value| !value.is_empty())
})
})
}
fn grid_names(
first_name: Option<String>,
last_name: Option<String>,
) -> Result<(String, String), ProgramError> {
let first_name = first_name.or_else(|| credential("GRID_FIRST_NAME"));
let last_name = last_name.or_else(|| credential("GRID_LAST_NAME"));
match (first_name, last_name) {
(Some(first), Some(last)) if !first.trim().is_empty() && !last.trim().is_empty() => {
Ok((first, last))
}
(Some(_), None) | (None, Some(_)) => Err(ProgramError::Usage(
"FIRSTNAME and LASTNAME must be provided together",
)),
_ => split_grid_user(&credential("GRID_USER").ok_or(ProgramError::Usage(
"FIRSTNAME/LASTNAME or GRID_USER is required",
))?),
}
}
fn split_grid_user(value: &str) -> Result<(String, String), ProgramError> {
let mut names = value.split_whitespace();
let (Some(first), Some(last), None) = (names.next(), names.next(), names.next()) else {
return Err(ProgramError::Usage(
"GRID_USER must contain exactly FIRSTNAME LASTNAME",
));
};
Ok((first.to_owned(), last.to_owned()))
}
async fn wait_for_event_queue(
network: &NetworkManager,
timeout: Duration,
) -> Result<Simulator, ProgramError> {
tokio::time::timeout(timeout, async {
loop {
if let Some(simulator) = network.current_sim()
&& simulator
.is_event_queue_running(Some(false))
.unwrap_or(false)
{
return simulator;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.map_err(|_| ProgramError::Timeout("EventQueueRunning"))
}
fn capability(simulator: &Simulator, name: &'static str) -> Result<Uri, ProgramError> {
simulator
.native_capability_uri(name)
.map_err(|_| ProgramError::Grid("voice capability lookup failed"))?
.ok_or(ProgramError::Grid(
"required WebRTC voice capability is missing",
))
}
fn discover_bind_ip() -> IpAddr {
let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0));
if let Ok(socket) = socket
&& socket
.connect(SocketAddr::from(([192, 0, 2, 1], 9)))
.is_ok()
&& let Ok(address) = socket.local_addr()
&& !address.ip().is_unspecified()
{
return address.ip();
}
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
async fn wait_ready(
session: &mut WebRtcVoiceSession,
timeout: Duration,
) -> Result<(), ProgramError> {
tokio::time::timeout(timeout, async {
while !session.snapshot().data_channel_ready {
match session.next_event().await {
Some(VoiceEvent::Diagnostic(message)) => {
return Err(ProgramError::Input(message));
}
Some(VoiceEvent::Closed) | None => {
return Err(ProgramError::Grid(
"WebRTC peer closed before SLData opened",
));
}
Some(event) => print_event(&event),
}
}
Ok(())
})
.await
.map_err(|_| ProgramError::Timeout("WebRTC peer connection"))?
}
fn print_event(event: &VoiceEvent) {
match event {
VoiceEvent::PeerAudio(id, state) => println!(
"[Voice] PeerAudio {id} Power={:?} VAD={:?} JoinedPrimary={:?} Left={}",
state.power, state.voice_active, state.joined_primary, state.left
),
VoiceEvent::PeerPosition(id, position) => {
println!(
"[Voice] PeerPosition {id} sender={:?}",
position.sender_position
);
}
VoiceEvent::MuteMap(map) => {
for (id, value) in map {
println!("[Voice] Mute {id} = {value}");
}
}
VoiceEvent::GainMap(map) => {
for (id, value) in map {
println!("[Voice] Gain {id} = {value}");
}
}
_ => {}
}
}
async fn run_commands(
session: &mut WebRtcVoiceSession,
path: Option<&Path>,
) -> Result<(), ProgramError> {
if let Some(path) = path {
let file = io::BufReader::new(std::fs::File::open(path)?);
for line in file.lines().take(1_024) {
if !execute_command(session, &line?).await? {
break;
}
}
return Ok(());
}
if !io::stdin().is_terminal() {
return Ok(());
}
println!(
"Commands: peers, mute <uuid> <bool>, gain <uuid> <0-220>, playwav <path>, stopwav, quit"
);
let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
loop {
print!("> ");
io::stdout().flush()?;
tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal?;
println!("\nCancellation requested; closing WebRTC voice.");
break;
}
line = lines.next_line() => {
let Some(line) = line? else { break };
if !execute_command(session, &line).await? {
break;
}
}
}
}
Ok(())
}
async fn execute_command(
session: &mut WebRtcVoiceSession,
line: &str,
) -> Result<bool, ProgramError> {
let fields: Vec<_> = line.split_whitespace().collect();
let Some(command) = fields.first().map(|value| value.to_ascii_lowercase()) else {
return Ok(true);
};
match command.as_str() {
"quit" | "exit" => return Ok(false),
"peers" | "list" => {
for peer in session.snapshot().known_peers {
println!("{peer}");
}
}
"mute" if fields.len() == 3 => {
let peer = parse_peer(fields[1])?;
let mute = fields[2]
.parse()
.map_err(|_| ProgramError::Input("mute value must be true or false".into()))?;
session.set_peer_mute(peer, mute).await?;
}
"gain" if fields.len() == 3 => {
let peer = parse_peer(fields[1])?;
let gain = fields[2]
.parse()
.map_err(|_| ProgramError::Input("gain must be an integer".into()))?;
session.set_peer_gain(peer, gain).await?;
}
"playwav" if fields.len() == 2 => {
session.play_wav(Path::new(fields[1]), true).await?;
}
"stopwav" if fields.len() == 1 => session.stop_wav().await?,
_ => {
return Err(ProgramError::Input(
"unknown command or incorrect command arguments".into(),
));
}
}
Ok(true)
}
fn parse_peer(value: &str) -> Result<libremetaverse::types::UUID, ProgramError> {
libremetaverse::types::UUID::new_with_string(value.to_owned())
.map_err(|_| ProgramError::Input("peer ID must be a UUID".into()))
}
struct GridCapabilitySignaling {
client: GridClient,
provision_uri: Uri,
signaling_uri: Uri,
timeout: Duration,
}
impl GridCapabilitySignaling {
async fn post(&self, uri: &Uri, body: OSD) -> Result<OSD, WebRtcError> {
let http = self.client.http_caps_client();
let request = http.post_with_uri_osd_format_osd_cancellation_token_i_progress(
uri.clone(),
OSDFormat::Xml,
body,
CancellationToken::default(),
None,
);
let (response, bytes) = tokio::time::timeout(self.timeout, request)
.await
.map_err(|_| WebRtcError::Timeout("grid voice capability"))?
.map_err(|_| WebRtcError::Signaling("capability request failed".into()))?;
if !response.is_success_status_code() || bytes.len() > MAX_CAPABILITY_BYTES {
return Err(WebRtcError::Signaling(format!(
"capability returned HTTP {}",
response.status_code
)));
}
OSDParser::deserialize_with_bytes(bytes)
.map_err(|_| WebRtcError::Signaling("capability returned malformed LLSD".into()))
}
}
impl VoiceSignaling for GridCapabilitySignaling {
fn provision(
&self,
request: ProvisionRequest,
) -> Pin<Box<dyn Future<Output = Result<ProvisionResponse, WebRtcError>> + Send + '_>> {
Box::pin(async move {
let mut jsep = HashMap::new();
jsep.insert("type".into(), OSD::String(request.jsep.kind));
jsep.insert("sdp".into(), OSD::String(request.jsep.sdp));
let mut body = HashMap::new();
body.insert("jsep".into(), OSD::Map(jsep));
body.insert("channel_type".into(), OSD::String(request.channel_type));
body.insert(
"voice_server_type".into(),
OSD::String(request.voice_server_type),
);
if let Some(id) = request.parcel_local_id {
body.insert("parcel_local_id".into(), OSD::Integer(id));
}
let OSD::Map(response) = self.post(&self.provision_uri, OSD::Map(body)).await? else {
return Err(WebRtcError::Signaling(
"provision response was not a map".into(),
));
};
let answer_sdp = match response.get("jsep") {
Some(OSD::Map(jsep)) => jsep
.get("sdp")
.and_then(|value| value.as_string().ok())
.filter(|value| !value.is_empty()),
_ => None,
}
.ok_or_else(|| WebRtcError::Signaling("provision response omitted SDP".into()))?;
let viewer_session = response
.get("viewer_session")
.and_then(|value| value.as_uuid().ok())
.filter(|value| *value != libremetaverse::types::UUID::zero())
.ok_or_else(|| {
WebRtcError::Signaling("provision response omitted session ID".into())
})?;
let channel = response
.get("channel")
.and_then(|value| value.as_string().ok())
.filter(|value| !value.is_empty());
let credentials = response
.get("credentials")
.and_then(|value| value.as_string().ok())
.filter(|value| !value.is_empty())
.map(VoiceSecret::new)
.transpose()?;
Ok(ProvisionResponse {
answer_sdp,
viewer_session,
channel,
credentials,
})
})
}
fn complete(
&self,
request: SignalingCompleteRequest,
) -> Pin<Box<dyn Future<Output = Result<(), WebRtcError>> + Send + '_>> {
Box::pin(async move {
let mut complete = HashMap::new();
complete.insert(
"completed".into(),
OSD::Boolean(request.candidate.completed),
);
let mut body = HashMap::new();
body.insert(
"voice_server_type".into(),
OSD::String(request.voice_server_type),
);
body.insert("viewer_session".into(), OSD::String(request.viewer_session));
body.insert("candidate".into(), OSD::Map(complete));
let _ = self.post(&self.signaling_uri, OSD::Map(body)).await?;
Ok(())
})
}
fn close(
&self,
request: CloseRequest,
) -> Pin<Box<dyn Future<Output = Result<(), WebRtcError>> + Send + '_>> {
Box::pin(async move {
let mut body = HashMap::new();
body.insert("logout".into(), OSD::Boolean(request.logout));
body.insert(
"voice_server_type".into(),
OSD::String(request.voice_server_type),
);
body.insert("viewer_session".into(), OSD::String(request.viewer_session));
if let Some(channel) = request.channel {
body.insert("channel".into(), OSD::String(channel));
}
if let Some(credentials) = request.credentials {
body.insert(
"credentials".into(),
OSD::String(credentials.expose().to_owned()),
);
}
let _ = self.post(&self.provision_uri, OSD::Map(body)).await?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_mode_is_doubly_gated_and_does_not_echo_password() {
let cli = Cli::try_parse_from(["webrtc-test", "First", "Last", "secret"]).unwrap();
let error = tokio::runtime::Runtime::new()
.unwrap()
.block_on(run(cli))
.unwrap_err();
assert!(matches!(error, ProgramError::Usage(_)));
assert!(!error.to_string().contains("secret"));
}
#[test]
fn bind_discovery_returns_a_concrete_address() {
assert!(!discover_bind_ip().is_unspecified());
}
#[test]
fn grid_user_requires_the_repository_first_last_shape() {
assert_eq!(
split_grid_user("First Last").unwrap(),
("First".to_owned(), "Last".to_owned())
);
assert!(split_grid_user("First").is_err());
assert!(split_grid_user("First Middle Last").is_err());
}
}

View File

@@ -0,0 +1,103 @@
use std::process::{Command, Output, Stdio};
const EXIT_USAGE: i32 = 2;
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_webrtc-test"))
.args(args)
.env_remove("GRID_FIRST_NAME")
.env_remove("GRID_LAST_NAME")
.env_remove("GRID_PASSWORD")
.env_remove("GRID_LOGIN_URL")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run webrtc-test")
}
fn text(bytes: &[u8]) -> &str {
std::str::from_utf8(bytes).expect("UTF-8 process output")
}
#[test]
fn help_documents_native_prerequisites_fake_flow_and_live_gates() {
let output = run(&["--help"]);
assert!(output.status.success());
let help = text(&output.stdout);
for marker in [
"--fake",
"--list-devices",
"--allow-live-login",
"--confirm-live-login",
"--allow-session-audio",
"--input-device",
"--output-device",
"--wav",
"ICE/DTLS/SRTP/SCTP",
"Native libopus",
"--features real-audio",
] {
assert!(help.contains(marker), "help omitted {marker}:\n{help}");
}
assert_eq!(run(&[]).status.code(), Some(EXIT_USAGE));
}
#[test]
fn fake_peer_exercises_media_data_channel_and_clean_teardown() {
let output = run(&["--fake", "--timeout-seconds", "10"]);
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
text(&output.stdout),
text(&output.stderr)
);
let stdout = text(&output.stdout);
for marker in [
"ice=connected",
"dtls=connected",
"srtp=opus",
"sctp=SLData",
"sent-audio=4",
"active-peer-tasks=0",
"active-audio-tasks=0",
] {
assert!(
stdout.contains(marker),
"output omitted {marker}:\n{stdout}"
);
}
assert!(output.stderr.is_empty(), "{}", text(&output.stderr));
}
#[test]
fn virtual_devices_are_available_without_physical_hardware() {
let output = run(&["--list-devices"]);
assert!(output.status.success(), "{}", text(&output.stderr));
let stdout = text(&output.stdout);
assert!(stdout.contains("input\tvirtual:microphone"));
assert!(stdout.contains("output\tvirtual:speaker"));
}
#[test]
fn live_inputs_are_rejected_before_network_without_both_opt_ins_or_secret_echo() {
let output = run(&["First", "Last", "do-not-echo"]);
assert_eq!(output.status.code(), Some(EXIT_USAGE));
assert!(text(&output.stderr).contains("--allow-live-login"));
assert!(!text(&output.stderr).contains("do-not-echo"));
let output = run(&[
"First",
"Last",
"do-not-echo",
"--allow-live-login",
"--confirm-live-login",
"WRONG",
]);
assert_eq!(output.status.code(), Some(EXIT_USAGE));
assert!(!text(&output.stderr).contains("do-not-echo"));
let output = run(&["First", "Last", "do-not-echo", "--fake"]);
assert_eq!(output.status.code(), Some(EXIT_USAGE));
assert!(!text(&output.stderr).contains("do-not-echo"));
}