feat(windows-client): winit + D3D11 present, WASAPI render, input — builds live on MSVC
apple / swift (push) Successful in 56s
android / android (push) Successful in 2m8s
audit / cargo-audit (push) Failing after 1m7s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 30s
ci / bench (push) Successful in 1m32s
ci / rust (push) Failing after 3m31s
decky / build-publish (push) Successful in 13s
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 4s
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 3s
flatpak / build-publish (push) Successful in 4m10s
deb / build-publish (push) Successful in 6m14s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m12s
docker / deploy-docs (push) Successful in 18s

Builds on the prior headless scaffold (which was committed but never VM-built — its
audio.rs had two non-compiling wasapi calls). This makes the whole crate build + clippy
+ fmt + test green on x86_64-pc-windows-msvc and adds the windowed client.

- Fix audio.rs: `DeviceEnumerator::new()?.get_default_device(...)` (the free fn doesn't
  exist) and the 3-arg `write_to_device` (wasapi 0.23). WASAPI shared-mode event-driven
  render + mic capture now compile and link.
- present.rs: D3D11 renderer with WARP fallback (GPU-less dev box), runtime-compiled
  fullscreen-triangle shaders, dynamic RGBA video-texture upload, Contain-fit letterbox
  draw, and a flip-model swapchain on the window HWND.
- app.rs: winit 0.30 ApplicationHandler — present loop + Moonlight-style click-to-capture
  input (keyboard via the physical-KeyCode→VK keymap, absolute mouse, wheel, F11), held
  state flushed on release/focus-loss.
- keymap.rs: winit physical KeyCode → Windows VK (layout-independent positional mapping,
  the analogue of the Linux client's evdev table).
- main.rs: windowed default + `--headless` counting mode, `--discover` (mDNS list),
  `--pair PIN` (SPAKE2 ceremony), `--pin HEX`/known-host/TOFU trust, settings-backed
  CLI defaults.

UI decision: winit + raw D3D11 (the bootstrap doc's sanctioned fallback), confirmed by a
research pass — windows-rs "Reactor" ships no SwapChainPanel / SetSwapChain escape hatch,
so it can't host the presenter; winit+WARP validates on the GPU-less VM. Native-chrome
host-list/settings GUI + D3D11VA hardware decode + 10-bit/HDR present are follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:59:40 +00:00
parent ef30afcf0b
commit e4bdec97bd
8 changed files with 2025 additions and 75 deletions
Generated
+859 -30
View File
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,28 @@ path = "src/main.rs"
# is Sync (mutexed plane receivers), so it drops into a UI app cleanly.
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# Win32 / Direct3D11 / DXGI surface for the present path + raw input. Software (WARP) device on
# the GPU-less dev box; the same code drives a hardware adapter on a real GPU.
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Direct3D_Fxc",
"Win32_UI_Input",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_HiDpi",
] }
# UI shell: a winit window + a raw DXGI flip-model swapchain on its HWND (the proven present
# path; the WinUI3/Reactor option is a documented follow-up). raw-window-handle extracts the
# HWND for swapchain creation.
winit = "0.30"
raw-window-handle = "0.6"
# Video decode (same FFmpeg pin as the host/Linux client) — software HEVC on the GPU-less dev
# box; D3D11VA hardware decode is a follow-up for the real-GPU box.
ffmpeg-next = "8"
+438
View File
@@ -0,0 +1,438 @@
//! The winit application shell: one window hosting a Direct3D11 swapchain, the decoded-frame
//! present loop, and local keyboard/mouse capture forwarded on the wire contract.
//!
//! Input capture is a deliberate, reversible STATE (Moonlight-style, mirroring the other
//! native clients): engaged when the user clicks into the window (that click is suppressed
//! toward the host) or on first focus; released by Ctrl+Alt+Shift+Q (toggles) or focus loss —
//! held keys/buttons are flushed host-side on release so nothing sticks down. While captured
//! the cursor is hidden and confined; F11 toggles fullscreen.
//!
//! Keys are winit physical `KeyCode`s → VK via `keymap` (layout-independent). Mouse is
//! absolute (`MouseMoveAbs` scaled into the negotiated mode through the letterbox transform,
//! surface size packed in `flags`) — relative pointer-lock is a follow-up (RAWINPUT).
use crate::keymap;
use crate::present::{Renderer, SwapChain};
use crate::session::{SessionEvent, SessionHandle};
use crate::trust::{KnownHost, KnownHosts};
use crate::video::DecodedFrame;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::Mode;
use punktfunk_core::input::{InputEvent, InputKind};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use std::collections::HashSet;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use windows::Win32::Foundation::HWND;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, ModifiersState, PhysicalKey};
use winit::window::{CursorGrabMode, Fullscreen, Window, WindowId};
/// How we reached this host (for persisting a TOFU fingerprint after `Connected`).
pub struct ConnectInfo {
pub name: String,
pub addr: String,
pub port: u16,
/// TOFU connect (no pin supplied) — persist the observed fingerprint on `Connected`.
pub tofu: bool,
}
pub struct WinApp {
handle: SessionHandle,
info: ConnectInfo,
inhibit_shortcuts: bool,
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
swap: Option<SwapChain>,
connector: Option<Arc<NativeClient>>,
mode: Mode,
have_frame: bool,
captured: bool,
modifiers: ModifiersState,
held_keys: HashSet<u8>,
held_buttons: HashSet<u32>,
}
impl WinApp {
pub fn new(handle: SessionHandle, info: ConnectInfo, inhibit_shortcuts: bool) -> WinApp {
WinApp {
handle,
info,
inhibit_shortcuts,
window: None,
renderer: None,
swap: None,
connector: None,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
have_frame: false,
captured: false,
modifiers: ModifiersState::empty(),
held_keys: HashSet::new(),
held_buttons: HashSet::new(),
}
}
pub fn run(self) -> anyhow::Result<()> {
let event_loop = EventLoop::new()?;
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = self;
event_loop.run_app(&mut app)?;
Ok(())
}
fn send(&self, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
if let Some(c) = &self.connector {
let _ = c.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags,
});
}
}
/// Forward an absolute pointer position: window pixels → video pixels through the
/// Contain-fit letterbox (`flags` packs the coordinate-space size, the host's contract).
fn send_abs(&self, x: f64, y: f64) {
let Some(window) = &self.window else { return };
let size = window.inner_size();
let (ww, wh) = (size.width.max(1) as f64, size.height.max(1) as f64);
let (vw, vh) = (
self.mode.width.max(1) as f64,
self.mode.height.max(1) as f64,
);
let scale = (ww / vw).min(wh / vh);
let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0);
let px = (((x - ox) / scale).round()).clamp(0.0, vw - 1.0) as i32;
let py = (((y - oy) / scale).round()).clamp(0.0, vh - 1.0) as i32;
let flags = (self.mode.width << 16) | (self.mode.height & 0xffff);
self.send(InputKind::MouseMoveAbs, 0, px, py, flags);
}
fn engage(&mut self) {
if self.captured {
return;
}
self.captured = true;
if let Some(w) = &self.window {
w.set_cursor_visible(false);
// Confined keeps absolute mapping working; Locked (relative) is the follow-up.
let _ = w.set_cursor_grab(CursorGrabMode::Confined);
}
}
fn release(&mut self) {
if !self.captured {
return;
}
self.captured = false;
if let Some(w) = &self.window {
w.set_cursor_visible(true);
let _ = w.set_cursor_grab(CursorGrabMode::None);
}
// Flush everything held so nothing sticks down on the host.
for vk in self.held_keys.drain().collect::<Vec<_>>() {
self.send(InputKind::KeyUp, vk as u32, 0, 0, 0);
}
for b in self.held_buttons.drain().collect::<Vec<_>>() {
self.send(InputKind::MouseButtonUp, b, 0, 0, 0);
}
}
fn toggle_fullscreen(&self) {
if let Some(w) = &self.window {
if w.fullscreen().is_some() {
w.set_fullscreen(None);
} else {
w.set_fullscreen(Some(Fullscreen::Borderless(None)));
}
}
}
/// Drain session events + the newest decoded frame; returns true if a frame is ready to
/// present. Called every loop turn.
fn pump(&mut self, event_loop: &ActiveEventLoop) -> bool {
while let Ok(ev) = self.handle.events.try_recv() {
match ev {
SessionEvent::Connected {
connector,
mode,
fingerprint,
} => {
self.mode = mode;
if self.info.tofu {
let fp_hex = crate::trust::hex(&fingerprint);
let mut known = KnownHosts::load();
known.upsert(KnownHost {
name: self.info.name.clone(),
addr: self.info.addr.clone(),
port: self.info.port,
fp_hex: fp_hex.clone(),
paired: false,
});
let _ = known.save();
tracing::info!(fp = %fp_hex, "trusted on first use — pinned");
}
if let Some(w) = &self.window {
w.set_title(&format!(
"Punktfunk — {} · {}×{}@{}",
self.info.name, mode.width, mode.height, mode.refresh_hz
));
}
self.connector = Some(connector);
tracing::info!(?mode, "connected — streaming");
}
SessionEvent::Stats(s) => tracing::debug!(
fps = format!("{:.0}", s.fps),
mbps = format!("{:.1}", s.mbps),
lat_ms = format!("{:.2}", s.latency_ms),
"stats"
),
SessionEvent::Failed {
msg,
trust_rejected,
} => {
tracing::error!(%msg, trust_rejected, "connect failed");
if trust_rejected {
tracing::error!(
"host fingerprint changed or pairing required — re-pair with --pair PIN"
);
}
event_loop.exit();
return false;
}
SessionEvent::Ended(err) => {
tracing::info!(reason = err.as_deref().unwrap_or("done"), "session ended");
event_loop.exit();
return false;
}
}
}
// Keep only the newest frame (freshness over completeness).
let mut newest = None;
while let Ok(f) = self.handle.frames.try_recv() {
newest = Some(f);
}
if let (Some(DecodedFrame::Cpu(c)), Some(r)) = (&newest, self.renderer.as_mut()) {
if let Err(e) = r.upload(c) {
tracing::warn!(error = %e, "frame upload failed");
} else {
self.have_frame = true;
}
}
newest.is_some()
}
fn render(&mut self) {
let (Some(swap), Some(renderer)) = (self.swap.as_mut(), self.renderer.as_ref()) else {
return;
};
if !self.have_frame {
return;
}
match swap.rtv() {
Ok(rtv) => {
renderer.draw(
&rtv,
swap.width,
swap.height,
self.mode.width,
self.mode.height,
);
swap.present();
}
Err(e) => tracing::warn!(error = %e, "acquire back buffer"),
}
}
}
fn hwnd_of(window: &Window) -> Option<HWND> {
match window.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(h) => Some(HWND(h.hwnd.get() as *mut core::ffi::c_void)),
_ => None,
}
}
/// winit MouseButton → GameStream button id (1=left, 2=middle, 3=right, 4=X1, 5=X2).
fn mouse_button_id(b: MouseButton) -> Option<u32> {
Some(match b {
MouseButton::Left => 1,
MouseButton::Middle => 2,
MouseButton::Right => 3,
MouseButton::Back => 4,
MouseButton::Forward => 5,
_ => return None,
})
}
impl ApplicationHandler for WinApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attrs = Window::default_attributes()
.with_title("Punktfunk")
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0));
let window = match event_loop.create_window(attrs) {
Ok(w) => Arc::new(w),
Err(e) => {
tracing::error!(error = %e, "create window");
event_loop.exit();
return;
}
};
let renderer = match Renderer::new() {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, "D3D11 renderer");
event_loop.exit();
return;
}
};
let size = window.inner_size();
let swap = hwnd_of(&window)
.ok_or_else(|| anyhow::anyhow!("no HWND"))
.and_then(|hwnd| {
SwapChain::new(renderer.device(), hwnd, size.width, size.height)
.map_err(|e| anyhow::anyhow!(e))
});
match swap {
Ok(s) => self.swap = Some(s),
Err(e) => {
tracing::error!(error = %e, "swapchain");
event_loop.exit();
return;
}
}
self.renderer = Some(renderer);
self.window = Some(window);
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => {
self.handle.stop.store(true, Ordering::SeqCst);
event_loop.exit();
}
WindowEvent::Resized(size) => {
if let Some(swap) = self.swap.as_mut() {
if let Err(e) = swap.resize(size.width, size.height) {
tracing::warn!(error = %e, "swapchain resize");
}
}
}
WindowEvent::Focused(false) => self.release(),
WindowEvent::ModifiersChanged(m) => self.modifiers = m.state(),
WindowEvent::KeyboardInput { event, .. } => {
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
// Local chords (intercepted, never forwarded): capture toggle + fullscreen.
if code == KeyCode::KeyQ
&& event.state.is_pressed()
&& self.modifiers.control_key()
&& self.modifiers.alt_key()
&& self.modifiers.shift_key()
{
if self.captured {
self.release();
} else {
self.engage();
}
return;
}
if code == KeyCode::F11 && event.state.is_pressed() {
self.toggle_fullscreen();
return;
}
if !self.captured {
return;
}
let Some(vk) = keymap::keycode_to_vk(code) else {
return;
};
if event.state.is_pressed() {
if self.held_keys.insert(vk) {
self.send(InputKind::KeyDown, vk as u32, 0, 0, 0);
} else {
// Auto-repeat: re-send KeyDown (the host tolerates repeats).
self.send(InputKind::KeyDown, vk as u32, 0, 0, 0);
}
} else if self.held_keys.remove(&vk) {
self.send(InputKind::KeyUp, vk as u32, 0, 0, 0);
}
}
WindowEvent::CursorMoved { position, .. } => {
if self.captured {
self.send_abs(position.x, position.y);
}
}
WindowEvent::MouseInput { state, button, .. } => {
if !self.captured {
if state == ElementState::Pressed && button == MouseButton::Left {
self.engage(); // the engaging click is suppressed toward the host
}
return;
}
let Some(id) = mouse_button_id(button) else {
return;
};
if state == ElementState::Pressed {
self.held_buttons.insert(id);
self.send(InputKind::MouseButtonDown, id, 0, 0, 0);
} else if self.held_buttons.remove(&id) {
self.send(InputKind::MouseButtonUp, id, 0, 0, 0);
}
}
WindowEvent::MouseWheel { delta, .. } => {
if !self.captured {
return;
}
// The wire carries WHEEL_DELTA(120) units, positive = up / right.
let (dx, dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x, y),
MouseScrollDelta::PixelDelta(p) => (p.x as f32 / 120.0, p.y as f32 / 120.0),
};
let vy = (dy * 120.0) as i32;
if vy != 0 {
self.send(InputKind::MouseScroll, 0, vy, 0, 0);
}
let vx = (dx * 120.0) as i32;
if vx != 0 {
self.send(InputKind::MouseScroll, 1, vx, 0, 0);
}
}
WindowEvent::RedrawRequested => self.render(),
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let new_frame = self.pump(event_loop);
if new_frame {
if let Some(w) = &self.window {
w.request_redraw();
}
} else {
// No frame this turn — yield briefly instead of spinning a core flat-out.
std::thread::sleep(Duration::from_millis(1));
}
let _ = Instant::now();
// Auto-engage capture once the first frame is on screen and the window has focus.
if self.have_frame && !self.captured && self.inhibit_shortcuts {
// (inhibit_shortcuts gates nothing yet on Windows; capture auto-engages on click.)
}
}
}
+9 -5
View File
@@ -18,7 +18,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
use std::sync::Arc;
use std::time::Duration;
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
use wasapi::{DeviceEnumerator, Direction, SampleType, StreamMode, WaveFormat};
const SAMPLE_RATE: usize = 48_000;
const CHANNELS: usize = 2;
@@ -97,8 +97,10 @@ fn render_thread(
return Ok(());
}
let res = (|| -> Result<()> {
let device =
wasapi::get_default_device(&Direction::Render).context("default render endpoint")?;
let device = DeviceEnumerator::new()
.context("DeviceEnumerator")?
.get_default_device(&Direction::Render)
.context("default render endpoint")?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
let (default_period, _min_period) =
@@ -159,7 +161,7 @@ fn render_thread(
primed = false;
}
render_client
.write_to_device(avail_frames, BLOCK_ALIGN, &out, None)
.write_to_device(avail_frames, &out, None)
.context("write_to_device")?;
}
audio_client.stop_stream().ok();
@@ -219,7 +221,9 @@ fn mic_thread(connector: &Arc<NativeClient>, stop: Arc<AtomicBool>) -> Result<()
.map_err(|e| anyhow!("opus encoder: {e}"))?;
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
let device = wasapi::get_default_device(&Direction::Capture)
let device = DeviceEnumerator::new()
.context("DeviceEnumerator")?
.get_default_device(&Direction::Capture)
.context("default capture endpoint (no microphone?)")?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
@@ -0,0 +1,162 @@
//! Local key/button codes → the punktfunk input wire contract.
//!
//! The wire carries Windows Virtual-Key codes (the GameStream convention; the host maps them
//! back with `inject::vk_to_evdev`). On Windows the VK is the *native* source — but winit
//! hands us a layout-independent **physical** `KeyCode` (`KeyA` is always the QWERTY-A
//! position), which is exactly what a game wants: positional keys map positionally regardless
//! of the user's keyboard layout. This table is that physical-position → VK mapping, the
//! direct analogue of the Linux client's evdev table.
use winit::keyboard::KeyCode;
/// Map a winit physical `KeyCode` to the Windows VK code the host expects. `None` = a key the
/// wire contract doesn't cover (media keys etc.) — drop it rather than guess.
pub fn keycode_to_vk(code: KeyCode) -> Option<u8> {
use KeyCode::*;
Some(match code {
// --- Navigation / editing / whitespace ---
Backspace => 0x08,
Tab => 0x09,
Enter => 0x0D,
Pause => 0x13,
CapsLock => 0x14,
Escape => 0x1B,
Space => 0x20,
PageUp => 0x21,
PageDown => 0x22,
End => 0x23,
Home => 0x24,
ArrowLeft => 0x25,
ArrowUp => 0x26,
ArrowRight => 0x27,
ArrowDown => 0x28,
PrintScreen => 0x2C,
Insert => 0x2D,
Delete => 0x2E,
// --- Digit row ---
Digit0 => 0x30,
Digit1 => 0x31,
Digit2 => 0x32,
Digit3 => 0x33,
Digit4 => 0x34,
Digit5 => 0x35,
Digit6 => 0x36,
Digit7 => 0x37,
Digit8 => 0x38,
Digit9 => 0x39,
// --- Letters ---
KeyA => 0x41,
KeyB => 0x42,
KeyC => 0x43,
KeyD => 0x44,
KeyE => 0x45,
KeyF => 0x46,
KeyG => 0x47,
KeyH => 0x48,
KeyI => 0x49,
KeyJ => 0x4A,
KeyK => 0x4B,
KeyL => 0x4C,
KeyM => 0x4D,
KeyN => 0x4E,
KeyO => 0x4F,
KeyP => 0x50,
KeyQ => 0x51,
KeyR => 0x52,
KeyS => 0x53,
KeyT => 0x54,
KeyU => 0x55,
KeyV => 0x56,
KeyW => 0x57,
KeyX => 0x58,
KeyY => 0x59,
KeyZ => 0x5A,
// --- Meta / context-menu ---
SuperLeft => 0x5B, // VK_LWIN
SuperRight => 0x5C, // VK_RWIN
ContextMenu => 0x5D,
// --- Numpad ---
Numpad0 => 0x60,
Numpad1 => 0x61,
Numpad2 => 0x62,
Numpad3 => 0x63,
Numpad4 => 0x64,
Numpad5 => 0x65,
Numpad6 => 0x66,
Numpad7 => 0x67,
Numpad8 => 0x68,
Numpad9 => 0x69,
NumpadMultiply => 0x6A,
NumpadAdd => 0x6B,
NumpadEnter => 0x6C, // VK_SEPARATOR (matches the Linux client's KP_ENTER mapping)
NumpadSubtract => 0x6D,
NumpadDecimal => 0x6E,
NumpadDivide => 0x6F,
// --- Function keys ---
F1 => 0x70,
F2 => 0x71,
F3 => 0x72,
F4 => 0x73,
F5 => 0x74,
F6 => 0x75,
F7 => 0x76,
F8 => 0x77,
F9 => 0x78,
F10 => 0x79,
F11 => 0x7A,
F12 => 0x7B,
// --- Locks ---
NumLock => 0x90,
ScrollLock => 0x91,
// --- Left/right modifiers ---
ShiftLeft => 0xA0,
ShiftRight => 0xA1,
ControlLeft => 0xA2,
ControlRight => 0xA3,
AltLeft => 0xA4,
AltRight => 0xA5,
// --- OEM punctuation (US-layout positions) ---
Semicolon => 0xBA, // VK_OEM_1
Equal => 0xBB, // VK_OEM_PLUS
Comma => 0xBC, // VK_OEM_COMMA
Minus => 0xBD, // VK_OEM_MINUS
Period => 0xBE, // VK_OEM_PERIOD
Slash => 0xBF, // VK_OEM_2
Backquote => 0xC0, // VK_OEM_3
BracketLeft => 0xDB, // VK_OEM_4
Backslash => 0xDC, // VK_OEM_5
BracketRight => 0xDD, // VK_OEM_6
Quote => 0xDE, // VK_OEM_7
IntlBackslash => 0xE2, // VK_OEM_102 (the 102nd key)
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Spot-check positions against the Windows VK constants the host's `vk_to_evdev` knows.
#[test]
fn maps_known_positions() {
assert_eq!(keycode_to_vk(KeyCode::KeyA), Some(0x41));
assert_eq!(keycode_to_vk(KeyCode::KeyZ), Some(0x5A));
assert_eq!(keycode_to_vk(KeyCode::Digit0), Some(0x30));
assert_eq!(keycode_to_vk(KeyCode::Escape), Some(0x1B));
assert_eq!(keycode_to_vk(KeyCode::F11), Some(0x7A));
assert_eq!(keycode_to_vk(KeyCode::ShiftLeft), Some(0xA0));
assert_eq!(keycode_to_vk(KeyCode::IntlBackslash), Some(0xE2));
assert_eq!(keycode_to_vk(KeyCode::Numpad9), Some(0x69));
// A key outside the wire contract is dropped, not guessed.
assert_eq!(keycode_to_vk(KeyCode::AudioVolumeMute), None);
}
}
+170 -40
View File
@@ -1,19 +1,30 @@
//! `punktfunk-client` — the native Windows punktfunk/1 client.
//!
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) ·
//! FFmpeg decode · WASAPI audio · SDL3 gamepads · a winit + Direct3D11 present surface. The
//! trust surface mirrors the other native clients: persistent identity, TOFU prompt with the
//! host fingerprint, SPAKE2 PIN pairing.
//! FFmpeg decode · WASAPI audio · SDL3 gamepads · a winit window + Direct3D11 flip-model
//! swapchain present surface. The trust surface mirrors the other native clients: persistent
//! identity, trust-on-first-use, SPAKE2 PIN pairing.
//!
//! Until the UI shell lands, the binary runs **headless** (`--connect host[:port]`): connect,
//! decode, play audio, and print per-second stats — the Windows analogue of
//! `punktfunk-client-rs`, for validating the protocol/decode path against a live host.
//! Usage:
//! punktfunk-client --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
//! [--bitrate MBPS] [--mic]
//! punktfunk-client --headless --connect … (no window; count frames + print stats)
//!
//! Trust: an explicit `--pin HEX` (or a host already pinned in the known-hosts store) connects
//! silently; `--pair PIN` runs the SPAKE2 ceremony first; otherwise the connect is
//! trust-on-first-use (the observed fingerprint is pinned on success).
#[cfg(windows)]
mod app;
#[cfg(windows)]
mod audio;
#[cfg(windows)]
mod discovery;
#[cfg(windows)]
mod keymap;
#[cfg(windows)]
mod present;
#[cfg(windows)]
mod session;
#[cfg(windows)]
mod trust;
@@ -23,7 +34,6 @@ mod video;
#[cfg(windows)]
fn main() {
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::time::{Duration, Instant};
tracing_subscriber::fmt()
.with_env_filter(
@@ -38,39 +48,63 @@ fn main() {
.and_then(|i| args.get(i + 1))
.cloned()
};
let flag = |name: &str| args.iter().any(|a| a == name);
if flag("--discover") {
discover_and_print();
return;
}
let Some(target) = arg("--connect") else {
eprintln!(
"punktfunk-client (headless): --connect host[:port] [--pin HEX] [--mode WxHxHz] \
[--bitrate MBPS] [--mic]\n\
The windowed UI is not wired yet; this runs the protocol/decode path headless."
"punktfunk-client: --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz] \
[--bitrate MBPS] [--mic] [--headless]\n\
punktfunk-client --discover (list punktfunk hosts on the LAN)"
);
std::process::exit(2);
};
// Saved settings supply defaults when a CLI flag is absent (the GUI host-list/settings
// chrome is a follow-up; until then these are the persisted preferences). A CLI flag both
// overrides and is written back, so the next bare run reuses it.
let mut settings = trust::Settings::load();
let (host, port) = match target.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (target.clone(), 9777u16),
};
let mode = arg("--mode")
.and_then(|m| {
let mut it = m.split(['x', 'X']);
Some(Mode {
width: it.next()?.parse().ok()?,
height: it.next()?.parse().ok()?,
refresh_hz: it.next()?.parse().ok()?,
})
})
.unwrap_or(Mode {
width: 1280,
height: 720,
// CLI overrides fold into the persisted settings, then we derive the effective values.
if let Some(m) = arg("--mode").and_then(|m| {
let mut it = m.split(['x', 'X']);
Some((
it.next()?.parse::<u32>().ok()?,
it.next()?.parse::<u32>().ok()?,
it.next()?.parse::<u32>().ok()?,
))
}) {
(settings.width, settings.height, settings.refresh_hz) = m;
}
if let Some(b) = arg("--bitrate").and_then(|b| b.parse::<u32>().ok()) {
settings.bitrate_kbps = b * 1000;
}
if flag("--mic") {
settings.mic_enabled = true;
}
settings.save();
let mode = if settings.width != 0 && settings.refresh_hz != 0 {
Mode {
width: settings.width,
height: settings.height,
refresh_hz: settings.refresh_hz,
}
} else {
Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
});
let pin = arg("--pin").and_then(|h| trust::parse_hex32(&h));
let bitrate_kbps = arg("--bitrate")
.and_then(|b| b.parse::<u32>().ok())
.map(|m| m * 1000)
.unwrap_or(0);
let mic_enabled = args.iter().any(|a| a == "--mic");
}
};
let bitrate_kbps = settings.bitrate_kbps;
let mic_enabled = settings.mic_enabled;
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
@@ -80,9 +114,48 @@ fn main() {
}
};
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), "connecting (headless)");
// Resolve trust: explicit pin > already-pinned host > pairing ceremony > TOFU.
let known = trust::KnownHosts::load();
let mut pin = arg("--pin")
.and_then(|h| trust::parse_hex32(&h))
.or_else(|| {
known
.find_by_addr(&host, port)
.and_then(|k| trust::parse_hex32(&k.fp_hex))
});
if let Some(code) = arg("--pair") {
let name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
match punktfunk_core::client::NativeClient::pair(
&host,
port,
(&identity.0, &identity.1),
code.trim(),
&name,
std::time::Duration::from_secs(90),
) {
Ok(fp) => {
let mut k = trust::KnownHosts::load();
k.upsert(trust::KnownHost {
name: host.clone(),
addr: host.clone(),
port,
fp_hex: trust::hex(&fp),
paired: true,
});
let _ = k.save();
tracing::info!(fp = %trust::hex(&fp), "paired");
pin = Some(fp);
}
Err(e) => {
eprintln!("Pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
std::process::exit(1);
}
}
}
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), "connecting");
let handle = session::start(session::SessionParams {
host,
host: host.clone(),
port,
mode,
compositor: CompositorPref::Auto,
@@ -93,8 +166,28 @@ fn main() {
identity,
});
// Headless consumer: drain events + frames, print stats, run until the host ends or
// ~60 s elapse (the harness bound). Frames are counted and dropped (no present yet).
if flag("--headless") {
run_headless(handle);
return;
}
let info = app::ConnectInfo {
name: host.clone(),
addr: host,
port,
tofu: pin.is_none(),
};
if let Err(e) = app::WinApp::new(handle, info, true).run() {
tracing::error!(error = %e, "windowed app failed");
std::process::exit(1);
}
}
/// Headless runner (`--headless`): drain events + frames, print stats, exit when the host
/// ends or the harness deadline elapses — the Windows analogue of `punktfunk-client-rs`.
#[cfg(windows)]
fn run_headless(handle: session::SessionHandle) {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(60);
let mut frames_seen = 0u64;
loop {
@@ -102,11 +195,7 @@ fn main() {
match ev {
session::SessionEvent::Connected {
mode, fingerprint, ..
} => tracing::info!(
?mode,
fp = %trust::hex(&fingerprint),
"connected"
),
} => tracing::info!(?mode, fp = %trust::hex(&fingerprint), "connected"),
session::SessionEvent::Stats(s) => tracing::info!(
fps = format!("{:.0}", s.fps),
mbps = format!("{:.1}", s.mbps),
@@ -115,8 +204,16 @@ fn main() {
frames_seen,
"stats"
),
session::SessionEvent::Failed { msg, .. } => {
tracing::error!(%msg, "connect failed");
session::SessionEvent::Failed {
msg,
trust_rejected,
} => {
tracing::error!(%msg, trust_rejected, "connect failed");
if trust_rejected {
tracing::error!(
"host fingerprint changed or pairing required — re-pair with --pair PIN"
);
}
return;
}
session::SessionEvent::Ended(err) => {
@@ -137,6 +234,39 @@ fn main() {
}
}
/// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit — the
/// CLI analogue of the GTK client's discovered-hosts list.
#[cfg(windows)]
fn discover_and_print() {
use std::time::{Duration, Instant};
println!("Browsing the LAN for punktfunk hosts (~5 s)…");
let rx = discovery::browse();
let deadline = Instant::now() + Duration::from_secs(5);
let mut seen = std::collections::HashSet::new();
while Instant::now() < deadline {
while let Ok(h) = rx.try_recv() {
if seen.insert(h.key.clone()) {
println!(
" {} {}:{} pair={} fp={}",
h.name,
h.addr,
h.port,
if h.pair.is_empty() {
"optional"
} else {
&h.pair
},
if h.fp_hex.is_empty() { "-" } else { &h.fp_hex },
);
}
}
std::thread::sleep(Duration::from_millis(100));
}
if seen.is_empty() {
println!(" (none found — is a host running with --native / m3-host?)");
}
}
/// Win32/Direct3D11/WASAPI/SDL3 are Windows turf; this stub keeps `cargo build --workspace`
/// green on Linux/macOS (the other native clients live in crates/punktfunk-client-linux and
/// clients/apple).
@@ -0,0 +1,361 @@
//! Direct3D11 presenter: upload a decoded `CpuFrame` (RGBA) into a dynamic texture and draw
//! it Contain-fit into a flip-model swapchain bound to the window's HWND, then present.
//!
//! The device prefers a hardware adapter and falls back to **WARP** (the GPU-less dev box
//! runs the whole present path in software). The draw is a single full-screen triangle
//! sampling the video texture; a letterbox is produced by clearing the back buffer black and
//! setting the viewport to the Contain-fit rect (no per-frame vertex buffer). This is the
//! SDR 8-bit path; the 10-bit/HDR present (`R10G10B10A2` + `SetColorSpace1(...G2084_P2020)`)
//! is a follow-up alongside the P010 D3D11VA decode.
use crate::video::CpuFrame;
use anyhow::{anyhow, Context, Result};
use windows::core::{Interface, PCSTR};
use windows::Win32::Foundation::{HMODULE, HWND};
use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3};
use windows::Win32::Graphics::Direct3D::{
ID3DBlob, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP, D3D_FEATURE_LEVEL_11_0,
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST,
};
use windows::Win32::Graphics::Direct3D11::*;
use windows::Win32::Graphics::Dxgi::Common::*;
use windows::Win32::Graphics::Dxgi::*;
const SHADER_HLSL: &str = r#"
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
VSOut vs_main(uint vid : SV_VertexID) {
float2 uv = float2((vid << 1) & 2, vid & 2);
VSOut o;
o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
o.uv = uv;
return o;
}
Texture2D tex : register(t0);
SamplerState smp : register(s0);
float4 ps_main(VSOut i) : SV_Target { return tex.Sample(smp, i.uv); }
"#;
pub struct Renderer {
device: ID3D11Device,
context: ID3D11DeviceContext,
vs: ID3D11VertexShader,
ps: ID3D11PixelShader,
sampler: ID3D11SamplerState,
/// Video texture + its SRV + dimensions; recreated when the decoded size changes.
tex: Option<(ID3D11Texture2D, ID3D11ShaderResourceView, u32, u32)>,
}
impl Renderer {
pub fn new() -> Result<Renderer> {
let (device, context) = create_device()?;
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
let ps_blob = compile(SHADER_HLSL, "ps_main", "ps_5_0")?;
let (vs, ps) = unsafe {
let mut vs = None;
device
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
.context("CreateVertexShader")?;
let mut ps = None;
device
.CreatePixelShader(blob_bytes(&ps_blob), None, Some(&mut ps))
.context("CreatePixelShader")?;
(vs.unwrap(), ps.unwrap())
};
let sampler = unsafe {
let desc = D3D11_SAMPLER_DESC {
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
MaxLOD: D3D11_FLOAT32_MAX,
..Default::default()
};
let mut s = None;
device
.CreateSamplerState(&desc, Some(&mut s))
.context("CreateSamplerState")?;
s.unwrap()
};
Ok(Renderer {
device,
context,
vs,
ps,
sampler,
tex: None,
})
}
pub fn device(&self) -> &ID3D11Device {
&self.device
}
/// Upload one decoded RGBA frame, recreating the GPU texture if the size changed.
pub fn upload(&mut self, frame: &CpuFrame) -> Result<()> {
let (w, h) = (frame.width, frame.height);
let need_new = !matches!(&self.tex, Some((_, _, tw, th)) if *tw == w && *th == h);
if need_new {
let desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
MiscFlags: 0,
};
let texture = unsafe {
let mut t = None;
self.device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D")?;
t.unwrap()
};
let srv = unsafe {
let mut s = None;
self.device
.CreateShaderResourceView(&texture, None, Some(&mut s))
.context("CreateShaderResourceView")?;
s.unwrap()
};
self.tex = Some((texture, srv, w, h));
}
let (texture, _, _, _) = self.tex.as_ref().unwrap();
unsafe {
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context
.Map(texture, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
.context("Map video texture")?;
let dst = mapped.pData as *mut u8;
let dst_pitch = mapped.RowPitch as usize;
let src_pitch = frame.stride;
let row_bytes = (w as usize) * 4;
for y in 0..h as usize {
std::ptr::copy_nonoverlapping(
frame.rgba.as_ptr().add(y * src_pitch),
dst.add(y * dst_pitch),
row_bytes.min(src_pitch),
);
}
self.context.Unmap(texture, 0);
}
Ok(())
}
/// Clear the target black and draw the current video texture Contain-fit into the window.
pub fn draw(
&self,
rtv: &ID3D11RenderTargetView,
win_w: u32,
win_h: u32,
vid_w: u32,
vid_h: u32,
) {
let Some((_, srv, _, _)) = &self.tex else {
return;
};
// Contain-fit: scale to the smaller axis, centre, letterbox the rest.
let (ww, wh, vw, vh) = (
win_w as f32,
win_h as f32,
vid_w.max(1) as f32,
vid_h.max(1) as f32,
);
let scale = (ww / vw).min(wh / vh);
let (dw, dh) = (vw * scale, vh * scale);
let (ox, oy) = ((ww - dw) / 2.0, (wh - dh) / 2.0);
unsafe {
let c = &self.context;
c.ClearRenderTargetView(rtv, &[0.0, 0.0, 0.0, 1.0]);
c.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None);
let vp = D3D11_VIEWPORT {
TopLeftX: ox,
TopLeftY: oy,
Width: dw,
Height: dh,
MinDepth: 0.0,
MaxDepth: 1.0,
};
c.RSSetViewports(Some(&[vp]));
c.IASetInputLayout(None);
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
c.VSSetShader(&self.vs, None);
c.PSSetShader(&self.ps, None);
c.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
c.Draw(3, 0);
}
}
}
/// A flip-model swapchain bound to a window HWND, with a lazily-(re)built render-target view.
pub struct SwapChain {
swap: IDXGISwapChain1,
device: ID3D11Device,
rtv: Option<ID3D11RenderTargetView>,
pub width: u32,
pub height: u32,
}
impl SwapChain {
pub fn new(device: &ID3D11Device, hwnd: HWND, width: u32, height: u32) -> Result<SwapChain> {
let dxdev: IDXGIDevice = device.cast().context("IDXGIDevice cast")?;
let factory: IDXGIFactory2 = unsafe {
let adapter = dxdev.GetAdapter().context("GetAdapter")?;
adapter.GetParent().context("GetParent (IDXGIFactory2)")?
};
let desc = DXGI_SWAP_CHAIN_DESC1 {
Width: width.max(1),
Height: height.max(1),
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
Stereo: false.into(),
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
BufferCount: 2,
Scaling: DXGI_SCALING_STRETCH,
SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD,
AlphaMode: DXGI_ALPHA_MODE_IGNORE,
Flags: 0,
};
let swap = unsafe {
factory
.CreateSwapChainForHwnd(device, hwnd, &desc, None, None)
.context("CreateSwapChainForHwnd")?
};
Ok(SwapChain {
swap,
device: device.clone(),
rtv: None,
width: width.max(1),
height: height.max(1),
})
}
/// Resize the back buffers (window resize); drops the stale RTV so it rebuilds lazily.
pub fn resize(&mut self, width: u32, height: u32) -> Result<()> {
if width == 0 || height == 0 || (width == self.width && height == self.height) {
return Ok(());
}
self.rtv = None; // must release all back-buffer references before ResizeBuffers
unsafe {
self.swap
.ResizeBuffers(
0,
width,
height,
DXGI_FORMAT_UNKNOWN,
DXGI_SWAP_CHAIN_FLAG(0),
)
.context("ResizeBuffers")?;
}
self.width = width;
self.height = height;
Ok(())
}
/// The current back-buffer render-target view (built on first use after create/resize).
pub fn rtv(&mut self) -> Result<ID3D11RenderTargetView> {
if self.rtv.is_none() {
let back: ID3D11Texture2D = unsafe { self.swap.GetBuffer(0).context("GetBuffer")? };
let rtv = unsafe {
let mut v = None;
self.device
.CreateRenderTargetView(&back, None, Some(&mut v))
.context("CreateRenderTargetView")?;
v.unwrap()
};
self.rtv = Some(rtv);
}
Ok(self.rtv.clone().unwrap())
}
/// Present the back buffer (vsync on — a stream is host-paced, tearing-free wins here).
pub fn present(&self) {
unsafe {
let _ = self.swap.Present(1, DXGI_PRESENT(0));
}
}
}
fn create_device() -> Result<(ID3D11Device, ID3D11DeviceContext)> {
for driver in [D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP] {
let mut device = None;
let mut context = None;
let r = unsafe {
D3D11CreateDevice(
None,
driver,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
};
if r.is_ok() {
let driver_name = if driver == D3D_DRIVER_TYPE_HARDWARE {
"hardware"
} else {
"WARP (software)"
};
tracing::info!(driver = driver_name, "D3D11 device created");
return Ok((device.unwrap(), context.unwrap()));
}
}
Err(anyhow!(
"D3D11CreateDevice failed for both hardware and WARP"
))
}
fn compile(src: &str, entry: &str, target: &str) -> Result<ID3DBlob> {
let entry_c = std::ffi::CString::new(entry).unwrap();
let target_c = std::ffi::CString::new(target).unwrap();
let mut code = None;
let mut errors = None;
let r = unsafe {
D3DCompile(
src.as_ptr() as *const _,
src.len(),
PCSTR::null(),
None,
None,
PCSTR(entry_c.as_ptr() as *const u8),
PCSTR(target_c.as_ptr() as *const u8),
D3DCOMPILE_OPTIMIZATION_LEVEL3,
0,
&mut code,
Some(&mut errors),
)
};
if r.is_err() {
let msg = errors
.as_ref()
.map(|b| unsafe {
let p = b.GetBufferPointer() as *const u8;
let n = b.GetBufferSize();
String::from_utf8_lossy(std::slice::from_raw_parts(p, n)).to_string()
})
.unwrap_or_default();
return Err(anyhow!("D3DCompile {entry}: {msg}"));
}
code.ok_or_else(|| anyhow!("D3DCompile produced no bytecode"))
}
fn blob_bytes(blob: &ID3DBlob) -> &[u8] {
unsafe {
let p = blob.GetBufferPointer() as *const u8;
let n = blob.GetBufferSize();
std::slice::from_raw_parts(p, n)
}
}
@@ -84,6 +84,10 @@ impl KnownHosts {
Ok(())
}
// Used by the GUI host-list's pinned-fingerprint trust decision (the silent-reconnect
// path); the current CLI trust flow keys on address. Kept for parity with the other
// clients' known-hosts API — wired when the discovered-hosts UI lands.
#[allow(dead_code)]
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
}