Compare commits
2 Commits
feature/ws
...
09a18b086b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a18b086b | ||
|
|
f3c8e11995 |
60
Cargo.lock
generated
60
Cargo.lock
generated
@@ -169,7 +169,6 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core 0.4.5",
|
"axum-core 0.4.5",
|
||||||
"base64",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -185,10 +184,8 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.24.0",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -223,7 +220,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.28.0",
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -383,12 +380,6 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -3149,18 +3140,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"log",
|
|
||||||
"tokio",
|
|
||||||
"tungstenite 0.24.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -3170,7 +3149,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite 0.28.0",
|
"tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3387,24 +3366,6 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"bytes",
|
|
||||||
"data-encoding",
|
|
||||||
"http",
|
|
||||||
"httparse",
|
|
||||||
"log",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"sha1",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -4267,7 +4228,6 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.7.9",
|
"axum 0.7.9",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"quinn",
|
"quinn",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4276,7 +4236,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tower-http",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"wzp-client",
|
"wzp-client",
|
||||||
@@ -4302,6 +4261,21 @@ dependencies = [
|
|||||||
"wzp-proto",
|
"wzp-proto",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wzp-wasm"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20poly1305",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hkdf",
|
||||||
|
"js-sys",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"raptorq",
|
||||||
|
"sha2",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"x25519-dalek",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wzp-web"
|
name = "wzp-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"crates/wzp-relay",
|
"crates/wzp-relay",
|
||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
|
"crates/wzp-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -46,23 +46,6 @@ impl MediaHeader {
|
|||||||
/// Header size in bytes on the wire.
|
/// Header size in bytes on the wire.
|
||||||
pub const WIRE_SIZE: usize = 12;
|
pub const WIRE_SIZE: usize = 12;
|
||||||
|
|
||||||
/// Create a default header for raw PCM relay (used by WebSocket bridge).
|
|
||||||
pub fn default_pcm() -> Self {
|
|
||||||
Self {
|
|
||||||
version: 0,
|
|
||||||
is_repair: false,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
has_quality_report: false,
|
|
||||||
fec_ratio_encoded: 0,
|
|
||||||
seq: 0,
|
|
||||||
timestamp: 0,
|
|
||||||
fec_block: 0,
|
|
||||||
fec_symbol: 0,
|
|
||||||
reserved: 0,
|
|
||||||
csrc_count: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
|
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
|
||||||
pub fn encode_fec_ratio(ratio: f32) -> u8 {
|
pub fn encode_fec_ratio(ratio: f32) -> u8 {
|
||||||
// Map 0.0-2.0 to 0-127, clamping at 127
|
// Map 0.0-2.0 to 0-127, clamping at 127
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ serde_json = "1"
|
|||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
quinn = { workspace = true }
|
quinn = { workspace = true }
|
||||||
prometheus = "0.13"
|
prometheus = "0.13"
|
||||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1"] }
|
||||||
tower-http = { version = "0.6", features = ["fs"] }
|
|
||||||
futures-util = "0.3"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-relay"
|
name = "wzp-relay"
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ pub struct RelayConfig {
|
|||||||
/// reducing per-packet QUIC datagram overhead.
|
/// reducing per-packet QUIC datagram overhead.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trunking_enabled: bool,
|
pub trunking_enabled: bool,
|
||||||
/// Port for the WebSocket listener (browser clients connect here).
|
|
||||||
/// If None, WebSocket support is disabled.
|
|
||||||
pub ws_port: Option<u16>,
|
|
||||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
|
||||||
pub static_dir: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RelayConfig {
|
impl Default for RelayConfig {
|
||||||
@@ -60,8 +55,6 @@ impl Default for RelayConfig {
|
|||||||
probe_targets: Vec::new(),
|
probe_targets: Vec::new(),
|
||||||
probe_mesh: false,
|
probe_mesh: false,
|
||||||
trunking_enabled: false,
|
trunking_enabled: false,
|
||||||
ws_port: None,
|
|
||||||
static_dir: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ pub mod room;
|
|||||||
pub mod route;
|
pub mod route;
|
||||||
pub mod session_mgr;
|
pub mod session_mgr;
|
||||||
pub mod trunk;
|
pub mod trunk;
|
||||||
pub mod ws;
|
|
||||||
|
|
||||||
pub use config::RelayConfig;
|
pub use config::RelayConfig;
|
||||||
pub use handshake::accept_handshake;
|
pub use handshake::accept_handshake;
|
||||||
|
|||||||
@@ -68,19 +68,6 @@ fn parse_args() -> RelayConfig {
|
|||||||
"--trunking" => {
|
"--trunking" => {
|
||||||
config.trunking_enabled = true;
|
config.trunking_enabled = true;
|
||||||
}
|
}
|
||||||
"--ws-port" => {
|
|
||||||
i += 1;
|
|
||||||
config.ws_port = Some(
|
|
||||||
args.get(i).expect("--ws-port requires a port number")
|
|
||||||
.parse().expect("invalid --ws-port number"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--static-dir" => {
|
|
||||||
i += 1;
|
|
||||||
config.static_dir = Some(
|
|
||||||
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--mesh-status" => {
|
"--mesh-status" => {
|
||||||
// Print mesh table from a fresh registry and exit.
|
// Print mesh table from a fresh registry and exit.
|
||||||
// In practice this is useful after the relay has been running;
|
// In practice this is useful after the relay has been running;
|
||||||
@@ -102,8 +89,6 @@ fn parse_args() -> RelayConfig {
|
|||||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
||||||
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
|
||||||
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Room mode (default):");
|
eprintln!("Room mode (default):");
|
||||||
eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU).");
|
eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU).");
|
||||||
@@ -248,20 +233,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tokio::spawn(async move { mesh.run_all().await });
|
tokio::spawn(async move { mesh.run_all().await });
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket server for browser clients
|
|
||||||
if let Some(ws_port) = config.ws_port {
|
|
||||||
let ws_state = wzp_relay::ws::WsState {
|
|
||||||
room_mgr: room_mgr.clone(),
|
|
||||||
session_mgr: session_mgr.clone(),
|
|
||||||
auth_url: config.auth_url.clone(),
|
|
||||||
metrics: metrics.clone(),
|
|
||||||
presence: presence.clone(),
|
|
||||||
};
|
|
||||||
let static_dir = config.static_dir.clone();
|
|
||||||
tokio::spawn(wzp_relay::ws::run_ws_server(ws_port, ws_state, static_dir));
|
|
||||||
info!(ws_port, "WebSocket listener enabled for browser clients");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref url) = config.auth_url {
|
if let Some(ref url) = config.auth_url {
|
||||||
info!(url, "auth enabled — clients must present featherChat token");
|
info!(url, "auth enabled — clients must present featherChat token");
|
||||||
} else {
|
} else {
|
||||||
@@ -502,7 +473,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let participant_id = {
|
let participant_id = {
|
||||||
let mut mgr = room_mgr.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) {
|
match mgr.join(&room_name, addr, transport.clone(), authenticated_fp.as_deref()) {
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -27,51 +27,11 @@ fn next_id() -> ParticipantId {
|
|||||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum ParticipantSender {
|
|
||||||
Quic(Arc<wzp_transport::QuinnTransport>),
|
|
||||||
WebSocket(tokio::sync::mpsc::Sender<Bytes>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParticipantSender {
|
|
||||||
/// Send raw bytes to this participant.
|
|
||||||
pub async fn send_raw(&self, data: &[u8]) -> Result<(), String> {
|
|
||||||
match self {
|
|
||||||
ParticipantSender::WebSocket(tx) => {
|
|
||||||
tx.try_send(Bytes::copy_from_slice(data))
|
|
||||||
.map_err(|e| format!("ws send: {e}"))
|
|
||||||
}
|
|
||||||
ParticipantSender::Quic(transport) => {
|
|
||||||
let pkt = wzp_proto::MediaPacket {
|
|
||||||
header: wzp_proto::packet::MediaHeader::default_pcm(),
|
|
||||||
payload: Bytes::copy_from_slice(data),
|
|
||||||
quality_report: None,
|
|
||||||
};
|
|
||||||
transport.send_media(&pkt).await.map_err(|e| format!("quic send: {e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a QUIC participant.
|
|
||||||
pub fn is_quic(&self) -> bool {
|
|
||||||
matches!(self, ParticipantSender::Quic(_))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the QUIC transport if this is a QUIC participant.
|
|
||||||
pub fn as_quic(&self) -> Option<&Arc<wzp_transport::QuinnTransport>> {
|
|
||||||
match self {
|
|
||||||
ParticipantSender::Quic(t) => Some(t),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A participant in a room.
|
/// A participant in a room.
|
||||||
struct Participant {
|
struct Participant {
|
||||||
id: ParticipantId,
|
id: ParticipantId,
|
||||||
_addr: std::net::SocketAddr,
|
_addr: std::net::SocketAddr,
|
||||||
sender: ParticipantSender,
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A room holding multiple participants.
|
/// A room holding multiple participants.
|
||||||
@@ -86,10 +46,10 @@ impl Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId {
|
fn add(&mut self, addr: std::net::SocketAddr, transport: Arc<wzp_transport::QuinnTransport>) -> ParticipantId {
|
||||||
let id = next_id();
|
let id = next_id();
|
||||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||||
self.participants.push(Participant { id, _addr: addr, sender });
|
self.participants.push(Participant { id, _addr: addr, transport });
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,11 +58,11 @@ impl Room {
|
|||||||
info!(room_size = self.participants.len(), participant = id, "left room");
|
info!(room_size = self.participants.len(), participant = id, "left room");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn others(&self, exclude_id: ParticipantId) -> Vec<ParticipantSender> {
|
fn others(&self, exclude_id: ParticipantId) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
||||||
self.participants
|
self.participants
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.id != exclude_id)
|
.filter(|p| p.id != exclude_id)
|
||||||
.map(|p| p.sender.clone())
|
.map(|p| p.transport.clone())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +130,7 @@ impl RoomManager {
|
|||||||
&mut self,
|
&mut self,
|
||||||
room_name: &str,
|
room_name: &str,
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
sender: ParticipantSender,
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
fingerprint: Option<&str>,
|
fingerprint: Option<&str>,
|
||||||
) -> Result<ParticipantId, String> {
|
) -> Result<ParticipantId, String> {
|
||||||
if !self.is_authorized(room_name, fingerprint) {
|
if !self.is_authorized(room_name, fingerprint) {
|
||||||
@@ -178,18 +138,7 @@ impl RoomManager {
|
|||||||
return Err("not authorized for this room".to_string());
|
return Err("not authorized for this room".to_string());
|
||||||
}
|
}
|
||||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||||
Ok(room.add(addr, sender))
|
Ok(room.add(addr, transport))
|
||||||
}
|
|
||||||
|
|
||||||
/// Join a room via WebSocket. Convenience wrapper around `join()`.
|
|
||||||
pub fn join_ws(
|
|
||||||
&mut self,
|
|
||||||
room_name: &str,
|
|
||||||
addr: std::net::SocketAddr,
|
|
||||||
sender: tokio::sync::mpsc::Sender<Bytes>,
|
|
||||||
fingerprint: Option<&str>,
|
|
||||||
) -> Result<ParticipantId, String> {
|
|
||||||
self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leave a room. Removes the room if empty.
|
/// Leave a room. Removes the room if empty.
|
||||||
@@ -203,12 +152,12 @@ impl RoomManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get senders for all OTHER participants in a room.
|
/// Get transports for all OTHER participants in a room.
|
||||||
pub fn others(
|
pub fn others(
|
||||||
&self,
|
&self,
|
||||||
room_name: &str,
|
room_name: &str,
|
||||||
participant_id: ParticipantId,
|
participant_id: ParticipantId,
|
||||||
) -> Vec<ParticipantSender> {
|
) -> Vec<Arc<wzp_transport::QuinnTransport>> {
|
||||||
self.rooms
|
self.rooms
|
||||||
.get(room_name)
|
.get(room_name)
|
||||||
.map(|r| r.others(participant_id))
|
.map(|r| r.others(participant_id))
|
||||||
@@ -356,14 +305,10 @@ async fn run_participant_plain(
|
|||||||
// Forward to all others
|
// Forward to all others
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
for other in &others {
|
for other in &others {
|
||||||
match other {
|
// Best-effort: if one send fails, continue to others
|
||||||
ParticipantSender::Quic(t) => {
|
if let Err(e) = other.send_media(&pkt).await {
|
||||||
let _ = t.send_media(&pkt).await;
|
// Don't log every failure — they'll be cleaned up when their recv loop breaks
|
||||||
}
|
let _ = e;
|
||||||
ParticipantSender::WebSocket(_) => {
|
|
||||||
// WS clients receive raw payload bytes
|
|
||||||
let _ = other.send_raw(&pkt.payload).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,20 +390,12 @@ async fn run_participant_trunked(
|
|||||||
|
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
for other in &others {
|
for other in &others {
|
||||||
match other {
|
let peer_addr = other.connection().remote_address();
|
||||||
ParticipantSender::Quic(t) => {
|
let fwd = forwarders
|
||||||
let peer_addr = t.connection().remote_address();
|
.entry(peer_addr)
|
||||||
let fwd = forwarders
|
.or_insert_with(|| TrunkedForwarder::new(other.clone(), sid_bytes));
|
||||||
.entry(peer_addr)
|
if let Err(e) = fwd.send(&pkt).await {
|
||||||
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
let _ = e;
|
||||||
if let Err(e) = fwd.send(&pkt).await {
|
|
||||||
let _ = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ParticipantSender::WebSocket(_) => {
|
|
||||||
// WS clients bypass trunking — send raw payload directly
|
|
||||||
let _ = other.send_raw(&pkt.payload).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
//! WebSocket transport for browser clients.
|
|
||||||
//!
|
|
||||||
//! Browsers connect via `GET /ws/{room}` → WebSocket upgrade.
|
|
||||||
//! First message must be auth JSON (if auth is enabled).
|
|
||||||
//! Subsequent messages are binary PCM frames forwarded to/from the room.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::{
|
|
||||||
ws::{Message, WebSocket},
|
|
||||||
Path, State, WebSocketUpgrade,
|
|
||||||
},
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::get,
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
use crate::auth;
|
|
||||||
use crate::metrics::RelayMetrics;
|
|
||||||
use crate::presence::PresenceRegistry;
|
|
||||||
use crate::room::RoomManager;
|
|
||||||
use crate::session_mgr::SessionManager;
|
|
||||||
|
|
||||||
/// Shared state for WebSocket handlers.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsState {
|
|
||||||
pub room_mgr: Arc<Mutex<RoomManager>>,
|
|
||||||
pub session_mgr: Arc<Mutex<SessionManager>>,
|
|
||||||
pub auth_url: Option<String>,
|
|
||||||
pub metrics: Arc<RelayMetrics>,
|
|
||||||
pub presence: Arc<Mutex<PresenceRegistry>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the WebSocket + static file server.
|
|
||||||
pub async fn run_ws_server(port: u16, state: WsState, static_dir: Option<String>) {
|
|
||||||
let mut app = Router::new()
|
|
||||||
.route("/ws/{room}", get(ws_upgrade_handler))
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
if let Some(dir) = static_dir {
|
|
||||||
info!(dir = %dir, "serving static files");
|
|
||||||
app = app.fallback_service(ServeDir::new(dir));
|
|
||||||
}
|
|
||||||
|
|
||||||
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
|
|
||||||
info!(%addr, "WebSocket server listening");
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr)
|
|
||||||
.await
|
|
||||||
.expect("failed to bind WS listener");
|
|
||||||
axum::serve(listener, app).await.expect("WS server failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ws_upgrade_handler(
|
|
||||||
Path(room): Path<String>,
|
|
||||||
State(state): State<WsState>,
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
ws.on_upgrade(move |socket| handle_ws_connection(socket, room, state))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
|
|
||||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
|
||||||
|
|
||||||
// 1. Auth: if auth_url is set, first message must be {"type":"auth","token":"..."}
|
|
||||||
let fingerprint: Option<String> = if let Some(ref auth_url) = state.auth_url {
|
|
||||||
match ws_rx.next().await {
|
|
||||||
Some(Ok(Message::Text(text))) => {
|
|
||||||
match serde_json::from_str::<serde_json::Value>(&text) {
|
|
||||||
Ok(parsed) if parsed["type"] == "auth" => {
|
|
||||||
if let Some(token) = parsed["token"].as_str() {
|
|
||||||
match auth::validate_token(auth_url, token).await {
|
|
||||||
Ok(client) => {
|
|
||||||
state.metrics.auth_attempts.with_label_values(&["ok"]).inc();
|
|
||||||
info!(fingerprint = %client.fingerprint, "WS authenticated");
|
|
||||||
let _ = ws_tx
|
|
||||||
.send(Message::Text(r#"{"type":"auth_ok"}"#.into()))
|
|
||||||
.await;
|
|
||||||
Some(client.fingerprint)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
state
|
|
||||||
.metrics
|
|
||||||
.auth_attempts
|
|
||||||
.with_label_values(&["fail"])
|
|
||||||
.inc();
|
|
||||||
let _ = ws_tx
|
|
||||||
.send(Message::Text(
|
|
||||||
format!(r#"{{"type":"auth_error","error":"{e}"}}"#)
|
|
||||||
.into(),
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
warn!("WS auth failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("WS auth: missing token field");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
warn!("WS: expected auth message as first frame");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
warn!("WS: connection closed before auth");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let _ = ws_tx
|
|
||||||
.send(Message::Text(r#"{"type":"auth_ok"}"#.into()))
|
|
||||||
.await;
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Create mpsc channel for outbound frames (room → browser)
|
|
||||||
let (tx, mut rx) = mpsc::channel::<Bytes>(64);
|
|
||||||
|
|
||||||
// 3. Create session
|
|
||||||
let session_id = {
|
|
||||||
let mut smgr = state.session_mgr.lock().await;
|
|
||||||
match smgr.create_session(&room, fingerprint.clone()) {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(e) => {
|
|
||||||
error!(room = %room, "WS session rejected: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
state.metrics.active_sessions.inc();
|
|
||||||
|
|
||||||
// 4. Join room with WS sender
|
|
||||||
let addr: SocketAddr = ([0, 0, 0, 0], 0).into();
|
|
||||||
let participant_id = {
|
|
||||||
let mut mgr = state.room_mgr.lock().await;
|
|
||||||
match mgr.join_ws(&room, addr, tx, fingerprint.as_deref()) {
|
|
||||||
Ok(id) => {
|
|
||||||
state.metrics.active_rooms.set(mgr.list().len() as i64);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!(room = %room, "WS room join denied: {e}");
|
|
||||||
state.metrics.active_sessions.dec();
|
|
||||||
let mut smgr = state.session_mgr.lock().await;
|
|
||||||
smgr.remove_session(session_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. Register presence
|
|
||||||
if let Some(ref fp) = fingerprint {
|
|
||||||
let mut reg = state.presence.lock().await;
|
|
||||||
reg.register_local(fp, None, Some(room.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(room = %room, participant = participant_id, "WS client joined");
|
|
||||||
|
|
||||||
// 6. Outbound task: mpsc rx → WS binary frames
|
|
||||||
let send_task = tokio::spawn(async move {
|
|
||||||
while let Some(data) = rx.recv().await {
|
|
||||||
if ws_tx
|
|
||||||
.send(Message::Binary(data.to_vec().into()))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Inbound: WS recv → fan-out to room
|
|
||||||
loop {
|
|
||||||
match ws_rx.next().await {
|
|
||||||
Some(Ok(Message::Binary(data))) => {
|
|
||||||
let others = {
|
|
||||||
let mgr = state.room_mgr.lock().await;
|
|
||||||
mgr.others(&room, participant_id)
|
|
||||||
};
|
|
||||||
for other in &others {
|
|
||||||
let _ = other.send_raw(&data).await;
|
|
||||||
}
|
|
||||||
state
|
|
||||||
.metrics
|
|
||||||
.packets_forwarded
|
|
||||||
.inc_by(others.len() as u64);
|
|
||||||
state
|
|
||||||
.metrics
|
|
||||||
.bytes_forwarded
|
|
||||||
.inc_by(data.len() as u64 * others.len() as u64);
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Close(_))) | None => break,
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Cleanup
|
|
||||||
send_task.abort();
|
|
||||||
info!(room = %room, participant = participant_id, "WS client disconnected");
|
|
||||||
|
|
||||||
if let Some(ref fp) = fingerprint {
|
|
||||||
let mut reg = state.presence.lock().await;
|
|
||||||
reg.unregister_local(fp);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut mgr = state.room_mgr.lock().await;
|
|
||||||
mgr.leave(&room, participant_id);
|
|
||||||
state.metrics.active_rooms.set(mgr.list().len() as i64);
|
|
||||||
}
|
|
||||||
|
|
||||||
let session_id_str: String = session_id.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
state.metrics.remove_session_metrics(&session_id_str);
|
|
||||||
state.metrics.active_sessions.dec();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut smgr = state.session_mgr.lock().await;
|
|
||||||
smgr.remove_session(session_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ws_state_is_clone() {
|
|
||||||
// WsState must be Clone for axum's State extractor
|
|
||||||
fn assert_clone<T: Clone>() {}
|
|
||||||
assert_clone::<WsState>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
crates/wzp-wasm/Cargo.toml
Normal file
25
crates/wzp-wasm/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-wasm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
raptorq = "2"
|
||||||
|
js-sys = "0.3"
|
||||||
|
|
||||||
|
# Crypto (ChaCha20-Poly1305 + X25519 key exchange)
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
|
rand = "0.8"
|
||||||
|
getrandom = { version = "0.2", features = ["js"] } # CRITICAL for WASM randomness
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
692
crates/wzp-wasm/src/lib.rs
Normal file
692
crates/wzp-wasm/src/lib.rs
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
//! WarzonePhone WASM bindings.
|
||||||
|
//!
|
||||||
|
//! Exports two subsystems for browser-side usage:
|
||||||
|
//!
|
||||||
|
//! **FEC** — RaptorQ forward error correction (encode/decode).
|
||||||
|
//! Audio frames are padded to a fixed symbol size (default 256 bytes) with a
|
||||||
|
//! 2-byte little-endian length prefix, matching the native wzp-fec wire format.
|
||||||
|
//!
|
||||||
|
//! Wire format per symbol:
|
||||||
|
//! [block_id:1][symbol_idx:1][is_repair:1][symbol_data:symbol_size]
|
||||||
|
//!
|
||||||
|
//! Encoder output: concatenated symbols in the above format when a block completes.
|
||||||
|
//! Decoder input: individual symbols in the above format.
|
||||||
|
//! Decoder output: concatenated original source data (length-prefix stripped).
|
||||||
|
//!
|
||||||
|
//! **Crypto** — X25519 key exchange + ChaCha20-Poly1305 AEAD encryption.
|
||||||
|
//! Mirrors `wzp-crypto` nonce/session/handshake logic so WASM and native
|
||||||
|
//! peers produce interoperable ciphertext.
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use raptorq::{
|
||||||
|
EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder,
|
||||||
|
SourceBlockEncoder,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Header size prepended to each symbol on the wire: block_id + symbol_idx + is_repair.
|
||||||
|
const HEADER_SIZE: usize = 3;
|
||||||
|
|
||||||
|
/// Length prefix size inside each padded symbol (u16 LE), matching wzp-fec.
|
||||||
|
const LEN_PREFIX: usize = 2;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encoder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpFecEncoder {
|
||||||
|
block_id: u8,
|
||||||
|
frames_per_block: usize,
|
||||||
|
symbol_size: usize,
|
||||||
|
source_symbols: Vec<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpFecEncoder {
|
||||||
|
/// Create a new FEC encoder.
|
||||||
|
///
|
||||||
|
/// * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||||
|
/// * `symbol_size` — padded byte size of each symbol (default 256).
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(block_size: usize, symbol_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
block_id: 0,
|
||||||
|
frames_per_block: block_size,
|
||||||
|
symbol_size,
|
||||||
|
source_symbols: Vec::with_capacity(block_size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a source symbol (audio frame).
|
||||||
|
///
|
||||||
|
/// Returns encoded packets (all source + repair) when the block is complete,
|
||||||
|
/// or `undefined` if the block is still accumulating.
|
||||||
|
///
|
||||||
|
/// Each returned packet carries the 3-byte header:
|
||||||
|
/// `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||||
|
pub fn add_symbol(&mut self, data: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
self.source_symbols.push(data.to_vec());
|
||||||
|
|
||||||
|
if self.source_symbols.len() >= self.frames_per_block {
|
||||||
|
Some(self.encode_block())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-flush the current (possibly partial) block.
|
||||||
|
///
|
||||||
|
/// Returns all source + repair symbols with headers, or empty vec if no
|
||||||
|
/// symbols have been accumulated.
|
||||||
|
pub fn flush(&mut self) -> Vec<u8> {
|
||||||
|
if self.source_symbols.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.encode_block()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal: encode accumulated source symbols into a block, generate repair,
|
||||||
|
/// and return the concatenated wire-format output.
|
||||||
|
fn encode_block(&mut self) -> Vec<u8> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
let num_source = self.source_symbols.len();
|
||||||
|
let block_id = self.block_id;
|
||||||
|
|
||||||
|
// Build length-prefixed, padded block data (matches wzp-fec format).
|
||||||
|
let block_data = self.build_block_data();
|
||||||
|
|
||||||
|
let config =
|
||||||
|
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, ss as u16);
|
||||||
|
let encoder = SourceBlockEncoder::new(block_id, &config, &block_data);
|
||||||
|
|
||||||
|
// Generate source packets.
|
||||||
|
let source_packets = encoder.source_packets();
|
||||||
|
|
||||||
|
// Generate repair packets — 50% overhead by default.
|
||||||
|
let num_repair = ((num_source as f32) * 0.5).ceil() as u32;
|
||||||
|
let repair_packets = encoder.repair_packets(0, num_repair);
|
||||||
|
|
||||||
|
// Allocate output buffer.
|
||||||
|
let total_packets = source_packets.len() + repair_packets.len();
|
||||||
|
let packet_wire_size = HEADER_SIZE + ss;
|
||||||
|
let mut output = Vec::with_capacity(total_packets * packet_wire_size);
|
||||||
|
|
||||||
|
// Write source symbols.
|
||||||
|
for (i, pkt) in source_packets.iter().enumerate() {
|
||||||
|
output.push(block_id);
|
||||||
|
output.push(i as u8);
|
||||||
|
output.push(0); // is_repair = false
|
||||||
|
let pkt_data = pkt.data();
|
||||||
|
let copy_len = pkt_data.len().min(ss);
|
||||||
|
output.extend_from_slice(&pkt_data[..copy_len]);
|
||||||
|
// Pad if shorter.
|
||||||
|
if copy_len < ss {
|
||||||
|
output.resize(output.len() + (ss - copy_len), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write repair symbols.
|
||||||
|
for (i, pkt) in repair_packets.iter().enumerate() {
|
||||||
|
output.push(block_id);
|
||||||
|
output.push((num_source + i) as u8);
|
||||||
|
output.push(1); // is_repair = true
|
||||||
|
let pkt_data = pkt.data();
|
||||||
|
let copy_len = pkt_data.len().min(ss);
|
||||||
|
output.extend_from_slice(&pkt_data[..copy_len]);
|
||||||
|
if copy_len < ss {
|
||||||
|
output.resize(output.len() + (ss - copy_len), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance block.
|
||||||
|
self.block_id = self.block_id.wrapping_add(1);
|
||||||
|
self.source_symbols.clear();
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the contiguous, length-prefixed block data buffer.
|
||||||
|
fn build_block_data(&self) -> Vec<u8> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
let mut data = vec![0u8; self.source_symbols.len() * ss];
|
||||||
|
for (i, sym) in self.source_symbols.iter().enumerate() {
|
||||||
|
let max_payload = ss - LEN_PREFIX;
|
||||||
|
let payload_len = sym.len().min(max_payload);
|
||||||
|
let offset = i * ss;
|
||||||
|
data[offset..offset + LEN_PREFIX]
|
||||||
|
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||||
|
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||||
|
.copy_from_slice(&sym[..payload_len]);
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decoder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Per-block decoder state.
|
||||||
|
struct BlockState {
|
||||||
|
packets: Vec<EncodingPacket>,
|
||||||
|
decoded: bool,
|
||||||
|
result: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpFecDecoder {
|
||||||
|
frames_per_block: usize,
|
||||||
|
symbol_size: usize,
|
||||||
|
blocks: Vec<(u8, BlockState)>, // poor man's map (no std HashMap in tiny WASM)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpFecDecoder {
|
||||||
|
/// Create a new FEC decoder.
|
||||||
|
///
|
||||||
|
/// * `block_size` — expected number of source symbols per block.
|
||||||
|
/// * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(block_size: usize, symbol_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
frames_per_block: block_size,
|
||||||
|
symbol_size,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a received symbol.
|
||||||
|
///
|
||||||
|
/// Returns the decoded block (concatenated original frames, unpadded) if
|
||||||
|
/// enough symbols have been received to recover the block, or `undefined`.
|
||||||
|
pub fn add_symbol(
|
||||||
|
&mut self,
|
||||||
|
block_id: u8,
|
||||||
|
symbol_idx: u8,
|
||||||
|
_is_repair: bool,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
|
||||||
|
// Pad incoming data to symbol_size.
|
||||||
|
let mut padded = vec![0u8; ss];
|
||||||
|
let len = data.len().min(ss);
|
||||||
|
padded[..len].copy_from_slice(&data[..len]);
|
||||||
|
|
||||||
|
let esi = symbol_idx as u32;
|
||||||
|
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
|
||||||
|
|
||||||
|
// Find or create block state.
|
||||||
|
let block = self.get_or_create_block(block_id);
|
||||||
|
|
||||||
|
if block.decoded {
|
||||||
|
return block.result.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
block.packets.push(packet);
|
||||||
|
|
||||||
|
// Attempt decode.
|
||||||
|
self.try_decode(block_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to decode a block; returns the original frames if successful.
|
||||||
|
fn try_decode(&mut self, block_id: u8) -> Option<Vec<u8>> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
let num_source = self.frames_per_block;
|
||||||
|
let block_length = (num_source as u64) * (ss as u64);
|
||||||
|
|
||||||
|
let block = self.get_block_mut(block_id)?;
|
||||||
|
if block.decoded {
|
||||||
|
return block.result.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let config =
|
||||||
|
ObjectTransmissionInformation::with_defaults(block_length, ss as u16);
|
||||||
|
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
|
||||||
|
|
||||||
|
let decoded = decoder.decode(block.packets.clone());
|
||||||
|
|
||||||
|
match decoded {
|
||||||
|
Some(data) => {
|
||||||
|
// Extract original frames by stripping length prefixes.
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for i in 0..num_source {
|
||||||
|
let offset = i * ss;
|
||||||
|
if offset + LEN_PREFIX > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let payload_len = u16::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
]) as usize;
|
||||||
|
let payload_start = offset + LEN_PREFIX;
|
||||||
|
let payload_end = (payload_start + payload_len).min(data.len());
|
||||||
|
output.extend_from_slice(&data[payload_start..payload_end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = self.get_block_mut(block_id).unwrap();
|
||||||
|
block.decoded = true;
|
||||||
|
block.result = Some(output.clone());
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
|
||||||
|
if let Some(pos) = self.blocks.iter().position(|(id, _)| *id == block_id) {
|
||||||
|
return &mut self.blocks[pos].1;
|
||||||
|
}
|
||||||
|
self.blocks.push((
|
||||||
|
block_id,
|
||||||
|
BlockState {
|
||||||
|
packets: Vec::new(),
|
||||||
|
decoded: false,
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let last = self.blocks.len() - 1;
|
||||||
|
&mut self.blocks[last].1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_block_mut(&mut self, block_id: u8) -> Option<&mut BlockState> {
|
||||||
|
self.blocks
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(id, _)| *id == block_id)
|
||||||
|
.map(|(_, state)| state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Crypto — X25519 key exchange
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||||
|
///
|
||||||
|
/// Usage from JS:
|
||||||
|
/// ```js
|
||||||
|
/// const kx = new WzpKeyExchange();
|
||||||
|
/// const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
/// // ... send ourPub to peer, receive peerPub ...
|
||||||
|
/// const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||||
|
/// const session = new WzpCryptoSession(secret);
|
||||||
|
/// ```
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpKeyExchange {
|
||||||
|
secret: x25519_dalek::StaticSecret,
|
||||||
|
public: x25519_dalek::PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpKeyExchange {
|
||||||
|
/// Generate a new random X25519 keypair.
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let secret = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
let public = x25519_dalek::PublicKey::from(&secret);
|
||||||
|
Self { secret, public }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our public key (32 bytes).
|
||||||
|
pub fn public_key(&self) -> Vec<u8> {
|
||||||
|
self.public.as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a 32-byte session key from the peer's public key.
|
||||||
|
///
|
||||||
|
/// Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||||
|
/// matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||||
|
pub fn derive_shared_secret(&self, peer_public: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||||
|
if peer_public.len() != 32 {
|
||||||
|
return Err(JsValue::from_str("peer public key must be 32 bytes"));
|
||||||
|
}
|
||||||
|
let mut peer_bytes = [0u8; 32];
|
||||||
|
peer_bytes.copy_from_slice(peer_public);
|
||||||
|
let peer_pk = x25519_dalek::PublicKey::from(peer_bytes);
|
||||||
|
|
||||||
|
// Rebuild secret from bytes (StaticSecret doesn't impl Clone).
|
||||||
|
let secret_bytes = self.secret.to_bytes();
|
||||||
|
let secret_clone = x25519_dalek::StaticSecret::from(secret_bytes);
|
||||||
|
let shared = secret_clone.diffie_hellman(&peer_pk);
|
||||||
|
|
||||||
|
// HKDF expand — same derivation as wzp-crypto handshake.rs
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use sha2::Sha256;
|
||||||
|
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
|
||||||
|
let mut session_key = [0u8; 32];
|
||||||
|
hk.expand(b"warzone-session-key", &mut session_key)
|
||||||
|
.expect("HKDF expand should not fail for 32-byte output");
|
||||||
|
|
||||||
|
Ok(session_key.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Crypto — ChaCha20-Poly1305 AEAD session
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Build a 12-byte nonce (mirrors `wzp-crypto::nonce::build_nonce`).
|
||||||
|
///
|
||||||
|
/// Layout: `session_id[4] || seq(u32 BE) || direction(1) || pad(3 zero)`.
|
||||||
|
fn build_nonce(session_id: &[u8; 4], seq: u32, direction: u8) -> [u8; 12] {
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce[0..4].copy_from_slice(session_id);
|
||||||
|
nonce[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||||
|
nonce[8] = direction;
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symmetric encryption session using ChaCha20-Poly1305.
|
||||||
|
///
|
||||||
|
/// Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||||
|
/// and key setup are identical so WASM and native peers interoperate.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpCryptoSession {
|
||||||
|
cipher: chacha20poly1305::ChaCha20Poly1305,
|
||||||
|
session_id: [u8; 4],
|
||||||
|
send_seq: u32,
|
||||||
|
recv_seq: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpCryptoSession {
|
||||||
|
/// Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(shared_secret: &[u8]) -> Result<WzpCryptoSession, JsValue> {
|
||||||
|
if shared_secret.len() != 32 {
|
||||||
|
return Err(JsValue::from_str("shared secret must be 32 bytes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
use chacha20poly1305::KeyInit;
|
||||||
|
use sha2::Digest;
|
||||||
|
|
||||||
|
let session_id_hash = sha2::Sha256::digest(shared_secret);
|
||||||
|
let mut session_id = [0u8; 4];
|
||||||
|
session_id.copy_from_slice(&session_id_hash[..4]);
|
||||||
|
|
||||||
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(shared_secret)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("invalid key: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
cipher,
|
||||||
|
session_id,
|
||||||
|
send_seq: 0,
|
||||||
|
recv_seq: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||||
|
///
|
||||||
|
/// Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||||
|
pub fn encrypt(&mut self, header_aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||||
|
use chacha20poly1305::aead::{Aead, Payload};
|
||||||
|
use chacha20poly1305::Nonce;
|
||||||
|
|
||||||
|
let nonce_bytes = build_nonce(&self.session_id, self.send_seq, 0); // 0 = Send
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let payload = Payload {
|
||||||
|
msg: plaintext,
|
||||||
|
aad: header_aad,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ciphertext = self
|
||||||
|
.cipher
|
||||||
|
.encrypt(nonce, payload)
|
||||||
|
.map_err(|_| JsValue::from_str("encryption failed"))?;
|
||||||
|
|
||||||
|
self.send_seq = self.send_seq.wrapping_add(1);
|
||||||
|
Ok(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a media payload with AAD.
|
||||||
|
///
|
||||||
|
/// Returns plaintext on success, or throws on auth failure.
|
||||||
|
pub fn decrypt(&mut self, header_aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||||
|
use chacha20poly1305::aead::{Aead, Payload};
|
||||||
|
use chacha20poly1305::Nonce;
|
||||||
|
|
||||||
|
// direction=0 (Send) matches the sender's nonce — same as native code.
|
||||||
|
let nonce_bytes = build_nonce(&self.session_id, self.recv_seq, 0);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let payload = Payload {
|
||||||
|
msg: ciphertext,
|
||||||
|
aad: header_aad,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = self
|
||||||
|
.cipher
|
||||||
|
.decrypt(nonce, payload)
|
||||||
|
.map_err(|_| JsValue::from_str("decryption failed — bad key or corrupted data"))?;
|
||||||
|
|
||||||
|
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current send sequence number (for diagnostics / UI stats).
|
||||||
|
pub fn send_seq(&self) -> u32 {
|
||||||
|
self.send_seq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current receive sequence number (for diagnostics / UI stats).
|
||||||
|
pub fn recv_seq(&self) -> u32 {
|
||||||
|
self.recv_seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests (native only — not compiled to WASM)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_decode_roundtrip() {
|
||||||
|
let block_size = 5;
|
||||||
|
let symbol_size = 256;
|
||||||
|
|
||||||
|
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
|
||||||
|
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
|
||||||
|
|
||||||
|
// Create test frames of varying sizes.
|
||||||
|
let frames: Vec<Vec<u8>> = (0..block_size)
|
||||||
|
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 80 + i * 10])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Feed frames to encoder; last one triggers block encoding.
|
||||||
|
let mut wire_data = None;
|
||||||
|
for frame in &frames {
|
||||||
|
wire_data = encoder.add_symbol(frame);
|
||||||
|
}
|
||||||
|
let wire_data = wire_data.expect("block should be complete");
|
||||||
|
|
||||||
|
// Parse wire packets and feed to decoder.
|
||||||
|
let packet_size = HEADER_SIZE + symbol_size;
|
||||||
|
assert_eq!(wire_data.len() % packet_size, 0);
|
||||||
|
|
||||||
|
let mut result = None;
|
||||||
|
for chunk in wire_data.chunks(packet_size) {
|
||||||
|
let blk_id = chunk[0];
|
||||||
|
let sym_idx = chunk[1];
|
||||||
|
let is_repair = chunk[2] != 0;
|
||||||
|
let sym_data = &chunk[HEADER_SIZE..];
|
||||||
|
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
|
||||||
|
result = Some(decoded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded_data = result.expect("should decode with all symbols");
|
||||||
|
|
||||||
|
// Verify: decoded data should be all original frames concatenated.
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for frame in &frames {
|
||||||
|
expected.extend_from_slice(frame);
|
||||||
|
}
|
||||||
|
assert_eq!(decoded_data, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_with_packet_loss() {
|
||||||
|
let block_size = 5;
|
||||||
|
let symbol_size = 256;
|
||||||
|
|
||||||
|
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
|
||||||
|
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
|
||||||
|
|
||||||
|
let frames: Vec<Vec<u8>> = (0..block_size)
|
||||||
|
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 100])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut wire_data = None;
|
||||||
|
for frame in &frames {
|
||||||
|
wire_data = encoder.add_symbol(frame);
|
||||||
|
}
|
||||||
|
let wire_data = wire_data.unwrap();
|
||||||
|
|
||||||
|
let packet_size = HEADER_SIZE + symbol_size;
|
||||||
|
let packets: Vec<&[u8]> = wire_data.chunks(packet_size).collect();
|
||||||
|
|
||||||
|
// Drop 2 source packets (simulate 40% source loss).
|
||||||
|
// We have 5 source + 3 repair = 8 packets. Drop packets at index 1 and 3.
|
||||||
|
let mut result = None;
|
||||||
|
for (i, chunk) in packets.iter().enumerate() {
|
||||||
|
if i == 1 || i == 3 {
|
||||||
|
continue; // simulate loss
|
||||||
|
}
|
||||||
|
let blk_id = chunk[0];
|
||||||
|
let sym_idx = chunk[1];
|
||||||
|
let is_repair = chunk[2] != 0;
|
||||||
|
let sym_data = &chunk[HEADER_SIZE..];
|
||||||
|
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
|
||||||
|
result = Some(decoded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded_data = result.expect("should recover with FEC despite 2 lost packets");
|
||||||
|
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for frame in &frames {
|
||||||
|
expected.extend_from_slice(frame);
|
||||||
|
}
|
||||||
|
assert_eq!(decoded_data, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_partial_block() {
|
||||||
|
let mut encoder = WzpFecEncoder::new(5, 256);
|
||||||
|
|
||||||
|
// Add only 3 of 5 expected symbols, then flush.
|
||||||
|
encoder.add_symbol(&[1; 50]);
|
||||||
|
encoder.add_symbol(&[2; 60]);
|
||||||
|
encoder.add_symbol(&[3; 70]);
|
||||||
|
|
||||||
|
let wire_data = encoder.flush();
|
||||||
|
assert!(!wire_data.is_empty());
|
||||||
|
|
||||||
|
// Verify block_id advanced.
|
||||||
|
assert_eq!(encoder.block_id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Crypto tests -------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crypto_encrypt_decrypt_roundtrip() {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
|
||||||
|
let header = b"test-header";
|
||||||
|
let plaintext = b"hello warzone from wasm";
|
||||||
|
|
||||||
|
let ciphertext = alice.encrypt(header, plaintext).unwrap();
|
||||||
|
let decrypted = bob.decrypt(header, &ciphertext).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: crypto_wrong_aad_fails and crypto_wrong_key_fails return
|
||||||
|
// Err(JsValue) which aborts on non-wasm32 (JsValue::from_str uses an
|
||||||
|
// extern "C" shim that panics with "cannot unwind"). These tests are
|
||||||
|
// gated to wasm32-only; on native the encrypt/decrypt roundtrip and
|
||||||
|
// nonce-layout tests provide sufficient coverage.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[test]
|
||||||
|
fn crypto_wrong_aad_fails() {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
|
||||||
|
let ciphertext = alice.encrypt(b"correct", b"secret").unwrap();
|
||||||
|
let result = bob.decrypt(b"wrong", &ciphertext);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[test]
|
||||||
|
fn crypto_wrong_key_fails() {
|
||||||
|
let mut alice = WzpCryptoSession::new(&[0xAA; 32]).unwrap();
|
||||||
|
let mut eve = WzpCryptoSession::new(&[0xBB; 32]).unwrap();
|
||||||
|
|
||||||
|
let ciphertext = alice.encrypt(b"hdr", b"secret").unwrap();
|
||||||
|
let result = eve.decrypt(b"hdr", &ciphertext);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crypto_multiple_packets() {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
|
||||||
|
for i in 0..100u32 {
|
||||||
|
let msg = format!("message {}", i);
|
||||||
|
let ct = alice.encrypt(b"hdr", msg.as_bytes()).unwrap();
|
||||||
|
let pt = bob.decrypt(b"hdr", &ct).unwrap();
|
||||||
|
assert_eq!(pt, msg.as_bytes());
|
||||||
|
}
|
||||||
|
assert_eq!(alice.send_seq(), 100);
|
||||||
|
assert_eq!(bob.recv_seq(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_exchange_roundtrip() {
|
||||||
|
let alice_kx = WzpKeyExchange::new();
|
||||||
|
let bob_kx = WzpKeyExchange::new();
|
||||||
|
|
||||||
|
let alice_secret = alice_kx
|
||||||
|
.derive_shared_secret(&bob_kx.public_key())
|
||||||
|
.unwrap();
|
||||||
|
let bob_secret = bob_kx
|
||||||
|
.derive_shared_secret(&alice_kx.public_key())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(alice_secret, bob_secret);
|
||||||
|
assert_eq!(alice_secret.len(), 32);
|
||||||
|
|
||||||
|
// Verify the derived secret actually works for encrypt/decrypt.
|
||||||
|
let mut alice_session = WzpCryptoSession::new(&alice_secret).unwrap();
|
||||||
|
let mut bob_session = WzpCryptoSession::new(&bob_secret).unwrap();
|
||||||
|
|
||||||
|
let ct = alice_session.encrypt(b"hdr", b"hello").unwrap();
|
||||||
|
let pt = bob_session.decrypt(b"hdr", &ct).unwrap();
|
||||||
|
assert_eq!(&pt, b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce_layout_matches_native() {
|
||||||
|
// Verify our build_nonce matches wzp-crypto::nonce::build_nonce layout.
|
||||||
|
let sid = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||||
|
let seq: u32 = 0x00000100;
|
||||||
|
let nonce = build_nonce(&sid, seq, 1); // 1 = Recv direction
|
||||||
|
assert_eq!(&nonce[0..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
|
||||||
|
assert_eq!(&nonce[4..8], &[0x00, 0x00, 0x01, 0x00]);
|
||||||
|
assert_eq!(nonce[8], 1);
|
||||||
|
assert_eq!(&nonce[9..12], &[0, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
||||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||||
|
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
|
||||||
|
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
|
||||||
|
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
|
||||||
.room-input { margin-bottom: 1.5rem; }
|
.room-input { margin-bottom: 1.5rem; }
|
||||||
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
||||||
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
||||||
@@ -31,15 +35,22 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>WarzonePhone</h1>
|
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
|
||||||
<p class="subtitle">Lossy VoIP Protocol</p>
|
<p class="subtitle">Lossy VoIP Protocol</p>
|
||||||
|
|
||||||
|
<div class="variant-selector">
|
||||||
|
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
|
||||||
|
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
|
||||||
|
<label><input type="radio" name="variant" value="full"> Full WASM</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="room-input">
|
<div class="room-input">
|
||||||
<label for="room">Room</label>
|
<label for="room">Room</label>
|
||||||
<input type="text" id="room" placeholder="enter room name" value="">
|
<input type="text" id="room" placeholder="enter room name" value="">
|
||||||
</div>
|
</div>
|
||||||
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
<button id="callBtn">Connect</button>
|
||||||
<div class="controls" id="controls" style="display:none;">
|
<div class="controls" id="controls" style="display:none;">
|
||||||
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
|
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
|
||||||
</div>
|
</div>
|
||||||
<button id="pttBtn">Hold to Talk</button>
|
<button id="pttBtn">Hold to Talk</button>
|
||||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||||
@@ -47,302 +58,126 @@
|
|||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="js/wzp-core.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const SAMPLE_RATE = 48000;
|
// ---------------------------------------------------------------------------
|
||||||
const FRAME_SIZE = 960;
|
// Load the selected variant script dynamically
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
let ws = null;
|
|
||||||
let audioCtx = null;
|
|
||||||
let mediaStream = null;
|
|
||||||
let captureNode = null;
|
|
||||||
let playbackNode = null;
|
|
||||||
let active = false;
|
|
||||||
let transmitting = true; // in open-mic mode, always transmitting
|
|
||||||
let pttMode = false;
|
|
||||||
let framesSent = 0;
|
|
||||||
let framesRecv = 0;
|
|
||||||
let startTime = 0;
|
|
||||||
let statsInterval = null;
|
|
||||||
|
|
||||||
// Use room from URL path or input field
|
|
||||||
function getRoom() {
|
|
||||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
|
||||||
if (path && path !== 'index.html') return path;
|
|
||||||
const hash = location.hash.replace('#', '');
|
|
||||||
if (hash) return hash;
|
|
||||||
return document.getElementById('room').value.trim() || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fill room input from URL on page load
|
|
||||||
(function() {
|
(function() {
|
||||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
var variant = WZPCore.detectVariant();
|
||||||
if (path && path !== 'index.html') {
|
var scriptMap = {
|
||||||
document.getElementById('room').value = path;
|
pure: 'js/wzp-pure.js',
|
||||||
}
|
hybrid: 'js/wzp-hybrid.js',
|
||||||
|
full: 'js/wzp-full.js',
|
||||||
|
};
|
||||||
|
var src = scriptMap[variant] || scriptMap.pure;
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.onload = function() { wzpBoot(); };
|
||||||
|
s.onerror = function() {
|
||||||
|
WZPCore.updateStatus('Failed to load variant: ' + variant);
|
||||||
|
};
|
||||||
|
document.body.appendChild(s);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
// ---------------------------------------------------------------------------
|
||||||
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
// Boot: wire UI to the loaded client variant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function wzpBoot() {
|
||||||
|
var client = null;
|
||||||
|
var capture = null;
|
||||||
|
var playback = null;
|
||||||
|
var transmitting = true;
|
||||||
|
|
||||||
function toggleCall() {
|
var ui = WZPCore.initUI({
|
||||||
if (active) stopCall();
|
onConnect: function(room) {
|
||||||
else startCall();
|
doConnect(room);
|
||||||
}
|
},
|
||||||
|
onDisconnect: function() {
|
||||||
|
doDisconnect();
|
||||||
|
},
|
||||||
|
onTransmit: function(tx) {
|
||||||
|
transmitting = tx;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function startCall() {
|
async function doConnect(room) {
|
||||||
const btn = document.getElementById('callBtn');
|
WZPCore.updateStatus('Requesting microphone...');
|
||||||
const room = getRoom();
|
|
||||||
if (!room) { setStatus('Enter a room name'); return; }
|
|
||||||
|
|
||||||
btn.disabled = true;
|
var audioCtx;
|
||||||
setStatus('Requesting microphone...');
|
try {
|
||||||
|
audioCtx = await WZPCore.startAudioContext();
|
||||||
|
} catch (e) {
|
||||||
|
WZPCore.updateStatus('Audio init failed: ' + e.message);
|
||||||
|
ui.setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Build WebSocket URL
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||||
|
|
||||||
|
// Create client (currently always WZPPureClient; future: switch on variant)
|
||||||
|
client = new WZPPureClient({
|
||||||
|
wsUrl: wsUrl,
|
||||||
|
room: room,
|
||||||
|
onAudio: function(pcm) {
|
||||||
|
if (playback) playback.play(pcm);
|
||||||
|
},
|
||||||
|
onStatus: function(msg) {
|
||||||
|
WZPCore.updateStatus(msg);
|
||||||
|
},
|
||||||
|
onStats: function(stats) {
|
||||||
|
WZPCore.updateStats(stats);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch(e) {
|
|
||||||
setStatus('Mic access denied: ' + e.message);
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
try {
|
||||||
|
await client.connect();
|
||||||
// Connect WebSocket with room name
|
} catch (e) {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
WZPCore.updateStatus('Connection failed: ' + e.message);
|
||||||
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
ui.setConnected(false);
|
||||||
setStatus('Connecting to room: ' + room + '...');
|
return;
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
ws.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
ws.onopen = async () => {
|
|
||||||
setStatus('Connected to room: ' + room);
|
|
||||||
btn.textContent = 'Disconnect';
|
|
||||||
btn.classList.add('active');
|
|
||||||
btn.disabled = false;
|
|
||||||
active = true;
|
|
||||||
framesSent = 0;
|
|
||||||
framesRecv = 0;
|
|
||||||
startTime = Date.now();
|
|
||||||
showControls(true);
|
|
||||||
await startAudioCapture();
|
|
||||||
await startAudioPlayback();
|
|
||||||
startStatsUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const pcmData = new Int16Array(event.data);
|
|
||||||
framesRecv++;
|
|
||||||
playAudio(pcmData);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (active) {
|
|
||||||
setStatus('Disconnected — reconnecting to ' + room + '...');
|
|
||||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
|
||||||
} else {
|
|
||||||
setStatus('Disconnected');
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
// Start audio capture and playback
|
||||||
if (active) {
|
try {
|
||||||
setStatus('Error — reconnecting...');
|
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
|
||||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
if (!transmitting) return;
|
||||||
|
var pcm = new Int16Array(pcmBuffer);
|
||||||
|
WZPCore.updateLevel(pcm);
|
||||||
|
if (client) client.sendAudio(pcmBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
playback = await WZPCore.connectPlayback(audioCtx);
|
||||||
|
} catch (e) {
|
||||||
|
WZPCore.updateStatus('Audio error: ' + e.message);
|
||||||
|
if (client) client.disconnect();
|
||||||
|
client = null;
|
||||||
|
ui.setConnected(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopCall() {
|
ui.setConnected(true);
|
||||||
active = false;
|
|
||||||
const btn = document.getElementById('callBtn');
|
|
||||||
btn.textContent = 'Connect';
|
|
||||||
btn.classList.remove('active');
|
|
||||||
btn.disabled = false;
|
|
||||||
showControls(false);
|
|
||||||
cleanupAudio();
|
|
||||||
if (ws) { ws.close(); ws = null; }
|
|
||||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
|
||||||
setStatus('');
|
|
||||||
setStats('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupAudio() {
|
|
||||||
if (captureNode) { captureNode.disconnect(); captureNode = null; }
|
|
||||||
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
|
|
||||||
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
|
|
||||||
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let workletLoaded = false;
|
|
||||||
|
|
||||||
async function loadWorkletModule() {
|
|
||||||
if (workletLoaded) return true;
|
|
||||||
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
|
||||||
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
|
||||||
workletLoaded = true;
|
|
||||||
return true;
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startAudioCapture() {
|
function doDisconnect() {
|
||||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
if (capture) { capture.stop(); capture = null; }
|
||||||
const hasWorklet = await loadWorkletModule();
|
if (playback) { playback.stop(); playback = null; }
|
||||||
|
if (client) { client.disconnect(); client = null; }
|
||||||
|
|
||||||
if (hasWorklet) {
|
var audioCtx = WZPCore.getAudioContext();
|
||||||
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
if (audioCtx && audioCtx.state !== 'closed') {
|
||||||
captureNode.port.onmessage = (e) => {
|
audioCtx.close();
|
||||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
|
||||||
ws.send(e.data);
|
|
||||||
framesSent++;
|
|
||||||
|
|
||||||
// Level meter from the PCM data
|
|
||||||
const pcm = new Int16Array(e.data);
|
|
||||||
let max = 0;
|
|
||||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
|
||||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
|
||||||
};
|
|
||||||
source.connect(captureNode);
|
|
||||||
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
|
|
||||||
} else {
|
|
||||||
// Fallback to ScriptProcessorNode (deprecated but widely supported)
|
|
||||||
console.warn('Capture: using ScriptProcessorNode fallback');
|
|
||||||
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
||||||
let acc = new Float32Array(0);
|
|
||||||
captureNode.onaudioprocess = (ev) => {
|
|
||||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
|
||||||
const input = ev.inputBuffer.getChannelData(0);
|
|
||||||
const n = new Float32Array(acc.length + input.length);
|
|
||||||
n.set(acc); n.set(input, acc.length); acc = n;
|
|
||||||
while (acc.length >= FRAME_SIZE) {
|
|
||||||
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
|
|
||||||
const pcm = new Int16Array(FRAME_SIZE);
|
|
||||||
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
|
||||||
let max = 0;
|
|
||||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
|
||||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
|
||||||
ws.send(pcm.buffer);
|
|
||||||
framesSent++;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
source.connect(captureNode);
|
|
||||||
captureNode.connect(audioCtx.destination);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startAudioPlayback() {
|
|
||||||
const hasWorklet = await loadWorkletModule();
|
|
||||||
|
|
||||||
if (hasWorklet) {
|
|
||||||
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
|
||||||
playbackNode.connect(audioCtx.destination);
|
|
||||||
} else {
|
|
||||||
console.warn('Playback: using scheduled BufferSource fallback');
|
|
||||||
playbackNode = null; // will use createBufferSource fallback in playAudio()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextPlayTime = 0;
|
|
||||||
|
|
||||||
function playAudio(pcmInt16) {
|
|
||||||
if (!audioCtx) return;
|
|
||||||
|
|
||||||
if (playbackNode && playbackNode.port) {
|
|
||||||
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
|
|
||||||
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
|
|
||||||
} else {
|
|
||||||
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
|
|
||||||
const floatData = new Float32Array(pcmInt16.length);
|
|
||||||
for (let i = 0; i < pcmInt16.length; i++) {
|
|
||||||
floatData[i] = pcmInt16[i] / 32768.0;
|
|
||||||
}
|
}
|
||||||
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
|
|
||||||
buffer.getChannelData(0).set(floatData);
|
WZPCore.updateStatus('');
|
||||||
const source = audioCtx.createBufferSource();
|
WZPCore.updateStats('');
|
||||||
source.buffer = buffer;
|
document.getElementById('levelBar').style.width = '0%';
|
||||||
source.connect(audioCtx.destination);
|
|
||||||
const now = audioCtx.currentTime;
|
ui.setConnected(false);
|
||||||
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
|
||||||
nextPlayTime = now + 0.02;
|
|
||||||
}
|
|
||||||
source.start(nextPlayTime);
|
|
||||||
nextPlayTime += buffer.duration;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStatsUpdate() {
|
|
||||||
statsInterval = setInterval(() => {
|
|
||||||
if (!active) { clearInterval(statsInterval); return; }
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
||||||
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Push-to-talk ---
|
|
||||||
|
|
||||||
function togglePTT() {
|
|
||||||
pttMode = document.getElementById('pttMode').checked;
|
|
||||||
const btn = document.getElementById('pttBtn');
|
|
||||||
if (pttMode) {
|
|
||||||
transmitting = false;
|
|
||||||
btn.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
transmitting = true;
|
|
||||||
btn.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PTT button — hold to talk (mouse + touch)
|
|
||||||
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
|
|
||||||
|
|
||||||
// Spacebar PTT
|
|
||||||
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
|
|
||||||
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
|
|
||||||
|
|
||||||
function startTransmit() {
|
|
||||||
if (!pttMode || !active) return;
|
|
||||||
transmitting = true;
|
|
||||||
document.getElementById('pttBtn').classList.add('transmitting');
|
|
||||||
document.getElementById('pttBtn').textContent = 'Transmitting...';
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopTransmit() {
|
|
||||||
if (!pttMode) return;
|
|
||||||
transmitting = false;
|
|
||||||
document.getElementById('pttBtn').classList.remove('transmitting');
|
|
||||||
document.getElementById('pttBtn').textContent = 'Hold to Talk';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show controls when connected
|
|
||||||
function showControls(show) {
|
|
||||||
document.getElementById('controls').style.display = show ? 'flex' : 'none';
|
|
||||||
if (!show) {
|
|
||||||
document.getElementById('pttBtn').style.display = 'none';
|
|
||||||
pttMode = false;
|
|
||||||
transmitting = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set room from URL on load
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const room = getRoom();
|
|
||||||
if (room && room !== 'default') {
|
|
||||||
document.getElementById('room').value = room;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
378
crates/wzp-web/static/js/wzp-core.js
Normal file
378
crates/wzp-web/static/js/wzp-core.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
// WarzonePhone — Shared UI logic for all client variants.
|
||||||
|
// Provides: audio context management, mic capture, playback, UI wiring.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const WZP_SAMPLE_RATE = 48000;
|
||||||
|
const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Variant detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpDetectVariant() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const v = (params.get('variant') || 'pure').toLowerCase();
|
||||||
|
if (v === 'hybrid' || v === 'full') return v;
|
||||||
|
return 'pure';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Room helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpGetRoom() {
|
||||||
|
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||||
|
if (path && path !== 'index.html') return path;
|
||||||
|
const hash = location.hash.replace('#', '');
|
||||||
|
if (hash) return hash;
|
||||||
|
const el = document.getElementById('room');
|
||||||
|
return (el && el.value.trim()) || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpPrefillRoom() {
|
||||||
|
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||||
|
if (path && path !== 'index.html') {
|
||||||
|
const el = document.getElementById('room');
|
||||||
|
if (el) el.value = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status / stats helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpUpdateStatus(msg) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
if (el) el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpUpdateStats(stats) {
|
||||||
|
const el = document.getElementById('stats');
|
||||||
|
if (!el) return;
|
||||||
|
if (typeof stats === 'string') {
|
||||||
|
el.textContent = stats;
|
||||||
|
} else {
|
||||||
|
const parts = [];
|
||||||
|
if (stats.elapsed != null) parts.push(stats.elapsed.toFixed(1) + 's');
|
||||||
|
if (stats.sent != null) parts.push('sent: ' + stats.sent);
|
||||||
|
if (stats.recv != null) parts.push('recv: ' + stats.recv);
|
||||||
|
if (stats.loss != null) parts.push('loss: ' + (stats.loss * 100).toFixed(1) + '%');
|
||||||
|
if (stats.fecRecovered != null && stats.fecRecovered > 0) parts.push('fec: ' + stats.fecRecovered);
|
||||||
|
if (stats.fecReady != null) parts.push(stats.fecReady ? 'FEC:on' : 'FEC:off');
|
||||||
|
el.textContent = parts.join(' | ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpUpdateLevel(pcmInt16) {
|
||||||
|
const bar = document.getElementById('levelBar');
|
||||||
|
if (!bar) return;
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < pcmInt16.length; i += 16) {
|
||||||
|
const v = Math.abs(pcmInt16[i]);
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
bar.style.width = (max / 32768 * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio context + worklet
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _wzpAudioCtx = null;
|
||||||
|
let _wzpWorkletLoaded = false;
|
||||||
|
|
||||||
|
async function wzpStartAudioContext() {
|
||||||
|
if (_wzpAudioCtx && _wzpAudioCtx.state !== 'closed') return _wzpAudioCtx;
|
||||||
|
_wzpAudioCtx = new AudioContext({ sampleRate: WZP_SAMPLE_RATE });
|
||||||
|
_wzpWorkletLoaded = false;
|
||||||
|
return _wzpAudioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpGetAudioContext() {
|
||||||
|
return _wzpAudioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _wzpLoadWorklet(audioCtx) {
|
||||||
|
if (_wzpWorkletLoaded) return true;
|
||||||
|
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
||||||
|
console.warn('[wzp-core] AudioWorklet not supported, will use fallback');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
||||||
|
_wzpWorkletLoaded = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[wzp-core] AudioWorklet load failed:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mic capture — returns { node, stop() }
|
||||||
|
// onFrame(ArrayBuffer) called for each 960-sample Int16 PCM frame
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function wzpConnectCapture(audioCtx, onFrame) {
|
||||||
|
let mediaStream;
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
sampleRate: WZP_SAMPLE_RATE,
|
||||||
|
channelCount: 1,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Mic access denied: ' + e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||||
|
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
||||||
|
let captureNode;
|
||||||
|
|
||||||
|
if (hasWorklet) {
|
||||||
|
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
||||||
|
captureNode.port.onmessage = (e) => {
|
||||||
|
onFrame(e.data); // ArrayBuffer of Int16 PCM
|
||||||
|
};
|
||||||
|
source.connect(captureNode);
|
||||||
|
captureNode.connect(audioCtx.destination); // keep worklet alive
|
||||||
|
} else {
|
||||||
|
// ScriptProcessorNode fallback
|
||||||
|
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||||
|
let acc = new Float32Array(0);
|
||||||
|
captureNode.onaudioprocess = (ev) => {
|
||||||
|
const input = ev.inputBuffer.getChannelData(0);
|
||||||
|
const n = new Float32Array(acc.length + input.length);
|
||||||
|
n.set(acc);
|
||||||
|
n.set(input, acc.length);
|
||||||
|
acc = n;
|
||||||
|
while (acc.length >= WZP_FRAME_SIZE) {
|
||||||
|
const frame = acc.slice(0, WZP_FRAME_SIZE);
|
||||||
|
acc = acc.slice(WZP_FRAME_SIZE);
|
||||||
|
const pcm = new Int16Array(WZP_FRAME_SIZE);
|
||||||
|
for (let i = 0; i < WZP_FRAME_SIZE; i++) {
|
||||||
|
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||||
|
}
|
||||||
|
onFrame(pcm.buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source.connect(captureNode);
|
||||||
|
captureNode.connect(audioCtx.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: captureNode,
|
||||||
|
stop() {
|
||||||
|
captureNode.disconnect();
|
||||||
|
mediaStream.getTracks().forEach((t) => t.stop());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playback — returns { node, play(Int16Array), stop() }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function wzpConnectPlayback(audioCtx) {
|
||||||
|
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
||||||
|
let playbackNode;
|
||||||
|
let nextPlayTime = 0;
|
||||||
|
|
||||||
|
if (hasWorklet) {
|
||||||
|
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
||||||
|
playbackNode.connect(audioCtx.destination);
|
||||||
|
return {
|
||||||
|
node: playbackNode,
|
||||||
|
play(pcmInt16) {
|
||||||
|
// Transfer Int16 buffer to worklet
|
||||||
|
const buf = pcmInt16.buffer.slice(
|
||||||
|
pcmInt16.byteOffset,
|
||||||
|
pcmInt16.byteOffset + pcmInt16.byteLength
|
||||||
|
);
|
||||||
|
playbackNode.port.postMessage(buf, [buf]);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
playbackNode.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: scheduled BufferSource
|
||||||
|
return {
|
||||||
|
node: null,
|
||||||
|
play(pcmInt16) {
|
||||||
|
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||||
|
const floatData = new Float32Array(pcmInt16.length);
|
||||||
|
for (let i = 0; i < pcmInt16.length; i++) {
|
||||||
|
floatData[i] = pcmInt16[i] / 32768.0;
|
||||||
|
}
|
||||||
|
const buffer = audioCtx.createBuffer(1, floatData.length, WZP_SAMPLE_RATE);
|
||||||
|
buffer.getChannelData(0).set(floatData);
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(audioCtx.destination);
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
||||||
|
nextPlayTime = now + 0.02;
|
||||||
|
}
|
||||||
|
source.start(nextPlayTime);
|
||||||
|
nextPlayTime += buffer.duration;
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
// nothing to disconnect for fallback
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI wiring — call after DOM ready
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpInitUI(callbacks) {
|
||||||
|
// callbacks: { onConnect(room), onDisconnect() }
|
||||||
|
const btn = document.getElementById('callBtn');
|
||||||
|
const pttBtn = document.getElementById('pttBtn');
|
||||||
|
const pttCheckbox = document.getElementById('pttMode');
|
||||||
|
let connected = false;
|
||||||
|
let pttMode = false;
|
||||||
|
|
||||||
|
wzpPrefillRoom();
|
||||||
|
|
||||||
|
// Variant badge
|
||||||
|
const variant = wzpDetectVariant();
|
||||||
|
const badge = document.getElementById('variantBadge');
|
||||||
|
if (badge) badge.textContent = variant.toUpperCase();
|
||||||
|
|
||||||
|
// Variant selector radio buttons
|
||||||
|
document.querySelectorAll('input[name="variant"]').forEach((radio) => {
|
||||||
|
if (radio.value === variant) radio.checked = true;
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
if (radio.checked) {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
params.set('variant', radio.value);
|
||||||
|
location.search = params.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.onclick = () => {
|
||||||
|
if (connected) {
|
||||||
|
connected = false;
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
_showControls(false);
|
||||||
|
if (callbacks.onDisconnect) callbacks.onDisconnect();
|
||||||
|
} else {
|
||||||
|
const room = wzpGetRoom();
|
||||||
|
if (!room) {
|
||||||
|
wzpUpdateStatus('Enter a room name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connected = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
if (callbacks.onConnect) callbacks.onConnect(room);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// PTT toggle
|
||||||
|
if (pttCheckbox) {
|
||||||
|
pttCheckbox.onchange = () => {
|
||||||
|
pttMode = pttCheckbox.checked;
|
||||||
|
if (pttMode) {
|
||||||
|
pttBtn.style.display = 'block';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
||||||
|
} else {
|
||||||
|
pttBtn.style.display = 'none';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTT button events
|
||||||
|
function startTx() {
|
||||||
|
if (!pttMode || !connected) return;
|
||||||
|
pttBtn.classList.add('transmitting');
|
||||||
|
pttBtn.textContent = 'Transmitting...';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
||||||
|
}
|
||||||
|
function stopTx() {
|
||||||
|
if (!pttMode) return;
|
||||||
|
pttBtn.classList.remove('transmitting');
|
||||||
|
pttBtn.textContent = 'Hold to Talk';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pttBtn) {
|
||||||
|
pttBtn.addEventListener('mousedown', startTx);
|
||||||
|
pttBtn.addEventListener('mouseup', stopTx);
|
||||||
|
pttBtn.addEventListener('mouseleave', stopTx);
|
||||||
|
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTx(); });
|
||||||
|
pttBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopTx(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spacebar PTT
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (pttMode && connected && e.code === 'Space' && !e.repeat) {
|
||||||
|
e.preventDefault();
|
||||||
|
startTx();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', (e) => {
|
||||||
|
if (pttMode && connected && e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
stopTx();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _showControls(show) {
|
||||||
|
const controls = document.getElementById('controls');
|
||||||
|
if (controls) controls.style.display = show ? 'flex' : 'none';
|
||||||
|
if (!show && pttBtn) {
|
||||||
|
pttBtn.style.display = 'none';
|
||||||
|
pttMode = false;
|
||||||
|
if (pttCheckbox) pttCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setConnected(isConnected) {
|
||||||
|
connected = isConnected;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (isConnected) {
|
||||||
|
btn.textContent = 'Disconnect';
|
||||||
|
btn.classList.add('active');
|
||||||
|
_showControls(true);
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
_showControls(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isPTT() {
|
||||||
|
return pttMode;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exports (global)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPCore = {
|
||||||
|
SAMPLE_RATE: WZP_SAMPLE_RATE,
|
||||||
|
FRAME_SIZE: WZP_FRAME_SIZE,
|
||||||
|
detectVariant: wzpDetectVariant,
|
||||||
|
getRoom: wzpGetRoom,
|
||||||
|
updateStatus: wzpUpdateStatus,
|
||||||
|
updateStats: wzpUpdateStats,
|
||||||
|
updateLevel: wzpUpdateLevel,
|
||||||
|
startAudioContext: wzpStartAudioContext,
|
||||||
|
getAudioContext: wzpGetAudioContext,
|
||||||
|
connectCapture: wzpConnectCapture,
|
||||||
|
connectPlayback: wzpConnectPlayback,
|
||||||
|
initUI: wzpInitUI,
|
||||||
|
};
|
||||||
524
crates/wzp-web/static/js/wzp-full.js
Normal file
524
crates/wzp-web/static/js/wzp-full.js
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
// WarzonePhone — Full WASM + WebTransport client (Variant 3).
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// - WebTransport for unreliable datagrams (UDP-like, no head-of-line blocking)
|
||||||
|
// - ChaCha20-Poly1305 encryption via WASM (wzp-wasm WzpCryptoSession)
|
||||||
|
// - RaptorQ FEC via WASM (wzp-wasm WzpFecEncoder/WzpFecDecoder)
|
||||||
|
// - X25519 key exchange via WASM (wzp-wasm WzpKeyExchange)
|
||||||
|
//
|
||||||
|
// NOTE: WebTransport requires the relay to support HTTP/3 (h3-quinn).
|
||||||
|
// The current wzp-relay uses raw QUIC. This variant demonstrates the full
|
||||||
|
// architecture but will need relay-side HTTP/3 support to work end-to-end.
|
||||||
|
// For development / testing, use the hybrid variant (WebSocket + WASM FEC).
|
||||||
|
//
|
||||||
|
// Relies on wzp-core.js for UI and audio helpers.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
|
||||||
|
|
||||||
|
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||||
|
const MEDIA_HEADER_SIZE = 12;
|
||||||
|
|
||||||
|
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||||
|
const FEC_HEADER_SIZE = 3;
|
||||||
|
|
||||||
|
class WZPFullClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.url WebTransport URL (https://host:port)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback(Object) for UI stats
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.url = options.url;
|
||||||
|
this.room = options.room;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.wt = null; // WebTransport instance
|
||||||
|
this.datagramWriter = null; // WritableStreamDefaultWriter
|
||||||
|
this.datagramReader = null; // ReadableStreamDefaultReader
|
||||||
|
this.cryptoSession = null; // WzpCryptoSession (WASM)
|
||||||
|
this.fecEncoder = null; // WzpFecEncoder (WASM)
|
||||||
|
this.fecDecoder = null; // WzpFecDecoder (WASM)
|
||||||
|
this.sequence = 0;
|
||||||
|
this._wasmModule = null;
|
||||||
|
this._connected = false;
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._recvLoopRunning = false;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect: load WASM, open WebTransport, perform key exchange,
|
||||||
|
* initialise FEC, and start the receive loop.
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
// --- Guard: WebTransport support ---
|
||||||
|
if (typeof WebTransport === 'undefined') {
|
||||||
|
throw new Error(
|
||||||
|
'WebTransport is not supported in this browser. ' +
|
||||||
|
'Use the hybrid (?variant=hybrid) or pure (?variant=pure) variant instead.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._status('Loading WASM module...');
|
||||||
|
|
||||||
|
// 1. Load WASM
|
||||||
|
this._wasmModule = await import(WZP_WASM_PATH);
|
||||||
|
await this._wasmModule.default();
|
||||||
|
|
||||||
|
this._status('Connecting via WebTransport to ' + this.url + '...');
|
||||||
|
|
||||||
|
// 2. WebTransport connection
|
||||||
|
// The URL should include the room, e.g. https://host:port/room
|
||||||
|
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
|
||||||
|
this.wt = new WebTransport(wtUrl);
|
||||||
|
|
||||||
|
this.wt.closed.then(() => {
|
||||||
|
const wasConnected = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (wasConnected) {
|
||||||
|
this._status('WebTransport closed');
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
this._cleanup();
|
||||||
|
this._status('WebTransport error: ' + err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.wt.ready;
|
||||||
|
|
||||||
|
// 3. Get datagram streams (unreliable, QUIC DATAGRAM frames)
|
||||||
|
this.datagramWriter = this.wt.datagrams.writable.getWriter();
|
||||||
|
this.datagramReader = this.wt.datagrams.readable.getReader();
|
||||||
|
|
||||||
|
// 4. Key exchange over a bidirectional stream
|
||||||
|
this._status('Performing key exchange...');
|
||||||
|
await this._performKeyExchange();
|
||||||
|
|
||||||
|
// 5. Initialise FEC (5 source symbols per block, 256-byte symbols)
|
||||||
|
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
||||||
|
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
||||||
|
|
||||||
|
this._connected = true;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._startStatsTimer();
|
||||||
|
|
||||||
|
// 6. Start receive loop (runs until disconnect)
|
||||||
|
this._recvLoop();
|
||||||
|
|
||||||
|
this._status('Connected to room: ' + this.room + ' (encrypted, FEC active)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect and clean up all resources.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.wt) {
|
||||||
|
try { this.wt.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.wt = null;
|
||||||
|
}
|
||||||
|
this._cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame.
|
||||||
|
*
|
||||||
|
* Pipeline: PCM -> FEC encode -> encrypt -> datagram send.
|
||||||
|
*
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.datagramWriter || !this.cryptoSession) return;
|
||||||
|
|
||||||
|
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||||
|
|
||||||
|
// Build a minimal 12-byte MediaHeader for AAD.
|
||||||
|
const header = this._buildMediaHeader(this.sequence);
|
||||||
|
|
||||||
|
// FEC encode: feed the frame; when a block completes we get wire packets.
|
||||||
|
const fecOutput = this.fecEncoder.add_symbol(pcmBytes);
|
||||||
|
|
||||||
|
if (fecOutput) {
|
||||||
|
// FEC block completed — send all packets (source + repair).
|
||||||
|
const packetSize = FEC_HEADER_SIZE + 256; // header + symbol_size
|
||||||
|
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
|
||||||
|
const fecPacket = fecOutput.slice(offset, offset + packetSize);
|
||||||
|
|
||||||
|
// Encrypt: header bytes as AAD, FEC packet as plaintext.
|
||||||
|
const ciphertext = this.cryptoSession.encrypt(header, fecPacket);
|
||||||
|
this.stats.encrypted++;
|
||||||
|
|
||||||
|
// Build wire datagram: header (12) + ciphertext
|
||||||
|
const datagram = new Uint8Array(MEDIA_HEADER_SIZE + ciphertext.length);
|
||||||
|
datagram.set(header, 0);
|
||||||
|
datagram.set(ciphertext, MEDIA_HEADER_SIZE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.datagramWriter.write(datagram);
|
||||||
|
} catch (e) {
|
||||||
|
// Datagram send can fail if the transport is closing.
|
||||||
|
if (this._connected) {
|
||||||
|
console.warn('[wzp-full] datagram write failed:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If FEC block not yet complete, accumulate (no packets sent yet).
|
||||||
|
|
||||||
|
this.sequence = (this.sequence + 1) & 0xFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
||||||
|
* Useful for verifying the WASM module works correctly in the browser.
|
||||||
|
*
|
||||||
|
* @returns {Object} test results
|
||||||
|
*/
|
||||||
|
testCryptoFec() {
|
||||||
|
if (!this._wasmModule) {
|
||||||
|
return { success: false, error: 'WASM module not loaded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
const wasm = this._wasmModule;
|
||||||
|
|
||||||
|
// Key exchange
|
||||||
|
const alice = new wasm.WzpKeyExchange();
|
||||||
|
const bob = new wasm.WzpKeyExchange();
|
||||||
|
const aliceSecret = alice.derive_shared_secret(bob.public_key());
|
||||||
|
const bobSecret = bob.derive_shared_secret(alice.public_key());
|
||||||
|
|
||||||
|
// Verify secrets match
|
||||||
|
let secretsMatch = aliceSecret.length === bobSecret.length;
|
||||||
|
if (secretsMatch) {
|
||||||
|
for (let i = 0; i < aliceSecret.length; i++) {
|
||||||
|
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt/decrypt
|
||||||
|
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
||||||
|
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
||||||
|
|
||||||
|
const header = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
|
||||||
|
const plaintext = new TextEncoder().encode('hello warzone from full variant');
|
||||||
|
|
||||||
|
const ciphertext = aliceSession.encrypt(header, plaintext);
|
||||||
|
const decrypted = bobSession.decrypt(header, ciphertext);
|
||||||
|
|
||||||
|
let cryptoOk = decrypted.length === plaintext.length;
|
||||||
|
if (cryptoOk) {
|
||||||
|
for (let i = 0; i < plaintext.length; i++) {
|
||||||
|
if (decrypted[i] !== plaintext[i]) { cryptoOk = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FEC test (same as hybrid testFec)
|
||||||
|
const encoder = new wasm.WzpFecEncoder(5, 256);
|
||||||
|
const decoder = new wasm.WzpFecDecoder(5, 256);
|
||||||
|
|
||||||
|
const frames = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const frame = new Uint8Array(100);
|
||||||
|
for (let j = 0; j < 100; j++) frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||||
|
frames.push(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
let wireData = null;
|
||||||
|
for (const frame of frames) {
|
||||||
|
const result = encoder.add_symbol(frame);
|
||||||
|
if (result) wireData = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PACKET_SIZE = FEC_HEADER_SIZE + 256;
|
||||||
|
const packets = [];
|
||||||
|
if (wireData) {
|
||||||
|
for (let off = 0; off + PACKET_SIZE <= wireData.length; off += PACKET_SIZE) {
|
||||||
|
packets.push({
|
||||||
|
blockId: wireData[off],
|
||||||
|
symbolIdx: wireData[off + 1],
|
||||||
|
isRepair: wireData[off + 2] !== 0,
|
||||||
|
data: wireData.slice(off + FEC_HEADER_SIZE, off + PACKET_SIZE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop 2 packets, try to recover
|
||||||
|
let fecDecoded = null;
|
||||||
|
for (let i = 0; i < packets.length; i++) {
|
||||||
|
if (i === 1 || i === 3) continue; // simulate loss
|
||||||
|
const pkt = packets[i];
|
||||||
|
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
||||||
|
if (result) { fecDecoded = result; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let fecOk = false;
|
||||||
|
if (fecDecoded) {
|
||||||
|
const expected = new Uint8Array(5 * 100);
|
||||||
|
let off = 0;
|
||||||
|
for (const f of frames) { expected.set(f, off); off += f.length; }
|
||||||
|
fecOk = fecDecoded.length === expected.length;
|
||||||
|
if (fecOk) {
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (fecDecoded[i] !== expected[i]) { fecOk = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup WASM objects
|
||||||
|
alice.free();
|
||||||
|
bob.free();
|
||||||
|
aliceSession.free();
|
||||||
|
bobSession.free();
|
||||||
|
encoder.free();
|
||||||
|
decoder.free();
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: secretsMatch && cryptoOk && fecOk,
|
||||||
|
secretsMatch,
|
||||||
|
cryptoOk,
|
||||||
|
fecOk,
|
||||||
|
fecPacketsTotal: packets.length,
|
||||||
|
fecDropped: 2,
|
||||||
|
elapsed: elapsed.toFixed(2) + 'ms',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Internal
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform X25519 key exchange over a WebTransport bidirectional stream.
|
||||||
|
*
|
||||||
|
* Protocol (simplified DH, not the full SignalMessage handshake):
|
||||||
|
* 1. Open a bidirectional stream.
|
||||||
|
* 2. Send our 32-byte X25519 public key.
|
||||||
|
* 3. Read the peer's 32-byte public key.
|
||||||
|
* 4. Derive shared secret via HKDF.
|
||||||
|
* 5. Create WzpCryptoSession from the shared secret.
|
||||||
|
*
|
||||||
|
* In production this would use the full SignalMessage protocol over the
|
||||||
|
* bidirectional stream (offer/answer/encrypted-session). For now we do
|
||||||
|
* a simple DH swap to prove the architecture.
|
||||||
|
*/
|
||||||
|
async _performKeyExchange() {
|
||||||
|
const wasm = this._wasmModule;
|
||||||
|
const kx = new wasm.WzpKeyExchange();
|
||||||
|
const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
|
||||||
|
// Open a bidirectional stream for signaling.
|
||||||
|
const stream = await this.wt.createBidirectionalStream();
|
||||||
|
const writer = stream.writable.getWriter();
|
||||||
|
const reader = stream.readable.getReader();
|
||||||
|
|
||||||
|
// Send our public key.
|
||||||
|
await writer.write(new Uint8Array(ourPub));
|
||||||
|
|
||||||
|
// Read peer's public key (exactly 32 bytes).
|
||||||
|
// WebTransport streams are byte-oriented; we may get it in chunks.
|
||||||
|
let peerPub = new Uint8Array(0);
|
||||||
|
while (peerPub.length < 32) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
throw new Error('Key exchange stream closed before receiving peer public key');
|
||||||
|
}
|
||||||
|
const combined = new Uint8Array(peerPub.length + value.length);
|
||||||
|
combined.set(peerPub, 0);
|
||||||
|
combined.set(value, peerPub.length);
|
||||||
|
peerPub = combined;
|
||||||
|
}
|
||||||
|
peerPub = peerPub.slice(0, 32);
|
||||||
|
|
||||||
|
// Derive shared secret and create crypto session.
|
||||||
|
const secret = kx.derive_shared_secret(peerPub);
|
||||||
|
this.cryptoSession = new wasm.WzpCryptoSession(secret);
|
||||||
|
|
||||||
|
// Close the signaling stream (key exchange complete).
|
||||||
|
try {
|
||||||
|
writer.releaseLock();
|
||||||
|
reader.releaseLock();
|
||||||
|
await stream.writable.close();
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort close.
|
||||||
|
}
|
||||||
|
|
||||||
|
kx.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive loop: read datagrams, decrypt, FEC decode, play audio.
|
||||||
|
*
|
||||||
|
* Runs until the transport closes or disconnect() is called.
|
||||||
|
*/
|
||||||
|
async _recvLoop() {
|
||||||
|
if (this._recvLoopRunning) return;
|
||||||
|
this._recvLoopRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this._connected && this.datagramReader) {
|
||||||
|
const { value, done } = await this.datagramReader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
this.stats.recv++;
|
||||||
|
|
||||||
|
// value is a Uint8Array datagram: header(12) + ciphertext
|
||||||
|
if (value.length <= MEDIA_HEADER_SIZE) continue; // too short
|
||||||
|
|
||||||
|
const headerAad = value.slice(0, MEDIA_HEADER_SIZE);
|
||||||
|
const ciphertext = value.slice(MEDIA_HEADER_SIZE);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let fecPacket;
|
||||||
|
try {
|
||||||
|
fecPacket = this.cryptoSession.decrypt(headerAad, ciphertext);
|
||||||
|
this.stats.decrypted++;
|
||||||
|
} catch (e) {
|
||||||
|
// Decryption failure — corrupted or out-of-order packet.
|
||||||
|
// In a real implementation we'd handle sequence number gaps.
|
||||||
|
console.warn('[wzp-full] decrypt failed:', e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FEC decode: parse the FEC wire header and feed to decoder.
|
||||||
|
if (fecPacket.length < FEC_HEADER_SIZE) continue;
|
||||||
|
const blockId = fecPacket[0];
|
||||||
|
const symbolIdx = fecPacket[1];
|
||||||
|
const isRepair = fecPacket[2] !== 0;
|
||||||
|
const symbolData = fecPacket.slice(FEC_HEADER_SIZE);
|
||||||
|
|
||||||
|
const decoded = this.fecDecoder.add_symbol(blockId, symbolIdx, isRepair, symbolData);
|
||||||
|
if (decoded) {
|
||||||
|
this.stats.fecRecovered++;
|
||||||
|
// decoded is concatenated original PCM frames.
|
||||||
|
// Each frame is 1920 bytes (960 Int16 samples @ 48kHz mono).
|
||||||
|
const FRAME_BYTES = 1920;
|
||||||
|
for (let off = 0; off + FRAME_BYTES <= decoded.length; off += FRAME_BYTES) {
|
||||||
|
const pcmSlice = decoded.slice(off, off + FRAME_BYTES);
|
||||||
|
const pcm = new Int16Array(pcmSlice.buffer, pcmSlice.byteOffset, pcmSlice.byteLength / 2);
|
||||||
|
if (this.onAudio) {
|
||||||
|
this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (this._connected) {
|
||||||
|
console.warn('[wzp-full] recv loop error:', e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._recvLoopRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal 12-byte MediaHeader for use as AAD.
|
||||||
|
*
|
||||||
|
* Wire layout (from wzp-proto::packet::MediaHeader):
|
||||||
|
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
|
||||||
|
* Byte 1: FecRatioLo(6)|unused(2)
|
||||||
|
* Bytes 2-3: Sequence number (BE u16)
|
||||||
|
* Bytes 4-7: Timestamp ms (BE u32)
|
||||||
|
* Byte 8: FEC block ID
|
||||||
|
* Byte 9: FEC symbol index
|
||||||
|
* Byte 10: Reserved
|
||||||
|
* Byte 11: CSRC count
|
||||||
|
*
|
||||||
|
* @param {number} seq Sequence number (u16)
|
||||||
|
* @returns {Uint8Array} 12-byte header
|
||||||
|
*/
|
||||||
|
_buildMediaHeader(seq) {
|
||||||
|
const buf = new Uint8Array(MEDIA_HEADER_SIZE);
|
||||||
|
// Byte 0: version=0, is_repair=0, codec=0 (Opus), quality_report=0, fec_ratio_hi=0
|
||||||
|
buf[0] = 0x00;
|
||||||
|
// Byte 1: fec_ratio_lo=0
|
||||||
|
buf[1] = 0x00;
|
||||||
|
// Bytes 2-3: sequence (BE u16)
|
||||||
|
buf[2] = (seq >> 8) & 0xFF;
|
||||||
|
buf[3] = seq & 0xFF;
|
||||||
|
// Bytes 4-7: timestamp (BE u32) — ms since session start
|
||||||
|
const ts = Date.now() - this._startTime;
|
||||||
|
buf[4] = (ts >> 24) & 0xFF;
|
||||||
|
buf[5] = (ts >> 16) & 0xFF;
|
||||||
|
buf[6] = (ts >> 8) & 0xFF;
|
||||||
|
buf[7] = ts & 0xFF;
|
||||||
|
// Bytes 8-11: FEC block/symbol/reserved/csrc — filled by FEC layer in production
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss,
|
||||||
|
elapsed,
|
||||||
|
encrypted: this.stats.encrypted,
|
||||||
|
decrypted: this.stats.decrypted,
|
||||||
|
fecRecovered: this.stats.fecRecovered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this.datagramWriter = null;
|
||||||
|
this.datagramReader = null;
|
||||||
|
if (this.cryptoSession) {
|
||||||
|
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.cryptoSession = null;
|
||||||
|
}
|
||||||
|
if (this.fecEncoder) {
|
||||||
|
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecEncoder = null;
|
||||||
|
}
|
||||||
|
if (this.fecDecoder) {
|
||||||
|
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecDecoder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPFullClient = WZPFullClient;
|
||||||
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal file
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
// WarzonePhone — Hybrid JS + WASM client (Variant 2).
|
||||||
|
// WebSocket transport, raw PCM, WASM FEC (RaptorQ) ready for WebTransport.
|
||||||
|
// Relies on wzp-core.js for UI and audio helpers.
|
||||||
|
//
|
||||||
|
// The WASM FEC module is loaded and exposed but not used on the wire yet,
|
||||||
|
// because WebSocket is TCP (no packet loss). FEC will activate when
|
||||||
|
// WebTransport (UDP) is added. A testFec() method demonstrates FEC
|
||||||
|
// encode -> simulate loss -> decode in the browser.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||||
|
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
|
||||||
|
|
||||||
|
class WZPHybridClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback({sent, recv, loss, elapsed, fecRecovered}) for UI
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.wsUrl = options.wsUrl;
|
||||||
|
this.room = options.room;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._connected = false;
|
||||||
|
|
||||||
|
// WASM FEC instances (loaded in connect()).
|
||||||
|
this._wasmModule = null;
|
||||||
|
this.fecEncoder = null;
|
||||||
|
this.fecDecoder = null;
|
||||||
|
this._fecReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open WebSocket connection and load the WASM FEC module.
|
||||||
|
* @returns {Promise<void>} resolves when connected
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
// Load WASM module in parallel with WebSocket connect.
|
||||||
|
const wasmPromise = this._loadWasm();
|
||||||
|
|
||||||
|
const wsPromise = new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting to room: ' + this.room + '...');
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this._connected = true;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._startStatsTimer();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
this._handleMessage(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
const wasConnected = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (wasConnected) {
|
||||||
|
this._status('Disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
} else {
|
||||||
|
this._status('Connection error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for both WASM load and WS connect.
|
||||||
|
await Promise.all([wasmPromise, wsPromise]);
|
||||||
|
|
||||||
|
const fecStatus = this._fecReady ? 'FEC ready' : 'FEC unavailable';
|
||||||
|
this._status('Connected to room: ' + this.room + ' (' + fecStatus + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket and clean up.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
// Keep WASM module loaded (reusable).
|
||||||
|
this.fecEncoder = null;
|
||||||
|
this.fecDecoder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame over the WebSocket.
|
||||||
|
* Currently sends raw PCM (same as pure client) since WebSocket is TCP.
|
||||||
|
* When WebTransport is added, this will FEC-encode before sending.
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over WebSocket (TCP): send raw PCM, no FEC needed.
|
||||||
|
// Over WebTransport (UDP, future): would call this.fecEncoder.add_symbol()
|
||||||
|
// and send the resulting FEC-protected packets.
|
||||||
|
this.ws.send(pcmBuffer);
|
||||||
|
this.sequence++;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test FEC encode -> simulate loss -> decode in the browser.
|
||||||
|
* Demonstrates that the WASM RaptorQ module works correctly.
|
||||||
|
*
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {number} [opts.blockSize=5] Source symbols per block
|
||||||
|
* @param {number} [opts.symbolSize=256] Padded symbol size
|
||||||
|
* @param {number} [opts.frameSize=100] Bytes per test frame
|
||||||
|
* @param {number} [opts.dropCount=2] Number of packets to drop
|
||||||
|
* @returns {Object} { success, sourcePackets, repairPackets, dropped, recovered, elapsed }
|
||||||
|
*/
|
||||||
|
testFec(opts) {
|
||||||
|
if (!this._fecReady) {
|
||||||
|
return { success: false, error: 'WASM FEC module not loaded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockSize = (opts && opts.blockSize) || 5;
|
||||||
|
const symbolSize = (opts && opts.symbolSize) || 256;
|
||||||
|
const frameSize = (opts && opts.frameSize) || 100;
|
||||||
|
const dropCount = (opts && opts.dropCount) || 2;
|
||||||
|
|
||||||
|
const HEADER_SIZE = 3; // block_id + symbol_idx + is_repair
|
||||||
|
const packetSize = HEADER_SIZE + symbolSize;
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
// Create fresh encoder/decoder for the test.
|
||||||
|
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
|
||||||
|
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
|
||||||
|
|
||||||
|
// Generate test frames with known data.
|
||||||
|
const frames = [];
|
||||||
|
for (let i = 0; i < blockSize; i++) {
|
||||||
|
const frame = new Uint8Array(frameSize);
|
||||||
|
for (let j = 0; j < frameSize; j++) {
|
||||||
|
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||||
|
}
|
||||||
|
frames.push(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode: feed frames to encoder; last one triggers block output.
|
||||||
|
let wireData = null;
|
||||||
|
for (const frame of frames) {
|
||||||
|
const result = encoder.add_symbol(frame);
|
||||||
|
if (result) {
|
||||||
|
wireData = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wireData) {
|
||||||
|
// Flush if block didn't complete (shouldn't happen with exact blockSize).
|
||||||
|
wireData = encoder.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse wire packets.
|
||||||
|
const packets = [];
|
||||||
|
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
|
||||||
|
packets.push({
|
||||||
|
blockId: wireData[offset],
|
||||||
|
symbolIdx: wireData[offset + 1],
|
||||||
|
isRepair: wireData[offset + 2] !== 0,
|
||||||
|
data: wireData.slice(offset + HEADER_SIZE, offset + packetSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePackets = packets.filter(p => !p.isRepair).length;
|
||||||
|
const repairPackets = packets.filter(p => p.isRepair).length;
|
||||||
|
|
||||||
|
// Simulate packet loss: drop `dropCount` packets from the front (source symbols).
|
||||||
|
const dropped = [];
|
||||||
|
const surviving = [];
|
||||||
|
for (let i = 0; i < packets.length; i++) {
|
||||||
|
if (i < dropCount) {
|
||||||
|
dropped.push(i);
|
||||||
|
} else {
|
||||||
|
surviving.push(packets[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode from surviving packets.
|
||||||
|
let decoded = null;
|
||||||
|
for (const pkt of surviving) {
|
||||||
|
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
||||||
|
if (result) {
|
||||||
|
decoded = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
|
||||||
|
// Verify decoded data matches original frames.
|
||||||
|
let success = false;
|
||||||
|
if (decoded) {
|
||||||
|
const expected = new Uint8Array(blockSize * frameSize);
|
||||||
|
let off = 0;
|
||||||
|
for (const frame of frames) {
|
||||||
|
expected.set(frame, off);
|
||||||
|
off += frame.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = decoded.length === expected.length;
|
||||||
|
if (success) {
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
if (decoded[i] !== expected[i]) {
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free WASM objects.
|
||||||
|
encoder.free();
|
||||||
|
decoder.free();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
sourcePackets,
|
||||||
|
repairPackets,
|
||||||
|
totalPackets: packets.length,
|
||||||
|
dropped: dropCount,
|
||||||
|
recovered: success,
|
||||||
|
decodedBytes: decoded ? decoded.length : 0,
|
||||||
|
expectedBytes: blockSize * frameSize,
|
||||||
|
elapsed: elapsed.toFixed(2) + 'ms',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async _loadWasm() {
|
||||||
|
try {
|
||||||
|
// Dynamic import of the wasm-pack generated JS glue.
|
||||||
|
this._wasmModule = await import(WZP_WASM_PATH);
|
||||||
|
// Initialize the WASM module (calls __wbg_init).
|
||||||
|
await this._wasmModule.default();
|
||||||
|
|
||||||
|
// Create FEC encoder/decoder instances.
|
||||||
|
// 5 symbols per block, 256-byte symbols — matches native wzp-fec defaults.
|
||||||
|
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
||||||
|
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
||||||
|
this._fecReady = true;
|
||||||
|
|
||||||
|
console.log('[wzp-hybrid] WASM FEC module loaded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[wzp-hybrid] WASM FEC module failed to load:', e);
|
||||||
|
this._fecReady = false;
|
||||||
|
// Non-fatal: client still works without FEC (like pure variant).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleMessage(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const pcm = new Int16Array(event.data);
|
||||||
|
this.stats.recv++;
|
||||||
|
if (this.onAudio) {
|
||||||
|
this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss: loss,
|
||||||
|
elapsed: elapsed,
|
||||||
|
fecRecovered: this.stats.fecRecovered,
|
||||||
|
fecReady: this._fecReady,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPHybridClient = WZPHybridClient;
|
||||||
168
crates/wzp-web/static/js/wzp-pure.js
Normal file
168
crates/wzp-web/static/js/wzp-pure.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// WarzonePhone — Pure JS client (Variant 1).
|
||||||
|
// WebSocket transport, raw PCM, no WASM, no FEC.
|
||||||
|
// Relies on wzp-core.js for UI and audio helpers.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
class WZPPureClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.wsUrl = options.wsUrl;
|
||||||
|
this.room = options.room;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0 };
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open WebSocket connection to the wzp-web bridge.
|
||||||
|
* @returns {Promise<void>} resolves when connected
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting to room: ' + this.room + '...');
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this._connected = true;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._status('Connected to room: ' + this.room);
|
||||||
|
this._startStatsTimer();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
this._handleMessage(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
const wasConnected = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (wasConnected) {
|
||||||
|
this._status('Disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (err) => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
} else {
|
||||||
|
this._status('Connection error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket and clean up.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame over the WebSocket.
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure JS variant: send raw PCM directly (no encryption, no header).
|
||||||
|
// The wzp-web bridge handles QUIC-side encryption.
|
||||||
|
this.ws.send(pcmBuffer);
|
||||||
|
this.sequence++;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_handleMessage(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const pcm = new Int16Array(event.data);
|
||||||
|
this.stats.recv++;
|
||||||
|
if (this.onAudio) {
|
||||||
|
this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
// Simple loss estimate: if we sent frames, the other side should
|
||||||
|
// receive roughly the same count. Since we only see our own recv,
|
||||||
|
// we report raw counts and let the UI decide.
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss: loss,
|
||||||
|
elapsed: elapsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPPureClient = WZPPureClient;
|
||||||
2
crates/wzp-web/static/wasm/.gitignore
vendored
Normal file
2
crates/wzp-web/static/wasm/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
package.json
|
||||||
|
*.d.ts
|
||||||
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal file
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
/* @ts-self-types="./wzp_wasm.d.ts" */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetric encryption session using ChaCha20-Poly1305.
|
||||||
|
*
|
||||||
|
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||||
|
* and key setup are identical so WASM and native peers interoperate.
|
||||||
|
*/
|
||||||
|
export class WzpCryptoSession {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpCryptoSessionFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpcryptosession_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Decrypt a media payload with AAD.
|
||||||
|
*
|
||||||
|
* Returns plaintext on success, or throws on auth failure.
|
||||||
|
* @param {Uint8Array} header_aad
|
||||||
|
* @param {Uint8Array} ciphertext
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
decrypt(header_aad, ciphertext) {
|
||||||
|
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passArray8ToWasm0(ciphertext, wasm.__wbindgen_malloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpcryptosession_decrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||||
|
if (ret[3]) {
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v3;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||||
|
*
|
||||||
|
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||||
|
* @param {Uint8Array} header_aad
|
||||||
|
* @param {Uint8Array} plaintext
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
encrypt(header_aad, plaintext) {
|
||||||
|
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passArray8ToWasm0(plaintext, wasm.__wbindgen_malloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpcryptosession_encrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||||
|
if (ret[3]) {
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v3;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||||
|
* @param {Uint8Array} shared_secret
|
||||||
|
*/
|
||||||
|
constructor(shared_secret) {
|
||||||
|
const ptr0 = passArray8ToWasm0(shared_secret, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpcryptosession_new(ptr0, len0);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
this.__wbg_ptr = ret[0] >>> 0;
|
||||||
|
WzpCryptoSessionFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Current receive sequence number (for diagnostics / UI stats).
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
recv_seq() {
|
||||||
|
const ret = wasm.wzpcryptosession_recv_seq(this.__wbg_ptr);
|
||||||
|
return ret >>> 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Current send sequence number (for diagnostics / UI stats).
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
send_seq() {
|
||||||
|
const ret = wasm.wzpcryptosession_send_seq(this.__wbg_ptr);
|
||||||
|
return ret >>> 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpCryptoSession.prototype[Symbol.dispose] = WzpCryptoSession.prototype.free;
|
||||||
|
|
||||||
|
export class WzpFecDecoder {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpFecDecoderFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpfecdecoder_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Feed a received symbol.
|
||||||
|
*
|
||||||
|
* Returns the decoded block (concatenated original frames, unpadded) if
|
||||||
|
* enough symbols have been received to recover the block, or `undefined`.
|
||||||
|
* @param {number} block_id
|
||||||
|
* @param {number} symbol_idx
|
||||||
|
* @param {boolean} _is_repair
|
||||||
|
* @param {Uint8Array} data
|
||||||
|
* @returns {Uint8Array | undefined}
|
||||||
|
*/
|
||||||
|
add_symbol(block_id, symbol_idx, _is_repair, data) {
|
||||||
|
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpfecdecoder_add_symbol(this.__wbg_ptr, block_id, symbol_idx, _is_repair, ptr0, len0);
|
||||||
|
let v2;
|
||||||
|
if (ret[0] !== 0) {
|
||||||
|
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
}
|
||||||
|
return v2;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a new FEC decoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — expected number of source symbols per block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||||
|
* @param {number} block_size
|
||||||
|
* @param {number} symbol_size
|
||||||
|
*/
|
||||||
|
constructor(block_size, symbol_size) {
|
||||||
|
const ret = wasm.wzpfecdecoder_new(block_size, symbol_size);
|
||||||
|
this.__wbg_ptr = ret >>> 0;
|
||||||
|
WzpFecDecoderFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpFecDecoder.prototype[Symbol.dispose] = WzpFecDecoder.prototype.free;
|
||||||
|
|
||||||
|
export class WzpFecEncoder {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpFecEncoderFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpfecencoder_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Add a source symbol (audio frame).
|
||||||
|
*
|
||||||
|
* Returns encoded packets (all source + repair) when the block is complete,
|
||||||
|
* or `undefined` if the block is still accumulating.
|
||||||
|
*
|
||||||
|
* Each returned packet carries the 3-byte header:
|
||||||
|
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||||
|
* @param {Uint8Array} data
|
||||||
|
* @returns {Uint8Array | undefined}
|
||||||
|
*/
|
||||||
|
add_symbol(data) {
|
||||||
|
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpfecencoder_add_symbol(this.__wbg_ptr, ptr0, len0);
|
||||||
|
let v2;
|
||||||
|
if (ret[0] !== 0) {
|
||||||
|
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
}
|
||||||
|
return v2;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Force-flush the current (possibly partial) block.
|
||||||
|
*
|
||||||
|
* Returns all source + repair symbols with headers, or empty vec if no
|
||||||
|
* symbols have been accumulated.
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
flush() {
|
||||||
|
const ret = wasm.wzpfecencoder_flush(this.__wbg_ptr);
|
||||||
|
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v1;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a new FEC encoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (default 256).
|
||||||
|
* @param {number} block_size
|
||||||
|
* @param {number} symbol_size
|
||||||
|
*/
|
||||||
|
constructor(block_size, symbol_size) {
|
||||||
|
const ret = wasm.wzpfecencoder_new(block_size, symbol_size);
|
||||||
|
this.__wbg_ptr = ret >>> 0;
|
||||||
|
WzpFecEncoderFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpFecEncoder.prototype[Symbol.dispose] = WzpFecEncoder.prototype.free;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||||
|
*
|
||||||
|
* Usage from JS:
|
||||||
|
* ```js
|
||||||
|
* const kx = new WzpKeyExchange();
|
||||||
|
* const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
* // ... send ourPub to peer, receive peerPub ...
|
||||||
|
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||||
|
* const session = new WzpCryptoSession(secret);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class WzpKeyExchange {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpKeyExchangeFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpkeyexchange_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte session key from the peer's public key.
|
||||||
|
*
|
||||||
|
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||||
|
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||||
|
* @param {Uint8Array} peer_public
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
derive_shared_secret(peer_public) {
|
||||||
|
const ptr0 = passArray8ToWasm0(peer_public, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpkeyexchange_derive_shared_secret(this.__wbg_ptr, ptr0, len0);
|
||||||
|
if (ret[3]) {
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v2;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Generate a new random X25519 keypair.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
const ret = wasm.wzpkeyexchange_new();
|
||||||
|
this.__wbg_ptr = ret >>> 0;
|
||||||
|
WzpKeyExchangeFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Our public key (32 bytes).
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
public_key() {
|
||||||
|
const ret = wasm.wzpkeyexchange_public_key(this.__wbg_ptr);
|
||||||
|
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpKeyExchange.prototype[Symbol.dispose] = WzpKeyExchange.prototype.free;
|
||||||
|
|
||||||
|
function __wbg_get_imports() {
|
||||||
|
const import0 = {
|
||||||
|
__proto__: null,
|
||||||
|
__wbg___wbindgen_is_function_3c846841762788c1: function(arg0) {
|
||||||
|
const ret = typeof(arg0) === 'function';
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_is_object_781bc9f159099513: function(arg0) {
|
||||||
|
const val = arg0;
|
||||||
|
const ret = typeof(val) === 'object' && val !== null;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_is_string_7ef6b97b02428fae: function(arg0) {
|
||||||
|
const ret = typeof(arg0) === 'string';
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_is_undefined_52709e72fb9f179c: function(arg0) {
|
||||||
|
const ret = arg0 === undefined;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) {
|
||||||
|
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||||
|
},
|
||||||
|
__wbg_call_2d781c1f4d5c0ef8: function() { return handleError(function (arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.call(arg1, arg2);
|
||||||
|
return ret;
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_crypto_38df2bab126b63dc: function(arg0) {
|
||||||
|
const ret = arg0.crypto;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
|
||||||
|
arg0.getRandomValues(arg1);
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_length_ea16607d7b61445b: function(arg0) {
|
||||||
|
const ret = arg0.length;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
|
||||||
|
const ret = arg0.msCrypto;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_new_with_length_825018a1616e9e55: function(arg0) {
|
||||||
|
const ret = new Uint8Array(arg0 >>> 0);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_node_84ea875411254db1: function(arg0) {
|
||||||
|
const ret = arg0.node;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_process_44c7a14e11e9f69e: function(arg0) {
|
||||||
|
const ret = arg0.process;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_prototypesetcall_d62e5099504357e6: function(arg0, arg1, arg2) {
|
||||||
|
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||||
|
},
|
||||||
|
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
|
||||||
|
arg0.randomFillSync(arg1);
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
|
||||||
|
const ret = module.require;
|
||||||
|
return ret;
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_static_accessor_GLOBAL_8adb955bd33fac2f: function() {
|
||||||
|
const ret = typeof global === 'undefined' ? null : global;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_static_accessor_GLOBAL_THIS_ad356e0db91c7913: function() {
|
||||||
|
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_static_accessor_SELF_f207c857566db248: function() {
|
||||||
|
const ret = typeof self === 'undefined' ? null : self;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_static_accessor_WINDOW_bb9f1ba69d61b386: function() {
|
||||||
|
const ret = typeof window === 'undefined' ? null : window;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_subarray_a068d24e39478a8a: function(arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_versions_276b2795b1c6a219: function(arg0) {
|
||||||
|
const ret = arg0.versions;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
|
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
||||||
|
const ret = getArrayU8FromWasm0(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
|
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||||
|
const ret = getStringFromWasm0(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_init_externref_table: function() {
|
||||||
|
const table = wasm.__wbindgen_externrefs;
|
||||||
|
const offset = table.grow(4);
|
||||||
|
table.set(0, undefined);
|
||||||
|
table.set(offset + 0, undefined);
|
||||||
|
table.set(offset + 1, null);
|
||||||
|
table.set(offset + 2, true);
|
||||||
|
table.set(offset + 3, false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
__proto__: null,
|
||||||
|
"./wzp_wasm_bg.js": import0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WzpCryptoSessionFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpcryptosession_free(ptr >>> 0, 1));
|
||||||
|
const WzpFecDecoderFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecdecoder_free(ptr >>> 0, 1));
|
||||||
|
const WzpFecEncoderFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecencoder_free(ptr >>> 0, 1));
|
||||||
|
const WzpKeyExchangeFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpkeyexchange_free(ptr >>> 0, 1));
|
||||||
|
|
||||||
|
function addToExternrefTable0(obj) {
|
||||||
|
const idx = wasm.__externref_table_alloc();
|
||||||
|
wasm.__wbindgen_externrefs.set(idx, obj);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArrayU8FromWasm0(ptr, len) {
|
||||||
|
ptr = ptr >>> 0;
|
||||||
|
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringFromWasm0(ptr, len) {
|
||||||
|
ptr = ptr >>> 0;
|
||||||
|
return decodeText(ptr, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedUint8ArrayMemory0 = null;
|
||||||
|
function getUint8ArrayMemory0() {
|
||||||
|
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||||
|
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachedUint8ArrayMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(f, args) {
|
||||||
|
try {
|
||||||
|
return f.apply(this, args);
|
||||||
|
} catch (e) {
|
||||||
|
const idx = addToExternrefTable0(e);
|
||||||
|
wasm.__wbindgen_exn_store(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikeNone(x) {
|
||||||
|
return x === undefined || x === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passArray8ToWasm0(arg, malloc) {
|
||||||
|
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||||
|
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||||
|
WASM_VECTOR_LEN = arg.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeFromExternrefTable0(idx) {
|
||||||
|
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||||
|
wasm.__externref_table_dealloc(idx);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||||
|
let numBytesDecoded = 0;
|
||||||
|
function decodeText(ptr, len) {
|
||||||
|
numBytesDecoded += len;
|
||||||
|
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||||
|
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
numBytesDecoded = len;
|
||||||
|
}
|
||||||
|
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||||
|
}
|
||||||
|
|
||||||
|
let WASM_VECTOR_LEN = 0;
|
||||||
|
|
||||||
|
let wasmModule, wasm;
|
||||||
|
function __wbg_finalize_init(instance, module) {
|
||||||
|
wasm = instance.exports;
|
||||||
|
wasmModule = module;
|
||||||
|
cachedUint8ArrayMemory0 = null;
|
||||||
|
wasm.__wbindgen_start();
|
||||||
|
return wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_load(module, imports) {
|
||||||
|
if (typeof Response === 'function' && module instanceof Response) {
|
||||||
|
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||||
|
try {
|
||||||
|
return await WebAssembly.instantiateStreaming(module, imports);
|
||||||
|
} catch (e) {
|
||||||
|
const validResponse = module.ok && expectedResponseType(module.type);
|
||||||
|
|
||||||
|
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||||
|
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||||
|
|
||||||
|
} else { throw e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await module.arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(bytes, imports);
|
||||||
|
} else {
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
|
||||||
|
if (instance instanceof WebAssembly.Instance) {
|
||||||
|
return { instance, module };
|
||||||
|
} else {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedResponseType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'basic': case 'cors': case 'default': return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSync(module) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||||
|
({module} = module)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
if (!(module instanceof WebAssembly.Module)) {
|
||||||
|
module = new WebAssembly.Module(module);
|
||||||
|
}
|
||||||
|
const instance = new WebAssembly.Instance(module, imports);
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_init(module_or_path) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module_or_path !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||||
|
({module_or_path} = module_or_path)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (module_or_path === undefined) {
|
||||||
|
module_or_path = new URL('wzp_wasm_bg.wasm', import.meta.url);
|
||||||
|
}
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
|
||||||
|
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||||
|
module_or_path = fetch(module_or_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||||
|
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initSync, __wbg_init as default };
|
||||||
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
Binary file not shown.
@@ -1,257 +0,0 @@
|
|||||||
# WS Support in wzp-relay — Implementation Spec
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add WebSocket listener to `wzp-relay` so browsers connect directly, eliminating `wzp-web` bridge.
|
|
||||||
|
|
||||||
```
|
|
||||||
Before: Browser → WS → wzp-web → QUIC → wzp-relay
|
|
||||||
After: Browser → WS → wzp-relay (handles both WS + QUIC)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
wzp-relay
|
|
||||||
├── QUIC listener (:4433) — native clients, inter-relay
|
|
||||||
├── WS listener (:8080) — browsers via Caddy
|
|
||||||
│ ├── GET /ws/{room} — WebSocket upgrade
|
|
||||||
│ └── Auth: first msg = {"type":"auth","token":"..."}
|
|
||||||
└── Shared RoomManager — both transports in same rooms
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Changes
|
|
||||||
|
|
||||||
### 1. Abstract `Participant` over transport type
|
|
||||||
|
|
||||||
**File: `room.rs`**
|
|
||||||
|
|
||||||
Currently:
|
|
||||||
```rust
|
|
||||||
struct Participant {
|
|
||||||
id: ParticipantId,
|
|
||||||
_addr: std::net::SocketAddr,
|
|
||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Change to:
|
|
||||||
```rust
|
|
||||||
struct Participant {
|
|
||||||
id: ParticipantId,
|
|
||||||
_addr: std::net::SocketAddr,
|
|
||||||
sender: ParticipantSender,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How to send a media packet to a participant.
|
|
||||||
enum ParticipantSender {
|
|
||||||
Quic(Arc<wzp_transport::QuinnTransport>),
|
|
||||||
WebSocket(tokio::sync::mpsc::Sender<bytes::Bytes>),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `others()` method returns `Vec<ParticipantSender>` instead of `Vec<Arc<QuinnTransport>>`.
|
|
||||||
|
|
||||||
`ParticipantSender` implements a `send_pcm(&self, data: &[u8])` method:
|
|
||||||
- **Quic**: wraps in `MediaPacket`, calls `transport.send_media()`
|
|
||||||
- **WebSocket**: sends raw binary frame via the mpsc channel
|
|
||||||
|
|
||||||
### 2. Add `join_ws()` to RoomManager
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub fn join_ws(
|
|
||||||
&mut self,
|
|
||||||
room_name: &str,
|
|
||||||
addr: std::net::SocketAddr,
|
|
||||||
sender: tokio::sync::mpsc::Sender<bytes::Bytes>,
|
|
||||||
fingerprint: Option<&str>,
|
|
||||||
) -> Result<ParticipantId, String>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add WS listener in `main.rs`
|
|
||||||
|
|
||||||
New flag: `--ws-port 8080`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
if let Some(ws_port) = config.ws_port {
|
|
||||||
let room_mgr = room_mgr.clone();
|
|
||||||
let auth_url = config.auth_url.clone();
|
|
||||||
let metrics = metrics.clone();
|
|
||||||
tokio::spawn(run_ws_server(ws_port, room_mgr, auth_url, metrics));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. WebSocket handler (`ws.rs` — new file)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use axum::{
|
|
||||||
extract::{ws::{Message, WebSocket}, Path, WebSocketUpgrade},
|
|
||||||
routing::get,
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
|
|
||||||
async fn ws_handler(
|
|
||||||
Path(room): Path<String>,
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
/* state */
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
ws.on_upgrade(move |socket| handle_ws(socket, room, state))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_ws(mut socket: WebSocket, room: String, state: WsState) {
|
|
||||||
let addr = /* peer addr */;
|
|
||||||
|
|
||||||
// 1. Auth: first message must be {"type":"auth","token":"..."}
|
|
||||||
let fingerprint = if let Some(ref auth_url) = state.auth_url {
|
|
||||||
match socket.recv().await {
|
|
||||||
Some(Ok(Message::Text(text))) => {
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&text)?;
|
|
||||||
if parsed["type"] == "auth" {
|
|
||||||
let token = parsed["token"].as_str().unwrap();
|
|
||||||
let client = auth::validate_token(auth_url, token).await?;
|
|
||||||
Some(client.fingerprint)
|
|
||||||
} else { return; }
|
|
||||||
}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
} else { None };
|
|
||||||
|
|
||||||
// 2. Create mpsc channel for outbound frames
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(64);
|
|
||||||
|
|
||||||
// 3. Join room
|
|
||||||
let participant_id = {
|
|
||||||
let mut mgr = state.room_mgr.lock().await;
|
|
||||||
mgr.join_ws(&room, addr, tx, fingerprint.as_deref())?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Run send/recv loops
|
|
||||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
|
||||||
|
|
||||||
// Outbound: mpsc rx → WS send
|
|
||||||
let send_task = tokio::spawn(async move {
|
|
||||||
while let Some(data) = rx.recv().await {
|
|
||||||
if ws_tx.send(Message::Binary(data.to_vec())).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inbound: WS recv → fan-out to room
|
|
||||||
loop {
|
|
||||||
match ws_rx.next().await {
|
|
||||||
Some(Ok(Message::Binary(data))) => {
|
|
||||||
// Raw PCM Int16 from browser — fan-out to all others
|
|
||||||
let others = {
|
|
||||||
let mgr = state.room_mgr.lock().await;
|
|
||||||
mgr.others(&room, participant_id)
|
|
||||||
};
|
|
||||||
for other in &others {
|
|
||||||
other.send_raw(&data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Close(_))) | None => break,
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Cleanup
|
|
||||||
send_task.abort();
|
|
||||||
let mut mgr = state.room_mgr.lock().await;
|
|
||||||
mgr.leave(&room, participant_id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Cross-transport fan-out
|
|
||||||
|
|
||||||
When a QUIC participant sends audio → WS participants receive raw PCM bytes.
|
|
||||||
When a WS participant sends audio → QUIC participants receive a `MediaPacket`.
|
|
||||||
|
|
||||||
The `ParticipantSender::send_raw()` method:
|
|
||||||
```rust
|
|
||||||
impl ParticipantSender {
|
|
||||||
async fn send_raw(&self, pcm_bytes: &[u8]) {
|
|
||||||
match self {
|
|
||||||
ParticipantSender::WebSocket(tx) => {
|
|
||||||
let _ = tx.try_send(bytes::Bytes::copy_from_slice(pcm_bytes));
|
|
||||||
}
|
|
||||||
ParticipantSender::Quic(transport) => {
|
|
||||||
// Wrap raw PCM in a MediaPacket
|
|
||||||
let pkt = MediaPacket {
|
|
||||||
header: MediaHeader::default_pcm(),
|
|
||||||
payload: bytes::Bytes::copy_from_slice(pcm_bytes),
|
|
||||||
quality_report: None,
|
|
||||||
};
|
|
||||||
let _ = transport.send_media(&pkt).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For QUIC→WS direction, `run_participant` extracts `pkt.payload` bytes and sends to WS channels.
|
|
||||||
|
|
||||||
### 6. Dependencies to add
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# wzp-relay/Cargo.toml
|
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
|
||||||
tokio = { version = "1", features = ["full"] } # already present
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Config change
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// config.rs
|
|
||||||
pub struct RelayConfig {
|
|
||||||
// ... existing fields ...
|
|
||||||
pub ws_port: Option<u16>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Docker compose change (featherChat side)
|
|
||||||
|
|
||||||
Remove `wzp-web` service entirely. Update Caddy to proxy `/audio/*` to relay's WS port:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Before:
|
|
||||||
wzp-web:
|
|
||||||
entrypoint: ["wzp-web"]
|
|
||||||
command: ["--port", "8080", "--relay", "172.28.0.10:4433"]
|
|
||||||
|
|
||||||
# After: REMOVED. Relay handles WS directly.
|
|
||||||
|
|
||||||
wzp-relay:
|
|
||||||
command:
|
|
||||||
- "--listen"
|
|
||||||
- "0.0.0.0:4433"
|
|
||||||
- "--ws-port"
|
|
||||||
- "8080"
|
|
||||||
- "--auth-url"
|
|
||||||
- "http://warzone-server:7700/v1/auth/validate"
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Stays the Same
|
|
||||||
|
|
||||||
- Browser's `startAudio()` — unchanged, still connects WS to `/audio/ws/ROOM`
|
|
||||||
- Caddy proxies `/audio/*` → relay:8080 (same path, different backend)
|
|
||||||
- Auth flow — same JSON token as first message
|
|
||||||
- PCM format — same Int16 binary frames
|
|
||||||
- QUIC clients — unchanged, still connect to :4433
|
|
||||||
- Room naming, ACL, session management — all unchanged
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
1. Start relay with `--ws-port 8080 --listen 0.0.0.0:4433`
|
|
||||||
2. Open browser, initiate call via featherChat
|
|
||||||
3. Verify audio flows (both directions)
|
|
||||||
4. Verify QUIC + WS clients can be in same room (mixed mode)
|
|
||||||
5. Verify auth works
|
|
||||||
6. Verify room cleanup on disconnect
|
|
||||||
|
|
||||||
## Migration Path
|
|
||||||
|
|
||||||
1. Implement WS in relay
|
|
||||||
2. Test with featherChat (no featherChat changes needed)
|
|
||||||
3. Remove wzp-web from Docker stack
|
|
||||||
4. Later: add WebTransport alongside WS
|
|
||||||
Reference in New Issue
Block a user