feat(windows-client): WinUI 3 (windows-reactor) UI — host list, settings, pairing, SwapChainPanel present
audit / cargo-audit (push) Failing after 1m5s
apple / swift (push) Successful in 3m37s
ci / rust (push) Failing after 3m46s
android / android (push) Successful in 5m20s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 27s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish (push) Successful in 9m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m38s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m10s
flatpak / build-publish (push) Failing after 4m55s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 20s
audit / cargo-audit (push) Failing after 1m5s
apple / swift (push) Successful in 3m37s
ci / rust (push) Failing after 3m46s
android / android (push) Successful in 5m20s
ci / web (push) Successful in 33s
ci / docs-site (push) Successful in 27s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish (push) Successful in 9m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m38s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m10s
flatpak / build-publish (push) Failing after 4m55s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m36s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 20s
Replaces the winit + raw-HWND-D3D11 shell with a native WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). The earlier "Reactor can't host a swapchain" read was wrong — PR #4499 (merged 2026-06-01) added a SwapChainPanel widget with `set_swap_chain` over `CreateSwapChainForComposition`. Builds + clippy + fmt green on x86_64-pc-windows-msvc. - Cargo: drop winit/raw-window-handle; add windows-reactor + the `windows` crate, both pinned to the SAME windows-rs commit (b4129fcc) so the `IDXGISwapChain1` handed to `set_swap_chain` satisfies reactor's `windows_core::Interface`. Reactor's build.rs downloads the Windows App SDK NuGets + stages the bootstrap DLL/resources.pri — it requires `CARGO_WORKSPACE_DIR` set (now in the VM build env); /temp + /winmd gitignored. - present.rs: composition swapchain (B8G8R8A8 FLIP_SEQUENTIAL premultiplied) bound to the SwapChainPanel; WARP fallback, runtime D3DCompile shaders, dynamic RGBA texture, Contain-fit letterbox; driven by reactor's per-frame `on_rendering`. - app.rs: the WinUI 3 shell — host list (live mDNS + saved + manual), settings (resolution/ refresh/mic combos+toggle), in-app SPAKE2 PIN pairing screen, and the stream page. Trust gate mirrors the GTK client (pinned → silent, pair=optional → TOFU, else PIN); a pinned-fp mismatch routes to re-pair. The session pump + decoded-frame handoff cross to the UI thread via a Mutex side-channel + thread-locals (the SwapChainPanel sample's pattern). - gamepad: `ctl` sender now `Arc<Mutex<…>>` so GamepadService is `Sync` (shared across the UI and session-pump threads). main.rs: windowed = in-app UI; `--headless`/`--discover` keep the CLI paths. Not yet wired: raw stream keyboard/mouse input (next commit — reactor exposes no raw key/ pointer events, so it needs Win32 low-level hooks or Microsoft.UI.Xaml bindings). On-glass validation pending a display (the dev VM is headless/GPU-less). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,3 +16,7 @@ xcuserdata/
|
||||
|
||||
# Debian package build output
|
||||
/dist/
|
||||
|
||||
# Windows App SDK staging by windows-reactor build.rs
|
||||
/temp/
|
||||
/winmd/
|
||||
|
||||
Generated
+190
-884
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "punktfunk-client-windows"
|
||||
description = "Native Windows punktfunk/1 client — winit/D3D11 shell, FFmpeg decode, WASAPI audio, SDL3 gamepads"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, D3D11/SwapChainPanel present, FFmpeg decode, WASAPI audio, SDL3 gamepads"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -21,28 +21,23 @@ 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 = [
|
||||
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||
# next to the exe; it requires `CARGO_WORKSPACE_DIR` to be set in the build env. Unpublished
|
||||
# (version 0.0.0) and fast-moving, so pinned to a verified commit.
|
||||
windows-reactor = { git = "https://github.com/microsoft/windows-rs", rev = "b4129fcc1ae81eec8bf1217539883db821bca3a1" }
|
||||
# Win32 / Direct3D11 / DXGI for the SwapChainPanel composition swapchain. Pulled from the SAME
|
||||
# windows-rs commit as windows-reactor so their `windows-core` unifies — the `IDXGISwapChain1`
|
||||
# we hand to `SwapChainPanelHandle::set_swap_chain` must satisfy reactor's `windows_core::Interface`.
|
||||
windows = { git = "https://github.com/microsoft/windows-rs", rev = "b4129fcc1ae81eec8bf1217539883db821bca3a1", 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"
|
||||
|
||||
@@ -1,440 +1,602 @@
|
||||
//! 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.
|
||||
//! The WinUI 3 (windows-reactor) application shell — host list, settings, PIN/TOFU pairing, and
|
||||
//! the stream page (a `SwapChainPanel` bound to the D3D11 composition swapchain in
|
||||
//! [`crate::present`], driven by reactor's per-frame `on_rendering`).
|
||||
//!
|
||||
//! 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).
|
||||
//! Declarative React-like model: a single root component routes on a `Screen` value held in
|
||||
//! `use_async_state` so background threads (discovery, the session pump) can drive navigation.
|
||||
//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel
|
||||
//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame
|
||||
//! present must not go through state/rerender.
|
||||
|
||||
use crate::keymap;
|
||||
use crate::present::{Renderer, SwapChain};
|
||||
use crate::session::{SessionEvent, SessionHandle};
|
||||
use crate::trust::{KnownHost, KnownHosts};
|
||||
use crate::discovery::{self, DiscoveredHost};
|
||||
use crate::gamepad::GamepadService;
|
||||
use crate::present::Presenter;
|
||||
use crate::session::{self, SessionEvent, SessionParams};
|
||||
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
||||
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;
|
||||
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};
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows_reactor::*;
|
||||
|
||||
/// 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,
|
||||
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
(0, 0),
|
||||
(1280, 720),
|
||||
(1920, 1080),
|
||||
(2560, 1440),
|
||||
(3840, 2160),
|
||||
];
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Screen {
|
||||
Hosts,
|
||||
Connecting,
|
||||
Stream,
|
||||
Settings,
|
||||
Pair,
|
||||
}
|
||||
|
||||
pub struct WinApp {
|
||||
handle: SessionHandle,
|
||||
info: ConnectInfo,
|
||||
/// App-lifetime SDL gamepad service: per-session capture + rumble/HID feedback.
|
||||
gamepad: crate::gamepad::GamepadService,
|
||||
|
||||
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>,
|
||||
/// The host we're about to connect to / pair with (carried into the Pair screen).
|
||||
#[derive(Clone, Default)]
|
||||
struct Target {
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp_hex: Option<String>,
|
||||
pair_optional: bool,
|
||||
}
|
||||
|
||||
impl WinApp {
|
||||
pub fn new(
|
||||
handle: SessionHandle,
|
||||
info: ConnectInfo,
|
||||
gamepad: crate::gamepad::GamepadService,
|
||||
) -> WinApp {
|
||||
WinApp {
|
||||
handle,
|
||||
info,
|
||||
/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver.
|
||||
struct PresentCtx {
|
||||
presenter: Presenter,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static PRESENT: RefCell<Option<PresentCtx>> = const { RefCell::new(None) };
|
||||
static PENDING_FRAMES: RefCell<Option<async_channel::Receiver<DecodedFrame>>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
handoff: Mutex<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
|
||||
target: Mutex<Target>,
|
||||
}
|
||||
|
||||
pub struct AppCtx {
|
||||
identity: (String, String),
|
||||
settings: Mutex<Settings>,
|
||||
gamepad: GamepadService,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
settings: Mutex::new(Settings::load()),
|
||||
gamepad,
|
||||
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(),
|
||||
}
|
||||
shared: Arc::new(Shared::default()),
|
||||
});
|
||||
App::new()
|
||||
.title("Punktfunk")
|
||||
.inner_size(1100.0, 720.0)
|
||||
.render(move |cx| root(cx, &ctx))
|
||||
}
|
||||
|
||||
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 root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
|
||||
let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
|
||||
let (status, set_status) = cx.use_async_state(String::new());
|
||||
|
||||
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,
|
||||
// Continuous LAN discovery (spawned once).
|
||||
cx.use_effect((), {
|
||||
let set_hosts = set_hosts.clone();
|
||||
move || {
|
||||
let rx = discovery::browse();
|
||||
std::thread::spawn(move || {
|
||||
let mut acc: Vec<DiscoveredHost> = Vec::new();
|
||||
while let Ok(h) = rx.recv_blocking() {
|
||||
if let Some(e) = acc.iter_mut().find(|e| e.key == h.key) {
|
||||
*e = h;
|
||||
} else {
|
||||
acc.push(h);
|
||||
}
|
||||
set_hosts.call(acc.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
match screen {
|
||||
Screen::Hosts => hosts_page(cx, ctx, &hosts, &status, &set_screen, &set_status),
|
||||
Screen::Connecting => vstack((
|
||||
text_block("Connecting…").font_size(20.0),
|
||||
text_block(status.clone()),
|
||||
))
|
||||
.spacing(12.0)
|
||||
.into(),
|
||||
Screen::Settings => settings_page(ctx, &set_screen),
|
||||
Screen::Pair => pair_page(cx, ctx, &set_screen, &set_status),
|
||||
Screen::Stream => stream_page(cx, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
fn hosts_page(
|
||||
cx: &mut RenderCx,
|
||||
ctx: &Arc<AppCtx>,
|
||||
hosts: &[DiscoveredHost],
|
||||
status: &str,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) -> Element {
|
||||
let (manual, set_manual) = cx.use_state(String::new());
|
||||
let known = KnownHosts::load();
|
||||
|
||||
let mut rows: Vec<Element> = Vec::new();
|
||||
rows.push(text_block("Punktfunk").font_size(28.0).bold().into());
|
||||
|
||||
// Saved (trusted/paired) hosts.
|
||||
if !known.hosts.is_empty() {
|
||||
rows.push(text_block("Saved hosts").font_size(16.0).bold().into());
|
||||
for k in &known.hosts {
|
||||
let t = Target {
|
||||
name: k.name.clone(),
|
||||
addr: k.addr.clone(),
|
||||
port: k.port,
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
rows.push(
|
||||
button(format!(
|
||||
"{} · {}:{} · {}",
|
||||
k.name,
|
||||
k.addr,
|
||||
k.port,
|
||||
if k.paired { "paired" } else { "trusted" }
|
||||
))
|
||||
.on_click(move || initiate(&ctx2, t.clone(), &ss, &st))
|
||||
.into(),
|
||||
);
|
||||
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;
|
||||
// Discovered hosts.
|
||||
rows.push(
|
||||
text_block("Hosts on this network")
|
||||
.font_size(16.0)
|
||||
.bold()
|
||||
.into(),
|
||||
);
|
||||
if hosts.is_empty() {
|
||||
rows.push(text_block("Searching the LAN…").into());
|
||||
}
|
||||
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);
|
||||
for h in hosts {
|
||||
let t = Target {
|
||||
name: h.name.clone(),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
pair_optional: h.pair == "optional",
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
rows.push(
|
||||
button(format!(
|
||||
"{} · {}:{} · pairing {}",
|
||||
h.name,
|
||||
h.addr,
|
||||
h.port,
|
||||
if h.pair.is_empty() {
|
||||
"optional"
|
||||
} else {
|
||||
w.set_fullscreen(Some(Fullscreen::Borderless(None)));
|
||||
&h.pair
|
||||
}
|
||||
))
|
||||
.on_click(move || initiate(&ctx2, t.clone(), &ss, &st))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Manual connection.
|
||||
rows.push(
|
||||
text_block("Manual connection")
|
||||
.font_size(16.0)
|
||||
.bold()
|
||||
.into(),
|
||||
);
|
||||
rows.push(
|
||||
text_box(manual.clone())
|
||||
.placeholder("host:port")
|
||||
.on_changed(move |s| set_manual.call(s))
|
||||
.into(),
|
||||
);
|
||||
{
|
||||
let (ctx2, ss, st, text) = (ctx.clone(), set_screen.clone(), set_status.clone(), manual);
|
||||
rows.push(
|
||||
button("Connect")
|
||||
.accent()
|
||||
.on_click(move || {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (addr, port) = match text.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
|
||||
None => (text.to_string(), 9777),
|
||||
};
|
||||
initiate(
|
||||
&ctx2,
|
||||
Target {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
},
|
||||
&ss,
|
||||
&st,
|
||||
);
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let ss = set_screen.clone();
|
||||
rows.push(
|
||||
button("Settings")
|
||||
.on_click(move || ss.call(Screen::Settings))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if !status.is_empty() {
|
||||
rows.push(text_block(status.to_string()).into());
|
||||
}
|
||||
|
||||
vstack(rows).spacing(8.0).into()
|
||||
}
|
||||
|
||||
/// The trust gate (mirrors the GTK client's `initiate_connect`): pinned fingerprint → silent
|
||||
/// connect; known address → stored pin; advertised `pair=optional` → TOFU; otherwise → PIN
|
||||
/// pairing.
|
||||
fn initiate(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
let known = KnownHosts::load();
|
||||
let pin = target
|
||||
.fp_hex
|
||||
.as_ref()
|
||||
.and_then(|fp| known.find_by_fp(fp).map(|_| fp.clone()))
|
||||
.or_else(|| {
|
||||
known
|
||||
.find_by_addr(&target.addr, target.port)
|
||||
.map(|k| k.fp_hex.clone())
|
||||
})
|
||||
.and_then(|fp| trust::parse_hex32(&fp));
|
||||
|
||||
if let Some(pin) = pin {
|
||||
connect(ctx, &target, Some(pin), set_screen, set_status);
|
||||
} else if target.pair_optional {
|
||||
connect(ctx, &target, None, set_screen, set_status); // TOFU
|
||||
} else {
|
||||
*ctx.shared.target.lock().unwrap() = target;
|
||||
set_screen.call(Screen::Pair);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
fn connect(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: &Target,
|
||||
pin: Option<[u8; 32]>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
||||
Mode {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
refresh_hz: s.refresh_hz,
|
||||
}
|
||||
} else {
|
||||
Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
}
|
||||
};
|
||||
let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
};
|
||||
let handle = session::start(SessionParams {
|
||||
host: target.addr.clone(),
|
||||
port: target.port,
|
||||
mode,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: gamepad_pref,
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
mic_enabled: s.mic_enabled,
|
||||
pin,
|
||||
identity: ctx.identity.clone(),
|
||||
});
|
||||
set_screen.call(Screen::Connecting);
|
||||
|
||||
let tofu = pin.is_none();
|
||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
match handle.events.recv_blocking() {
|
||||
Ok(SessionEvent::Connected {
|
||||
connector,
|
||||
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(),
|
||||
..
|
||||
}) => {
|
||||
if tofu {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: trust::hex(&fingerprint),
|
||||
paired: false,
|
||||
});
|
||||
let _ = known.save();
|
||||
tracing::info!(fp = %fp_hex, "trusted on first use — pinned");
|
||||
let _ = k.save();
|
||||
}
|
||||
if let Some(w) = &self.window {
|
||||
w.set_title(&format!(
|
||||
"Punktfunk — {} · {}×{}@{}",
|
||||
self.info.name, mode.width, mode.height, mode.refresh_hz
|
||||
));
|
||||
gamepad.attach(connector.clone());
|
||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
self.gamepad.attach(connector.clone());
|
||||
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 {
|
||||
Ok(SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => {
|
||||
tracing::error!(%msg, trust_rejected, "connect failed");
|
||||
}) => {
|
||||
st.call(msg);
|
||||
gamepad.detach();
|
||||
if trust_rejected {
|
||||
tracing::error!(
|
||||
"host fingerprint changed or pairing required — re-pair with --pair PIN"
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
} else {
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(SessionEvent::Ended(err)) => {
|
||||
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
Ok(SessionEvent::Stats(_)) => {}
|
||||
Err(_) => {
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn pair_page(
|
||||
cx: &mut RenderCx,
|
||||
ctx: &Arc<AppCtx>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) -> Element {
|
||||
let (code, set_code) = cx.use_state(String::new());
|
||||
let target = ctx.shared.target.lock().unwrap().clone();
|
||||
|
||||
let (ctx2, ss, st, code2, target2) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
code.clone(),
|
||||
target.clone(),
|
||||
);
|
||||
let pair_btn = button("Pair & Connect").accent().on_click(move || {
|
||||
let pin = code2.trim().to_string();
|
||||
let (ctx3, ss, st, target3) = (ctx2.clone(), ss.clone(), st.clone(), target2.clone());
|
||||
std::thread::spawn(move || {
|
||||
let name = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
|
||||
match NativeClient::pair(
|
||||
&target3.addr,
|
||||
target3.port,
|
||||
(&ctx3.identity.0, &ctx3.identity.1),
|
||||
&pin,
|
||||
&name,
|
||||
std::time::Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target3.name.clone(),
|
||||
addr: target3.addr.clone(),
|
||||
port: target3.port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
});
|
||||
let _ = k.save();
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
}
|
||||
self.gamepad.detach();
|
||||
event_loop.exit();
|
||||
return false;
|
||||
}
|
||||
SessionEvent::Ended(err) => {
|
||||
tracing::info!(reason = err.as_deref().unwrap_or("done"), "session ended");
|
||||
self.gamepad.detach();
|
||||
event_loop.exit();
|
||||
return false;
|
||||
Err(e) => {
|
||||
st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)"));
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let back = {
|
||||
let ss = set_screen.clone();
|
||||
button("Cancel").on_click(move || ss.call(Screen::Hosts))
|
||||
};
|
||||
|
||||
vstack((
|
||||
text_block(format!("Pair with {}", target.name))
|
||||
.font_size(22.0)
|
||||
.bold(),
|
||||
text_block("Arm pairing on the host (console or web UI), then enter the 4-digit PIN."),
|
||||
text_box(code)
|
||||
.placeholder("PIN")
|
||||
.on_changed(move |s| set_code.call(s)),
|
||||
hstack((pair_btn, back)).spacing(8.0),
|
||||
))
|
||||
.spacing(12.0)
|
||||
.into()
|
||||
}
|
||||
// Keep only the newest frame (freshness over completeness).
|
||||
|
||||
fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let res_i = RESOLUTIONS
|
||||
.iter()
|
||||
.position(|&(w, h)| w == s.width && h == s.height)
|
||||
.unwrap_or(0) as i32;
|
||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0) as i32;
|
||||
|
||||
let res_names: Vec<String> = RESOLUTIONS
|
||||
.iter()
|
||||
.map(|&(w, h)| {
|
||||
if w == 0 {
|
||||
"Native display".into()
|
||||
} else {
|
||||
format!("{w} × {h}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let hz_names: Vec<String> = REFRESH
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
if r == 0 {
|
||||
"Native".into()
|
||||
} else {
|
||||
format!("{r} Hz")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let res_combo = {
|
||||
let ctx = ctx.clone();
|
||||
ComboBox::new(res_names)
|
||||
.header("Resolution")
|
||||
.selected_index(res_i)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let (w, h) = RESOLUTIONS[(i.max(0) as usize).min(RESOLUTIONS.len() - 1)];
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
(s.width, s.height) = (w, h);
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let hz_combo = {
|
||||
let ctx = ctx.clone();
|
||||
ComboBox::new(hz_names)
|
||||
.header("Refresh rate")
|
||||
.selected_index(hz_i)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.refresh_hz = REFRESH[(i.max(0) as usize).min(REFRESH.len() - 1)];
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let mic_toggle = {
|
||||
let ctx = ctx.clone();
|
||||
check_box(s.mic_enabled)
|
||||
.label("Stream microphone to the host")
|
||||
.on_changed(move |on: bool| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.mic_enabled = on;
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let back = {
|
||||
let ss = set_screen.clone();
|
||||
button("Back")
|
||||
.accent()
|
||||
.on_click(move || ss.call(Screen::Hosts))
|
||||
};
|
||||
|
||||
vstack((
|
||||
text_block("Settings").font_size(28.0).bold(),
|
||||
res_combo,
|
||||
hz_combo,
|
||||
mic_toggle,
|
||||
back,
|
||||
))
|
||||
.spacing(12.0)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn present_newest(ctx: &mut PresentCtx) {
|
||||
let mut newest = None;
|
||||
while let Ok(f) = self.handle.frames.try_recv() {
|
||||
while let Ok(f) = ctx.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()
|
||||
let cpu = newest.as_ref().map(|DecodedFrame::Cpu(c)| c);
|
||||
ctx.presenter.present(cpu);
|
||||
}
|
||||
|
||||
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 stream_page(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// Take the connector + frames handoff once on mount; keep the connector alive (and for
|
||||
// input once that lands) in a use_ref, stash frames for `on_ready`.
|
||||
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||
cx.use_effect((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let connector_ref = connector_ref.clone();
|
||||
move || {
|
||||
if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
|
||||
connector_ref.set(Some(connector));
|
||||
PENDING_FRAMES.with(|c| *c.borrow_mut() = Some(frames));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
self.gamepad.detach();
|
||||
event_loop.exit();
|
||||
let rendering = cx.use_ref::<Option<Rendering>>(None);
|
||||
cx.use_effect((), {
|
||||
let rendering = rendering.clone();
|
||||
move || {
|
||||
if let Ok(r) = on_rendering(|| {
|
||||
PRESENT.with(|cell| {
|
||||
if let Some(ctx) = cell.borrow_mut().as_mut() {
|
||||
present_newest(ctx);
|
||||
}
|
||||
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() {
|
||||
// Track held state for flush-on-release; re-send on auto-repeat too (the
|
||||
// host treats KeyDown as a state set, so repeats are harmless).
|
||||
self.held_keys.insert(vk);
|
||||
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(),
|
||||
_ => {}
|
||||
});
|
||||
}) {
|
||||
rendering.set(Some(r));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
swap_chain_panel()
|
||||
.on_ready(|panel| match Presenter::new(1280, 720) {
|
||||
Ok(p) => {
|
||||
if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
|
||||
tracing::error!(error = %e, "set_swap_chain");
|
||||
}
|
||||
} else {
|
||||
// No frame this turn — yield briefly instead of spinning a core flat-out.
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) {
|
||||
PRESENT.with(|cell| {
|
||||
*cell.borrow_mut() = Some(PresentCtx {
|
||||
presenter: p,
|
||||
frames,
|
||||
});
|
||||
});
|
||||
tracing::info!("stream presenter bound to SwapChainPanel");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(error = %e, "create presenter"),
|
||||
})
|
||||
.on_resize(|w, h| {
|
||||
PRESENT.with(|cell| {
|
||||
if let Some(ctx) = cell.borrow_mut().as_mut() {
|
||||
ctx.presenter.resize(w as u32, h as u32);
|
||||
}
|
||||
});
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DiscoveredHost {
|
||||
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
||||
pub key: String,
|
||||
|
||||
@@ -51,7 +51,9 @@ pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active: Arc<Mutex<Option<PadInfo>>>,
|
||||
pinned: Arc<Mutex<Option<u32>>>,
|
||||
ctl: Sender<Ctl>,
|
||||
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
|
||||
// WinUI app shares it across the UI thread and the session-pump thread (attach/detach).
|
||||
ctl: Arc<Mutex<Sender<Ctl>>>,
|
||||
}
|
||||
|
||||
impl GamepadService {
|
||||
@@ -75,7 +77,7 @@ impl GamepadService {
|
||||
pads,
|
||||
active,
|
||||
pinned,
|
||||
ctl,
|
||||
ctl: Arc::new(Mutex::new(ctl)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,15 +97,15 @@ impl GamepadService {
|
||||
|
||||
#[allow(dead_code)] // consumed by the settings GUI (follow-up)
|
||||
pub fn set_pinned(&self, id: Option<u32>) {
|
||||
let _ = self.ctl.send(Ctl::Pin(id));
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Attach(connector));
|
||||
}
|
||||
|
||||
pub fn detach(&self) {
|
||||
let _ = self.ctl.send(Ctl::Detach);
|
||||
let _ = self.ctl.lock().unwrap().send(Ctl::Detach);
|
||||
}
|
||||
|
||||
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
//! `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 window + Direct3D11 flip-model
|
||||
//! swapchain present surface. The trust surface mirrors the other native clients: persistent
|
||||
//! identity, trust-on-first-use, SPAKE2 PIN pairing.
|
||||
//! Pure Rust: `NativeClient` linked as a crate (no C ABI, like the GTK Linux client) · FFmpeg
|
||||
//! decode · WASAPI audio · SDL3 gamepads · a **WinUI 3** shell (windows-reactor) with the video
|
||||
//! on a `SwapChainPanel` bound to a D3D11 composition swapchain. The trust surface mirrors the
|
||||
//! other native clients: persistent identity, trust-on-first-use, SPAKE2 PIN pairing — all in-app
|
||||
//! (host list, settings, pairing). `--headless` keeps a CLI connect path for tests/measurement.
|
||||
//!
|
||||
//! 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).
|
||||
//! punktfunk-client (open the WinUI 3 window: host list, settings, pairing)
|
||||
//! punktfunk-client --discover (list punktfunk hosts on the LAN)
|
||||
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
|
||||
//! [--bitrate MBPS] [--mic] (no window; count frames + print stats)
|
||||
|
||||
#[cfg(windows)]
|
||||
mod app;
|
||||
@@ -23,8 +21,6 @@ mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gamepad;
|
||||
#[cfg(windows)]
|
||||
mod keymap;
|
||||
#[cfg(windows)]
|
||||
mod present;
|
||||
#[cfg(windows)]
|
||||
mod session;
|
||||
@@ -35,8 +31,6 @@ mod video;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
@@ -44,12 +38,6 @@ fn main() {
|
||||
.init();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let arg = |name: &str| -> Option<String> {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let flag = |name: &str| args.iter().any(|a| a == name);
|
||||
|
||||
if flag("--discover") {
|
||||
@@ -57,57 +45,6 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(target) = arg("--connect") else {
|
||||
eprintln!(
|
||||
"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),
|
||||
};
|
||||
// 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 bitrate_kbps = settings.bitrate_kbps;
|
||||
let mic_enabled = settings.mic_enabled;
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
@@ -116,7 +53,61 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve trust: explicit pin > already-pinned host > pairing ceremony > TOFU.
|
||||
if flag("--headless") {
|
||||
run_headless_cli(&args, identity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Windowed (default): the WinUI 3 app owns host selection, settings, and pairing.
|
||||
let gamepad = gamepad::GamepadService::start();
|
||||
if let Err(e) = app::run(identity, gamepad) {
|
||||
tracing::error!(error = %e, "WinUI app failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `--headless --connect host[:port] …`: connect from the CLI, count frames, print stats — the
|
||||
/// Windows analogue of `punktfunk-client-rs`.
|
||||
#[cfg(windows)]
|
||||
fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let arg = |name: &str| -> Option<String> {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned()
|
||||
};
|
||||
let flag = |name: &str| args.iter().any(|a| a == name);
|
||||
|
||||
let Some(target) = arg("--connect") else {
|
||||
eprintln!("--headless requires --connect host[:port]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
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,
|
||||
refresh_hz: 60,
|
||||
});
|
||||
let bitrate_kbps = arg("--bitrate")
|
||||
.and_then(|b| b.parse::<u32>().ok())
|
||||
.map(|m| m * 1000)
|
||||
.unwrap_or(0);
|
||||
|
||||
let known = trust::KnownHosts::load();
|
||||
let mut pin = arg("--pin")
|
||||
.and_then(|h| trust::parse_hex32(&h))
|
||||
@@ -133,7 +124,7 @@ fn main() {
|
||||
(&identity.0, &identity.1),
|
||||
code.trim(),
|
||||
&name,
|
||||
std::time::Duration::from_secs(90),
|
||||
Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let mut k = trust::KnownHosts::load();
|
||||
@@ -149,59 +140,25 @@ fn main() {
|
||||
pin = Some(fp);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
|
||||
eprintln!("Pairing failed: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let headless = flag("--headless");
|
||||
// The app-lifetime gamepad service runs only for the windowed client; it also resolves the
|
||||
// "Automatic" pad type to whatever physical controller is attached (other-client parity).
|
||||
let gamepad_service = (!headless).then(gamepad::GamepadService::start);
|
||||
let gamepad_pref = match GamepadPref::from_name(&settings.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => gamepad_service
|
||||
.as_ref()
|
||||
.map_or(GamepadPref::Auto, |s| s.auto_pref()),
|
||||
Some(explicit) => explicit,
|
||||
};
|
||||
|
||||
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), "connecting");
|
||||
tracing::info!(%host, port, ?mode, tofu = pin.is_none(), "connecting (headless)");
|
||||
let handle = session::start(session::SessionParams {
|
||||
host: host.clone(),
|
||||
host,
|
||||
port,
|
||||
mode,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: gamepad_pref,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps,
|
||||
mic_enabled,
|
||||
mic_enabled: flag("--mic"),
|
||||
pin,
|
||||
identity,
|
||||
});
|
||||
|
||||
if headless {
|
||||
run_headless(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
let info = app::ConnectInfo {
|
||||
name: host.clone(),
|
||||
addr: host,
|
||||
port,
|
||||
tofu: pin.is_none(),
|
||||
};
|
||||
let gamepad_service = gamepad_service.expect("started for the windowed path");
|
||||
if let Err(e) = app::WinApp::new(handle, info, gamepad_service).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 {
|
||||
@@ -218,16 +175,8 @@ fn run_headless(handle: session::SessionHandle) {
|
||||
frames_seen,
|
||||
"stats"
|
||||
),
|
||||
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"
|
||||
);
|
||||
}
|
||||
session::SessionEvent::Failed { msg, .. } => {
|
||||
tracing::error!(%msg, "connect failed");
|
||||
return;
|
||||
}
|
||||
session::SessionEvent::Ended(err) => {
|
||||
@@ -248,8 +197,7 @@ fn run_headless(handle: session::SessionHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit — the
|
||||
/// CLI analogue of the GTK client's discovered-hosts list.
|
||||
/// `--discover`: browse the LAN for punktfunk hosts (mDNS) and print them, then exit.
|
||||
#[cfg(windows)]
|
||||
fn discover_and_print() {
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -281,9 +229,9 @@ fn discover_and_print() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// WinUI 3 / 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).
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
//! 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.
|
||||
//! Direct3D11 presenter for a WinUI 3 `SwapChainPanel`: upload a decoded `CpuFrame` (RGBA)
|
||||
//! into a dynamic texture and draw it Contain-fit into a **composition** flip-model swapchain,
|
||||
//! which the reactor stream page binds to the panel via `SwapChainPanelHandle::set_swap_chain`.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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). SDR 8-bit path; the
|
||||
//! 10-bit/HDR present (`R10G10B10A2` + `SetColorSpace1`) is a follow-up alongside P010 decode.
|
||||
//!
|
||||
//! All `windows` types here come from the same windows-rs commit as `windows-reactor`, so the
|
||||
//! `IDXGISwapChain1` handed to `set_swap_chain` satisfies reactor's `windows_core::Interface`.
|
||||
|
||||
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,
|
||||
@@ -35,63 +37,114 @@ SamplerState smp : register(s0);
|
||||
float4 ps_main(VSOut i) : SV_Target { return tex.Sample(smp, i.uv); }
|
||||
"#;
|
||||
|
||||
pub struct Renderer {
|
||||
pub struct Presenter {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
vs: ID3D11VertexShader,
|
||||
ps: ID3D11PixelShader,
|
||||
sampler: ID3D11SamplerState,
|
||||
/// Video texture + its SRV + dimensions; recreated when the decoded size changes.
|
||||
swap: IDXGISwapChain1,
|
||||
rtv: Option<ID3D11RenderTargetView>,
|
||||
/// Video texture + SRV + dimensions; recreated when the decoded size changes.
|
||||
tex: Option<(ID3D11Texture2D, ID3D11ShaderResourceView, u32, u32)>,
|
||||
/// Panel (swapchain) size in pixels, updated on resize.
|
||||
panel_w: u32,
|
||||
panel_h: u32,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn new() -> Result<Renderer> {
|
||||
impl Presenter {
|
||||
/// Create the D3D11 device + composition swapchain + shaders, sized to the panel.
|
||||
pub fn new(width: u32, height: u32) -> Result<Presenter> {
|
||||
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 {
|
||||
let (vs, ps, sampler) = build_pipeline(&device)?;
|
||||
let swap = create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
||||
Ok(Presenter {
|
||||
device,
|
||||
context,
|
||||
vs,
|
||||
ps,
|
||||
sampler,
|
||||
swap,
|
||||
rtv: None,
|
||||
tex: None,
|
||||
panel_w: width.max(1),
|
||||
panel_h: height.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn device(&self) -> &ID3D11Device {
|
||||
&self.device
|
||||
/// The DXGI swapchain to hand to `SwapChainPanelHandle::set_swap_chain`.
|
||||
pub fn swap_chain(&self) -> &IDXGISwapChain1 {
|
||||
&self.swap
|
||||
}
|
||||
|
||||
/// Upload one decoded RGBA frame, recreating the GPU texture if the size changed.
|
||||
pub fn upload(&mut self, frame: &CpuFrame) -> Result<()> {
|
||||
/// Resize the back buffers to the panel's new size (drops the stale RTV).
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
if width == 0 || height == 0 || (width == self.panel_w && height == self.panel_h) {
|
||||
return;
|
||||
}
|
||||
self.rtv = None; // release all back-buffer refs before ResizeBuffers
|
||||
unsafe {
|
||||
let _ = self.swap.ResizeBuffers(
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
DXGI_FORMAT_UNKNOWN,
|
||||
DXGI_SWAP_CHAIN_FLAG(0),
|
||||
);
|
||||
}
|
||||
self.panel_w = width;
|
||||
self.panel_h = height;
|
||||
}
|
||||
|
||||
/// Present one decoded frame (Contain-fit) — or, when `frame` is `None`, just re-present the
|
||||
/// last texture (or black). Called from the reactor `on_rendering` per-frame callback.
|
||||
pub fn present(&mut self, frame: Option<&CpuFrame>) {
|
||||
if let Some(f) = frame {
|
||||
if let Err(e) = self.upload(f) {
|
||||
tracing::warn!(error = %e, "frame upload failed");
|
||||
}
|
||||
}
|
||||
let Ok(rtv) = self.rtv() else {
|
||||
return;
|
||||
};
|
||||
let (pw, ph) = (self.panel_w, self.panel_h);
|
||||
unsafe {
|
||||
let c = &self.context;
|
||||
c.ClearRenderTargetView(&rtv, &[0.0, 0.0, 0.0, 1.0]);
|
||||
if let Some((_, srv, vw, vh)) = &self.tex {
|
||||
// Contain-fit viewport: scale to the smaller axis, centre, letterbox the rest.
|
||||
let (ww, wh, vfw, vfh) = (
|
||||
pw as f32,
|
||||
ph as f32,
|
||||
(*vw).max(1) as f32,
|
||||
(*vh).max(1) as f32,
|
||||
);
|
||||
let scale = (ww / vfw).min(wh / vfh);
|
||||
let (dw, dh) = (vfw * scale, vfh * scale);
|
||||
let (ox, oy) = ((ww - dw) / 2.0, (wh - dh) / 2.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);
|
||||
}
|
||||
let _ = self.swap.Present(1, DXGI_PRESENT(0));
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -148,122 +201,7 @@ impl Renderer {
|
||||
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> {
|
||||
fn rtv(&mut self) -> Result<ID3D11RenderTargetView> {
|
||||
if self.rtv.is_none() {
|
||||
let back: ID3D11Texture2D = unsafe { self.swap.GetBuffer(0).context("GetBuffer")? };
|
||||
let rtv = unsafe {
|
||||
@@ -277,13 +215,6 @@ impl SwapChain {
|
||||
}
|
||||
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)> {
|
||||
@@ -294,7 +225,7 @@ fn create_device() -> Result<(ID3D11Device, ID3D11DeviceContext)> {
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
driver,
|
||||
HMODULE::default(),
|
||||
None,
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
@@ -304,12 +235,12 @@ fn create_device() -> Result<(ID3D11Device, ID3D11DeviceContext)> {
|
||||
)
|
||||
};
|
||||
if r.is_ok() {
|
||||
let driver_name = if driver == D3D_DRIVER_TYPE_HARDWARE {
|
||||
let name = if driver == D3D_DRIVER_TYPE_HARDWARE {
|
||||
"hardware"
|
||||
} else {
|
||||
"WARP (software)"
|
||||
};
|
||||
tracing::info!(driver = driver_name, "D3D11 device created");
|
||||
tracing::info!(driver = name, "D3D11 device created");
|
||||
return Ok((device.unwrap(), context.unwrap()));
|
||||
}
|
||||
}
|
||||
@@ -318,6 +249,70 @@ fn create_device() -> Result<(ID3D11Device, ID3D11DeviceContext)> {
|
||||
))
|
||||
}
|
||||
|
||||
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`.
|
||||
fn create_composition_swapchain(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<IDXGISwapChain1> {
|
||||
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,
|
||||
Height: height,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_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_SEQUENTIAL,
|
||||
AlphaMode: DXGI_ALPHA_MODE_PREMULTIPLIED,
|
||||
Flags: 0,
|
||||
};
|
||||
unsafe {
|
||||
factory
|
||||
.CreateSwapChainForComposition(device, &desc, None)
|
||||
.context("CreateSwapChainForComposition")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_pipeline(
|
||||
device: &ID3D11Device,
|
||||
) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> {
|
||||
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
||||
let ps_blob = compile(SHADER_HLSL, "ps_main", "ps_5_0")?;
|
||||
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")?;
|
||||
let sdesc = 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 sampler = None;
|
||||
device
|
||||
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
||||
.context("CreateSamplerState")?;
|
||||
Ok((vs.unwrap(), ps.unwrap(), sampler.unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user