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:
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user