every.channel: sanitized baseline
This commit is contained in:
commit
897e556bea
258 changed files with 74298 additions and 0 deletions
16
crates/ec-direct/Cargo.toml
Normal file
16
crates/ec-direct/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "ec-direct"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64 = "0.22"
|
||||
just-webrtc = { version = "0.2", default-features = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
bytes = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||
94
crates/ec-direct/src/lib.rs
Normal file
94
crates/ec-direct/src/lib.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use just_webrtc::types::{ICECandidate, SessionDescription};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DirectCodeV1 {
|
||||
pub v: u8,
|
||||
pub desc: SessionDescription,
|
||||
pub candidates: Vec<ICECandidate>,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
const PREFIX: &str = "every.channel://";
|
||||
|
||||
pub fn encode_code(code: &DirectCodeV1) -> Result<String> {
|
||||
let json = serde_json::to_vec(code)?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(json))
|
||||
}
|
||||
|
||||
pub fn decode_code(code: &str) -> Result<DirectCodeV1> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(code.trim())
|
||||
.context("invalid base64url code")?;
|
||||
let parsed: DirectCodeV1 = serde_json::from_slice(&bytes).context("invalid code json")?;
|
||||
if parsed.v != 1 {
|
||||
return Err(anyhow!("unsupported direct code version {}", parsed.v));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn build_direct_link(code_b64: &str) -> String {
|
||||
format!("every.channel://direct?c={code_b64}")
|
||||
}
|
||||
|
||||
pub fn encode_direct_link(code: &DirectCodeV1) -> Result<String> {
|
||||
let b64 = encode_code(code)?;
|
||||
Ok(build_direct_link(&b64))
|
||||
}
|
||||
|
||||
pub fn decode_direct_link(link_or_code: &str) -> Result<DirectCodeV1> {
|
||||
let s = link_or_code.trim();
|
||||
if !s.starts_with(PREFIX) {
|
||||
return decode_code(s);
|
||||
}
|
||||
let rest = &s[PREFIX.len()..];
|
||||
let (path, query) = rest.split_once('?').ok_or_else(|| anyhow!("missing '?'"))?;
|
||||
if !path.eq_ignore_ascii_case("direct") {
|
||||
return Err(anyhow!("not a direct link"));
|
||||
}
|
||||
for pair in query.split('&') {
|
||||
let pair = pair.trim();
|
||||
if pair.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if k.eq_ignore_ascii_case("c") {
|
||||
return decode_code(v);
|
||||
}
|
||||
}
|
||||
Err(anyhow!("missing code parameter"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use just_webrtc::types::SDPType;
|
||||
|
||||
#[test]
|
||||
fn code_roundtrips() {
|
||||
let code = DirectCodeV1 {
|
||||
v: 1,
|
||||
desc: SessionDescription {
|
||||
sdp_type: SDPType::Offer,
|
||||
sdp: "x".to_string(),
|
||||
},
|
||||
candidates: vec![ICECandidate {
|
||||
candidate: "c".to_string(),
|
||||
sdp_mid: Some("0".to_string()),
|
||||
sdp_mline_index: Some(0),
|
||||
username_fragment: None,
|
||||
}],
|
||||
label: Some("ec".to_string()),
|
||||
};
|
||||
let enc = encode_code(&code).unwrap();
|
||||
let dec = decode_code(&enc).unwrap();
|
||||
assert_eq!(dec, code);
|
||||
let link = encode_direct_link(&code).unwrap();
|
||||
let dec2 = decode_direct_link(&link).unwrap();
|
||||
assert_eq!(dec2, code);
|
||||
}
|
||||
}
|
||||
134
crates/ec-direct/tests/e2e_loopback.rs
Normal file
134
crates/ec-direct/tests/e2e_loopback.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use bytes::Bytes;
|
||||
use ec_direct::{decode_direct_link, encode_direct_link, DirectCodeV1};
|
||||
use just_webrtc::types::{
|
||||
DataChannelOptions, PeerConfiguration, PeerConnectionState, SessionDescription,
|
||||
};
|
||||
use just_webrtc::{DataChannelExt, PeerConnectionBuilder, PeerConnectionExt};
|
||||
|
||||
async fn wait_connected(pc: &impl PeerConnectionExt) -> Result<()> {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
match pc.state_change().await {
|
||||
PeerConnectionState::Connected => break Ok(()),
|
||||
PeerConnectionState::Failed => break Err(anyhow!("peer connection failed")),
|
||||
PeerConnectionState::Closed => break Err(anyhow!("peer connection closed")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow!("timed out waiting for peer connection"))?
|
||||
}
|
||||
|
||||
// Ignored by default: WebRTC can be timing-sensitive on some hosts.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn e2e_direct_connect_loopback_sends_bytes() -> Result<()> {
|
||||
// Avoid depending on external STUN servers in tests: use host candidates only.
|
||||
let cfg = PeerConfiguration {
|
||||
ice_servers: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let offerer = PeerConnectionBuilder::new()
|
||||
.set_config(cfg.clone())
|
||||
.with_channel_options(vec![(
|
||||
"simple_channel_".to_string(),
|
||||
DataChannelOptions::default(),
|
||||
)])
|
||||
.map_err(|e| anyhow!("{e:#}"))?
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
|
||||
let offer_desc: SessionDescription = offerer
|
||||
.get_local_description()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("missing offer local description"))?;
|
||||
let offer_candidates = offerer
|
||||
.collect_ice_candidates()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let offer_link = encode_direct_link(&DirectCodeV1 {
|
||||
v: 1,
|
||||
desc: offer_desc,
|
||||
candidates: offer_candidates,
|
||||
label: Some("every.channel0".to_string()),
|
||||
})?;
|
||||
|
||||
let offer_code = decode_direct_link(&offer_link)?;
|
||||
let answerer = PeerConnectionBuilder::new()
|
||||
.set_config(cfg.clone())
|
||||
.with_remote_offer(Some(offer_code.desc.clone()))
|
||||
.map_err(|e| anyhow!("{e:#}"))?
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
answerer
|
||||
.add_ice_candidates(offer_code.candidates.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let answer_desc = answerer
|
||||
.get_local_description()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("missing answer local description"))?;
|
||||
let answer_candidates = answerer
|
||||
.collect_ice_candidates()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let answer_link = encode_direct_link(&DirectCodeV1 {
|
||||
v: 1,
|
||||
desc: answer_desc,
|
||||
candidates: answer_candidates,
|
||||
label: Some("every.channel0".to_string()),
|
||||
})?;
|
||||
|
||||
let answer_code = decode_direct_link(&answer_link)?;
|
||||
offerer
|
||||
.set_remote_description(answer_code.desc.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
offerer
|
||||
.add_ice_candidates(answer_code.candidates.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
|
||||
// Wait for both peers to report a full connection before waiting for the data channel.
|
||||
wait_connected(&offerer).await?;
|
||||
wait_connected(&answerer).await?;
|
||||
|
||||
let offerer_ch = offerer
|
||||
.receive_channel()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let answerer_ch = answerer
|
||||
.receive_channel()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
offerer_ch.wait_ready().await;
|
||||
answerer_ch.wait_ready().await;
|
||||
|
||||
let payload = Bytes::from_static(b"hello");
|
||||
offerer_ch
|
||||
.send(&payload)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let got = tokio::time::timeout(std::time::Duration::from_secs(10), answerer_ch.receive())
|
||||
.await
|
||||
.map_err(|_| anyhow!("timed out waiting for receive"))?
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
assert_eq!(&got[..], b"hello");
|
||||
|
||||
// Confirm the reverse direction works too (this also guards against one-way readiness bugs).
|
||||
answerer_ch
|
||||
.send(&Bytes::from_static(b"world"))
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
let got = tokio::time::timeout(std::time::Duration::from_secs(10), offerer_ch.receive())
|
||||
.await
|
||||
.map_err(|_| anyhow!("timed out waiting for receive"))?
|
||||
.map_err(|e| anyhow!("{e:#}"))?;
|
||||
assert_eq!(&got[..], b"world");
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue