193 lines
8.3 KiB
Rust
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(())
|
|
}
|