diff --git a/Cargo.lock b/Cargo.lock index 779b150..7fd8234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -222,7 +228,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -253,7 +259,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -279,7 +285,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -773,6 +779,21 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -989,6 +1010,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1146,6 +1181,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -2463,6 +2523,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -2872,6 +2934,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -2890,6 +2961,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3172,6 +3256,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3199,6 +3289,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3349,6 +3448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3906,6 +4006,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4184,7 +4290,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.5.2", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4549,6 +4655,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d4215fb79ef19442a0c71616aabb0715a386e6a16ed9031775ee3e3f20e7502" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4779,6 +4906,19 @@ dependencies = [ "transpose", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4788,7 +4928,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5319,6 +5459,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5453,6 +5604,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strength_reduce" version = "0.2.4" @@ -5520,6 +5677,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5972,7 +6151,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6609,6 +6788,29 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -7798,10 +8000,13 @@ dependencies = [ "async-trait", "bytes", "chrono", + "clap 4.6.0", "coreaudio-rs", "cpal", + "crossterm", "if-addrs", "libc", + "ratatui", "rustls", "serde", "serde_json", @@ -8078,7 +8283,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", diff --git a/crates/wzp-client/Cargo.toml b/crates/wzp-client/Cargo.toml index 95fee81..b55367d 100644 --- a/crates/wzp-client/Cargo.toml +++ b/crates/wzp-client/Cargo.toml @@ -21,6 +21,9 @@ anyhow = "1" serde = { workspace = true } serde_json = "1" chrono = "0.4" +clap = { version = "4", features = ["derive"] } +ratatui = "0.29" +crossterm = "0.28" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } cpal = { version = "0.15", optional = true } libc = "0.2" @@ -99,6 +102,10 @@ linux-aec = ["dep:webrtc-audio-processing"] name = "wzp-client" path = "src/cli.rs" +[[bin]] +name = "wzp-analyzer" +path = "src/analyzer.rs" + [[bin]] name = "wzp-bench" path = "src/bench_cli.rs" diff --git a/crates/wzp-client/src/analyzer.rs b/crates/wzp-client/src/analyzer.rs new file mode 100644 index 0000000..19748de --- /dev/null +++ b/crates/wzp-client/src/analyzer.rs @@ -0,0 +1,581 @@ +//! WarzonePhone Protocol Analyzer — passive call quality observer. +//! +//! Joins a relay room as a passive participant (no media sent) and displays +//! real-time per-participant quality metrics in a terminal UI. +//! +//! Usage: +//! wzp-analyzer 127.0.0.1:4433 --room test +//! wzp-analyzer 1.2.3.4:4433 --room test --capture session.wzp +//! wzp-analyzer 1.2.3.4:4433 --room test --no-tui --duration 60 + +use std::io::Write; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use clap::Parser; +use tracing::info; + +use wzp_proto::{CodecId, MediaPacket, MediaTransport}; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +/// WarzonePhone Protocol Analyzer — passive call quality observer +#[derive(Parser)] +#[command(name = "wzp-analyzer", version)] +struct Args { + /// Relay address (host:port) + relay: String, + + /// Room name to observe + #[arg(short, long)] + room: String, + + /// Auth token for relay + #[arg(long)] + token: Option, + + /// Identity seed (64-char hex) + #[arg(long)] + seed: Option, + + /// Capture packets to file + #[arg(long)] + capture: Option, + + /// Auto-stop after N seconds + #[arg(long)] + duration: Option, + + /// Disable TUI (print stats to stdout instead) + #[arg(long)] + no_tui: bool, +} + +// --------------------------------------------------------------------------- +// Per-participant statistics +// --------------------------------------------------------------------------- + +struct ParticipantStats { + /// Stream identifier (index, assigned when we detect a new seq stream) + stream_id: usize, + /// Display name from RoomUpdate (if available) + alias: Option, + /// Current codec + codec: CodecId, + /// Total packets received + packets: u64, + /// Detected lost packets (sequence gaps) + lost: u64, + /// Last seen sequence number + last_seq: u16, + /// Whether we've seen the first packet (for gap detection) + seq_initialized: bool, + /// EWMA jitter in ms + jitter_ms: f64, + /// Last packet arrival time + last_arrival: Option, + /// Codec changes observed + codec_switches: u32, + /// First packet time + first_seen: Instant, + /// Last packet time + last_seen: Instant, +} + +impl ParticipantStats { + fn new(id: usize, codec: CodecId) -> Self { + let now = Instant::now(); + Self { + stream_id: id, + alias: None, + codec, + packets: 0, + lost: 0, + last_seq: 0, + seq_initialized: false, + jitter_ms: 0.0, + last_arrival: None, + codec_switches: 0, + first_seen: now, + last_seen: now, + } + } + + fn ingest(&mut self, pkt: &MediaPacket, now: Instant) { + self.packets += 1; + self.last_seen = now; + + // Codec switch detection + if pkt.header.codec_id != self.codec { + self.codec_switches += 1; + self.codec = pkt.header.codec_id; + } + + // Loss detection from sequence gaps + if self.seq_initialized { + let expected = self.last_seq.wrapping_add(1); + let gap = pkt.header.seq.wrapping_sub(expected); + if gap > 0 && gap < 100 { + self.lost += gap as u64; + } + } + self.last_seq = pkt.header.seq; + self.seq_initialized = true; + + // Jitter (inter-arrival time variance, EWMA) + if let Some(last) = self.last_arrival { + let interval_ms = now.duration_since(last).as_secs_f64() * 1000.0; + let expected_ms = pkt.header.codec_id.frame_duration_ms() as f64; + let diff = (interval_ms - expected_ms).abs(); + self.jitter_ms = 0.1 * diff + 0.9 * self.jitter_ms; + } + self.last_arrival = Some(now); + } + + fn loss_percent(&self) -> f64 { + let total = self.packets + self.lost; + if total == 0 { + 0.0 + } else { + (self.lost as f64 / total as f64) * 100.0 + } + } + + fn duration(&self) -> Duration { + self.last_seen.duration_since(self.first_seen) + } + + fn display_name(&self) -> String { + self.alias + .as_deref() + .map(String::from) + .unwrap_or_else(|| format!("Stream {}", self.stream_id)) + } +} + +// --------------------------------------------------------------------------- +// Participant identification by sequence stream +// --------------------------------------------------------------------------- + +/// Find the participant whose sequence counter is close to `seq`, or create a +/// new one. Each sender has an independent wrapping u16 counter, so we can +/// distinguish streams by proximity of consecutive sequence numbers. +fn find_or_create_participant( + participants: &mut Vec, + seq: u16, + codec: CodecId, +) -> usize { + for (i, p) in participants.iter().enumerate() { + if p.seq_initialized { + let delta = seq.wrapping_sub(p.last_seq); + if delta > 0 && delta < 50 { + return i; + } + } + } + // New stream detected + let id = participants.len(); + participants.push(ParticipantStats::new(id, codec)); + id +} + +// --------------------------------------------------------------------------- +// Capture writer (binary packet log for later replay) +// --------------------------------------------------------------------------- + +struct CaptureWriter { + file: std::io::BufWriter, + start: Instant, +} + +impl CaptureWriter { + fn new(path: &str, room: &str, relay: &str) -> anyhow::Result { + let file = std::fs::File::create(path)?; + let mut writer = std::io::BufWriter::new(file); + // Magic + version + writer.write_all(b"WZP\x01")?; + let header = serde_json::json!({ + "room": room, + "relay": relay, + "start_time": chrono::Utc::now().to_rfc3339(), + "version": 1, + }); + let header_bytes = serde_json::to_vec(&header)?; + writer.write_all(&(header_bytes.len() as u32).to_le_bytes())?; + writer.write_all(&header_bytes)?; + Ok(Self { + file: writer, + start: Instant::now(), + }) + } + + fn write_packet(&mut self, pkt: &MediaPacket, now: Instant) -> anyhow::Result<()> { + let elapsed_us = now.duration_since(self.start).as_micros() as u64; + self.file.write_all(&elapsed_us.to_le_bytes())?; + let raw = pkt.to_bytes(); + self.file.write_all(&(raw.len() as u32).to_le_bytes())?; + self.file.write_all(&raw)?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// No-TUI mode (print stats to stdout periodically) +// --------------------------------------------------------------------------- + +async fn run_no_tui( + transport: &wzp_transport::QuinnTransport, + participants: &mut Vec, + total_packets: &mut u64, + deadline: Option, + mut capture_writer: Option<&mut CaptureWriter>, +) -> anyhow::Result<()> { + let mut print_timer = Instant::now(); + loop { + if let Some(dl) = deadline { + if Instant::now() > dl { + break; + } + } + match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await { + Ok(Ok(Some(pkt))) => { + let now = Instant::now(); + let idx = + find_or_create_participant(participants, pkt.header.seq, pkt.header.codec_id); + participants[idx].ingest(&pkt, now); + *total_packets += 1; + if let Some(ref mut w) = capture_writer { + w.write_packet(&pkt, now)?; + } + } + Ok(Ok(None)) => break, // connection closed + Ok(Err(e)) => { + tracing::warn!("recv error: {e}"); + break; + } + Err(_) => {} // timeout, loop again + } + if print_timer.elapsed() >= Duration::from_secs(2) { + print_stats(participants, *total_packets); + print_timer = Instant::now(); + } + } + Ok(()) +} + +fn print_stats(participants: &[ParticipantStats], total: u64) { + eprintln!("--- {} participants | {} total packets ---", participants.len(), total); + for p in participants { + eprintln!( + " {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {:.0}s", + p.display_name(), + p.packets, + p.loss_percent(), + p.jitter_ms, + p.codec, + p.duration().as_secs_f64(), + ); + } +} + +// --------------------------------------------------------------------------- +// TUI mode (ratatui + crossterm) +// --------------------------------------------------------------------------- + +async fn run_tui( + transport: &wzp_transport::QuinnTransport, + participants: &mut Vec, + total_packets: &mut u64, + start_time: Instant, + deadline: Option, + mut capture_writer: Option<&mut CaptureWriter>, +) -> anyhow::Result<()> { + crossterm::terminal::enable_raw_mode()?; + let mut stdout = std::io::stdout(); + crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?; + let backend = ratatui::backend::CrosstermBackend::new(stdout); + let mut terminal = ratatui::Terminal::new(backend)?; + + let mut redraw_timer = Instant::now(); + + let result: anyhow::Result<()> = async { + loop { + // Check for quit key (q or Ctrl+C) + if crossterm::event::poll(Duration::from_millis(0))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::{KeyCode, KeyModifiers}; + if key.code == KeyCode::Char('q') + || (key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL)) + { + break; + } + } + } + + if let Some(dl) = deadline { + if Instant::now() > dl { + break; + } + } + + // Receive packets (non-blocking with short timeout) + match tokio::time::timeout(Duration::from_millis(20), transport.recv_media()).await { + Ok(Ok(Some(pkt))) => { + let now = Instant::now(); + let idx = find_or_create_participant( + participants, + pkt.header.seq, + pkt.header.codec_id, + ); + participants[idx].ingest(&pkt, now); + *total_packets += 1; + if let Some(ref mut w) = capture_writer { + w.write_packet(&pkt, now)?; + } + } + Ok(Ok(None)) => break, + Ok(Err(e)) => { + tracing::warn!("recv error: {e}"); + break; + } + Err(_) => {} + } + + // Redraw TUI at ~10 FPS + if redraw_timer.elapsed() >= Duration::from_millis(100) { + terminal.draw(|f| draw_ui(f, participants, *total_packets, start_time))?; + redraw_timer = Instant::now(); + } + } + Ok(()) + } + .await; + + // Always restore terminal, even on error + crossterm::terminal::disable_raw_mode()?; + crossterm::execute!( + std::io::stdout(), + crossterm::terminal::LeaveAlternateScreen + )?; + + result +} + +fn draw_ui( + f: &mut ratatui::Frame, + participants: &[ParticipantStats], + total_packets: u64, + start_time: Instant, +) { + use ratatui::layout::{Constraint, Direction, Layout}; + use ratatui::style::{Color, Modifier, Style}; + use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; + + let elapsed = start_time.elapsed(); + let elapsed_str = format!( + "{:02}:{:02}:{:02}", + elapsed.as_secs() / 3600, + (elapsed.as_secs() % 3600) / 60, + elapsed.as_secs() % 60 + ); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // header + Constraint::Min(5), // participant table + Constraint::Length(3), // footer + ]) + .split(f.area()); + + // Header + let header = Paragraph::new(format!( + " WZP Analyzer | {} participants | {} packets | {}", + participants.len(), + total_packets, + elapsed_str + )) + .block(Block::default().borders(Borders::ALL).title(" Protocol Analyzer ")); + f.render_widget(header, chunks[0]); + + // Participant table + let header_row = Row::new(vec![ + "#", "Name", "Codec", "Packets", "Loss%", "Jitter", "Switches", "Duration", + ]) + .style(Style::default().add_modifier(Modifier::BOLD)); + + let rows: Vec = participants + .iter() + .map(|p| { + let loss_color = if p.loss_percent() > 5.0 { + Color::Red + } else if p.loss_percent() > 1.0 { + Color::Yellow + } else { + Color::Green + }; + + Row::new(vec![ + format!("{}", p.stream_id), + p.display_name(), + format!("{:?}", p.codec), + format!("{}", p.packets), + format!("{:.1}%", p.loss_percent()), + format!("{:.0}ms", p.jitter_ms), + format!("{}", p.codec_switches), + format!("{:.0}s", p.duration().as_secs_f64()), + ]) + .style(Style::default().fg(loss_color)) + }) + .collect(); + + let widths = [ + Constraint::Length(3), // # + Constraint::Length(20), // Name + Constraint::Length(12), // Codec + Constraint::Length(10), // Packets + Constraint::Length(8), // Loss% + Constraint::Length(10), // Jitter + Constraint::Length(10), // Switches + Constraint::Length(10), // Duration + ]; + + let table = Table::new(rows, widths) + .header(header_row) + .block(Block::default().borders(Borders::ALL).title(" Participants ")); + f.render_widget(table, chunks[1]); + + // Footer + let footer = + Paragraph::new(" Press 'q' to quit ").block(Block::default().borders(Borders::ALL)); + f.render_widget(footer, chunks[2]); +} + +// --------------------------------------------------------------------------- +// Summary (printed on exit) +// --------------------------------------------------------------------------- + +fn print_summary(participants: &[ParticipantStats], total: u64, elapsed: Duration) { + eprintln!("\n=== Session Summary ==="); + eprintln!( + "Duration: {:.1}s | Total packets: {} | Participants: {}", + elapsed.as_secs_f64(), + total, + participants.len() + ); + for p in participants { + eprintln!( + " {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {} codec switches", + p.display_name(), + p.packets, + p.loss_percent(), + p.jitter_ms, + p.codec, + p.codec_switches, + ); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + // Only init tracing subscriber in no-tui mode (it would corrupt the TUI otherwise) + if args.no_tui { + tracing_subscriber::fmt().init(); + } + + // TLS crypto provider + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Identity seed + let seed = match &args.seed { + Some(hex) => { + let s = wzp_crypto::Seed::from_hex(hex).map_err(|e| anyhow::anyhow!(e))?; + info!(fingerprint = %s.derive_identity().public_identity().fingerprint, "identity from --seed"); + s + } + None => { + let s = wzp_crypto::Seed::generate(); + info!(fingerprint = %s.derive_identity().public_identity().fingerprint, "generated ephemeral identity"); + s + } + }; + + // Connect to relay + let relay_addr: std::net::SocketAddr = args.relay.parse()?; + let bind_addr: std::net::SocketAddr = if relay_addr.is_ipv6() { + "[::]:0".parse()? + } else { + "0.0.0.0:0".parse()? + }; + let endpoint = wzp_transport::create_endpoint(bind_addr, None)?; + let client_config = wzp_transport::client_config(); + let conn = wzp_transport::connect(&endpoint, relay_addr, &args.room, client_config).await?; + let transport = Arc::new(wzp_transport::QuinnTransport::new(conn)); + + // Crypto handshake + let _crypto_session = + wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some("analyzer")).await?; + + // Auth if token provided + if let Some(ref token) = args.token { + let auth = wzp_proto::SignalMessage::AuthToken { + token: token.clone(), + }; + transport.send_signal(&auth).await?; + } + + // Capture file (optional) + let mut capture_writer = args + .capture + .as_ref() + .map(|path| CaptureWriter::new(path, &args.room, &args.relay)) + .transpose()?; + + // Duration timeout + let deadline = args + .duration + .map(|s| Instant::now() + Duration::from_secs(s)); + + // State + let mut participants: Vec = Vec::new(); + let mut total_packets: u64 = 0; + let start_time = Instant::now(); + + if args.no_tui { + run_no_tui( + &transport, + &mut participants, + &mut total_packets, + deadline, + capture_writer.as_mut(), + ) + .await?; + } else { + run_tui( + &transport, + &mut participants, + &mut total_packets, + start_time, + deadline, + capture_writer.as_mut(), + ) + .await?; + } + + // Print summary + print_summary(&participants, total_packets, start_time.elapsed()); + + // Clean close + transport.close().await?; + + Ok(()) +}