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
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:
@@ -68,7 +68,7 @@ jobs:
|
||||
- name: Install audited OpenJPEG 2.5.4
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes build-essential cmake pkg-config
|
||||
sudo apt-get install --yes build-essential cmake pkg-config libopus-dev libasound2-dev
|
||||
tools/install_openjpeg_2_5_4.sh "$OPENJPEG_PREFIX"
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -99,6 +99,10 @@ jobs:
|
||||
run: python3 tools/test_milestone_10.py
|
||||
- name: Compile every workspace target with bounded memory
|
||||
run: cargo check --workspace --all-targets --locked -j 1
|
||||
- name: Check optional cross-platform real audio backend
|
||||
run: |
|
||||
cargo check -p libremetaverse-voice-webrtc --all-targets --features real-audio --locked -j 1
|
||||
cargo check -p libremetaverse-programs --bin webrtc-test --features real-audio --locked -j 1
|
||||
- name: Lint every native extension target with bounded memory
|
||||
run: cargo clippy -p libremetaverse-rendering-simple -p libremetaverse-rendering-mesh-foundry -p libremetaverse-rlv -p libremetaverse-lsl-tools --all-targets --locked -j 1 -- -D warnings
|
||||
- name: Document every native extension crate
|
||||
|
||||
1335
Cargo.lock
generated
1335
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,23 @@ repository.workspace = true
|
||||
description = "WebRTC voice shims for the MetaCrate LibreMetaverse rewrite"
|
||||
|
||||
[dependencies]
|
||||
audiopus = "0.2"
|
||||
hound = "3.5"
|
||||
libremetaverse = { path = "../libremetaverse" }
|
||||
libremetaverse-structured-data = { path = "../libremetaverse-structured-data" }
|
||||
libremetaverse-types = { path = "../libremetaverse-types" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
str0m = { version = "0.22", default-features = false, features = ["rust-crypto"] }
|
||||
tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
real-audio = ["dep:cpal"]
|
||||
|
||||
[dependencies.cpal]
|
||||
version = "0.18"
|
||||
optional = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
extern crate self as libremetaverse_voice_webrtc;
|
||||
|
||||
mod generated;
|
||||
mod native;
|
||||
|
||||
pub use generated::*;
|
||||
pub use libremetaverse as core;
|
||||
pub use libremetaverse_structured_data as structured_data;
|
||||
pub use libremetaverse_types::Error;
|
||||
pub use native::*;
|
||||
|
||||
/// Project-owned boundary types for external JSON signatures.
|
||||
pub mod signaling {
|
||||
|
||||
1852
crates/libremetaverse-voice-webrtc/src/native.rs
Normal file
1852
crates/libremetaverse-voice-webrtc/src/native.rs
Normal file
File diff suppressed because it is too large
Load Diff
80
docs/webrtc.md
Normal file
80
docs/webrtc.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Native WebRTC voice validation
|
||||
|
||||
The `libremetaverse-voice-webrtc` native API owns the complete WebRTC voice
|
||||
path. `WebRtcVoiceSession::connect` binds a caller-selected interface, creates
|
||||
an Opus send/receive m-line and ordered `SLData` channel, posts the grid's
|
||||
provision body through a `VoiceSignaling` implementation, applies the SDP
|
||||
answer, and drives ICE, DTLS, SRTP, RTP, and SCTP from a single async run loop.
|
||||
There is no CLR, C# process, RPC bridge, proprietary SDK, or platform-specific
|
||||
fallback.
|
||||
|
||||
The grid wire shapes match the viewer protocol:
|
||||
|
||||
- Initial provisioning uses `jsep.type=offer`, `channel_type=local`, and
|
||||
`voice_server_type=webrtc`, with an optional top-level `parcel_local_id`.
|
||||
- ICE completion uses the singular `candidate: { completed: true }` field and
|
||||
the opaque viewer-session ID.
|
||||
- Teardown sends `logout=true`, then disconnects and joins the local peer loop.
|
||||
|
||||
Capability URLs, SDP, channel credentials, login secrets, and viewer-session
|
||||
credentials are not printed. `VoiceSecret` always formats as `<redacted>`.
|
||||
Incoming SDP and `SLData` messages are bounded and validated before use.
|
||||
|
||||
## Deterministic validation
|
||||
|
||||
`webrtc-test --fake` is the CI path. `LoopbackSignaling` accepts the real offer,
|
||||
creates a second native peer, and exchanges UDP ICE checks, DTLS, encrypted
|
||||
Opus RTP, and SCTP `SLData` on loopback. It publishes peer audio/position and
|
||||
mute/gain maps; the client replies with join, mute, clamped gain, position,
|
||||
ping/pong, and leave. A generated virtual 48 kHz tone is encoded with libopus,
|
||||
echoed by the remote peer, decoded into the virtual sink, and counted. Shutdown
|
||||
awaits both peers and reports zero peer/audio tasks.
|
||||
|
||||
WAV playback accepts bounded integer or float PCM, downmixes all channels,
|
||||
linearly resamples to 48 kHz mono, encodes 20 ms Opus frames, and paces them on
|
||||
the same peer loop. This keeps virtual-audio tests deterministic and prevents
|
||||
orphan playback tasks.
|
||||
|
||||
## Native prerequisites
|
||||
|
||||
The default virtual path requires native libopus development files at build
|
||||
time. Typical packages are:
|
||||
|
||||
- Ubuntu/Debian: `libopus-dev` (and `pkg-config`).
|
||||
- Fedora: `opus-devel`.
|
||||
- Windows MSVC: `audiopus` supplies supported prebuilt Opus libraries; a custom
|
||||
libopus can be selected with `OPUS_LIB_DIR`/`LIBOPUS_LIB_DIR`.
|
||||
- macOS: install `opus` with the system package manager when it is not already
|
||||
discoverable by `pkg-config`.
|
||||
|
||||
Real hardware is separately opt-in. Build the program with
|
||||
`--features real-audio`; CPAL uses ALSA on Linux, WASAPI on Windows, and
|
||||
CoreAudio on macOS. Linux builders need ALSA development headers (for example
|
||||
`libasound2-dev`). List stable endpoint IDs with `webrtc-test --list-devices`,
|
||||
then pass `--input-device cpal:input:...` and/or
|
||||
`--output-device cpal:output:...`. Selected capture and render streams are
|
||||
opened only during the session and dropped after the peer loop is joined.
|
||||
|
||||
## Live grid gate
|
||||
|
||||
Credential-only capability validation:
|
||||
|
||||
```sh
|
||||
webrtc-test --allow-live-login --confirm-live-login LOGIN
|
||||
```
|
||||
|
||||
The command reads `GRID_USER="First Last"`, `GRID_PASSWORD`, and
|
||||
`GRID_LOGIN_URL` from the process environment or the workspace `.env`, matching
|
||||
the compatibility-test convention. Positional names/password and the legacy
|
||||
`GRID_FIRST_NAME`/`GRID_LAST_NAME` pair remain supported. Merely having a
|
||||
credential file never permits a login: both command-line confirmations above
|
||||
are still mandatory.
|
||||
|
||||
Creating a live voice session and sending/receiving media requires the separate
|
||||
`--allow-session-audio` flag. `--bind-ip` can select the concrete interface
|
||||
advertised in the host ICE candidate; otherwise the program discovers the
|
||||
preferred route without sending a packet. `--wav FILE` starts looping microphone
|
||||
playback. Interactive commands preserve the upstream tool's `peers`, `mute`,
|
||||
`gain`, `playwav`, `stopwav`, and `quit` controls; `--commands FILE` supplies the
|
||||
same commands non-interactively. Grid logout and voice teardown run even when
|
||||
the validation body fails.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() -> std::process::ExitCode {
|
||||
libremetaverse_programs::pending_program("WebRtcTest")
|
||||
libremetaverse_programs::webrtc_test::main_entry()
|
||||
}
|
||||
|
||||
@@ -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
801
programs/src/webrtc_test.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
103
programs/tests/webrtc_test_cli.rs
Normal file
103
programs/tests/webrtc_test_cli.rs
Normal 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"));
|
||||
}
|
||||
@@ -20,6 +20,7 @@ libremetaverse-types = { path = "../../crates/libremetaverse-types" }
|
||||
libremetaverse-utilities = { path = "../../crates/libremetaverse-utilities" }
|
||||
libremetaverse-voice-vivox = { path = "../../crates/libremetaverse-voice-vivox" }
|
||||
libremetaverse-voice-webrtc = { path = "../../crates/libremetaverse-voice-webrtc" }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
|
||||
|
||||
[lints]
|
||||
|
||||
57
tests/compat/tests/webrtc_protocol_semantics.rs
Normal file
57
tests/compat/tests/webrtc_protocol_semantics.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use libremetaverse::types::UUID;
|
||||
use libremetaverse_voice_webrtc::{
|
||||
CloseRequest, Jsep, ProvisionRequest, SignalingComplete, SignalingCompleteRequest, VoiceSecret,
|
||||
encode_pcm_48k_mono,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn local_provision_matches_web_rtc_capability_wire_shape() {
|
||||
let request = ProvisionRequest {
|
||||
jsep: Jsep {
|
||||
kind: "offer".into(),
|
||||
sdp: "v=0\r\n".into(),
|
||||
},
|
||||
channel_type: "local".into(),
|
||||
voice_server_type: "webrtc".into(),
|
||||
parcel_local_id: Some(42),
|
||||
};
|
||||
let value = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(value["jsep"]["type"], "offer");
|
||||
assert_eq!(value["parcel_local_id"], 42);
|
||||
assert_eq!(value["channel_type"], "local");
|
||||
assert_eq!(value["voice_server_type"], "webrtc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_uses_singular_candidate_and_close_is_explicit() {
|
||||
let session = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap();
|
||||
let completion = serde_json::to_value(SignalingCompleteRequest {
|
||||
voice_server_type: "webrtc".into(),
|
||||
viewer_session: session.to_string(),
|
||||
candidate: SignalingComplete { completed: true },
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(completion["candidate"]["completed"], true);
|
||||
assert!(completion.get("candidates").is_none());
|
||||
|
||||
let close = serde_json::to_value(CloseRequest {
|
||||
logout: true,
|
||||
voice_server_type: "webrtc".into(),
|
||||
viewer_session: session.to_string(),
|
||||
channel: None,
|
||||
credentials: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(close["logout"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_opus_frames_are_nonempty_and_credentials_are_redacted() {
|
||||
let frames = encode_pcm_48k_mono(&vec![0_i16; 1_920]).unwrap();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert!(frames.iter().all(|frame| !frame.0.is_empty()));
|
||||
assert_eq!(
|
||||
format!("{:?}", VoiceSecret::new("capability-token").unwrap()),
|
||||
"VoiceSecret(<redacted>)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user