Files
punktfunk/clients/session/src/main.rs
T
enricobuehler 2271f67202
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
apple / swift (push) Successful in 4m26s
ci / bench (push) Successful in 5m53s
ci / rust (push) Failing after 7m35s
windows-host / package (push) Failing after 8m56s
flatpak / build-publish (push) Failing after 8m12s
arch / build-publish (push) Successful in 11m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
android / android (push) Successful in 13m28s
deb / build-publish (push) Successful in 13m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m33s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
docker / deploy-docs (push) Successful in 20s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
release / apple (push) Successful in 25m4s
apple / screenshots (push) Successful in 19m32s
style: rustfmt the recovery + resize changes (Windows CI fmt check)
The `cargo fmt --check` step on the x86_64-pc-windows-msvc job was
failing: the mid-stream loss-recovery and resize-overlay commits landed
with unformatted wraps across pf-presenter, pf-client-core, punktfunk-core,
pf-console-ui, and a few host files.

Applied `cargo fmt`, and hand-relocated two trailing comments in
session.rs (a decoded-frame note and the wrap-counter note) to their own
lines so rustfmt no longer column-aligns the following comment block to
a deep indent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:25:06 +02:00

433 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning
//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS
//! the power-user CLI build).
//!
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing there (or
//! via the shell's headless `--pair`) makes this binary connect silently.
//!
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
#[cfg(any(target_os = "linux", windows))]
mod session_main {
use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::SessionParams;
use pf_client_core::trust;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Duration;
pub const EXIT_CONNECT_FAILED: u8 = 2;
pub const EXIT_TRUST_REJECTED: u8 = 3;
pub const EXIT_PRESENTER_FAILED: u8 = 4;
/// The value following `flag` in argv, if present (`--flag value`).
pub(crate) fn arg_value(flag: &str) -> Option<String> {
std::env::args()
.skip_while(|a| a != flag)
.nth(1)
.filter(|v| !v.starts_with("--"))
}
pub(crate) fn arg_flag(flag: &str) -> bool {
std::env::args().any(|a| a == flag)
}
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
/// gated with `mod browse`, its one caller.)
#[cfg(feature = "ui")]
pub(crate) fn fullscreen_mode() -> bool {
arg_flag("--fullscreen")
|| std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
}
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
/// shell passes its own position so the session opens on the same monitor); absent or
/// unparsable = centered on the primary display.
pub(crate) fn window_pos() -> Option<(i32, i32)> {
let v = arg_value("--window-pos")?;
let (x, y) = v.split_once(',')?;
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
}
/// `host[:port]`, port defaulting to the native 9777.
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
match target.rsplit_once(':') {
Some((a, p)) => match p.parse() {
Ok(port) => (a.to_string(), port),
Err(_) => {
eprintln!("unparsable port in '{target}', using default 9777");
(a.to_string(), 9777)
}
},
None => (target.to_string(), 9777),
}
}
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
/// shell's request-access flow passes ~185 s because the host PARKS the connection
/// until the operator clicks Approve.
pub(crate) fn connect_timeout() -> Duration {
Duration::from_secs(
arg_value("--connect-timeout")
.and_then(|v| v.parse().ok())
.unwrap_or(15),
)
}
/// One session's pump parameters from the Settings store — shared by `--connect`
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
/// window's display (the GTK client reads the monitor under its window — same
/// contract).
#[allow(clippy::too_many_arguments)]
pub(crate) fn session_params(
settings: &trust::Settings,
addr: String,
port: u16,
pin: [u8; 32],
identity: (String, String),
launch: Option<String>,
gamepad: &GamepadService,
native: Mode,
force_software: Arc<AtomicBool>,
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
) -> SessionParams {
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
// key) to OUR gamepad service — the shells' in-process services can't reach this
// process. Applied per params-build (idempotent; browse re-launches included) so
// it lands before the session attaches. Empty = automatic (most recent).
if !settings.forward_pad.is_empty() {
gamepad.set_pinned(Some(settings.forward_pad.clone()));
}
let mode = Mode {
width: if settings.width == 0 {
native.width
} else {
settings.width
},
height: if settings.width == 0 {
native.height
} else {
settings.height
},
refresh_hz: if settings.refresh_hz == 0 {
native.refresh_hz.max(30)
} else {
settings.refresh_hz
},
};
SessionParams {
host: addr,
port,
mode,
compositor: CompositorPref::from_name(&settings.compositor)
.unwrap_or(CompositorPref::Auto),
gamepad: match GamepadPref::from_name(&settings.gamepad) {
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
Some(explicit) => explicit,
},
bitrate_kbps: settings.bitrate_kbps,
audio_channels: settings.audio_channels,
preferred_codec: settings.preferred_codec(),
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
video_caps: if settings.hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
},
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
// pump) pins one manually.
display_hdr: None,
mic_enabled: settings.mic_enabled,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
decoder: settings.decoder.clone(),
launch,
vulkan,
pin: Some(pin),
identity,
connect_timeout: connect_timeout(),
force_software,
}
}
/// The window's starting size under Match-window: the persisted last size, so the
/// first connect's mode already matches the glass; `None` (policy off / never
/// stored) = the 1280×720 default.
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
.then_some((settings.last_window_w, settings.last_window_h))
}
/// The Match-window policy hook for the presenter loop
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
/// logical window size (load-modify-save, like the console settings screen) so the
/// next launch opens at it.
pub(crate) fn match_window(settings: &trust::Settings) -> Option<Box<dyn FnMut(u32, u32)>> {
settings.match_window.then(|| {
Box::new(|w: u32, h: u32| {
let mut s = trust::Settings::load();
if (s.last_window_w, s.last_window_h) != (w, h) {
s.last_window_w = w;
s.last_window_h = h;
s.save();
}
}) as Box<dyn FnMut(u32, u32)>
})
}
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
/// failure through the same contract when spawned with `--json-status`.
pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option<bool>) {
let escaped: String = msg
.chars()
.flat_map(|c| match c {
'"' => vec!['\\', '"'],
'\\' => vec!['\\', '\\'],
'\n' => vec!['\\', 'n'],
c if (c as u32) < 0x20 => vec![' '],
c => vec![c],
})
.collect();
match trust_rejected {
Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"),
None => println!("{{\"{key}\":\"{escaped}\"}}"),
}
}
/// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*`
/// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`.
/// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s
/// `auto` path can't build the Vulkan decoder and the session silently falls back to
/// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks
/// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the
/// RADV driver loads (the Vulkan instance is created later, inside `run_session`).
///
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
/// decode is already the default just no-ops. Append rather than clobber so a user's own
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
#[cfg(target_os = "linux")]
fn enable_radv_video_decode() {
const TOKEN: &str = "video_decode";
match std::env::var("RADV_PERFTEST") {
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
}
tracing::info!(
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
"opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)"
);
}
pub fn run() -> u8 {
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
#[cfg(target_os = "linux")]
enable_radv_video_decode();
// The Settings GPU pick (the WinUI shell's picker stores the adapter's marketing
// name) → the presenter's device selection, unless the user already forced one.
// Before any Vulkan call, like the RADV knob (covers --connect and --browse).
if std::env::var_os("PUNKTFUNK_VK_ADAPTER").is_none() {
let adapter = trust::Settings::load().adapter;
if !adapter.is_empty() {
std::env::set_var("PUNKTFUNK_VK_ADAPTER", adapter);
}
}
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
// every pad Steam Input has virtualized; capturing the Deck's real built-in
// controller needs it cleared (same rationale as the GTK client's `app::run`).
for var in [
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
] {
if let Ok(v) = std::env::var(var) {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
std::env::remove_var(var);
}
}
if arg_flag("--browse") {
// Bare `--browse` opens the console home (hosts, pairing, settings);
// `--browse host[:port]` opens straight into that host's library.
let target = arg_value("--browse");
#[cfg(feature = "ui")]
return crate::console::run(target.as_deref());
#[cfg(not(feature = "ui"))]
{
let _ = target;
eprintln!(
"--browse needs the console UI — this is the minimal build \
(rebuild without --no-default-features)"
);
return EXIT_PRESENTER_FAILED;
}
}
let Some(target) = arg_value("--connect") else {
eprintln!(
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\n\
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
library. --connect never dials a host it has no pinned fingerprint for —\n\
pair in the console or via `punktfunk-client --pair <PIN> --connect …`."
);
return EXIT_CONNECT_FAILED;
};
let (addr, port) = parse_host_port(&target);
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
json_line("error", &format!("client identity: {e:#}"), None);
return EXIT_CONNECT_FAILED;
}
};
let settings = trust::Settings::load();
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
// silent TOFU would defeat the pinning model. Pair via the desktop client.
let known = trust::KnownHosts::load();
let known_host = known
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port);
let pin = arg_value("--fp")
.as_deref()
.and_then(trust::parse_hex32)
.or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex)));
let Some(pin) = pin else {
json_line(
"error",
&format!(
"no pinned fingerprint for {addr}:{port} — pair first \
(punktfunk-client --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
),
Some(true),
);
return EXIT_TRUST_REJECTED;
};
let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone());
let launch = arg_value("--launch");
let title = launch
.clone()
.map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}"));
let fullscreen = arg_flag("--fullscreen")
|| std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some();
let opts = pf_presenter::SessionOpts {
window_title: format!("Punktfunk · {title}"),
fullscreen,
window_pos: window_pos(),
// `--stats` forces the overlay visible (tooling/debug runs) without
// demoting an explicitly chosen richer tier.
stats_verbosity: match settings.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now.
trust::touch_last_used(&trust::hex(&fingerprint));
})),
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
// power-user build (`--no-default-features` drops the `ui` feature).
#[cfg(feature = "ui")]
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
#[cfg(not(feature = "ui"))]
overlay: None,
window_size: window_size(&settings),
match_window: match_window(&settings),
};
let outcome =
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
session_params(
&settings,
addr,
port,
pin,
identity,
launch,
gamepad,
native,
force_software,
vulkan,
)
});
match outcome {
Ok(pf_presenter::Outcome::Ended(None)) => 0,
Ok(pf_presenter::Outcome::Ended(Some(reason))) => {
// The host ending the session (game quit, host shutdown) is a normal end
// for a one-shot stream binary — report the reason, exit clean.
json_line("ended", &reason, None);
0
}
Ok(pf_presenter::Outcome::ConnectFailed {
msg,
trust_rejected,
}) => {
json_line("error", &msg, Some(trust_rejected));
if trust_rejected {
EXIT_TRUST_REJECTED
} else {
EXIT_CONNECT_FAILED
}
}
Err(e) => {
json_line("error", &format!("presenter: {e:#}"), None);
EXIT_PRESENTER_FAILED
}
}
}
}
#[cfg(any(target_os = "linux", windows))]
fn main() -> std::process::ExitCode {
std::process::ExitCode::from(session_main::run())
}
/// This stub keeps `cargo build --workspace` green elsewhere (the Mac client lives in
/// clients/apple).
#[cfg(not(any(target_os = "linux", windows)))]
fn main() {
eprintln!(
"punktfunk-session runs on Linux and Windows — the macOS client lives in clients/apple"
);
std::process::exit(2);
}