e60cda3939
Moved the remaining 6 SudoVDA reach-in helpers + SavedConfig (resolve_gdi_name, set_advanced_color, advanced_color_enabled, set_active_mode, isolate/restore_displays_ccd) verbatim from vdisplay::sudovda into a backend-neutral crate::win_display module (the plan's windows/display_ccd.rs). The capturers (idd_push/dxgi/wgc), pf_vdisplay, and punktfunk1 now depend on these as PEERS via crate::win_display instead of reaching into the SudoVDA backend. With win_adapter (F1 pt1), all 7 reach-in helpers are now neutral — the circular reach-in is broken, so SudoVDA can eventually be deleted (Goal 2) without losing the display utilities. sudovda re-exports the ones it still uses internally; its now-unused CCD/GDI imports were removed. Verified: host clippy (nvenc) clean on the RTX box; Linux check clean (the new modules are #[cfg(windows)]). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
707 lines
32 KiB
Rust
707 lines
32 KiB
Rust
//! `punktfunk-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 `punktfunk_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.
|
|
//!
|
|
//! Subcommands: `serve` runs the native punktfunk/1 host + management REST API by default, and —
|
|
//! with `--gamestream` — the GameStream/Moonlight-compat planes too (opt-in, trusted-LAN only);
|
|
//! `punktfunk1-host` runs the native punktfunk/1 host standalone; `spike` is a capture→encode→file
|
|
//! pipeline dev tool that also round-trips the encoded AUs through a `punktfunk_core` loopback.
|
|
|
|
// Scaffold: trait methods and config paths are defined ahead of their backends.
|
|
#![allow(dead_code)]
|
|
|
|
mod audio;
|
|
mod capture;
|
|
mod discovery;
|
|
#[cfg(target_os = "linux")]
|
|
mod dmabuf_fence;
|
|
#[cfg(target_os = "linux")]
|
|
mod drm_sync;
|
|
mod encode;
|
|
mod gamestream;
|
|
mod hdr;
|
|
mod inject;
|
|
mod library;
|
|
mod mgmt;
|
|
mod mgmt_token;
|
|
mod native_pairing;
|
|
mod pipeline;
|
|
mod punktfunk1;
|
|
mod pwinit;
|
|
#[cfg(target_os = "windows")]
|
|
mod service;
|
|
mod session_tuning;
|
|
mod spike;
|
|
mod vdisplay;
|
|
#[cfg(target_os = "windows")]
|
|
mod wgc_helper;
|
|
#[cfg(target_os = "windows")]
|
|
mod win_adapter;
|
|
#[cfg(target_os = "windows")]
|
|
mod win_display;
|
|
#[cfg(target_os = "linux")]
|
|
mod zerocopy;
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use encode::Codec;
|
|
use spike::{Options, Source};
|
|
use std::path::PathBuf;
|
|
|
|
fn main() {
|
|
let filter =
|
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
|
|
// `service run` is launched by the SCM with no console — log to a file instead of stderr.
|
|
#[cfg(target_os = "windows")]
|
|
let service_run = {
|
|
let a: Vec<String> = std::env::args().skip(1).take(2).collect();
|
|
a.first().map(String::as_str) == Some("service")
|
|
&& a.get(1).map(String::as_str) == Some("run")
|
|
};
|
|
#[cfg(not(target_os = "windows"))]
|
|
let service_run = false;
|
|
|
|
if service_run {
|
|
#[cfg(target_os = "windows")]
|
|
service::init_file_logging(filter);
|
|
} else {
|
|
// Logs go to stderr so stdout stays machine-readable (`punktfunk-host openapi > spec.json`).
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(filter)
|
|
.with_writer(std::io::stderr)
|
|
.init();
|
|
}
|
|
|
|
if let Err(e) = real_main() {
|
|
tracing::error!("{e:#}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn real_main() -> Result<()> {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
|
|
// `--version` prints the build-stamped version (build.rs) to stdout and exits — no logging.
|
|
if matches!(
|
|
args.first().map(String::as_str),
|
|
Some("--version") | Some("-V") | Some("version")
|
|
) {
|
|
println!("punktfunk-host {}", env!("PUNKTFUNK_VERSION"));
|
|
return Ok(());
|
|
}
|
|
|
|
tracing::info!(
|
|
"punktfunk-host {} (punktfunk_core ABI v{})",
|
|
env!("PUNKTFUNK_VERSION"),
|
|
punktfunk_core::ABI_VERSION
|
|
);
|
|
|
|
// Install Apollo's win32u GPU-preference hook BEFORE anything touches DXGI (the SudoVDA
|
|
// render-adapter selection creates a DXGI factory during virtual-display setup, well before
|
|
// capture). On a hybrid-GPU box this stops DXGI from reparenting the virtual output off the
|
|
// capture GPU — the ACCESS_LOST churn fix. Idempotent (Once); harmless on non-hybrid boxes.
|
|
#[cfg(target_os = "windows")]
|
|
crate::capture::dxgi::install_gpu_pref_hook();
|
|
|
|
match args.first().map(String::as_str) {
|
|
// The host: the native punktfunk/1 plane + management API by default (secure), and — with
|
|
// --gamestream — the GameStream/Moonlight-compat planes too (opt-in; #5/#9 trusted-LAN caveat).
|
|
Some("serve") => {
|
|
let (mgmt_opts, native, gamestream) = parse_serve(&args[1..])?;
|
|
gamestream::serve(mgmt_opts, native, gamestream)
|
|
}
|
|
// Print the management API's OpenAPI document (for client codegen).
|
|
Some("openapi") => {
|
|
print!("{}", mgmt::openapi_json());
|
|
Ok(())
|
|
}
|
|
// Dump the resolved game library (installed stores + custom entries) as JSON — the same
|
|
// payload `GET /api/v1/library` serves. A diagnostic for "does the host see my games?".
|
|
Some("library") => {
|
|
println!("{}", serde_json::to_string_pretty(&library::all_games())?);
|
|
Ok(())
|
|
}
|
|
// 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(),
|
|
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
|
// on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A
|
|
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
|
|
#[cfg(target_os = "linux")]
|
|
Some("nv12-selftest") => zerocopy::nv12_selftest(),
|
|
// HDR P010 colour self-test (Windows; no display/capture needed): upload a known scRGB FP16
|
|
// pattern, run the `HdrP010Converter` shader → P010 on the GPU, read the Y/UV planes back, and
|
|
// compare against an f64 BT.2020-PQ limited-range reference. Validates the
|
|
// `PUNKTFUNK_HDR_SHADER_P010` colour math without green-screening a live HDR stream. Prints
|
|
// PASS/FAIL + max Y/Cb/Cr error.
|
|
#[cfg(target_os = "windows")]
|
|
Some("hdr-p010-selftest") => crate::capture::dxgi::hdr_p010_selftest(),
|
|
// Compositor readiness probe: exit 0 iff the (detected or PUNKTFUNK_COMPOSITOR-forced)
|
|
// compositor is up and able to create a virtual output *now*. A session-bringup
|
|
// script polls this to gate on real readiness instead of a blind `sleep`.
|
|
Some("probe-compositor") => {
|
|
let compositor = vdisplay::detect()?;
|
|
vdisplay::probe(compositor).with_context(|| format!("{compositor:?} not ready"))?;
|
|
println!("{compositor:?} ready");
|
|
Ok(())
|
|
}
|
|
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
|
|
// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
|
|
// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
|
|
#[cfg(target_os = "linux")]
|
|
Some("dualsense-test") => {
|
|
use inject::dualsense::DualSensePad;
|
|
use inject::dualsense_proto::DsState;
|
|
let secs: u64 = args
|
|
.iter()
|
|
.skip_while(|a| *a != "--seconds")
|
|
.nth(1)
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(20);
|
|
use std::time::{Duration, Instant};
|
|
let mut pad =
|
|
DualSensePad::open(0).context("create virtual DualSense via /dev/uhid")?;
|
|
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
|
|
// devices before we start streaming state.
|
|
let init = Instant::now() + Duration::from_millis(800);
|
|
while Instant::now() < init {
|
|
pad.service(0);
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
println!(
|
|
"virtual DualSense created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
|
|
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
|
let (mut i, mut last_write) = (0i32, Instant::now());
|
|
while Instant::now() < deadline {
|
|
let fb = pad.service(0);
|
|
if let Some((low, high)) = fb.rumble {
|
|
println!(" rumble from kernel/game: low={low} high={high}");
|
|
}
|
|
for o in fb.hidout {
|
|
println!(" hid output from kernel/game: {o:?}");
|
|
}
|
|
if last_write.elapsed() >= Duration::from_millis(300) {
|
|
last_write = Instant::now();
|
|
i += 1;
|
|
let buttons = if i % 2 == 0 {
|
|
punktfunk_core::input::gamepad::BTN_A
|
|
} else {
|
|
0
|
|
};
|
|
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
|
let st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
|
pad.write_state(&st).context("write DualSense report")?;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(15));
|
|
}
|
|
println!("dualsense-test: done");
|
|
Ok(())
|
|
}
|
|
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
|
|
// + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives
|
|
// the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify
|
|
// while it holds: `Get-PnpDevice` shows a VID_054C device, and a HID read returns the pushed
|
|
// report (byte1=0xC0, byte8=0x28). On exit the pad drops → SwDeviceClose removes the devnode.
|
|
#[cfg(target_os = "windows")]
|
|
Some("dualsense-windows-test") => {
|
|
use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame};
|
|
use std::time::{Duration, Instant};
|
|
let secs: u64 = args
|
|
.iter()
|
|
.skip_while(|a| *a != "--seconds")
|
|
.nth(1)
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(20);
|
|
// `--index N` creates pad `pf_pad_N` (default 0) — use a spare index (e.g. 1) to test
|
|
// alongside a running host that already holds pad 0. `--ds4` drives the DualShock 4
|
|
// backend instead of the DualSense one.
|
|
let idx: u8 = args
|
|
.iter()
|
|
.skip_while(|a| *a != "--index")
|
|
.nth(1)
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(0);
|
|
let ds4 = args.iter().any(|a| a == "--ds4");
|
|
let xbox = args.iter().any(|a| a == "--xbox");
|
|
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
|
|
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
|
|
macro_rules! drive {
|
|
($mgr:expr, $label:expr) => {{
|
|
let mut mgr = $mgr;
|
|
mgr.handle(&GamepadEvent::Arrival {
|
|
index: idx,
|
|
kind: 2,
|
|
capabilities: 0,
|
|
});
|
|
println!(
|
|
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
|
|
it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
|
|
$label
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
|
let (mut i, mut last) = (0i32, Instant::now());
|
|
while Instant::now() < deadline {
|
|
mgr.pump(
|
|
|pad, lo, hi| {
|
|
println!(" rumble from game: pad={pad} low={lo} high={hi}")
|
|
},
|
|
|o| println!(" hid output from game: {o:?}"),
|
|
);
|
|
if last.elapsed() >= Duration::from_millis(400) {
|
|
last = Instant::now();
|
|
i += 1;
|
|
let buttons = if i % 2 == 0 {
|
|
punktfunk_core::input::gamepad::BTN_A // Cross
|
|
} else {
|
|
0
|
|
};
|
|
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
|
mgr.handle(&GamepadEvent::State(GamepadFrame {
|
|
index: idx as i16,
|
|
active_mask: 1 << idx,
|
|
buttons,
|
|
left_trigger: 0,
|
|
right_trigger: 0,
|
|
ls_x: lx,
|
|
ls_y: 0,
|
|
rs_x: 0,
|
|
rs_y: 0,
|
|
}));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(15));
|
|
}
|
|
}};
|
|
}
|
|
if xbox {
|
|
// Xbox 360 via the XUSB companion: a different surface (handle + pump_rumble, no
|
|
// HID-output plane), so drive it inline rather than via the macro.
|
|
let mut mgr = inject::gamepad::GamepadManager::new();
|
|
mgr.handle(&GamepadEvent::Arrival {
|
|
index: idx,
|
|
kind: 1,
|
|
capabilities: 0,
|
|
});
|
|
println!(
|
|
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
|
|
an XInput game or xinputtest.exe."
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
|
let mut t = 0i32;
|
|
while Instant::now() < deadline {
|
|
mgr.pump_rumble(|pad, lo, hi| {
|
|
println!(" rumble from game: pad={pad} low={lo} high={hi}")
|
|
});
|
|
t += 1;
|
|
let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700
|
|
let buttons = if (t / 67) % 2 == 0 {
|
|
punktfunk_core::input::gamepad::BTN_A
|
|
} else {
|
|
0
|
|
};
|
|
mgr.handle(&GamepadEvent::State(GamepadFrame {
|
|
index: idx as i16,
|
|
active_mask: 1 << idx,
|
|
buttons,
|
|
left_trigger: 0,
|
|
right_trigger: 0,
|
|
ls_x: lx,
|
|
ls_y: 0,
|
|
rs_x: 0,
|
|
rs_y: 0,
|
|
}));
|
|
std::thread::sleep(Duration::from_millis(15));
|
|
}
|
|
} else if ds4 {
|
|
drive!(
|
|
inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
|
"DualShock 4"
|
|
);
|
|
} else {
|
|
drive!(
|
|
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
|
"DualSense"
|
|
);
|
|
}
|
|
println!("dualsense-windows-test: done (devnode removed)");
|
|
Ok(())
|
|
}
|
|
// Capture→encode→file pipeline spike (dev tool).
|
|
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
|
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
|
Some("punktfunk1-host") => {
|
|
let get = |flag: &str| {
|
|
args.iter()
|
|
.skip_while(|a| *a != flag)
|
|
.nth(1)
|
|
.map(String::as_str)
|
|
};
|
|
let source = match get("--source") {
|
|
Some("virtual") => punktfunk1::Punktfunk1Source::Virtual,
|
|
_ => punktfunk1::Punktfunk1Source::Synthetic,
|
|
};
|
|
punktfunk1::run(punktfunk1::Punktfunk1Options {
|
|
port: get("--port").and_then(|s| s.parse().ok()).unwrap_or(9777),
|
|
source,
|
|
seconds: get("--seconds").and_then(|s| s.parse().ok()).unwrap_or(30),
|
|
frames: get("--frames").and_then(|s| s.parse().ok()).unwrap_or(300),
|
|
max_sessions: get("--max-sessions")
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(0),
|
|
max_concurrent: get("--max-concurrent")
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(punktfunk1::DEFAULT_MAX_CONCURRENT),
|
|
// Secure by default: REQUIRE PIN pairing (reject unpaired clients) unless
|
|
// --allow-tofu opts into trust-on-first-use — the host then accepts unpaired
|
|
// clients and advertises pair=optional. Pairing is always armed so a PIN is
|
|
// available (logged at startup); `--require-pairing`/`--allow-pairing` are now
|
|
// the default and accepted as no-ops for back-compat.
|
|
require_pairing: !args.iter().any(|a| a == "--allow-tofu"),
|
|
allow_pairing: true,
|
|
pairing_pin: None,
|
|
paired_store: None,
|
|
})
|
|
}
|
|
// USER-session WGC helper (Windows two-process secure-desktop design): capture the EXISTING
|
|
// SudoVDA via WGC + NVENC, stream AUs on stdout to the SYSTEM host. Spawned by the host
|
|
// (CreateProcessAsUser), not run by hand. See docs/windows-secure-desktop.md.
|
|
#[cfg(target_os = "windows")]
|
|
Some("wgc-helper") => {
|
|
let get = |flag: &str| {
|
|
args.iter()
|
|
.skip_while(|a| *a != flag)
|
|
.nth(1)
|
|
.map(String::as_str)
|
|
};
|
|
let (width, height, fps) = get("--mode")
|
|
.and_then(|m| {
|
|
let p: Vec<u32> = m.split('x').filter_map(|s| s.parse().ok()).collect();
|
|
(p.len() == 3).then(|| (p[0], p[1], p[2]))
|
|
})
|
|
.unwrap_or((1920, 1080, 60));
|
|
wgc_helper::run(wgc_helper::HelperOptions {
|
|
target_id: get("--target-id").and_then(|s| s.parse().ok()).unwrap_or(0),
|
|
gdi_name: get("--gdi").unwrap_or("").to_string(),
|
|
width,
|
|
height,
|
|
fps,
|
|
bitrate_kbps: get("--bitrate")
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(20000),
|
|
bit_depth: get("--bit-depth").and_then(|s| s.parse().ok()).unwrap_or(8),
|
|
})
|
|
}
|
|
// Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point.
|
|
// Replaces the ad-hoc launch chain — `service install` registers an auto-start SYSTEM service
|
|
// that launches the host into the active interactive session.
|
|
#[cfg(target_os = "windows")]
|
|
Some("service") => service::main(&args[1..]),
|
|
Some("-h") | Some("--help") | Some("help") | None => {
|
|
print_usage();
|
|
Ok(())
|
|
}
|
|
// Unknown subcommand → usage. (No implicit default; a bare `punktfunk-host` with no
|
|
// args hits the None arm above and prints help.)
|
|
Some(other) => bail!("unknown command '{other}' (try --help)"),
|
|
}
|
|
}
|
|
|
|
/// 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 punktfunk_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")
|
|
}
|
|
|
|
/// `serve` options. The **native punktfunk/1 plane + management API are the secure default and always
|
|
/// run**; `--gamestream` additionally enables the GameStream/Moonlight-compat planes (opt-in — they
|
|
/// carry the inherent on-path #5/#9 weaknesses, so only on a trusted LAN). Returns the mgmt options,
|
|
/// the native host config, and whether GameStream is enabled. Native pairing is **required by default**
|
|
/// (an open host any LAN device can stream from is insecure); `--open` turns it off.
|
|
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
|
|
let mut opts = mgmt::Options::default();
|
|
let mut native_port: u16 = 9777; // the native plane always runs now
|
|
let mut open = false;
|
|
let mut gamestream = false;
|
|
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 {
|
|
"--mgmt-bind" => {
|
|
opts.bind = next()?
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?
|
|
}
|
|
"--mgmt-token" => {
|
|
let token = next()?;
|
|
// An empty token would satisfy the non-loopback "token required" guard
|
|
// while authenticating nobody (or, worse, everybody) — refuse it loudly
|
|
// rather than letting `--mgmt-token "$UNSET_VAR"` ship a dead credential.
|
|
if token.trim().is_empty() {
|
|
bail!("--mgmt-token must not be empty");
|
|
}
|
|
opts.token = Some(token);
|
|
}
|
|
// The native plane is now the DEFAULT (always runs in `serve`); `--native` is kept as an
|
|
// accepted no-op for back-compat / explicitness.
|
|
"--native" => {}
|
|
"--native-port" => {
|
|
native_port = next()?
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
|
|
}
|
|
// Opt into the GameStream/Moonlight-compat planes (off by default — they carry the
|
|
// inherent on-path #5/#9 weaknesses; only for a trusted LAN).
|
|
"--gamestream" | "--moonlight" => gamestream = true,
|
|
// Disable mandatory native pairing — any device can connect (trusted single-user
|
|
// setups only). The default REQUIRES pairing.
|
|
"--open" => open = true,
|
|
"-h" | "--help" => {
|
|
print_usage();
|
|
std::process::exit(0);
|
|
}
|
|
other => bail!("unknown argument '{other}' (try --help)"),
|
|
}
|
|
i += 1;
|
|
}
|
|
// The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback). Resolve the token:
|
|
// the --mgmt-token flag (above) wins, else PUNKTFUNK_MGMT_TOKEN env, else the persisted
|
|
// ~/.config/punktfunk/mgmt-token, else a freshly generated + persisted one — so a bare `serve`
|
|
// Just Works with auth on, no operator step, and the bundled web console reads the same file.
|
|
if opts.token.is_none() {
|
|
opts.token = Some(crate::mgmt_token::load_or_generate()?);
|
|
}
|
|
let native = punktfunk1::NativeServe {
|
|
port: native_port,
|
|
require_pairing: !open,
|
|
};
|
|
Ok((opts, native, gamestream))
|
|
}
|
|
|
|
fn parse_spike(args: &[String]) -> Result<Options> {
|
|
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<PathBuf> = 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,
|
|
"kwin-virtual" => Source::KwinVirtual,
|
|
other => {
|
|
bail!("unknown --source '{other}' (synthetic|portal|kwin-virtual)")
|
|
}
|
|
}
|
|
}
|
|
"--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/punktfunk-spike.{ext}"))
|
|
});
|
|
|
|
Ok(Options {
|
|
source,
|
|
width,
|
|
height,
|
|
fps,
|
|
seconds,
|
|
codec,
|
|
bitrate_bps: bitrate_mbps.saturating_mul(1_000_000),
|
|
out,
|
|
loopback,
|
|
})
|
|
}
|
|
|
|
fn print_usage() {
|
|
eprintln!(
|
|
"punktfunk-host — Linux streaming host
|
|
|
|
USAGE:
|
|
punktfunk-host serve [OPTIONS] native punktfunk/1 host + management REST API
|
|
(secure default; add --gamestream for Moonlight compat)
|
|
punktfunk-host openapi print the management API's OpenAPI document (codegen)
|
|
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
|
|
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
|
|
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
|
|
|
|
SERVE OPTIONS:
|
|
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
|
|
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN);
|
|
required when --mgmt-bind is not loopback
|
|
--gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing,
|
|
RTSP, ENet control, _nvstream mDNS). OFF by default — they carry
|
|
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
|
|
reuse, security-review #5/#9); enable only on a TRUSTED LAN
|
|
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
|
|
--native-port <PORT> native QUIC port (default 9777)
|
|
--open disable mandatory native pairing (default: pairing REQUIRED —
|
|
an open host any LAN device can stream from is insecure)
|
|
|
|
PUNKTFUNK1-HOST OPTIONS:
|
|
--port <N> QUIC listen port (default: 9777)
|
|
--source <synthetic|virtual> test frames, or virtual display + NVENC (default: synthetic)
|
|
--seconds <N> per-session stream duration, virtual source (default: 30)
|
|
--frames <N> per-session frame count, synthetic source (default: 300)
|
|
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
|
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
|
|
in the accept queue; 0 = unlimited (default: 4)
|
|
--allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
|
|
pair=optional. Default: pairing REQUIRED — the host rejects
|
|
unpaired clients and logs a 4-digit pairing PIN at startup;
|
|
TOFU without pairing is insecure on a LAN
|
|
|
|
SPIKE OPTIONS:
|
|
--source <synthetic|portal|kwin-virtual>
|
|
frame source (default: portal). 'kwin-virtual' creates a
|
|
KWin virtual output at --width x --height and captures it
|
|
--seconds <N> capture duration in seconds (default: 5)
|
|
--fps <N> target frame rate (default: 60)
|
|
--codec <h264|h265|av1> NVENC codec (default: h265)
|
|
--bitrate <MBPS> target bitrate in Mbps (default: 20)
|
|
--width <W> --height <H> synthetic source size (default: 1920x1080)
|
|
--out <PATH> raw Annex-B output (default: /tmp/punktfunk-spike.<ext>)
|
|
--no-loopback skip the punktfunk_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
|
|
punktfunk_core host→client loopback that reassembles and byte-verifies each one.
|
|
Both 'serve' and 'punktfunk1-host' advertise the native service over mDNS
|
|
(_punktfunk._udp) for client auto-discovery — 'punktfunk-probe --discover' lists them."
|
|
);
|
|
#[cfg(target_os = "windows")]
|
|
eprintln!(
|
|
"\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\
|
|
\x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\
|
|
\x20 punktfunk-host service uninstall remove the service + firewall rules\n\
|
|
\x20 punktfunk-host service start|stop|status\n\
|
|
\x20 config: %ProgramData%\\punktfunk\\host.env\n\
|
|
\nWINDOWS DIAGNOSTICS:\n\
|
|
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
|
|
\x20 (scRGB FP16 -> P010 BT.2020 PQ shader vs an f64 reference)"
|
|
);
|
|
}
|