Files
netris-nestri/apps/neshub/src/main.rs
2026-08-31 17:24:00 +03:00

307 lines
10 KiB
Rust

mod dgram;
mod ipc_listener;
mod keyframe;
mod screenshot;
mod session;
mod ticket;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use clap::Parser;
use iroh::endpoint::presets;
use crate::session::SessionManager;
use crate::ticket::NestriTicket;
use nesprotocol::ALPN;
#[derive(Parser, Debug)]
#[command(name = "neshub")]
struct Args {
/// Relay mode: default, none, or a custom relay URL
#[arg(long, env = "NESTRI_RELAY", default_value = "default")]
relay: String,
/// Path for the video IPC socket (nescapture → neshub)
#[arg(
long,
env = "NESTRI_VIDEO_IPC",
default_value = "/tmp/nestri-video.sock"
)]
video_ipc: PathBuf,
/// Path for the audio IPC socket (neswire → neshub)
#[arg(
long,
env = "NESTRI_AUDIO_IPC",
default_value = "/tmp/nestri-audio.sock"
)]
audio_ipc: PathBuf,
/// Path for the input IPC socket (neshub → nescope).
#[arg(
long,
env = "NESTRI_INPUT_IPC",
default_value = "/tmp/nestri-input.sock"
)]
input_ipc: PathBuf,
/// Path for the stats IPC socket (nescapture → neshub stats).
#[arg(
long,
env = "NESTRI_STATS_IPC",
default_value = "/tmp/nestri-stats.sock"
)]
stats_ipc: PathBuf,
/// Socket the ticket is served on. neshub listens; nesinit dials and
/// carries the ticket to the host, because the person who needs it is
/// outside this VM and stdout here is a log file inside one.
#[arg(
long,
env = "NESTRI_TICKET_IPC",
default_value = "/tmp/nestri-ticket.sock"
)]
ticket_ipc: PathBuf,
/// Audio channels (from neswire config): 2 = stereo, 6 = 5.1, 8 = 7.1
#[arg(long, env = "NESTRI_AUDIO_CHANNELS", default_value_t = 2)]
audio_channels: u32,
/// Audio bitrate per channel in kbps
#[arg(long, env = "NESTRI_AUDIO_BITRATE", default_value_t = 64)]
audio_bitrate_per_channel: u32,
/// Socket nescope sends screenshots on. neshub listens; nescope dials out.
#[arg(
long,
env = "NESTRI_SCREENSHOT_IPC",
default_value = "/tmp/nestri-screenshot.sock"
)]
screenshot_ipc: PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::builder()
.with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
.from_env_lossy(),
)
.init();
let args = Args::parse();
let mut builder = iroh::Endpoint::builder(presets::N0)
.alpns(vec![ALPN.to_vec()])
.transport_config(crate::dgram::media_transport_config());
match args.relay.as_str() {
"default" | "" => {
builder = builder.relay_mode(iroh::endpoint::RelayMode::Default);
tracing::info!("using default n0-computer relays");
}
"none" | "off" | "disabled" => {
builder = builder.relay_mode(iroh::endpoint::RelayMode::Disabled);
tracing::info!("relays disabled (direct connections only)");
}
url => {
let relay_url: iroh::RelayUrl = url.parse()?;
let relay_map = iroh::RelayMap::empty();
relay_map.insert(
relay_url.clone(),
Arc::new(iroh::RelayConfig::new(relay_url, None)),
);
builder = builder.relay_mode(iroh::endpoint::RelayMode::Custom(relay_map));
tracing::info!("using custom relay: {url}");
}
}
let endpoint = builder.bind().await?;
let endpoint_addr = endpoint.addr();
let ep_id = endpoint_addr.id;
tracing::info!("endpoint online: {}", ep_id.fmt_short());
// Input broadcast channel: input reader -> input IPC listener -> nescope
let (input_broadcast_tx, _) = tokio::sync::broadcast::channel::<Vec<u8>>(256);
// Cursor channel: IPC listener (read side) -> client sessions -> desktop-app
let (cursor_tx, mut cursor_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
// Nescope stats channel: IPC listener -> client sessions
let (nescope_stats_tx, mut nescope_stats_rx) =
tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
let session_manager = Arc::new(SessionManager::new());
// IDR / encode settings command channel: input reader → nescapture
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
{
tokio::spawn(async move {
let cmd_path = std::path::PathBuf::from("/tmp/nescapture-cmd.sock");
while let Some(bytes) = cmd_rx.recv().await {
if let Ok(sock) = std::os::unix::net::UnixDatagram::unbound() {
if sock.send_to(&bytes, &cmd_path).is_err() {
tracing::warn!("nescapture cmd send failed at {}", cmd_path.display());
}
}
}
});
}
// Spawn cursor relay
{
let mgr = session_manager.clone();
tokio::spawn(async move {
while let Some(data) = cursor_rx.recv().await {
mgr.broadcast_cursor(data).await;
}
});
}
// Spawn nescope stats relay
{
let mgr = session_manager.clone();
tokio::spawn(async move {
while let Some(data) = nescope_stats_rx.recv().await {
mgr.broadcast_stats(data).await;
}
});
}
// Spawn periodic hub stats
{
let mgr = session_manager.clone();
let audio_channels = args.audio_channels as u8;
// The configured target is worth saying once, here, where it is a fact
// about this hub's arguments. It is deliberately not what gets reported
// in the stats below -- see `SessionManager::audio_bitrate_kbps`.
tracing::info!(
"audio configured for {}ch at {}kbps/channel; stats report measured ingest",
args.audio_channels,
args.audio_bitrate_per_channel
);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
loop {
interval.tick().await;
let clients = mgr.client_count().await as u8;
let bitrate = mgr.video_bitrate_bps();
let audio_kbps = mgr.audio_bitrate_kbps();
let relay_ms = mgr.relay_ms();
let mut buf = Vec::with_capacity(15);
nesprotocol::stats::encode_hub_stats(
&mut buf,
clients,
bitrate,
relay_ms,
audio_kbps,
audio_channels,
);
mgr.broadcast_stats(buf).await;
}
});
}
// ── Accept mode: generate ticket, wait for desktop-app to connect ─────
let stream_name = ticket::generate_stream_name();
let ticket = NestriTicket::new(endpoint_addr, stream_name);
tracing::info!("\n╔═══════════════╗");
tracing::info!("║ NESTRI TICKET ║");
tracing::info!("╚═══════════════╝");
tracing::info!("{ticket}\n");
// Spawn IPC listeners
let video_ipc = args.video_ipc.clone();
let audio_ipc = args.audio_ipc.clone();
let input_ipc = args.input_ipc.clone();
let stats_ipc = args.stats_ipc.clone();
let stats_tx_clone = nescope_stats_tx.clone();
tokio::spawn({
let mgr = session_manager.clone();
async move { ipc_listener::run_video_listener(video_ipc, mgr).await }
});
tokio::spawn({
let mgr = session_manager.clone();
async move { ipc_listener::run_audio_listener(audio_ipc, mgr).await }
});
let input_ipc_tx = input_broadcast_tx.clone();
let cursor_ipc_tx = cursor_tx.clone();
let ns_tx = nescope_stats_tx.clone();
tokio::spawn({
async move {
ipc_listener::run_input_ipc_listener(input_ipc, input_ipc_tx, cursor_ipc_tx, ns_tx)
.await
}
});
tokio::spawn({
let stx = stats_tx_clone.clone();
async move { ipc_listener::run_stats_ipc_listener(stats_ipc, stx).await }
});
let ticket_ipc = args.ticket_ipc.clone();
tokio::spawn({
async move { ipc_listener::run_ticket_ipc_listener(ticket_ipc, ticket).await }
});
// Accept loop
let ep = endpoint.clone();
let mgr = session_manager.clone();
let accept_handle = tokio::spawn(async move {
while let Some(incoming) = ep.accept().await {
match incoming.await {
Ok(conn) => {
let remote_id = conn.remote_id();
tracing::info!(remote = %remote_id.fmt_short(), "client connected");
let session = session::ClientSession::new(
conn.clone(),
input_broadcast_tx.clone(),
session_manager.relay_ms_atomic(),
cmd_tx.clone(),
);
mgr.add_session(remote_id, session).await;
let mgr_clone = mgr.clone();
let conn_clone = conn.clone();
tokio::spawn(async move {
conn_clone.closed().await;
mgr_clone.remove_session(&remote_id).await;
});
}
Err(e) => {
tracing::warn!("incoming connection failed: {e}");
}
}
}
tracing::info!("accept loop exited");
});
// Not wired to anything today. Kept because the capture works and "show me
// what the guest is displaying" is the first question when a payload
// renders black.
let _screenshots = match screenshot::listen(&args.screenshot_ipc) {
Ok(connection) => Some(connection),
Err(e) => {
tracing::warn!("screenshots unavailable: {e:#}");
None
}
};
tracing::info!("neshub running, ctrl+c to stop");
tokio::signal::ctrl_c().await?;
tracing::info!("shutting down..");
endpoint.close().await;
accept_handle.abort();
let _ = std::fs::remove_file(&args.video_ipc);
let _ = std::fs::remove_file(&args.audio_ipc);
let _ = std::fs::remove_file(&args.input_ipc);
let _ = std::fs::remove_file(&args.stats_ipc);
let _ = std::fs::remove_file("/tmp/nescapture-cmd.sock");
let _ = std::fs::remove_file(&args.ticket_ipc);
Ok(())
}