NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -0,0 +1,97 @@
|
||||
[package]
|
||||
name = "netdesk-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description = "Headless NetDesk native client — connects to a host and verifies the pipeline (dev/test harness)."
|
||||
|
||||
[[bin]]
|
||||
name = "netdesk"
|
||||
path = "src/main.rs"
|
||||
|
||||
# End-to-end feature test harness: connects to the live host and verifies every
|
||||
# Session feature + the read-only host_ctl backend. See src/bin/e2e.rs.
|
||||
[[bin]]
|
||||
name = "netdesk-e2e"
|
||||
path = "src/bin/e2e.rs"
|
||||
|
||||
# TCP tunnel client: forward a local port through the relay to a port on the
|
||||
# remote host (SSH/VNC/RDP/DB over NetDesk). Portal-authed.
|
||||
[[bin]]
|
||||
name = "netdesk-tunnel"
|
||||
path = "src/bin/tunnel.rs"
|
||||
|
||||
# LAN discovery scanner: find NetDesk hosts on the local network.
|
||||
[[bin]]
|
||||
name = "netdesk-lan-scan"
|
||||
path = "src/bin/lan_scan.rs"
|
||||
|
||||
# Recursive folder download: pull a whole remote folder tree to a local dir.
|
||||
[[bin]]
|
||||
name = "netdesk-getdir"
|
||||
path = "src/bin/getdir.rs"
|
||||
|
||||
# Interactive terminal: run commands on the remote host's shell (ConPTY).
|
||||
[[bin]]
|
||||
name = "netdesk-term"
|
||||
path = "src/bin/term.rs"
|
||||
|
||||
# Voice send-test: capture this PC's mic and stream it to the host over
|
||||
# CHAN_VOICE (drives the exact native-viewer voice path, headlessly).
|
||||
[[bin]]
|
||||
name = "netdesk-voice-send"
|
||||
path = "src/bin/voice_send.rs"
|
||||
|
||||
# FPS probe: count received video frames per 1-second bucket (observes the
|
||||
# host's startup FPS ramp end to end).
|
||||
[[bin]]
|
||||
name = "netdesk-fps-probe"
|
||||
path = "src/bin/fps_probe.rs"
|
||||
|
||||
# Privacy-mode integration test: toggle privacy + verify sys.res and that the
|
||||
# capture keeps delivering frames through the blackout.
|
||||
[[bin]]
|
||||
name = "netdesk-privacy-test"
|
||||
path = "src/bin/privacy_test.rs"
|
||||
|
||||
# Direct-IP connect test: dial a host's IP:port directly (no relay) over
|
||||
# DirectTcp and verify the E2E session + video.
|
||||
[[bin]]
|
||||
name = "netdesk-direct"
|
||||
path = "src/bin/direct_connect.rs"
|
||||
|
||||
[dependencies]
|
||||
netdesk-core = { path = "../core" }
|
||||
# e2e decodes received Opus audio packets to measure real per-source energy
|
||||
# (RMS/peak) — proving system + mic audio actually transmit, not just that
|
||||
# packets arrive.
|
||||
netdesk-opus = { path = "../opus" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
# e2e.rs parses sys.res / session.info JSON and pulls in the viewer's host_ctl
|
||||
# module verbatim (via #[path]); host_ctl needs serde derive, serde_json, a
|
||||
# blocking HTTP client (ureq) and the clipboard crate it references.
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ureq = { version = "2.10", features = ["json"] }
|
||||
arboard = "3"
|
||||
# netdesk-tunnel client: portal-login (ureq) then a raw tunnel WebSocket to
|
||||
# wss://server/tunnel/<hid>, piping a local TcpListener through it.
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
|
||||
# e2e.rs verifies REAL OS-level input injection on the host (which, for the live
|
||||
# test, runs on this same machine): GetCursorPos confirms the mouse actually
|
||||
# moved, GetAsyncKeyState confirms a key actually registered. DPI-aware so the
|
||||
# readings share the host's physical pixel space.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# the e2e bin #[path]-includes viewer/src/host_ctl.rs, which drives the in-process
|
||||
# native host engine for the HOSTCTL checks.
|
||||
netdesk-host = { path = "../host" }
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_HiDpi",
|
||||
] }
|
||||
@@ -0,0 +1,80 @@
|
||||
//! NetDesk direct-IP connect test — dials a host's IP:port directly (no relay),
|
||||
//! runs the same challenge/auth + E2E over a raw TCP socket (DirectTcp), and
|
||||
//! verifies video decodes. Proves encrypted direct-IP access. Developed by
|
||||
//! Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-direct <ip:port> <host_id> <password> [secs]
|
||||
|
||||
use netdesk_core::signaling::connect_with;
|
||||
use netdesk_core::transport::DirectTcp;
|
||||
use netdesk_core::{ConnectConfig, SessionEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 4 {
|
||||
eprintln!("usage: netdesk-direct <ip:port> <host_id> <password> [secs]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let addr = a[1].clone();
|
||||
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
|
||||
|
||||
eprintln!("[direct] dialing {addr} …");
|
||||
let stream = TcpStream::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("tcp connect {addr}: {e}"))?;
|
||||
let transport = DirectTcp::new(stream);
|
||||
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[2].clone(),
|
||||
password: a[3].clone(),
|
||||
ws_url: String::new(), // unused: the transport is provided directly
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let session = connect_with(cfg, tx, Box::new(transport))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("connect_with: {e}"))?;
|
||||
eprintln!("[direct] connected — E2E handshake completed over raw TCP; waiting for video …");
|
||||
|
||||
let mut frames = 0u32;
|
||||
let mut e2e = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(secs + 4);
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Connected { host_name, e2e: ee, w, h })) => {
|
||||
e2e = ee;
|
||||
eprintln!("[direct] session.accept host='{host_name}' {w}x{h} e2e={ee}");
|
||||
}
|
||||
Ok(Some(SessionEvent::Video { .. })) => {
|
||||
frames += 1;
|
||||
if frames >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => {
|
||||
eprintln!("[direct] denied: {reason}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
session.close();
|
||||
|
||||
if frames > 0 {
|
||||
println!("DIRECT_E2E_PASS frames={frames} e2e={e2e} addr={addr}");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("DIRECT_E2E_FAIL frames=0 e2e={e2e}");
|
||||
std::process::exit(4);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,73 @@
|
||||
//! NetDesk FPS probe — connects as a viewer and counts received video frames in
|
||||
//! 1-second buckets, so the host's startup FPS ramp (qos::startup_fps: begins at
|
||||
//! INIT_FPS, climbs to target over RAMP_MS) is observable end to end: the first
|
||||
//! buckets should be ~INIT_FPS and climb to the target. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let secs: usize = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[fps-probe] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
|
||||
// Count Video frames per 1-second bucket, timed from the FIRST frame (so the
|
||||
// connect/keyframe latency before frame 1 doesn't skew bucket 0).
|
||||
let mut buckets: Vec<u32> = vec![0; secs + 2];
|
||||
let mut first: Option<Instant> = None;
|
||||
let hard_deadline = Instant::now() + Duration::from_secs(secs as u64 + 6);
|
||||
loop {
|
||||
if Instant::now() >= hard_deadline {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Video { .. })) => {
|
||||
let t0 = *first.get_or_insert_with(Instant::now);
|
||||
let b = t0.elapsed().as_secs() as usize;
|
||||
if b < buckets.len() {
|
||||
buckets[b] += 1;
|
||||
}
|
||||
if t0.elapsed().as_secs() as usize >= secs {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => {
|
||||
eprintln!("[fps-probe] denied: {reason}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {} // 500ms idle tick; keep waiting
|
||||
}
|
||||
}
|
||||
session.close();
|
||||
|
||||
if first.is_none() {
|
||||
println!("FPS_BUCKETS no-frames");
|
||||
std::process::exit(4);
|
||||
}
|
||||
print!("FPS_BUCKETS");
|
||||
for (i, c) in buckets.iter().take(secs + 1).enumerate() {
|
||||
print!(" t{i}s={c}");
|
||||
}
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! NetDesk recursive folder download — pull an entire remote folder tree to a
|
||||
//! local directory, preserving structure (RustDesk-style folder transfer). It
|
||||
//! asks the host for a recursive manifest (`file.walk`) then downloads each file
|
||||
//! into `<local_dir>/<relative path>`. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage:
|
||||
//! netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 5 {
|
||||
eprintln!("usage: netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(5).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: a.get(6).cloned().unwrap_or_default(),
|
||||
takeover: false,
|
||||
};
|
||||
let remote_dir = a[3].trim_end_matches(['\\', '/']).to_string();
|
||||
let local_dir = PathBuf::from(&a[4]);
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[getdir] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
eprintln!("[getdir] connected; requesting manifest of {remote_dir}");
|
||||
session.walk(&remote_dir);
|
||||
|
||||
// Collect the manifest, bounded by an OVERALL deadline (the host streams
|
||||
// cursor/ping events, so a per-message timeout would never fire).
|
||||
let manifest = timeout(Duration::from_secs(45), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(SessionEvent::FileWalk { files, truncated, .. }) => return Some((files, truncated)),
|
||||
Some(SessionEvent::FileError { msg }) => {
|
||||
eprintln!("[getdir] host error: {msg}");
|
||||
return None;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let (files, truncated) = match manifest {
|
||||
Ok(Some(x)) => x,
|
||||
_ => return Err(anyhow::anyhow!("no manifest from host (timed out)")),
|
||||
};
|
||||
eprintln!(
|
||||
"[getdir] {} file(s){}; downloading to {}",
|
||||
files.len(),
|
||||
if truncated { " (TRUNCATED — hit the host's cap)" } else { "" },
|
||||
local_dir.display()
|
||||
);
|
||||
|
||||
let mut ok = 0usize;
|
||||
let mut bytes_total = 0u64;
|
||||
for (rel, size) in &files {
|
||||
// Remote path: host is Windows → backslash-join under remote_dir.
|
||||
let remote_full = format!("{}\\{}", remote_dir, rel.replace('/', "\\"));
|
||||
let fid = session.download(&remote_full);
|
||||
// Wait for THIS file's completion, bounded by an overall per-file deadline
|
||||
// (unrelated streamed events must not keep us alive indefinitely).
|
||||
let done_path = timeout(Duration::from_secs(90), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(SessionEvent::FileDownloadDone { fid: f, path, .. }) if f == fid => {
|
||||
return Some(path)
|
||||
}
|
||||
Some(SessionEvent::FileError { msg }) => {
|
||||
eprintln!("[getdir] skip {rel}: {msg}");
|
||||
return None;
|
||||
}
|
||||
Some(_) => continue, // progress / cursor / ping — ignore
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
eprintln!("[getdir] timeout on {rel}");
|
||||
None
|
||||
});
|
||||
if let Some(tmp) = done_path {
|
||||
// Move the downloaded temp file into <local_dir>/<rel>.
|
||||
let target = local_dir.join(PathBuf::from(rel.replace('/', std::path::MAIN_SEPARATOR_STR)));
|
||||
if let Some(parent) = target.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// rename across drives can fail → fall back to copy+remove.
|
||||
if std::fs::rename(&tmp, &target).is_err() {
|
||||
if std::fs::copy(&tmp, &target).is_ok() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
} else {
|
||||
eprintln!("[getdir] could not place {rel}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ok += 1;
|
||||
bytes_total += size;
|
||||
}
|
||||
}
|
||||
|
||||
session.close();
|
||||
println!(
|
||||
"[getdir] done: {}/{} files, {} bytes -> {}",
|
||||
ok,
|
||||
files.len(),
|
||||
bytes_total,
|
||||
local_dir.display()
|
||||
);
|
||||
if ok != files.len() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! NetDesk LAN scan — discover NetDesk hosts on the local network by UDP
|
||||
//! broadcast (the viewer end of the host's discovery responder). Prints each
|
||||
//! host's id + name so you can connect by id. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-lan-scan [timeout_ms] (default 800ms)
|
||||
|
||||
use netdesk_core::lan;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
let ms: u64 = std::env::args()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(800);
|
||||
eprintln!("[lan] scanning for NetDesk hosts on udp/{} for {ms}ms…", lan::LAN_PORT);
|
||||
match lan::scan(&lan::broadcast_targets(), Duration::from_millis(ms)) {
|
||||
Ok(hosts) if hosts.is_empty() => {
|
||||
println!("no NetDesk hosts found on this LAN");
|
||||
}
|
||||
Ok(hosts) => {
|
||||
println!("found {} host(s):", hosts.len());
|
||||
for h in hosts {
|
||||
// addr.port() is 0 until direct-IP connect ships; the id is what
|
||||
// a viewer uses to connect through the relay today.
|
||||
println!(" id={} name=\"{}\" ip={}", h.id, h.name, h.addr.ip());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[lan] scan failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Privacy-mode integration test. Connects to a host, confirms video is
|
||||
//! streaming, toggles privacy ON (expects sys.res {kind:"privacy", on:true} and
|
||||
//! that frames keep flowing — i.e. the capture survives the blackout), then
|
||||
//! toggles it OFF (on:false). Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-privacy-test <host_id> <password> [ws_url]
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
type Rx = mpsc::UnboundedReceiver<SessionEvent>;
|
||||
|
||||
async fn wait_for_frame(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<()> {
|
||||
let start = *frames;
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Video { .. })) => {
|
||||
*frames += 1;
|
||||
if *frames > start {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no video frame within {secs}s")
|
||||
}
|
||||
|
||||
async fn wait_privacy(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<bool> {
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
|
||||
Ok(Some(SessionEvent::Video { .. })) => *frames += 1,
|
||||
Ok(Some(SessionEvent::SysResult { json })) => {
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
|
||||
if v.get("kind").and_then(|k| k.as_str()) == Some("privacy") {
|
||||
return Ok(v.get("on").and_then(|o| o.as_bool()).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
|
||||
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
|
||||
Ok(Some(_)) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no privacy sys.res within {secs}s")
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-privacy-test <host_id> <password> [ws_url]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[privacy-test] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
|
||||
let mut frames = 0u32;
|
||||
wait_for_frame(&mut rx, &mut frames, 8).await?;
|
||||
eprintln!("[privacy-test] streaming confirmed; toggling privacy ON");
|
||||
|
||||
session.privacy();
|
||||
let on1 = wait_privacy(&mut rx, &mut frames, 8).await?;
|
||||
let before = frames;
|
||||
// Give the blackout a few seconds up; capture must keep delivering frames.
|
||||
let flowed = wait_for_frame(&mut rx, &mut frames, 5).await.is_ok();
|
||||
eprintln!("[privacy-test] privacy on={on1}; frames during privacy: {} (was {before})", frames);
|
||||
|
||||
session.privacy();
|
||||
let on2 = wait_privacy(&mut rx, &mut frames, 8).await?;
|
||||
eprintln!("[privacy-test] privacy on={on2} (toggled off)");
|
||||
|
||||
session.close();
|
||||
if on1 && !on2 && flowed {
|
||||
println!("PRIVACY_TEST_PASS on->true off->false frames_flowed_during_privacy=true total_frames={frames}");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("PRIVACY_TEST_FAIL on1={on1} on2={on2} flowed={flowed}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Tiny viewer that connects to a host and sends ONE tap (mouse down+up) at
|
||||
//! TAP_X,TAP_Y (in the host's capture pixel space). Used to verify Android-host
|
||||
//! input injection on BlueStacks: send a tap, then screencap to confirm effect.
|
||||
//! E2E_HOST_ID=.. E2E_PASSWORD=.. TAP_X=.. TAP_Y=.. tap-probe
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let id = std::env::var("E2E_HOST_ID").expect("E2E_HOST_ID");
|
||||
let pw = std::env::var("E2E_PASSWORD").expect("E2E_PASSWORD");
|
||||
let x: i32 = std::env::var("TAP_X").unwrap_or_else(|_| "480".into()).parse().unwrap();
|
||||
let y: i32 = std::env::var("TAP_Y").unwrap_or_else(|_| "800".into()).parse().unwrap();
|
||||
let swipe = std::env::var("SWIPE").ok(); // "x2,y2" -> drag instead of tap
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let cfg = ConnectConfig {
|
||||
ws_url: "wss://netdesk.space/ws".into(),
|
||||
host_id: id,
|
||||
password: pw,
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
let session = connect(cfg, tx).await.expect("connect");
|
||||
// drain until Connected
|
||||
let mut connected = false;
|
||||
for _ in 0..50 {
|
||||
if let Ok(Some(ev)) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
|
||||
if matches!(ev, SessionEvent::Connected { .. }) {
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !connected {
|
||||
println!("TAP_PROBE_FAIL not connected");
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
// press
|
||||
session.mouse(x, y, 1, 0.0, 0.0);
|
||||
if let Some(s) = swipe {
|
||||
// drag: move with button held to (x2,y2), then release
|
||||
let parts: Vec<i32> = s.split(',').filter_map(|v| v.trim().parse().ok()).collect();
|
||||
let (x2, y2) = (parts[0], parts[1]);
|
||||
for i in 1..=8 {
|
||||
let ix = x + (x2 - x) * i / 8;
|
||||
let iy = y + (y2 - y) * i / 8;
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
session.mouse(ix, iy, 1, 0.0, 0.0);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
session.mouse(x2, y2, 0, 0.0, 0.0);
|
||||
println!("SWIPE_SENT {x},{y} -> {x2},{y2}");
|
||||
} else {
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
session.mouse(x, y, 0, 0.0, 0.0); // release = tap
|
||||
println!("TAP_SENT {x},{y}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
session.close();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! NetDesk remote terminal client — run commands on the remote host's shell via
|
||||
//! the ConPTY terminal channel and stream the output back. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage:
|
||||
//! netdesk-term <host_id> <password> [ws_url] [totp]
|
||||
//! Input: set ND_TERM_CMD="<command>" to run one command, or pipe commands on
|
||||
//! stdin (e.g. `echo dir | netdesk-term <id> <pw>`). Output streams for a few
|
||||
//! seconds, then it exits. (A full raw-TTY interactive mode is a follow-on.)
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-term <host_id> <password> [ws_url] [totp] (ND_TERM_CMD=cmd, or pipe stdin)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: a.get(4).cloned().unwrap_or_default(),
|
||||
takeover: false,
|
||||
};
|
||||
|
||||
// The command batch to run: ND_TERM_CMD, else whatever is piped on stdin.
|
||||
let input = if let Ok(cmd) = std::env::var("ND_TERM_CMD") {
|
||||
format!("{cmd}\r\n")
|
||||
} else {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let mut s = String::new();
|
||||
let _ = std::io::stdin().read_to_string(&mut s);
|
||||
// normalize bare \n to \r\n for the shell
|
||||
s.replace("\r\n", "\n").replace('\n', "\r\n")
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[term] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
let tid = 1u32;
|
||||
session.term_open(tid);
|
||||
eprintln!("[term] shell opened (tid {tid})");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(700)).await; // let the shell print its prompt
|
||||
if !input.trim().is_empty() {
|
||||
session.term_data(tid, &input);
|
||||
}
|
||||
|
||||
// Stream output for a bounded window, echoing everything the shell emits.
|
||||
let _ = timeout(Duration::from_secs(6), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(SessionEvent::TermData { data, .. }) => {
|
||||
print!("{data}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
Some(SessionEvent::TermExit { .. }) | Some(SessionEvent::Ended { .. }) => break,
|
||||
Some(_) => continue,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
session.term_close(tid);
|
||||
session.close();
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! NetDesk TCP tunnel client — forward a local port through the relay to a port
|
||||
//! on the remote host (SSH/VNC/RDP/DB over NetDesk), the viewer end of the
|
||||
//! `/tunnel/<hid>` server endpoint the host now implements.
|
||||
//!
|
||||
//! Each accepted local connection opens its own `wss://server/tunnel/<hid>`
|
||||
//! WebSocket (portal-authed via the `ndportal` cookie), sends `{"t":"open",...}`
|
||||
//! and then pipes raw bytes both ways. The server asks the host to open the TCP
|
||||
//! connection and relays the stream.
|
||||
//!
|
||||
//! Usage:
|
||||
//! netdesk-tunnel <host_id> <local_port> <remote_port> [remote_host] \
|
||||
//! [wss://server/] [portal_password]
|
||||
//! Env fallbacks: ND_SERVER (https/wss base), ND_PORTAL_PASSWORD.
|
||||
//!
|
||||
//! Example — reach the remote host's RDP:
|
||||
//! netdesk-tunnel 123456789 13389 3389
|
||||
//! mstsc /v:127.0.0.1:13389
|
||||
//!
|
||||
//! Developed by Akronlogic.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message};
|
||||
|
||||
fn env_or(args: &[String], idx: usize, env: &str, default: &str) -> String {
|
||||
args.get(idx)
|
||||
.cloned()
|
||||
.or_else(|| std::env::var(env).ok())
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
/// Portal-login over HTTP to obtain the `ndportal` session cookie the tunnel
|
||||
/// endpoint requires. Returns the raw cookie value (`ndportal=…`).
|
||||
fn portal_cookie(http_base: &str, password: &str) -> Result<String> {
|
||||
// The server replies 303 with Set-Cookie: ndportal=…; don't follow the
|
||||
// redirect so we can read the cookie off the response.
|
||||
let agent = ureq::builder().redirects(0).build();
|
||||
let resp = agent
|
||||
.post(&format!("{http_base}/portal/login"))
|
||||
.send_form(&[("password", password)]);
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(_code, r)) => r, // 303 is an "error" status to ureq
|
||||
Err(e) => return Err(anyhow!("portal login request failed: {e}")),
|
||||
};
|
||||
let set_cookie = resp
|
||||
.header("set-cookie")
|
||||
.ok_or_else(|| anyhow!("portal login did not set a cookie (wrong password?)"))?;
|
||||
// "ndportal=XYZ; HttpOnly; …" -> "ndportal=XYZ"
|
||||
let cookie = set_cookie
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !cookie.starts_with("ndportal=") {
|
||||
return Err(anyhow!("unexpected portal cookie: {cookie}"));
|
||||
}
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// `--rdp` forwards to the remote host's RDP port (3389) and launches the
|
||||
// local Windows RDP client at the forwarded port once it's up — RustDesk's
|
||||
// "New RDP" workflow. It makes remote_port optional (defaults to 3389).
|
||||
let mut raw: Vec<String> = std::env::args().collect();
|
||||
let rdp = raw.iter().any(|a| a == "--rdp");
|
||||
raw.retain(|a| a != "--rdp");
|
||||
let args = raw;
|
||||
if args.len() < 3 || (!rdp && args.len() < 4) {
|
||||
eprintln!(
|
||||
"usage: netdesk-tunnel <host_id> <local_port> <remote_port> \
|
||||
[remote_host] [server] [portal_password]\n\
|
||||
or: netdesk-tunnel --rdp <host_id> <local_port> [remote_host] [server] [portal_password]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let host_id = args[1].clone();
|
||||
let local_port: u16 = args[2].parse().context("local_port")?;
|
||||
// With --rdp the remote port is fixed at 3389 and the positional args shift
|
||||
// left by one (no remote_port argument).
|
||||
let (remote_port, shift) = if rdp {
|
||||
(3389u16, 0usize)
|
||||
} else {
|
||||
(args[3].parse().context("remote_port")?, 1usize)
|
||||
};
|
||||
let remote_host = env_or(&args, 3 + shift, "ND_TUNNEL_RHOST", "127.0.0.1");
|
||||
let server = env_or(&args, 4 + shift, "ND_SERVER", "wss://netdesk.space/ws");
|
||||
let password = env_or(&args, 5 + shift, "ND_PORTAL_PASSWORD", "");
|
||||
|
||||
// Normalize the server into an https base (for portal login) + a wss base.
|
||||
let https_base = server
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.trim_end_matches("/ws")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let wss_base = https_base
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://");
|
||||
let tunnel_url = format!("{wss_base}/tunnel/{host_id}");
|
||||
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"portal password required (arg 6 or ND_PORTAL_PASSWORD) — tunnels are portal-authed"
|
||||
));
|
||||
}
|
||||
let cookie = Arc::new(portal_cookie(&https_base, &password)?);
|
||||
eprintln!("[tunnel] portal authed; forwarding 127.0.0.1:{local_port} -> host {host_id} :{remote_port} ({remote_host})");
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", local_port))
|
||||
.await
|
||||
.with_context(|| format!("bind 127.0.0.1:{local_port}"))?;
|
||||
eprintln!("[tunnel] listening on 127.0.0.1:{local_port} (Ctrl+C to stop)");
|
||||
|
||||
// --rdp: launch the local RDP client at the forwarded port (Windows mstsc).
|
||||
if rdp {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let target = format!("/v:127.0.0.1:{local_port}");
|
||||
match std::process::Command::new("mstsc").arg(&target).spawn() {
|
||||
Ok(_) => eprintln!("[tunnel] launched mstsc {target}"),
|
||||
Err(e) => eprintln!("[tunnel] could not launch mstsc ({e}); connect manually to 127.0.0.1:{local_port}"),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
eprintln!("[tunnel] --rdp: connect your RDP client to 127.0.0.1:{local_port}");
|
||||
}
|
||||
|
||||
loop {
|
||||
let (sock, peer) = listener.accept().await?;
|
||||
let cookie = cookie.clone();
|
||||
let url = tunnel_url.clone();
|
||||
let rhost = remote_host.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_conn(sock, &url, &cookie, &rhost, remote_port).await {
|
||||
eprintln!("[tunnel] {peer}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Pipe one accepted local TCP connection through its own tunnel WebSocket.
|
||||
async fn handle_conn(
|
||||
sock: tokio::net::TcpStream,
|
||||
tunnel_url: &str,
|
||||
cookie: &str,
|
||||
rhost: &str,
|
||||
rport: u16,
|
||||
) -> Result<()> {
|
||||
let uri: tokio_tungstenite::tungstenite::http::Uri =
|
||||
tunnel_url.parse().context("tunnel uri")?;
|
||||
let req = ClientRequestBuilder::new(uri).with_header("Cookie", cookie.to_string());
|
||||
let (ws, _resp) = connect_async(req).await.context("tunnel ws connect")?;
|
||||
let (mut ws_tx, mut ws_rx) = ws.split();
|
||||
|
||||
// Ask the host to open the remote TCP connection.
|
||||
ws_tx
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"t":"open","host":rhost,"port":rport}).to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
let (mut rd, mut wr) = sock.into_split();
|
||||
|
||||
// local socket -> ws (binary)
|
||||
let up = async move {
|
||||
let mut buf = vec![0u8; 32 * 1024];
|
||||
loop {
|
||||
match rd.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if ws_tx.send(Message::Binary(buf[..n].to_vec())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ws_tx.close().await;
|
||||
};
|
||||
|
||||
// ws -> local socket; tunnel.ok/err/closed are control text.
|
||||
let down = async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
match msg {
|
||||
Ok(Message::Binary(b)) => {
|
||||
if wr.write_all(&b).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Text(t)) => {
|
||||
if t.contains("tunnel.err") || t.contains("tunnel.closed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = wr.shutdown().await;
|
||||
};
|
||||
|
||||
tokio::join!(up, down);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! NetDesk voice send-test — drives the EXACT path the native viewer's voice
|
||||
//! button uses (`netdesk_host::audio::run_audio` with source = microphone, and
|
||||
//! `Session::voice` per Opus packet), headlessly, so we can prove viewer->host
|
||||
//! two-way voice end to end against a scratch host. Developed by Akronlogic.
|
||||
//!
|
||||
//! Usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]
|
||||
//! Watch the HOST log for `voice: rx N pkts from viewer mic, recent peak X`.
|
||||
|
||||
use netdesk_core::{connect, ConnectConfig, SessionEvent};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
|
||||
let cfg = ConnectConfig {
|
||||
host_id: a[1].clone(),
|
||||
password: a[2].clone(),
|
||||
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
|
||||
totp: String::new(),
|
||||
takeover: false,
|
||||
};
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
eprintln!("[voice-send] connecting to host {} …", cfg.host_id);
|
||||
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
|
||||
eprintln!("[voice-send] connected; capturing microphone and streaming CHAN_VOICE for {secs}s");
|
||||
|
||||
// Mirror viewer::toggle_voice exactly: share the Session behind a Mutex and
|
||||
// feed each captured Opus packet to Session::voice from the capture thread.
|
||||
let sess = Arc::new(Mutex::new(session));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let sent = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let s2 = sess.clone();
|
||||
let st2 = stop.clone();
|
||||
let n2 = sent.clone();
|
||||
std::thread::spawn(move || {
|
||||
let src = Arc::new(AtomicU8::new(2)); // 2 = microphone (default capture device)
|
||||
netdesk_host::audio::run_audio(st2, || true, src, move |opus| {
|
||||
s2.lock().unwrap().voice(&opus);
|
||||
n2.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
eprintln!("[voice-send] microphone capture is Windows-only");
|
||||
}
|
||||
|
||||
// Drain session events (ignore them) while the capture thread streams.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
ev = rx.recv() => match ev {
|
||||
Some(SessionEvent::Ended { .. }) | None => break,
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let n = sent.load(Ordering::Relaxed);
|
||||
sess.lock().unwrap().close();
|
||||
println!("VOICE_SEND_DONE sent={n} packets");
|
||||
if n == 0 {
|
||||
eprintln!("[voice-send] WARNING: 0 packets — no microphone/capture device?");
|
||||
std::process::exit(3);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 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(())
|
||||
}
|
||||
Reference in new issue
Block a user