Files
netdesk/native/cli/src/main.rs
T

193 lines
8.3 KiB
Rust

//! NetDesk native CLI — a headless end-to-end test harness for the Rust core.
//! Connects to a host through the real signaling server, completes the E2E
//! handshake, and streams video, reporting fps / keyframes / bitrate. This is
//! how the core is tested on CI and during the build-out, before the UI exists.
//!
//! Usage:
//! netdesk <host-id> <password> [ws-url] [seconds]
//! Example:
//! netdesk 123456789 'secret' wss://netdesk.space/ws 10
use netdesk_core::decode::best_decoder_for;
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: netdesk <host-id> <password> [ws-url] [seconds]");
std::process::exit(2);
}
let host_id = args[1].clone();
let password = args[2].clone();
let ws_url = args
.get(3)
.cloned()
.unwrap_or_else(|| "wss://netdesk.space/ws".to_string());
let secs: u64 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(10);
println!("NetDesk native core v{} (by {})", netdesk_core::VERSION, netdesk_core::VENDOR);
println!("connecting to host {host_id} via {ws_url} ...");
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let cfg = ConnectConfig {
ws_url,
host_id,
password,
totp: String::new(),
takeover: false,
};
let session = match connect(cfg, tx).await {
Ok(s) => s,
Err(e) => {
eprintln!("connect failed: {e}");
std::process::exit(1);
}
};
// ask for an immediate keyframe so we start decoding fast
session.request_keyframe();
// Report the monitors the host advertised (this is what drives the viewer's
// monitor-switch UI). With `switch` as the 5th arg, flip to another monitor
// mid-session to prove monitor.switch works end to end.
let mons: Vec<_> = session.monitors().to_vec();
let cur = session.current_monitor();
println!(
" monitors: {} [{}] current={cur}",
mons.len(),
mons.iter()
.map(|m| format!("#{} {}x{}", m.i, m.w, m.h))
.collect::<Vec<_>>()
.join(", ")
);
let do_switch = args.get(5).map(|s| s == "switch").unwrap_or(false);
let mut switched = false;
let start = Instant::now();
let mut frames = 0u64;
let mut keyframes = 0u64;
let mut bytes = 0u64;
let mut connected = false;
let mut last_report = Instant::now();
// Decode the stream to prove the pixels come through. Codec-aware: follows
// the host's per-frame codec tag (H.264 / VP9), swapping on a keyframe.
let mut cur_codec: u8 = 0;
let mut decoder = best_decoder_for(cur_codec);
let mut decoded = 0u64;
let mut audio_pkts = 0u64;
let mut audio_bytes = 0u64;
let mut last_decoded_dim = (0usize, 0usize);
let mut last_nonblack = 0u64; // non-black pixels in the last decoded frame
loop {
let remaining = Duration::from_secs(secs).checked_sub(start.elapsed());
let timeout = match remaining {
Some(r) if !r.is_zero() => r,
_ => break,
};
match tokio::time::timeout(timeout, rx.recv()).await {
Ok(Some(ev)) => match ev {
SessionEvent::Connecting => println!(" . connecting"),
SessionEvent::Challenged { e2e } => println!(" . challenged (e2e={e2e})"),
SessionEvent::Connected { host_name, e2e, w, h } => {
connected = true;
println!(" ✓ CONNECTED to \"{host_name}\" {w}x{h} e2e={e2e}");
}
SessionEvent::Video { keyframe, au, nals, codec } => {
frames += 1;
bytes += au.len() as u64;
if keyframe {
keyframes += 1;
}
// Live monitor-switch test: after a few frames, flip to a
// different monitor and watch the stream follow.
if do_switch && !switched && frames == 30 && mons.len() > 1 {
let other = mons.iter().map(|m| m.i).find(|&i| i != cur).unwrap_or(cur);
println!(" --> monitor.switch to #{other}");
session.monitor_switch(other);
switched = true;
}
if codec != cur_codec && keyframe {
println!(" codec switch -> {}", if codec == 1 { "VP9" } else { "H.264" });
decoder = best_decoder_for(codec);
cur_codec = codec;
}
if codec == cur_codec {
match decoder.decode(&au) {
Ok(Some(frame)) => {
decoded += 1;
last_decoded_dim = (frame.w, frame.h);
last_nonblack =
frame.rgb.chunks_exact(3).filter(|p| p[0] | p[1] | p[2] != 0).count() as u64;
}
Ok(None) => {}
Err(e) => {
if decoded == 0 && frames <= 3 {
eprintln!(" decode error: {e}");
}
}
}
}
if last_report.elapsed() >= Duration::from_secs(1) {
let secs_f = start.elapsed().as_secs_f64().max(0.001);
println!(
" {:>4} rx | {:>4} decoded | {:>3} key | {:>5.1} fps | {:>6.2} Mbps | last {}x{} ({} NAL, {} lit px)",
frames,
decoded,
keyframes,
decoded as f64 / secs_f,
(bytes as f64 * 8.0) / secs_f / 1_000_000.0,
last_decoded_dim.0,
last_decoded_dim.1,
nals,
last_nonblack
);
last_report = Instant::now();
}
}
SessionEvent::Audio { data } => {
audio_pkts += 1;
audio_bytes += data.len() as u64;
}
SessionEvent::Denied { reason } => {
eprintln!(" ✗ DENIED: {reason}");
std::process::exit(1);
}
SessionEvent::Ended { reason } => {
println!(" session ended: {reason}");
break;
}
// New control-protocol events are not exercised by this probe.
_ => {}
},
Ok(None) => break,
Err(_) => break, // overall timeout
}
}
session.close();
let secs_f = start.elapsed().as_secs_f64().max(0.001);
println!("\n==== RESULT ====");
println!("connected : {connected}");
println!("audio : {audio_pkts} Opus pkts ({audio_bytes} B)");
println!("frames rx : {frames} ({:.1} fps)", frames as f64 / secs_f);
println!("decoded : {decoded} frames (last {}x{}, {} lit px)", last_decoded_dim.0, last_decoded_dim.1, last_nonblack);
println!("keyframes : {keyframes}");
println!("video : {:.2} Mbps avg", (bytes as f64 * 8.0) / secs_f / 1_000_000.0);
if connected && decoded > 0 && last_nonblack > 0 {
println!("STATUS : OK — native core decoded live H.264 to real pixels ({}x{})", last_decoded_dim.0, last_decoded_dim.1);
} else if connected && frames > 0 {
println!("STATUS : stream received but not decoded (decoder issue)");
} else if connected {
println!("STATUS : CONNECTED but no frames (host idle? request keyframe?)");
} else {
println!("STATUS : FAILED to establish a session");
std::process::exit(1);
}
Ok(())
}