From e4bdec97bd13b8334a1545965fcbac22bee5702a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 21:59:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(windows-client):=20winit=20+=20D3D11=20pre?= =?UTF-8?q?sent,=20WASAPI=20render,=20input=20=E2=80=94=20builds=20live=20?= =?UTF-8?q?on=20MSVC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 889 +++++++++++++++++- crates/punktfunk-client-windows/Cargo.toml | 22 + crates/punktfunk-client-windows/src/app.rs | 438 +++++++++ crates/punktfunk-client-windows/src/audio.rs | 14 +- crates/punktfunk-client-windows/src/keymap.rs | 162 ++++ crates/punktfunk-client-windows/src/main.rs | 210 ++++- .../punktfunk-client-windows/src/present.rs | 361 +++++++ crates/punktfunk-client-windows/src/trust.rs | 4 + 8 files changed, 2025 insertions(+), 75 deletions(-) create mode 100644 crates/punktfunk-client-windows/src/app.rs create mode 100644 crates/punktfunk-client-windows/src/keymap.rs create mode 100644 crates/punktfunk-client-windows/src/present.rs diff --git a/Cargo.lock b/Cargo.lock index b50ce55..67cfc84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "aead" version = "0.5.2" @@ -37,6 +53,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -46,6 +75,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.0", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -144,6 +198,24 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -391,7 +463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "annotate-snippets", - "bitflags", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -418,6 +490,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -442,6 +520,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -466,7 +553,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -483,6 +570,32 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cast" version = "0.3.0" @@ -696,6 +809,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -712,6 +835,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -815,6 +962,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -893,6 +1046,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "displaydoc" version = "0.2.6" @@ -904,12 +1063,27 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dunce" version = "1.0.5" @@ -1027,7 +1201,7 @@ version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "ffmpeg-sys-next", "libc", ] @@ -1097,6 +1271,33 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1290,6 +1491,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1376,7 +1587,7 @@ version = "0.22.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" dependencies = [ - "bitflags", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -1793,6 +2004,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1924,13 +2165,25 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.8.1", +] + [[package]] name = "libspa" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cc", "convert_case", "cookie-factory", @@ -1952,6 +2205,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2079,7 +2338,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2088,6 +2347,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2103,7 +2368,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2241,6 +2506,209 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -2310,6 +2778,16 @@ dependencies = [ "audiopus_sys", ] +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2320,6 +2798,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "pango" version = "0.22.6" @@ -2368,7 +2855,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2404,6 +2891,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2417,7 +2924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "libc", "libspa", "libspa-sys", @@ -2465,6 +2972,26 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2528,7 +3055,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.13.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -2544,7 +3071,7 @@ name = "punktfunk-android" version = "0.0.1" dependencies = [ "android_logger", - "jni", + "jni 0.21.1", "log", "ndk", "opus", @@ -2595,11 +3122,14 @@ dependencies = [ "mdns-sd", "opus", "punktfunk-core", + "raw-window-handle", "serde", "serde_json", "tracing", "tracing-subscriber", "wasapi", + "windows", + "winit", ] [[package]] @@ -2896,13 +3426,31 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08ad765b21a08b1a8e5cdce052719188a23772bcbefb3c439f0baaf62c56ceac" +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.13.0", ] [[package]] @@ -2955,7 +3503,7 @@ dependencies = [ "enumflags2", "futures", "log", - "rustix", + "rustix 1.1.4", "tokio", ] @@ -3017,16 +3565,29 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3083,9 +3644,9 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -3176,19 +3737,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + [[package]] name = "sdl3" version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "sdl3-sys", ] @@ -3209,8 +3789,8 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.13.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3370,6 +3950,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3388,6 +3984,40 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.0", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket-pktinfo" version = "0.3.2" @@ -3440,6 +4070,12 @@ dependencies = [ "der", ] +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -3508,7 +4144,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3592,6 +4228,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -3834,6 +4495,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "typenum" version = "1.20.1" @@ -4065,6 +4732,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.123" @@ -4125,7 +4802,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -4139,7 +4816,8 @@ checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.4", + "scoped-tls", "smallvec", "wayland-sys", ] @@ -4150,19 +4828,41 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags", - "rustix", + "bitflags 2.13.0", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + [[package]] name = "wayland-protocols" version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -4174,7 +4874,20 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags", + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4187,7 +4900,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4211,9 +4924,22 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ + "dlib", + "log", + "once_cell", "pkg-config", ] +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -4615,6 +5341,58 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.0", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.7.15" @@ -4694,7 +5472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "indexmap", "log", "serde", @@ -4724,6 +5502,38 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x509-parser" version = "0.16.0" @@ -4741,6 +5551,12 @@ dependencies = [ "time", ] +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + [[package]] name = "xkbcommon" version = "0.8.0" @@ -4752,6 +5568,19 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + [[package]] name = "xkeysym" version = "0.2.1" @@ -4783,7 +5612,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tokio", diff --git a/crates/punktfunk-client-windows/Cargo.toml b/crates/punktfunk-client-windows/Cargo.toml index 870b5e8..c6e196a 100644 --- a/crates/punktfunk-client-windows/Cargo.toml +++ b/crates/punktfunk-client-windows/Cargo.toml @@ -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" diff --git a/crates/punktfunk-client-windows/src/app.rs b/crates/punktfunk-client-windows/src/app.rs new file mode 100644 index 0000000..b2242eb --- /dev/null +++ b/crates/punktfunk-client-windows/src/app.rs @@ -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>, + renderer: Option, + swap: Option, + + connector: Option>, + mode: Mode, + have_frame: bool, + + captured: bool, + modifiers: ModifiersState, + held_keys: HashSet, + held_buttons: HashSet, +} + +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::>() { + self.send(InputKind::KeyUp, vk as u32, 0, 0, 0); + } + for b in self.held_buttons.drain().collect::>() { + 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 { + 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 { + 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.) + } + } +} diff --git a/crates/punktfunk-client-windows/src/audio.rs b/crates/punktfunk-client-windows/src/audio.rs index 850faa1..07a7dc6 100644 --- a/crates/punktfunk-client-windows/src/audio.rs +++ b/crates/punktfunk-client-windows/src/audio.rs @@ -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, stop: Arc) -> 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); diff --git a/crates/punktfunk-client-windows/src/keymap.rs b/crates/punktfunk-client-windows/src/keymap.rs new file mode 100644 index 0000000..6969784 --- /dev/null +++ b/crates/punktfunk-client-windows/src/keymap.rs @@ -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 { + 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); + } +} diff --git a/crates/punktfunk-client-windows/src/main.rs b/crates/punktfunk-client-windows/src/main.rs index ec610ed..61a7587 100644 --- a/crates/punktfunk-client-windows/src/main.rs +++ b/crates/punktfunk-client-windows/src/main.rs @@ -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::().ok()?, + it.next()?.parse::().ok()?, + it.next()?.parse::().ok()?, + )) + }) { + (settings.width, settings.height, settings.refresh_hz) = m; + } + if let Some(b) = arg("--bitrate").and_then(|b| b.parse::().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::().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). diff --git a/crates/punktfunk-client-windows/src/present.rs b/crates/punktfunk-client-windows/src/present.rs new file mode 100644 index 0000000..f02bad1 --- /dev/null +++ b/crates/punktfunk-client-windows/src/present.rs @@ -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 { + 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, + pub width: u32, + pub height: u32, +} + +impl SwapChain { + pub fn new(device: &ID3D11Device, hwnd: HWND, width: u32, height: u32) -> Result { + 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 { + 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 { + 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) + } +} diff --git a/crates/punktfunk-client-windows/src/trust.rs b/crates/punktfunk-client-windows/src/trust.rs index f863723..aec6aeb 100644 --- a/crates/punktfunk-client-windows/src/trust.rs +++ b/crates/punktfunk-client-windows/src/trust.rs @@ -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) }