Files
punktfunk/clients/session/src/main.rs
T
enricobuehler c0f8f051c3 fix(client): the probe printed a device index the env var does not take
--probe-decode printed its DISPLAY position and called it the
PUNKTFUNK_VK_DEVICE value. It is not. pick_device resolves that variable against
the RAW vkEnumeratePhysicalDevices order (setup.rs, `devices.get(i)`) BEFORE any
ranking runs, while the probe sorts discrete-first for readability.

Those two orders disagree precisely on the hardware this flag exists to
diagnose. pick_device's own comment records why the ranking is there: "enumeration
order puts the iGPU FIRST on some hybrids (observed: Ryzen iGPU ahead of an RTX
dGPU)". So on a hybrid laptop the number the probe printed for the iGPU could
well be the number for the dGPU — a diagnostic handing out an actionable value
that selects the other GPU, which is worse than printing none.

Measured on the Arc + RTX 3500 Ada laptop, which is also where the first output
went out with the wrong claim in it: three adapters, and the same Arc iGPU
enumerated TWICE. So AdapterDecode now carries the raw enumeration index,
captured before the sort, and the printer uses it; the "default presenter"
marker stays on the first LISTED entry, because sorted-first is what pick_device
lands on when nothing overrides.

The duplicate is why the trailing hint names PUNKTFUNK_VK_ADAPTER as the safer
knob and admits its limit: two adapters sharing a marketing name cannot be told
apart by it, and a name match resolves to whichever enumerates first. The hint
also states the thing this whole output invites a reader to get wrong — that a
capable GPU in the list does not mean the decoder will use it, because Vulkan
Video decodes on the presenter's device and PUNKTFUNK_DECODER does not move the
presenter.

Gates: fmt clean; clippy -D warnings on punktfunk-client-session and pf-presenter.
2026-08-07 13:52:23 +02:00

937 lines
46 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 on either side
//! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
//! with no window and no toolkit, for machines that have only a shell.
//!
//! 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.
#![forbid(unsafe_code)]
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
/// The session control socket: a line-per-connection unix socket other same-user
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
/// `ok\n` / `err\n` back.
///
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
/// reach a flatpak-run session.
#[cfg(all(unix, any(target_os = "linux", windows)))]
mod ctl_socket {
use pf_client_core::gamepad::GamepadService;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
fn path() -> Option<PathBuf> {
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
if let Ok(id) = std::env::var("FLATPAK_ID") {
p.push("app");
p.push(id);
}
Some(p.join("punktfunk-session-ctl.sock"))
}
/// Bind + serve on a background thread, once per process (later calls no-op). Any
/// failure just logs at debug — the socket is a convenience surface, never worth
/// failing a stream over.
pub(crate) fn spawn(gamepad: GamepadService) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(move || {
let Some(path) = path() else { return };
// A previous session's socket file refuses the bind — it's ours to replace.
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(l) => l,
Err(e) => {
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
return;
}
};
let spawned = std::thread::Builder::new()
.name("pf-session-ctl".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
let mut line = String::new();
if BufReader::new(&s).read_line(&mut line).is_err() {
continue;
}
let ok = match line.trim() {
"guide" => {
gamepad.tap_guide();
true
}
"qam" => {
gamepad.tap_qam();
true
}
_ => false,
};
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
}
});
if let Err(e) = spawned {
tracing::debug!(error = %e, "session ctl thread failed to start");
}
});
}
}
#[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)
}
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
/// "auto" policy keys off this.
pub(crate) fn gaming_mode() -> bool {
std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
}
/// 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") || gaming_mode()
}
/// `--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()?))
}
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
/// client, a headless box, an image being provisioned. Writes the verified host into the
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
/// later stream connect silently.
///
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
/// enrolment must not be the reason an embedded image has to pull in Skia.
fn headless_pair(pin: &str) -> u8 {
let Some(target) = arg_value("--connect") else {
eprintln!("--pair requires --connect host[:port]");
return EXIT_CONNECT_FAILED;
};
let (addr, port) = parse_host_port(&target);
// The label the HOST files this client under. A headless box has nobody to ask, so
// the hostname is the only name that will mean anything in the paired-devices list.
let name = arg_value("--name").unwrap_or_else(trust::device_name);
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
return EXIT_CONNECT_FAILED;
}
};
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
Ok(fp) => {
let fp_hex = trust::hex(&fp);
trust::persist_host(
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
&addr,
port,
&fp_hex,
true,
);
trust::forget_placeholder(&addr, port);
println!("paired {addr}:{port} fp={fp_hex}");
0
}
Err(e) => {
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
EXIT_TRUST_REJECTED
}
}
}
/// `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),
}
}
/// `--profile <id|name>` — the settings profile this one session runs with, overriding the
/// host's own binding for this launch only (never rebinding it): the shells' "Connect
/// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's
/// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is
/// how "Connect with ▸ Default settings" reaches a bound host.
fn profile_arg() -> Option<String> {
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
}
/// 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 EFFECTIVE settings — 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).
///
/// `settings` is what [`trust::effective_settings`] returned, never a raw
/// `Settings::load()`: both callers resolve the host's profile first, so the two
/// construction sites cannot drift (they historically did — touching one and not the
/// other is a Windows-only build break). `profile` is that profile's name, for the
/// stats overlay's first line.
#[allow(clippy::too_many_arguments)]
pub(crate) fn session_params(
settings: &trust::Settings,
profile: Option<String>,
clipboard_override: Option<bool>,
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 {
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3). In spec
// mode the spawner already resolved it; otherwise this looks it up itself, which is
// the last store read the compat path still owes. `addr` is moved into the struct
// below, so read it first.
let clipboard = clipboard_override.unwrap_or_else(|| {
// The record this address RESOLVES to, not "any record mentioning it": a retired
// duplicate must never be the one that hands a host the clipboard.
trust::KnownHosts::load()
.find_by_addr(&addr, port)
.is_some_and(|h| h.clipboard_sync)
});
// 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()));
}
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
// reuses one service across launches, so a stream that follows one with it off must put
// it back. It goes on before the attach below, so a non-forwarding session never opens
// — never grabs — the device.
gamepad.set_forwarding(settings.gamepad_forwarding);
// System-button routing: whether raw guide/QAM presses ride the wire, and whether
// hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the
// local Steam UI reacts to the same physical buttons there no matter what, so
// forwarding raw opens BOTH overlays, the local one on top of the stream. Set
// unconditionally for the same browse-mode-reuse reason as the line above.
let game_mode = gaming_mode();
gamepad.set_system_buttons(
settings.system_buttons_forward(game_mode),
settings.guide_gesture_enabled(game_mode),
);
// The control socket (guide/QAM injection — the Decky panel's host buttons).
// Spawned at first params-build so it exists for --connect AND console launches.
#[cfg(unix)]
crate::ctl_socket::spawn(gamepad.clone());
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
// slots declare their render caps at open time, which happens on attach — after this.
gamepad.set_pad_audio_prefs(
settings.pad_haptics,
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
);
let mode = Mode {
width: if settings.width == 0 {
native.width
} else {
settings.width
},
height: if settings.height == 0 {
native.height
} else {
settings.height
},
refresh_hz: if settings.refresh_hz == 0 {
native.refresh_hz.max(30)
} else {
settings.refresh_hz
},
};
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
// Native/explicit resolution so it composes uniformly with both.
let (sw, sh) = punktfunk_core::render_scale::apply(
mode.width,
mode.height,
settings.render_scale,
punktfunk_core::render_scale::max_dimension(&settings.codec),
);
let mode = Mode {
width: sw,
height: sh,
..mode
};
// Before the struct literal — `vulkan` moves into it below.
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
SessionParams {
host: addr,
port,
mode,
compositor: CompositorPref::from_name(&settings.compositor)
.unwrap_or(CompositorPref::Auto),
gamepad: {
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
// builds each virtual pad from that pad's arrival and only falls back to this
// session default for a pad that never declares one, so an explicit choice that
// stopped here would be undone the moment a controller connected.
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
gamepad.set_kind_override(chosen);
match chosen {
GamepadPref::Auto => gamepad.auto_pref(),
explicit => explicit,
}
},
bitrate_kbps: settings.bitrate_kbps,
audio_channels: settings.audio_channels,
preferred_codec: settings.preferred_codec(),
// Nothing excluded on a fresh dial. Only the run loop's codec-fallback retry
// sets this, and it does so on a CLONE of these params — a Settings-level
// "never use HEVC" would be `preferred_codec`, not this.
exclude_codecs: 0,
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
// MULTI_SLICE is decoder truth for THIS embedder: every desktop decode stack
// (Vulkan Video, D3D11VA, VAAPI, openh264/rav1d) handles AUs carrying several
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit only says
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
// formats (hardware RExt decode where the driver offers it — NVIDIA today),
// with the decoder ladder demoting on its own. No capability probe gates the
// bit — but note (M8) that the software rung below it is 4:2:0 8-bit ONLY and
// refuses anything else rather than mis-scaling it, so on a box whose hardware
// 4:4:4 decode fails the floor is a codec fallback, not a converted picture.
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
// frames actually took.
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
| if settings.hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
}
| if settings.enable_444 {
punktfunk_core::quic::VIDEO_CAP_444
} else {
0
},
// This panel's HDR colour volume → the host's virtual-display EDID, so host
// apps tone-map to the real glass. Windows reads it from DXGI (the
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
// setting, since with 10-bit/HDR unadvertised above the volume is noise. No
// portable Wayland/X11 query exists yet, so Linux keeps the host's EDID
// defaults; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session pump) pins one
// manually on either OS and wins over both.
#[cfg(windows)]
display_hdr: settings
.hdr_enabled
.then(|| pf_client_core::video_d3d11::display_hdr_volume(window_pos()))
.flatten(),
#[cfg(not(windows))]
display_hdr: None,
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
// channel); capture-mode sessions keep the composited cursor, so only advertise
// when the session STARTS in desktop mode. The host gates further (Linux portal
// compositors only).
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
mic_enabled: settings.mic_enabled,
echo_cancel: settings.echo_cancel,
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
// service learns the same prefs below so tier-A slots declare their render caps
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
pad_haptics: settings.pad_haptics,
pad_speaker: settings.pad_speaker.clone(),
clipboard,
// 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,
profile,
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
// advertised only when the presenter has real on-glass latch stamps
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
// grid itself is written by the presenter (run_session clones the Arc out of
// these params) and folded into ~1 Hz PhaseReports by the session pump.
phase_lock,
latch_grid: std::sync::Arc::new(pf_client_core::session::LatchGrid::default()),
}
}
/// 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.
/// The Match-window policy hook (design/midstream-resolution-resize.md D1/D2). The
/// callback used to load-modify-save the shared settings file from inside the renderer —
/// one of that file's five concurrent writers, for a value only the parent needs. It now
/// REPORTS the size on stdout and the spawner persists it
/// (design/client-architecture-split.md §5).
///
/// `persist_locally` keeps a hand-run session remembering its own window: nobody is
/// listening to stdout there, so the event alone would drop the value. A spawned session
/// leaves the write to its parent, which is the whole point.
pub(crate) fn match_window(
settings: &trust::Settings,
persist_locally: bool,
) -> Option<Box<dyn FnMut(u32, u32)>> {
settings.match_window.then(|| {
Box::new(move |w: u32, h: u32| {
println!("{{\"window\":{{\"w\":{w},\"h\":{h}}}}}");
if persist_locally {
pf_client_core::orchestrate::persist_window_size(w, h);
}
}) 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=native-vaapi` still overrides the decoder
/// choice (the pre-M10 `vaapi` spelling reaches the same rung — it migrates, loudly).
#[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();
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
// line, discrete first) for the desktop shells' GPU picker, then exit.
if arg_flag("--list-adapters") {
return match pf_presenter::vk::list_adapters() {
Ok(names) => {
for n in names {
println!("{n}");
}
0
}
Err(e) => {
eprintln!("list-adapters: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--probe-decode`: per-adapter Vulkan Video decode capability, then exit. Human
// output on purpose — this is a triage tool, not a picker source, which is also
// why it is a separate flag: `--list-adapters` is parsed line-by-line by the
// desktop shells' GPU picker and must keep printing bare names.
if arg_flag("--probe-decode") {
return match pf_presenter::vk::probe_decode() {
Ok(adapters) => {
if adapters.is_empty() {
println!("no Vulkan physical devices");
}
for (i, a) in adapters.iter().enumerate() {
// The bracketed number is the PUNKTFUNK_VK_DEVICE value, and the
// FIRST listed entry is what
// a default run presents on — the decoder shares that device, so
// on a hybrid box this line is usually the answer.
let kind = if a.discrete { "discrete" } else { "integrated" };
// `a.index`, NOT the loop position. This list is sorted
// discrete-first for reading, but PUNKTFUNK_VK_DEVICE indexes the
// raw enumeration, which puts the iGPU first on some hybrids —
// printing the loop position would name the other GPU on exactly
// the machines this flag is for. The `i == 0` marker is still the
// loop position, because sorted-first IS what pick_device lands on
// when nothing overrides it.
println!(
"[{}] {} ({kind}){}",
a.index,
a.name,
if i == 0 { " <- default presenter" } else { "" }
);
println!(
" vulkan video decode: {}",
if a.usable { "YES" } else { "no" }
);
// Name every bit, and ACCOUNT for the ones we cannot name. The
// 5070 Ti reports 0xF — four bits — while punktfunk decodes three
// codecs, so the first version of this line printed three names
// beside a four-bit mask and looked complete. VP9 (bit 3) is a
// real decode operation this client has no rung for; a codec the
// tool cannot name must not silently vanish from a mask it prints,
// or the reader is left to trust that the words cover the number.
const OPS: [(u32, &str); 4] = [
(0x1, "H.264"),
(0x2, "H.265"),
(0x4, "AV1"),
(0x8, "VP9 (no punktfunk rung)"),
];
let mut codecs: Vec<String> = OPS
.iter()
.filter(|(bit, _)| a.codec_ops & bit != 0)
.map(|(_, n)| (*n).to_string())
.collect();
let named: u32 = OPS.iter().map(|(b, _)| b).sum();
let unknown = a.codec_ops & !named;
if unknown != 0 {
codecs.push(format!("unrecognised bits 0x{unknown:X}"));
}
println!(
" driver decode ops: {}",
if codecs.is_empty() {
format!("none (0x{:X})", a.codec_ops)
} else {
format!("{} (0x{:X})", codecs.join(", "), a.codec_ops)
}
);
if !a.usable {
// Say which conjunct failed. "no" with no reason is the thing
// this whole flag exists to stop.
let mut why: Vec<String> = Vec::new();
if !a.api_1_3 {
why.push("device is not Vulkan 1.3".into());
}
if !a.features_ok {
why.push(
"missing samplerYcbcrConversion / timelineSemaphore / \
synchronization2"
.into(),
);
}
if a.decode_family.is_none() {
why.push("no queue family advertises VIDEO_DECODE".into());
}
if !a.base_missing.is_empty() {
why.push(format!("missing {}", a.base_missing.join(", ")));
}
if a.codec_exts.is_empty() {
why.push("no VK_KHR_video_decode_{h264,h265,av1} extension".into());
}
println!(" why not: {}", why.join("; "));
} else {
println!(" extensions: {}", a.codec_exts.join(", "));
}
}
if adapters.len() > 1 {
// The single most common misreading of this output: seeing a
// capable GPU listed and concluding the decoder will use it.
// Vulkan Video decodes on the PRESENTER's device, and the decoder
// preference does not move the presenter.
println!();
println!(
"Vulkan Video decodes on the presenter's device. PUNKTFUNK_DECODER \
picks the rung,"
);
println!(
"not the GPU — move the presenter with PUNKTFUNK_VK_DEVICE=<index \
above> or"
);
println!(
"PUNKTFUNK_VK_ADAPTER=<name substring>, which is the safer knob \
where two"
);
println!("adapters share a name.");
}
0
}
Err(e) => {
eprintln!("probe-decode: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
// same enumeration the GTK shell probes.
#[cfg(target_os = "linux")]
if arg_flag("--list-audio") {
return match pf_client_core::audio::devices() {
Ok((sinks, sources)) => {
for d in sinks {
println!("sink\t{}\t{}", d.name, d.description);
}
for d in sources {
println!("source\t{}\t{}", d.name, d.description);
}
0
}
Err(e) => {
eprintln!("list-audio: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--pair <PIN>`: enrol this machine against a host and exit. DEPRECATED — pairing is
// a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell
// (design/client-architecture-split.md §5). It still works, with a notice, for the one
// release this needs; a renderer owning a trust ceremony is exactly the mixing of
// concerns the split exists to undo.
if let Some(pin) = arg_value("--pair") {
eprintln!(
"note: punktfunk-session --pair is deprecated \u{2014} use `punktfunk pair \
<host[:port]>` instead (same store, same result)."
);
return headless_pair(&pin);
}
// 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 device picks → env, unless the user already forced one by hand:
// the GPU (the shells' pickers store the adapter's marketing name) for the
// presenter's device selection, and the audio endpoints (PipeWire node names /
// WASAPI endpoint ids) for the playback/mic streams. Before any Vulkan call,
// like the RADV knob (covers --connect and --browse).
//
// Spec mode takes them from the SPEC's settings — the spawner's resolve — which
// keeps the §5 zero-store-reads invariant and lets a profile overlay reach these
// fields if they ever become profileable. Parsed leniently here (the `--connect`
// flow re-reads the spec authoritatively and errors there); the compat path and
// `--browse` (which never carries a spec) still load the store.
{
let s = arg_value("--resolved-spec")
.and_then(|p| {
pf_client_core::orchestrate::ResolvedSpec::read(std::path::Path::new(&p)).ok()
})
.map_or_else(trust::Settings::load, |spec| spec.settings);
for (var, value) in [
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
] {
if std::env::var_os(var).is_none() && !value.is_empty() {
std::env::set_var(var, value);
}
}
}
// 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] [--profile REF] [--fullscreen]\n\
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\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. --profile picks a settings profile by id or name for this session\n\
only (\"\" = the global defaults); without it the host's own profile applies.\n\
--connect never dials a host it has no pinned fingerprint for —\n\
enrol with --pair (no display needed), in the console, or from the desktop\n\
client."
);
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;
}
};
// `--resolved-spec <path>`: the spawner already did the resolving, so this process
// performs ZERO store reads (design/client-architecture-split.md §5) — no Settings
// load, no known-hosts lookup, no profile resolution. Without it (a hand-run
// `--connect`, an old Decky script) the session resolves for itself through the SAME
// helper, so the two modes cannot drift.
let spec = arg_value("--resolved-spec").map(std::path::PathBuf::from);
let (settings, profile_name, clipboard_override) = match &spec {
Some(path) => match pf_client_core::orchestrate::ResolvedSpec::read(path) {
Ok(s) => {
tracing::info!(path = %path.display(), "running from a resolved spec");
(s.settings, s.profile, Some(s.clipboard))
}
Err(e) => {
json_line("error", &format!("resolved spec: {e}"), None);
return EXIT_CONNECT_FAILED;
}
},
None => {
let (settings, profile) =
trust::effective_settings(&addr, port, profile_arg().as_deref());
(settings, profile.map(|p| p.name), None)
}
};
if let Some(name) = &profile_name {
tracing::info!(profile = %name, "streaming with a settings profile");
}
// 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.find_by_addr(&addr, 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-session --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,
},
touch_mode: settings.touch_mode(),
mouse_mode: settings.mouse_mode(),
invert_scroll: settings.invert_scroll,
inhibit_shortcuts: settings.inhibit_shortcuts,
present_priority: settings.present_priority(),
vsync: settings.vsync,
allow_vrr: settings.allow_vrr,
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),
// A spawned session (spec mode) reports its window; a hand-run one persists it.
match_window: match_window(&settings, spec.is_none()),
render_scale: settings.render_scale,
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
};
let outcome =
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
session_params(
&settings,
profile_name,
clipboard_override,
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);
}