feat(clients/windows): port the Vulkan session client to Windows — session-always

The punktfunk-session Vulkan client (clients/linux-session, now clients/session)
builds and runs on Windows; the WinUI shell spawns it for every stream. Verified
live: 10-bit HEVC via Vulkan Video on both AMD (iGPU) and NVIDIA, 5120x1440 at
130 fps / 8 ms end-to-end on the RTX 4090.

- pf-ffvk: Windows bindgen branch (FFMPEG_DIR + PF_FFVK_VULKAN_INCLUDE, no
  pkg-config); provisioning fetches Vulkan-Headers (pinned v1.4.309).
- pf-client-core: builds on Windows — WASAPI audio (audio_wasapi.rs, cfg-swapped
  via #[path], same surface as the PipeWire twin), VAAPI/dmabuf gated inline
  (chain = vulkan -> software), trust reads the WinUI shell's %APPDATA% stores
  (parity tests pin both serialized shapes), Settings gains adapter/hdr_enabled
  (serde-defaulted; Linux stores unaffected).
- pf-presenter: builds on Windows — dmabuf module Linux-gated; SDL keyboard grab
  while captured (Alt+Tab/Win reach the host); pick_device ranks discrete over
  integrated (device 0 was the iGPU on hybrid boxes — the silent footgun) and
  honors PUNKTFUNK_VK_ADAPTER (the Settings GPU pick, exported by the session).
- run loop: block in one SDL wait woken by input AND decoded frames (a per-
  session forwarder pushes a FrameWake user event) instead of a 1 ms poll —
  measured 111%% -> 5%% of a core (NVIDIA), 86%% -> 3.5%% (AMD), stats unchanged.
  The pump's decode-fence wait became once-per-window sampling (no per-frame
  pipeline stall; the stat now shows true backlog).
- pf-console-ui: builds on Windows (skia-safe msvc prebuilts); font lookup falls
  through fontconfig aliases to concrete DirectWrite families (Consolas/Segoe UI)
  — browse/coverflow works, verified against a live host.
- WinUI shell: session-always via new src/spawn.rs (GTK spawn.rs port —
  CREATE_NO_WINDOW, stdout contract, kill handle); the Stream screen is a status
  card (chips + stage lines from the child's stats). The legacy in-process
  D3D11VA path stays behind Settings "Streaming engine" / PUNKTFUNK_BUILTIN_
  STREAM=1 as the A/B baseline until Phase 8 deletes it. SessionParams.video_caps
  makes the HDR toggle real.
- clients/linux-session renamed to clients/session (builds for both OSes).
- CI/MSIX: both workflows build/test both bins with widened path filters; the
  MSIX ships punktfunk-session.exe. ARM64 session builds --no-default-features
  (rust-skia has no aarch64-pc-windows-msvc prebuilts; flip when it does).

A/B on this box (5120x1440 HEVC vs home-worker-5): NVIDIA Vulkan 130 fps / 8 ms
e2e / 1.6 ms decode — clearly better than the built-in path. The AMD iGPU VCN
saturates at ~52 fps where its own D3D11VA does ~70 — Adrenalin Vulkan decode is
slower on APU silicon; discrete RDNA validation gates Phase 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:21:36 +02:00
parent 838a1239cf
commit d6647b9183
37 changed files with 1447 additions and 195 deletions
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "punktfunk-client-session"
description = "punktfunk/1 Vulkan session binary — SDL3 window, ash presenter, terminal stats; the power-user / gamescope stream client"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "punktfunk-session"
path = "src/main.rs"
[features]
default = ["ui"]
# The Skia console UI (stats OSD, capture HUD, later the gamepad library). Dropping it
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
# stats on stdout only.
ui = ["dep:pf-console-ui", "dep:serde_json"]
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
# binary.
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
pf-presenter = { path = "../../crates/pf-presenter" }
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
pf-client-core = { path = "../../crates/pf-client-core" }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
serde_json = { version = "1", optional = true }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+51
View File
@@ -0,0 +1,51 @@
# punktfunk-session
The Vulkan session binary: one stream per invocation in an SDL3 window — no UI toolkit,
no widgets, terminal stats. The power-user / gamescope stream client, and the stage-2
presenter of the Linux client re-architecture (punktfunk-planning:
`linux-client-rearchitecture.md`).
```
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
```
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
instead of connecting: A launches the focused title as a stream in the same window,
session end returns to the library, B quits (Gaming Mode returns). Paired hosts only —
pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<file.json>`
feeds canned entries with no host (portrait paths starting with `/` load from disk).
Reads the same identity / known-hosts / settings stores as the desktop client
(`punktfunk-client`) — pair there (or via its headless `--pair`) first; this binary never
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), 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.
In-stream keys match the desktop client: click captures input (Ctrl+Alt+Shift+Q
releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller escape
chord (L1+R1+Start+Select, hold to disconnect) works the same.
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirror).
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
only, no Skia anywhere in the dependency tree.
Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software):
FFmpeg's Vulkan Video decoder runs on the presenter's own device where the stack
supports it (every vendor, zero copy); VAAPI dmabufs import per-plane elsewhere;
software is the universal fallback. 10-bit Main10 and HDR10 are advertised
(`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present
on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or
tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff,
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
policy.
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|software`, `PUNKTFUNK_PRESENT_MODE=
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
demotion to software on healthy hardware).
+186
View File
@@ -0,0 +1,186 @@
//! `--browse host[:port]` — the console game library (phase 4b of the plan): the Skia
//! coverflow idles in the session window, A launches the focused title as a stream in
//! the SAME window (no gamescope window handoff — the whole point of one process), the
//! session's end returns to the library, B quits to Gaming Mode.
//!
//! The host must already be paired (the stored pin fetches the library and connects
//! silently; no ceremony can run under gamescope) — an unpaired target renders the
//! pair-first scene. `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds canned entries with no
//! host (portrait paths starting with `/` load from disk), the GPU-only dev path.
use crate::session_main::{arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params};
use pf_client_core::{library, trust};
use pf_console_ui::{LibraryGame, LibraryPhase, LibraryShared, SkiaOverlay};
use pf_presenter::overlay::OverlayAction;
use pf_presenter::ActionOutcome;
use std::collections::VecDeque;
pub fn run(target: &str) -> u8 {
let (addr, port) = parse_host_port(target);
let known = trust::KnownHosts::load();
let k = known
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port);
let host_label = k.map_or_else(|| addr.clone(), |h| h.name.clone());
let paired = k.is_some_and(|h| h.paired);
let pin = k.and_then(|h| trust::parse_hex32(&h.fp_hex));
let mgmt = arg_value("--mgmt")
.and_then(|p| p.parse().ok())
.unwrap_or(library::DEFAULT_MGMT_PORT);
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
eprintln!("client identity: {e:#}");
return crate::session_main::EXIT_CONNECT_FAILED;
}
};
let settings = trust::Settings::load();
let (overlay, shared) = match SkiaOverlay::with_library(host_label.clone()) {
Ok(v) => v,
Err(e) => {
eprintln!("console UI: {e:#}");
return crate::session_main::EXIT_PRESENTER_FAILED;
}
};
// The library fetch — paired hosts only (the fake-library hook exists precisely for
// host-less/pairing-less UI work).
let fake = std::env::var_os("PUNKTFUNK_FAKE_LIBRARY").is_some();
if paired || fake {
spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin);
} else {
shared.set_phase(LibraryPhase::PairFirst);
}
let opts = pf_presenter::SessionOpts {
window_title: format!("Punktfunk · {host_label}"),
fullscreen: fullscreen_mode(),
print_stats: settings.show_stats || arg_flag("--stats"),
json_status: false, // browse has no shell parent reading stdout
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint));
})),
overlay: Some(Box::new(overlay)),
};
let result =
pf_presenter::run_browse(opts, |action, gamepad, native, force_software, vulkan| {
match action {
OverlayAction::Launch { id, title } => {
// The carousel only renders for a paired host, so the pin exists; the
// guard keeps a logic slip from turning into a pinless connect.
let Some(pin) = pin else {
tracing::warn!("launch without a stored pin — refusing");
return ActionOutcome::Handled;
};
tracing::info!(%id, %title, "launching from the library");
ActionOutcome::Start(Box::new(session_params(
&settings,
addr.clone(),
port,
pin,
identity.clone(),
Some(id),
gamepad,
native,
force_software,
vulkan,
)))
}
OverlayAction::Retry => {
spawn_fetch(shared.clone(), addr.clone(), mgmt, identity.clone(), pin);
ActionOutcome::Handled
}
OverlayAction::Quit => ActionOutcome::Quit,
}
});
match result {
Ok(()) => 0,
Err(e) => {
eprintln!("browse: {e:#}");
crate::session_main::EXIT_PRESENTER_FAILED
}
}
}
/// Fetch the library off the main thread, then stream poster art into the shared model
/// as results land (the GTK launcher's `load` + `load_art`, minus the main-loop hops —
/// the renderer drains `push_art` per frame).
fn spawn_fetch(
shared: LibraryShared,
addr: String,
mgmt: u16,
identity: (String, String),
pin: Option<[u8; 32]>,
) {
shared.set_phase(LibraryPhase::Loading);
std::thread::Builder::new()
.name("punktfunk-library".into())
.spawn(move || {
if let Ok(path) = std::env::var("PUNKTFUNK_FAKE_LIBRARY") {
load_fake(&shared, &path);
return;
}
match library::fetch_games(&addr, mgmt, &identity, pin) {
Ok(games) => {
let base = library::base_url(&addr, mgmt);
let jobs: VecDeque<(String, Vec<String>)> = games
.iter()
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
.filter(|(_, candidates)| !candidates.is_empty())
.collect();
shared.set_games(
games
.iter()
.map(|g| LibraryGame {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
})
.collect(),
);
if !jobs.is_empty() {
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
while let Ok((id, bytes)) = rx.recv_blocking() {
shared.push_art(id, bytes);
}
}
}
Err(e) => shared.set_phase(LibraryPhase::Error {
title: "Couldn't load the library".into(),
body: e.to_string(),
can_retry: true,
}),
}
})
.ok();
}
/// Dev hook: entries from a JSON file; portrait paths starting with `/` load from disk.
fn load_fake(shared: &LibraryShared, path: &str) {
let games: Vec<library::GameEntry> = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
for g in &games {
if let Some(p) = g.art.portrait.as_deref().filter(|p| p.starts_with('/')) {
if let Ok(bytes) = std::fs::read(p) {
shared.push_art(g.id.clone(), bytes);
}
}
}
shared.set_games(
games
.iter()
.map(|g| LibraryGame {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
})
.collect(),
);
}
+373
View File
@@ -0,0 +1,373 @@
//! `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 browse;
#[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()
}
/// `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 {
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
},
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,
}
}
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
/// the minimal rules a reason string can need).
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 let Some(target) = arg_value("--browse") {
#[cfg(feature = "ui")]
return crate::browse::run(&target);
#[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]\n\
\n\
Streams from a paired punktfunk host in a Vulkan window; --browse opens the\n\
console game library instead (paired hosts only). Pair first via the\n\
desktop client or `punktfunk-client --pair <PIN> --connect host[:port]` —\n\
this binary never connects to a host it has no pinned fingerprint for."
);
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,
print_stats: settings.show_stats || arg_flag("--stats"),
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,
};
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);
}