//! `lumen-host` — the Linux streaming host (plan §2, §6, §7). //! //! Creates a client-sized virtual display, captures it via PipeWire, encodes with //! VAAPI/NVENC, and hands encoded access units to `lumen_core` for FEC + packetization + //! pacing + send. Input flows back via libei/uinput. The platform backends are //! `#[cfg(target_os = "linux")]`; the crate compiles everywhere so the workspace builds //! on non-Linux dev machines — it just can't run the pipeline there. //! //! Status: M0. The `m0` subcommand runs the capture→encode→file pipeline spike and feeds //! the encoded AUs through a `lumen_core` loopback. M2 wires the full P1 host that a stock //! Moonlight client connects to. // Scaffold: trait methods and config paths are defined ahead of their backends. #![allow(dead_code)] mod audio; mod capture; mod encode; mod gamestream; mod inject; mod m0; mod pipeline; mod pwinit; mod vdisplay; mod web; #[cfg(target_os = "linux")] mod zerocopy; use anyhow::{bail, Result}; use encode::Codec; use m0::{Options, Source}; use std::path::PathBuf; fn main() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); if let Err(e) = real_main() { tracing::error!("{e:#}"); std::process::exit(1); } } fn real_main() -> Result<()> { tracing::info!("lumen-host (lumen_core ABI v{})", lumen_core::ABI_VERSION); let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { // M2 GameStream host control plane (P1.1: mDNS + serverinfo). Some("serve") => gamestream::serve(), // Standalone input-injection smoke test (no client needed): open the session's input // backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`. Some("input-test") => input_test(), // Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed). #[cfg(target_os = "linux")] Some("zerocopy-probe") => zerocopy::probe(), // M0 pipeline spike. Some("m0") => m0::run(parse_m0(&args[1..])?), Some("-h") | Some("--help") | Some("help") | None => { print_usage(); Ok(()) } // Bare flags (no subcommand) default to the m0 spike for back-compat. Some(_) => m0::run(parse_m0(&args)?), } } /// Inject a scripted mouse + keyboard pattern through the session's input backend (libei on /// KWin/GNOME, wlr on Sway). Lets us validate input injection without a Moonlight client. #[cfg(target_os = "linux")] fn input_test() -> Result<()> { use lumen_core::input::{InputEvent, InputKind}; use std::time::Duration; let backend = inject::default_backend(); tracing::info!(?backend, "input-test: opening injector"); let mut inj = inject::open(backend)?; // An async backend (libei) needs a moment to establish its portal/EIS session + device // resume; events injected before then are dropped. std::thread::sleep(Duration::from_secs(4)); let ev = |kind, code, x, y| InputEvent { kind, _pad: [0; 3], code, x, y, flags: 0, }; tracing::info!( "input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)" ); for i in 0..160u32 { let (dx, dy) = match (i / 10) % 4 { 0 => (12, 0), 1 => (0, 12), 2 => (-12, 0), _ => (0, -12), }; if let Err(e) = inj.inject(&ev(InputKind::MouseMove, 0, dx, dy)) { tracing::warn!(error = %format!("{e:#}"), "input-test: inject failed"); } if i % 20 == 0 { let _ = inj.inject(&ev(InputKind::KeyDown, 0x41, 0, 0)); // 'A' let _ = inj.inject(&ev(InputKind::KeyUp, 0x41, 0, 0)); let _ = inj.inject(&ev(InputKind::MouseButtonDown, 1, 0, 0)); // left click let _ = inj.inject(&ev(InputKind::MouseButtonUp, 1, 0, 0)); } std::thread::sleep(Duration::from_millis(50)); } tracing::info!("input-test: done"); Ok(()) } #[cfg(not(target_os = "linux"))] fn input_test() -> Result<()> { bail!("input-test requires Linux") } fn parse_m0(args: &[String]) -> Result { let mut source = Source::Portal; let mut width = 1920u32; let mut height = 1080u32; let mut fps = 60u32; let mut seconds = 5u32; let mut codec = Codec::H265; let mut bitrate_mbps = 20u64; let mut out: Option = None; let mut loopback = true; let mut i = 0; while i < args.len() { let arg = args[i].as_str(); let mut next = || { i += 1; args.get(i) .cloned() .ok_or_else(|| anyhow::anyhow!("missing value for {arg}")) }; match arg { "--source" => { source = match next()?.as_str() { "synthetic" => Source::Synthetic, "portal" => Source::Portal, other => bail!("unknown --source '{other}' (synthetic|portal)"), } } "--width" => { width = next()? .parse() .map_err(|_| anyhow::anyhow!("bad --width"))? } "--height" => { height = next()? .parse() .map_err(|_| anyhow::anyhow!("bad --height"))? } "--fps" => fps = next()?.parse().map_err(|_| anyhow::anyhow!("bad --fps"))?, "--seconds" => { seconds = next()? .parse() .map_err(|_| anyhow::anyhow!("bad --seconds"))? } "--codec" => { codec = match next()?.as_str() { "h264" => Codec::H264, "h265" | "hevc" => Codec::H265, "av1" => Codec::Av1, other => bail!("unknown --codec '{other}' (h264|h265|av1)"), } } "--bitrate" => { bitrate_mbps = next()? .parse() .map_err(|_| anyhow::anyhow!("bad --bitrate (Mbps)"))? } "--out" => out = Some(PathBuf::from(next()?)), "--no-loopback" => loopback = false, "-h" | "--help" => { print_usage(); std::process::exit(0); } other => bail!("unknown argument '{other}' (try --help)"), } i += 1; } if fps == 0 || width == 0 || height == 0 || seconds == 0 { bail!("--fps/--width/--height/--seconds must be > 0"); } let out = out.unwrap_or_else(|| { let ext = match codec { Codec::H264 => "h264", Codec::H265 => "h265", Codec::Av1 => "obu", }; PathBuf::from(format!("/tmp/lumen-m0.{ext}")) }); Ok(Options { source, width, height, fps, seconds, codec, bitrate_bps: bitrate_mbps.saturating_mul(1_000_000), out, loopback, }) } fn print_usage() { eprintln!( "lumen-host — Linux streaming host USAGE: lumen-host serve GameStream host control plane (M2: mDNS + serverinfo …) lumen-host m0 [OPTIONS] M0 capture→encode→file pipeline spike OPTIONS: --source frame source (default: portal) --seconds capture duration in seconds (default: 5) --fps target frame rate (default: 60) --codec NVENC codec (default: h265) --bitrate target bitrate in Mbps (default: 20) --width --height synthetic source size (default: 1920x1080) --out raw Annex-B output (default: /tmp/lumen-m0.) --no-loopback skip the lumen_core round-trip verification -h, --help this help NOTES: 'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session (see docs/linux-setup.md). 'synthetic' needs no capture session and always runs. Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a lumen_core host→client loopback that reassembles and byte-verifies each one." ); }