forked from unom/punktfunk
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>
257 lines
9.1 KiB
Rust
257 lines
9.1 KiB
Rust
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
|
//!
|
|
//! Ported near-verbatim from the GTK Linux client; the only platform change is the config
|
|
//! directory — `%APPDATA%\punktfunk` (the Windows analogue of `~/.config/punktfunk`), shared
|
|
//! with the Windows host's identity location. The identity files (`client-{cert,key}.pem`)
|
|
//! keep the same names so the trust model is identical across the native clients.
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use punktfunk_core::quic::endpoint;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
pub fn config_dir() -> Result<PathBuf> {
|
|
let appdata = std::env::var("APPDATA").context("APPDATA unset")?;
|
|
Ok(PathBuf::from(appdata).join("punktfunk"))
|
|
}
|
|
|
|
/// This client's persistent identity, generated on first use — presented on every connect
|
|
/// so hosts can recognize it once paired.
|
|
pub fn load_or_create_identity() -> Result<(String, String)> {
|
|
let dir = config_dir()?;
|
|
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
|
|
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
|
|
return Ok((c, k));
|
|
}
|
|
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
|
|
std::fs::create_dir_all(&dir)?;
|
|
std::fs::write(&cp, &c)?;
|
|
std::fs::write(&kp, &k)?;
|
|
tracing::info!(cert = %cp.display(), "generated client identity");
|
|
Ok((c, k))
|
|
}
|
|
|
|
pub fn hex(fp: &[u8; 32]) -> String {
|
|
fp.iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
pub fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
|
if s.len() != 64 {
|
|
return None;
|
|
}
|
|
let mut out = [0u8; 32];
|
|
for (i, b) in out.iter_mut().enumerate() {
|
|
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a
|
|
/// PIN ceremony) and where we last reached it.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct KnownHost {
|
|
pub name: String,
|
|
pub addr: String,
|
|
pub port: u16,
|
|
/// SHA-256 of the host certificate, lowercase hex — the pin for every later connect.
|
|
pub fp_hex: String,
|
|
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
|
pub paired: bool,
|
|
/// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
|
/// online, so we can wake it once it sleeps. `default` so pre-existing stores load; empty until
|
|
/// first learned.
|
|
#[serde(default)]
|
|
pub mac: Vec<String>,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
pub struct KnownHosts {
|
|
pub hosts: Vec<KnownHost>,
|
|
}
|
|
|
|
impl KnownHosts {
|
|
fn path() -> Result<PathBuf> {
|
|
Ok(config_dir()?.join("client-known-hosts.json"))
|
|
}
|
|
|
|
pub fn load() -> KnownHosts {
|
|
Self::path()
|
|
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn save(&self) -> Result<()> {
|
|
let p = Self::path()?;
|
|
std::fs::create_dir_all(p.parent().unwrap())?;
|
|
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
|
|
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
|
|
}
|
|
|
|
/// Forget a host (the hosts page's "Forget" action): drops the pinned fingerprint, so a
|
|
/// later connect goes back through pairing/TOFU.
|
|
pub fn remove_by_fp(&mut self, fp_hex: &str) {
|
|
self.hosts.retain(|h| h.fp_hex != fp_hex);
|
|
}
|
|
|
|
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
|
|
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
|
}
|
|
|
|
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
|
|
/// (a later TOFU connect must not demote a PIN-paired host).
|
|
pub fn upsert(&mut self, entry: KnownHost) {
|
|
if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) {
|
|
h.name = entry.name;
|
|
h.addr = entry.addr;
|
|
h.port = entry.port;
|
|
h.paired |= entry.paired;
|
|
// A trust-decision upsert (which carries no MAC) must not wipe learned MACs.
|
|
if !entry.mac.is_empty() {
|
|
h.mac = entry.mac;
|
|
}
|
|
} else {
|
|
self.hosts.push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host is
|
|
/// online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so the
|
|
/// hosts page can call it on every discovery tick without churning the store.
|
|
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
|
if mac.is_empty() {
|
|
return;
|
|
}
|
|
let mut known = KnownHosts::load();
|
|
let Some(h) = known
|
|
.hosts
|
|
.iter_mut()
|
|
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
|
else {
|
|
return;
|
|
};
|
|
if h.mac == mac {
|
|
return;
|
|
}
|
|
h.mac = mac.to_vec();
|
|
let _ = known.save();
|
|
}
|
|
|
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Settings {
|
|
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
|
|
/// resolved at connect time.
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub refresh_hz: u32,
|
|
/// Requested encoder bitrate (kbps); 0 = host default.
|
|
pub bitrate_kbps: u32,
|
|
pub gamepad: String,
|
|
/// Which host compositor backend to request (advisory; the host falls back to
|
|
/// auto-detect when unavailable).
|
|
pub compositor: String,
|
|
/// Grab system shortcuts (Alt+Tab, Win…) while input is captured.
|
|
pub inhibit_shortcuts: bool,
|
|
/// Stream the default microphone to the host's virtual mic source.
|
|
pub mic_enabled: bool,
|
|
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
|
|
/// can capture; the resolved count drives the decoder + WASAPI render layout.
|
|
pub audio_channels: u8,
|
|
/// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream (the client
|
|
/// presents it on a 10-bit ST.2084 swapchain). No effect on SDR content.
|
|
pub hdr_enabled: bool,
|
|
/// Video decode backend: `auto` (D3D11VA, fall back to software), `hardware`, or `software`.
|
|
pub decoder: String,
|
|
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
|
|
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
|
#[serde(default = "default_codec")]
|
|
pub codec: String,
|
|
/// Decode/present GPU: the DXGI adapter description to prefer on a multi-GPU box; empty =
|
|
/// automatic (the adapter driving the window's monitor). Applies from the next session; a
|
|
/// vanished adapter (eGPU unplugged) falls back to automatic.
|
|
#[serde(default)]
|
|
pub adapter: String,
|
|
/// Show the stats/info overlay (HUD) over the stream.
|
|
#[serde(default = "default_true")]
|
|
pub show_hud: bool,
|
|
/// Streaming engine: `""` = the punktfunk-session Vulkan window (the default),
|
|
/// `"builtin"` = the legacy in-process D3D11VA presenter. A temporary A/B knob —
|
|
/// removed with the legacy path once the Vulkan session is fully validated.
|
|
/// `default` so pre-existing stores load.
|
|
#[serde(default)]
|
|
pub engine: String,
|
|
}
|
|
|
|
fn default_codec() -> String {
|
|
"auto".into()
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Settings {
|
|
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
|
pub fn preferred_codec(&self) -> u8 {
|
|
match self.codec.as_str() {
|
|
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
|
|
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
|
|
"av1" => punktfunk_core::quic::CODEC_AV1,
|
|
_ => 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for Settings {
|
|
fn default() -> Self {
|
|
Settings {
|
|
width: 0,
|
|
height: 0,
|
|
refresh_hz: 0,
|
|
bitrate_kbps: 0,
|
|
gamepad: "auto".into(),
|
|
compositor: "auto".into(),
|
|
inhibit_shortcuts: true,
|
|
mic_enabled: false,
|
|
audio_channels: 2,
|
|
hdr_enabled: true,
|
|
decoder: "auto".into(),
|
|
codec: "auto".into(),
|
|
adapter: String::new(),
|
|
show_hud: true,
|
|
engine: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Settings {
|
|
fn path() -> Result<PathBuf> {
|
|
Ok(config_dir()?.join("client-windows-settings.json"))
|
|
}
|
|
|
|
pub fn load() -> Settings {
|
|
Self::path()
|
|
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn save(&self) {
|
|
let Ok(p) = Self::path() else { return };
|
|
let _ = std::fs::create_dir_all(p.parent().unwrap());
|
|
if let Ok(s) = serde_json::to_string_pretty(self) {
|
|
let _ = std::fs::write(&p, s);
|
|
}
|
|
}
|
|
}
|