feat(clients/windows): GPU picker, disconnect shortcut, richer stream HUD
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s
apple / swift (push) Successful in 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m16s
apple / screenshots (push) Successful in 5m30s
android / android (push) Successful in 3m21s
ci / web (push) Successful in 52s
ci / rust (push) Successful in 1m26s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m36s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m45s
docker / deploy-docs (push) Successful in 17s

- Settings gains a GPU selector (shown only on multi-GPU boxes): the picked
  DXGI adapter drives decode + present, persisted as Settings.adapter and
  applied at the next stream - gpu.rs now caches the shared device keyed by
  the resolved preference (env PUNKTFUNK_ADAPTER > Settings > the window's
  monitor's adapter) so a change needs no app restart.
- Ctrl+Alt+Shift+D disconnects the session (consumed locally, captured or
  released): the hook releases capture and trips the session stop flag,
  plumbed through the stream-page handoff; the pump winds down and the UI
  navigates back to the host list.
- Stream HUD extended: codec chip (HEVC/H.264/AV1), display-side line from
  the render thread (presents/s + capture-to-decoded vs capture-to-on-glass
  p50), session line (host name, duration, network-lost frames, skipped
  backlog frames), and both shortcut hints incl. the new disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:41:16 +02:00
parent 5ef63756ea
commit bf799b41e3
9 changed files with 257 additions and 71 deletions
+2 -1
View File
@@ -250,7 +250,8 @@ fn connect_with(
}
gamepad.attach(connector.clone());
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
*shared.handoff.lock().unwrap() =
Some((connector, handle.frames.clone(), handle.stop.clone()));
ss.call(Screen::Stream);
}
SessionEvent::Failed {
+7 -2
View File
@@ -95,10 +95,14 @@ impl PartialEq for Svc {
}
}
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread):
/// the connector (input sends), the decoded-frame channel (render thread), and the session's
/// stop flag (the disconnect shortcut trips it).
#[derive(Default)]
pub(crate) struct Shared {
pub(crate) handoff: Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx)>>,
#[allow(clippy::type_complexity)]
pub(crate) handoff:
Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx, Arc<AtomicBool>)>>,
pub(crate) target: Mutex<Target>,
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
/// by the HUD poll thread to drive the overlay.
@@ -231,6 +235,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
set_hud.call(stream::HudSample {
stats: *shared.stats.lock().unwrap(),
captured: crate::input::is_captured(),
present: crate::render::present_stats(),
});
})
.ok();
+36 -9
View File
@@ -166,6 +166,31 @@ pub(crate) fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
s.decoder = DECODERS[i].0.to_string();
});
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
let gpus = crate::gpu::adapter_names();
let gpu_combo = (gpus.len() > 1).then(|| {
let mut names = vec!["Automatic (the display's GPU)".to_string()];
names.extend(gpus.iter().cloned());
let current = gpus
.iter()
.position(|n| *n == s.adapter)
.map_or(0, |i| i + 1);
let gpus = gpus.clone();
setting_combo(
ctx,
"GPU (decode + present, applies to the next stream)",
names,
current,
move |s, i| {
s.adapter = if i == 0 {
String::new()
} else {
gpus[i - 1].clone()
};
},
)
});
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
s.codec = CODECS[i].0.to_string();
@@ -269,15 +294,17 @@ pub(crate) fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
section("VIDEO"),
settings_card(
"Video",
"Hardware decode (D3D11VA) is zero-copy and far lighter than software — keep it on \
Automatic unless debugging. Run a per-host speed test (host list) before setting a \
high bitrate.",
vec![
decoder_combo.into(),
codec_combo.into(),
bitrate_box.into(),
hdr_toggle.into(),
],
"Hardware decode (D3D11VA) is far lighter than software — keep it on Automatic \
unless debugging. Run a per-host speed test (host list) before setting a high \
bitrate.",
{
let mut controls: Vec<Element> = vec![decoder_combo.into()];
if let Some(c) = gpu_combo {
controls.push(c.into());
}
controls.extend([codec_combo.into(), bitrate_box.into(), hdr_toggle.into()]);
controls
},
),
section("INPUT"),
settings_card(
+68 -18
View File
@@ -15,12 +15,15 @@ use std::cell::RefCell;
use std::sync::Arc;
use windows_reactor::*;
/// One HUD refresh: the latest session stats plus the input hooks' capture state. Mirrored into
/// root state by the poll thread (`pf-hud`) and passed down as a prop.
/// One HUD refresh: the latest session stats, the input hooks' capture state, and the render
/// thread's display-side window. Mirrored into root state by the poll thread (`pf-hud`) and
/// passed down as a prop.
#[derive(Clone, Copy, Default, PartialEq)]
pub(crate) struct HudSample {
pub(crate) stats: Stats,
pub(crate) captured: bool,
/// `(presents/s, skipped/s, capture→presented p50 ms)` — see [`crate::render::present_stats`].
pub(crate) present: (u32, u32, f32),
}
/// Props for the stream page: the services plus the live HUD sample that drives the overlay
@@ -71,12 +74,12 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
let inhibit = ctx.settings.lock().unwrap().inhibit_shortcuts;
let connector_ref = connector_ref.clone();
move || {
if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
let mode = connector.mode();
let clock_offset = connector.clock_offset_ns;
connector_ref.set(Some(connector.clone()));
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
crate::input::install(connector, mode, inhibit);
crate::input::install(connector, mode, inhibit, stop);
}
Some(|| {
RENDER.with(|c| {
@@ -91,6 +94,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
});
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
let host = ctx.shared.target.lock().unwrap().name.clone();
grid((
swap_chain_panel()
.on_ready(|panel| {
@@ -128,7 +132,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
}
});
}),
hud_overlay(&props.hud, mode),
hud_overlay(&props.hud, mode, &host),
))
.into()
}
@@ -146,15 +150,39 @@ fn hud_chip(text: &str, color: Color) -> Border {
.padding(edges(8.0, 2.0, 8.0, 2.0))
}
/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode
/// path · HDR), the fps/throughput/latency line, and the capture-state hint. Layered over the
/// `SwapChainPanel` in the same grid cell.
fn hud_overlay(hud: &HudSample, mode: Option<Mode>) -> Element {
/// The negotiated wire codec's display name (`quic::CODEC_*` bit → label).
fn codec_name(bits: u8) -> &'static str {
match bits {
punktfunk_core::quic::CODEC_H264 => "H.264",
punktfunk_core::quic::CODEC_AV1 => "AV1",
_ => "HEVC",
}
}
/// `mm:ss` (or `h:mm:ss`) session time.
fn fmt_uptime(secs: u32) -> String {
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · codec ·
/// decode path · HDR), a stream line (decode fps / bitrate / decode time), a glass line (display
/// presents + end-to-end latency decoded vs on-glass), a session line (host · time · loss), and
/// the shortcut hints. Layered over the `SwapChainPanel` in the same grid cell.
fn hud_overlay(hud: &HudSample, mode: Option<Mode>, host: &str) -> Element {
let stats = &hud.stats;
let (pfps, skipped, glass_ms) = hud.present;
let res = mode
.map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
.unwrap_or_else(|| "\u{2014}".into());
let mut chips: Vec<Element> = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()];
let mut chips: Vec<Element> = vec![
hud_chip(&res, Color::rgb(235, 235, 235)).into(),
hud_chip(codec_name(stats.codec), Color::rgb(180, 190, 255)).into(),
];
chips.push(if stats.hardware {
hud_chip("GPU decode", Color::rgb(120, 220, 150)).into()
} else {
@@ -163,21 +191,43 @@ fn hud_overlay(hud: &HudSample, mode: Option<Mode>) -> Element {
if stats.hdr {
chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into());
}
let line = format!(
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms",
stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms
let stream_line = format!(
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} decode {:.1} ms",
stats.fps, stats.mbps, stats.decode_ms
);
// End-to-end latency (host-clock corrected): capture→decoded from the pump, capture→on-glass
// from the render thread's post-Present stamp. `skipped` = newest-wins drops (expected when
// the stream outpaces the display); `lost` = unrecoverable network drops.
let glass_line = format!(
"display {pfps} fps \u{00B7} latency {:.1} ms decoded / {glass_ms:.1} ms on-glass",
stats.latency_ms
);
let mut session_bits: Vec<String> = Vec::new();
if !host.is_empty() {
session_bits.push(host.to_string());
}
session_bits.push(fmt_uptime(stats.uptime_secs));
session_bits.push(format!("{} lost", stats.dropped));
if skipped > 0 {
session_bits.push(format!("{skipped} skipped"));
}
let session_line = session_bits.join(" \u{00B7} ");
let hint = if hud.captured {
"Ctrl+Alt+Shift+Q releases the mouse"
"Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects"
} else {
"Click the stream to capture the mouse"
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects"
};
let dim = |t: &str| {
text_block(t)
.font_size(11.0)
.foreground(Color::rgb(210, 210, 210))
};
border(
vstack((
hstack(chips).spacing(6.0),
text_block(line)
.font_size(11.0)
.foreground(Color::rgb(210, 210, 210)),
dim(&stream_line),
dim(&glass_line),
dim(&session_line),
text_block(hint)
.font_size(11.0)
.foreground(Color::rgb(150, 150, 150)),
+73 -29
View File
@@ -8,11 +8,15 @@
//! session pump when it builds the decoder, or the UI thread when it builds the presenter).
//!
//! **Adapter selection** (matters on hybrid boxes — e.g. an Intel iGPU driving the panel next to
//! an NVIDIA dGPU): `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring) wins; else the
//! adapter whose output owns the monitor our window is on — that's the adapter DWM composes that
//! monitor with, so presents are copy-free and decode runs on the near GPU; else the default
//! adapter. Deliberately NOT "the adapter with the best decoder": if the monitor's adapter can't
//! decode the codec we demote to software, which beats a per-frame cross-adapter present copy.
//! an NVIDIA dGPU): `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring, a debugging
//! override) wins; else the persisted Settings GPU pick ([`crate::trust::Settings::adapter`], the
//! Settings-page selector on multi-GPU boxes); else the adapter whose output owns the monitor our
//! window is on — that's the adapter DWM composes that monitor with, so presents are copy-free
//! and decode runs on the near GPU; else the default adapter. Deliberately NOT "the adapter with
//! the best decoder": if the monitor's adapter can't decode the codec we demote to software,
//! which beats a per-frame cross-adapter present copy. The device is cached **keyed by the
//! resolved preference**, so a Settings change takes effect at the next session (the pump and the
//! presenter both resolve at session start and read the same value) without an app restart.
//!
//! `PUNKTFUNK_D3D_DEBUG=1` adds the D3D11 debug layer (validation messages in the debugger /
//! DebugView) — invaluable for present-path bugs, which D3D11 otherwise drops silently.
@@ -27,7 +31,7 @@
//! state. That makes the `unsafe impl Send + Sync` below sound for exactly this usage.
use anyhow::{anyhow, Result};
use std::sync::OnceLock;
use std::sync::{Arc, Mutex};
use windows::core::Interface;
use windows::Win32::Graphics::Direct3D::{
D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE_WARP,
@@ -55,17 +59,38 @@ pub struct SharedDevice {
unsafe impl Send for SharedDevice {}
unsafe impl Sync for SharedDevice {}
static SHARED: OnceLock<Option<SharedDevice>> = OnceLock::new();
/// The shared device, cached with the GPU preference it was resolved from (empty = automatic).
/// Re-created when the preference changes — in practice only between sessions: within one session
/// the decoder and the presenter both call [`shared`] at session start with the same value.
static SHARED: Mutex<Option<(String, Arc<SharedDevice>)>> = Mutex::new(None);
/// The process-wide shared D3D11 device, created on first call. `None` only if D3D11 device
/// creation fails for both a hardware adapter and WARP (effectively never — WARP is always present).
pub fn shared() -> Option<&'static SharedDevice> {
SHARED.get_or_init(create).as_ref()
/// The user's decode/present GPU preference: the `PUNKTFUNK_ADAPTER` env (debugging override)
/// wins, else the persisted Settings pick; empty = automatic.
fn adapter_pref() -> String {
std::env::var("PUNKTFUNK_ADAPTER")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| crate::trust::Settings::load().adapter)
}
fn create() -> Option<SharedDevice> {
match create_device() {
Ok(d) => Some(d),
/// The process-shared D3D11 device for the current GPU preference, created (or re-created after
/// a preference change) on demand. `None` only if D3D11 device creation fails for both a hardware
/// adapter and WARP (effectively never — WARP is always present).
pub fn shared() -> Option<Arc<SharedDevice>> {
let pref = adapter_pref();
let mut cached = SHARED.lock().unwrap();
if let Some((key, dev)) = cached.as_ref() {
if *key == pref {
return Some(dev.clone());
}
}
match create_device(&pref) {
Ok(d) => {
let d = Arc::new(d);
*cached = Some((pref, d.clone()));
Some(d)
}
Err(e) => {
tracing::error!(error = %e, "shared D3D11 device creation failed — no present/decode");
None
@@ -87,12 +112,12 @@ fn adapter_name(adapter: &IDXGIAdapter) -> String {
}
}
/// Resolve an explicit adapter: `PUNKTFUNK_ADAPTER` (index or case-insensitive name substring)
/// wins; else the adapter whose output owns the monitor the app window is on (see module docs);
/// else `None` → the default adapter (also the headless-CLI path, where no window exists).
fn resolve_adapter() -> Option<IDXGIAdapter> {
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
let adapters: Vec<IDXGIAdapter> = {
/// Every DXGI adapter, in enumeration order (`PUNKTFUNK_ADAPTER=<index>` uses these indices).
fn all_adapters() -> Vec<IDXGIAdapter> {
let factory: IDXGIFactory1 = match unsafe { CreateDXGIFactory1() } {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let mut v = Vec::new();
let mut i = 0u32;
while let Ok(a) = unsafe { factory.EnumAdapters1(i) } {
@@ -102,10 +127,31 @@ fn resolve_adapter() -> Option<IDXGIAdapter> {
}
}
v
};
}
if let Ok(pref) = std::env::var("PUNKTFUNK_ADAPTER") {
let pref = pref.trim();
/// Descriptions of the real (hardware, non-WARP) GPUs — the Settings GPU picker's option list.
/// The picker only shows when this has more than one entry.
pub fn adapter_names() -> Vec<String> {
const DXGI_ADAPTER_FLAG_SOFTWARE: u32 = 2; // dxgi.h; not in this windows-rs feature set
all_adapters()
.iter()
.filter(|a| {
a.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter1>()
.and_then(|a1| unsafe { a1.GetDesc1() })
.map(|d| d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE == 0)
.unwrap_or(true)
})
.map(adapter_name)
.collect()
}
/// Resolve an explicit adapter: a non-empty `pref` (index or case-insensitive name substring, from
/// env or Settings) wins; else the adapter whose output owns the monitor the app window is on (see
/// module docs); else `None` → the default adapter (also the headless-CLI path with no window).
fn resolve_adapter(pref: &str) -> Option<IDXGIAdapter> {
let adapters = all_adapters();
if !pref.is_empty() {
let found = if let Ok(idx) = pref.parse::<usize>() {
adapters.get(idx).cloned()
} else {
@@ -116,10 +162,8 @@ fn resolve_adapter() -> Option<IDXGIAdapter> {
.cloned()
};
match &found {
Some(a) => {
tracing::info!(pref, adapter = %adapter_name(a), "PUNKTFUNK_ADAPTER matched")
}
None => tracing::warn!(pref, "PUNKTFUNK_ADAPTER matched no adapter — using default"),
Some(a) => tracing::info!(pref, adapter = %adapter_name(a), "GPU preference matched"),
None => tracing::warn!(pref, "GPU preference matched no adapter — using automatic"),
}
if found.is_some() {
return found;
@@ -153,12 +197,12 @@ fn resolve_adapter() -> Option<IDXGIAdapter> {
None
}
fn create_device() -> Result<SharedDevice> {
fn create_device(pref: &str) -> Result<SharedDevice> {
// Preference order: the resolved adapter (or the default hardware adapter) with video support
// (enables D3D11VA); the same without the VIDEO flag (a driver that rejects it still presents +
// software-decodes); finally WARP for the GPU-less box. BGRA_SUPPORT is required for the
// composition swapchain in every case. An explicit adapter requires D3D_DRIVER_TYPE_UNKNOWN.
let adapter = resolve_adapter();
let adapter = resolve_adapter(pref);
let attempts: [(Option<&IDXGIAdapter>, D3D_DRIVER_TYPE, bool, bool); 3] = match &adapter {
Some(a) => [
(Some(a), D3D_DRIVER_TYPE_UNKNOWN, true, true),
+22 -3
View File
@@ -23,7 +23,9 @@
//! **click on the stream** re-engages it. Losing foreground also releases the lock so the cursor
//! is never stranded; regaining it while still captured re-locks. When "capture system
//! shortcuts" is off in Settings, Alt+Tab / Alt+Esc / Ctrl+Esc / the Win keys act on the local
//! desktop instead of being forwarded.
//! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed
//! locally, works captured or released while our window is foreground): it trips the session's
//! stop flag, the pump winds down, and the event loop navigates back to the host list.
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::Mode;
@@ -34,7 +36,7 @@ use std::sync::{Arc, Mutex};
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
use windows::Win32::Graphics::Gdi::ClientToScreen;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Input::KeyboardAndMouse::VK_Q;
use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_Q};
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
@@ -46,6 +48,8 @@ use windows::Win32::UI::WindowsAndMessaging::{
struct State {
connector: Arc<NativeClient>,
mode: Mode,
/// The session's stop flag (Ctrl+Alt+Shift+D trips it; the pump then ends the session).
stop: Arc<AtomicBool>,
/// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not).
hwnd: isize,
/// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q / click-to-capture).
@@ -99,11 +103,18 @@ fn set_captured(st: &mut State, on: bool) {
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local.
pub fn install(connector: Arc<NativeClient>, mode: Mode, inhibit_shortcuts: bool) {
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
pub fn install(
connector: Arc<NativeClient>,
mode: Mode,
inhibit_shortcuts: bool,
stop: Arc<AtomicBool>,
) {
let hwnd = unsafe { GetForegroundWindow() };
let mut st = State {
connector,
mode,
stop,
hwnd: hwnd.0 as isize,
captured: false,
inhibit_shortcuts,
@@ -266,6 +277,14 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) ->
tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)");
return LRESULT(1);
}
// Disconnect: Ctrl+Alt+Shift+D (consumed locally). Release capture immediately so
// the cursor is free while the session winds down and the UI navigates home.
if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
set_captured(st, false);
st.stop.store(true, Ordering::SeqCst);
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
return LRESULT(1);
}
if st.captured {
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
// neither forwarded nor swallowed.
+23
View File
@@ -16,6 +16,23 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// The last 1-second render window, published for the HUD (one render thread at a time):
/// presents/s, frames skipped by the newest-wins drain, and the capture→presented p50 in µs.
/// Zeroed when a render thread starts so a new session never shows the previous one's numbers.
static PRESENT_FPS: AtomicU32 = AtomicU32::new(0);
static PRESENT_SKIPPED: AtomicU32 = AtomicU32::new(0);
static PRESENT_P50_US: AtomicU64 = AtomicU64::new(0);
/// `(presents/s, skipped/s, capture→presented p50 ms)` of the last render window — the HUD's
/// display-side line.
pub fn present_stats() -> (u32, u32, f32) {
(
PRESENT_FPS.load(Ordering::Relaxed),
PRESENT_SKIPPED.load(Ordering::Relaxed),
PRESENT_P50_US.load(Ordering::Relaxed) as f32 / 1000.0,
)
}
/// UI-thread → render-thread state. Size is packed into ONE atomic (w<<32|h) so a resize never
/// tears into a (new-width, old-height) pair.
pub struct RenderShared {
@@ -133,6 +150,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
let mut window_start = Instant::now();
let mut last_dpi_poll = Instant::now();
PRESENT_FPS.store(0, Ordering::Relaxed);
PRESENT_SKIPPED.store(0, Ordering::Relaxed);
PRESENT_P50_US.store(0, Ordering::Relaxed);
loop {
if shared.stop.load(Ordering::SeqCst) {
@@ -194,6 +214,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
lat_us.sort_unstable();
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0);
tracing::debug!(presented, dropped, present_p50_us = p50, "render window");
PRESENT_FPS.store(presented, Ordering::Relaxed);
PRESENT_SKIPPED.store(dropped, Ordering::Relaxed);
PRESENT_P50_US.store(p50, Ordering::Relaxed);
window_start = Instant::now();
presented = 0;
dropped = 0;
+12 -1
View File
@@ -51,10 +51,17 @@ pub struct Stats {
pub decode_ms: f32,
/// Median capture→decoded latency over the last window (host-clock corrected).
pub latency_ms: f32,
/// True when decoding on the GPU (D3D11VA zero-copy) vs. CPU (software).
/// True when decoding on the GPU (D3D11VA) vs. CPU (software).
pub hardware: bool,
/// True when the stream is BT.2020 PQ HDR10 (last decoded frame).
pub hdr: bool,
/// The negotiated wire codec (a `quic::CODEC_*` bit) — the HUD's codec chip.
pub codec: u8,
/// Frames lost to unrecoverable network drops since session start (reassembler count; each
/// triggers a keyframe re-request).
pub dropped: u64,
/// Seconds since the stream started.
pub uptime_secs: u32,
}
pub enum SessionEvent {
@@ -299,6 +306,7 @@ fn pump(
let clock_offset = connector.clock_offset_ns;
let mut total_frames = 0u64;
let session_start = Instant::now();
let mut window_start = Instant::now();
let mut frames_n = 0u32;
let mut bytes_n = 0u64;
@@ -424,6 +432,9 @@ fn pump(
latency_ms: p50 as f32 / 1000.0,
hardware,
hdr,
codec: connector.codec,
dropped: last_dropped,
uptime_secs: session_start.elapsed().as_secs() as u32,
}));
window_start = Instant::now();
frames_n = 0;
+6
View File
@@ -144,6 +144,11 @@ pub struct Settings {
/// 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,
}
fn default_codec() -> String {
@@ -177,6 +182,7 @@ impl Default for Settings {
hdr_enabled: true,
decoder: "auto".into(),
codec: "auto".into(),
adapter: String::new(),
}
}
}