Full project rename, decided 2026-06-10: - Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs. - C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h, PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl. PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants). - Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1. WIRE BREAK: clients must be rebuilt from this revision. - Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / …. - Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the persistent identity is unchanged, pinned fingerprints stay valid). - Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated. - scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated. Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of "desktop but no apps/settings" over the stream: plasmashell launched without XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and rendered an empty menu. The script sets the complete KDE session env (menu prefix, KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell. Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS, zero lumen references left outside .git. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
//! `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.
|
||||
//!
|
||||
//! Status: M0. The `m0` subcommand runs the capture→encode→file pipeline spike and feeds
|
||||
//! the encoded AUs through a `punktfunk_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 m3;
|
||||
mod mgmt;
|
||||
mod pipeline;
|
||||
mod pwinit;
|
||||
mod vdisplay;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod zerocopy;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use encode::Codec;
|
||||
use m0::{Options, Source};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
// Logs go to stderr so stdout stays machine-readable (`punktfunk-host openapi > spec.json`).
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
if let Err(e) = real_main() {
|
||||
tracing::error!("{e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn real_main() -> Result<()> {
|
||||
tracing::info!(
|
||||
"punktfunk-host (punktfunk_core ABI v{})",
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(String::as_str) {
|
||||
// M2 GameStream host control plane (P1.1: mDNS + serverinfo) + management API.
|
||||
Some("serve") => gamestream::serve(parse_serve(&args[1..])?),
|
||||
// Print the management API's OpenAPI document (for client codegen).
|
||||
Some("openapi") => {
|
||||
print!("{}", mgmt::openapi_json());
|
||||
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(),
|
||||
// M0 pipeline spike.
|
||||
Some("m0") => m0::run(parse_m0(&args[1..])?),
|
||||
// M3: native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||
Some("m3-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") => m3::M3Source::Virtual,
|
||||
_ => m3::M3Source::Synthetic,
|
||||
};
|
||||
m3::run(m3::M3Options {
|
||||
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),
|
||||
})
|
||||
}
|
||||
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 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 — all about the management API; the GameStream ports are protocol-fixed.
|
||||
fn parse_serve(args: &[String]) -> Result<mgmt::Options> {
|
||||
let mut opts = mgmt::Options::default();
|
||||
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);
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => bail!("unknown argument '{other}' (try --help)"),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// Flag wins over the environment so a unit file can set a default and a shell override it.
|
||||
if opts.token.is_none() {
|
||||
opts.token = std::env::var("PUNKTFUNK_MGMT_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty());
|
||||
}
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
fn parse_m0(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-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!(
|
||||
"punktfunk-host — Linux streaming host
|
||||
|
||||
USAGE:
|
||||
punktfunk-host serve [OPTIONS] GameStream host control plane (M2: mDNS + serverinfo …)
|
||||
+ the management REST API
|
||||
punktfunk-host openapi print the management API's OpenAPI document (codegen)
|
||||
punktfunk-host m3-host [OPTIONS] native punktfunk/1 host (QUIC control plane + UDP data plane)
|
||||
punktfunk-host m0 [OPTIONS] M0 capture→encode→file pipeline spike
|
||||
|
||||
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
|
||||
|
||||
M3-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)
|
||||
|
||||
M0 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-m0.<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."
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user