From fbeac16c96254079bf66b84fe5779d054e6fdc4c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 2 Jul 2026 18:23:25 +0200 Subject: [PATCH] feat(clients/windows): WinUI UX batch - tile hover, Settings NavigationView, modal slide-up Bump windows-reactor + windows to a4f7b2cb (from b4129fcc) for the new PointerEntered/PointerExited events; migration is mechanical renames only (SymbolGlyph->Symbol, placeholder->placeholder_text, on_changed-> on_text_changed/on_toggled, on_menu_item_clicked->on_item_clicked, on_ready->on_mounted). New runtime model: reactor lost its build.rs, so the client build.rs stages the WinAppSDK bootstrap via windows-reactor-setup::as_framework_dependent() and main calls windows_reactor::bootstrap() (missing either = 0x80040154 at launch); staged filenames unchanged, so pack-msix and the MSIX manifest are untouched. - Host tiles: WinUI pointer-over fill (ControlFillSecondary) via the new pointer enter/exit events, hover id in root state (backend-wired handlers bypass the reconciler flush, like the flyout clicks). - Settings: stock NavigationView sidebar (Windows-Settings pattern) with Display/Video/Input/Audio/About panes, built-in back arrow, wide content column, and a per-section content slide-up tween. The section card is KEYED by section: an in-place diff across sections re-sets a reused ComboBox's items (clearing WinUI's selection) but skips selected_index when the values compare equal, rendering a blank selection - the key forces a remount. Card titles/descriptions dropped; per-control guidance moved to hover tooltips (ToolTipService). - New "Show the stats overlay (HUD)" setting (show_hud, default on), honored mid-stream via the 400 ms HUD re-render. - Add-host modal: entrance fade + slide-up tween (scrim fades with it). - Self-initiated disconnect (Ctrl+Alt+Shift+D -> Ended(None)) returns to the host list silently instead of raising the error banner. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 28 +++- Cargo.lock | 87 +++++------ clients/windows/Cargo.toml | 7 +- clients/windows/build.rs | 4 + clients/windows/packaging/README.md | 15 +- clients/windows/src/app/connect.rs | 6 +- clients/windows/src/app/hosts.rs | 88 +++++++++--- clients/windows/src/app/licenses.rs | 2 +- clients/windows/src/app/mod.rs | 81 ++++++++++- clients/windows/src/app/pair.rs | 10 +- clients/windows/src/app/settings.rs | 214 ++++++++++++++++++---------- clients/windows/src/app/speed.rs | 6 +- clients/windows/src/app/stream.rs | 84 +++++------ clients/windows/src/main.rs | 6 + clients/windows/src/trust.rs | 8 ++ 15 files changed, 448 insertions(+), 198 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0f1149e..748c2ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,9 +274,17 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`, verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git - dep pinned to commit `b4129fcc`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies - with `set_swap_chain`); its `build.rs` downloads the Win App SDK NuGets + needs `CARGO_WORKSPACE_DIR` - set (in the VM build env; `/temp`+`/winmd` gitignored). Gotcha: `CARGO_HOME` must be an ASCII path + dep pinned to commit `a4f7b2cb`, bumped 2026-07-02 from `b4129fcc` for `on_pointer_entered`/ + `on_pointer_exited` hover events — mechanical renames only: `SymbolGlyph`→`Symbol`, + `placeholder`→`placeholder_text`, TextBox `on_changed`→`on_text_changed`, ToggleSwitch + `on_changed`→`on_toggled`, `on_menu_item_clicked`→`on_item_clicked`, SwapChainPanel + `on_ready`→`on_mounted`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies with + `set_swap_chain`). New-model runtime staging: reactor has NO build.rs anymore — the app's own + `build.rs` calls `windows_reactor_setup::as_framework_dependent()` (same-rev build-dep, stages + the bootstrap DLL + resources.pri that pack-msix expects) and `main` calls + `windows_reactor::bootstrap()` before `App` (packaged MSIX: a no-op, the manifest's + `Microsoft.WindowsAppRuntime.2` dependency resolves the runtime). `CARGO_WORKSPACE_DIR` is no + longer required (harmless where still set). Gotcha: `CARGO_HOME` must be an ASCII path — the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts · connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT @@ -294,7 +302,19 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI → session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005, - pre-existing; needs the console session, e.g. PsExec -i 1). + pre-existing; needs the console session, e.g. PsExec -i 1). **UX batch (2026-07-02 evening, + UIA-smoke-tested on the hybrid laptop)**: host tiles get the WinUI pointer-over fill + (`on_pointer_entered`/`exited` → root hover state → `ControlFillSecondary`), Settings is a stock + **NavigationView** sidebar (Windows-Settings pattern: Display/Video/Input/Audio/About panes, + built-in back arrow, section in root state; the section card is **keyed by section** — an + in-place diff across sections re-sets a reused ComboBox's items, clearing WinUI's selection, + but skips `selected_index` when the values compare equal → blank selection; the key forces a + remount — and the content column rides its own section-switch slide-up tween), new + **"Show the stats overlay (HUD)"** toggle + (`Settings::show_hud`, applies mid-stream via the 400 ms HUD re-render), the Add-host modal + slides up + fades in (root margin/opacity tween, same pattern as screen navigation), and a + self-initiated disconnect (Ctrl+Alt+Shift+D → `Ended(None)`) returns to the host list silently + instead of raising the error banner. Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop display), then RAWINPUT relative-mouse pointer-lock. **Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking diff --git a/Cargo.lock b/Cargo.lock index 2977bfa..0ee4150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,8 +2780,9 @@ dependencies = [ "tracing", "tracing-subscriber", "wasapi", - "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-reactor", + "windows-reactor-setup", "winresource", ] @@ -4622,12 +4623,12 @@ dependencies = [ [[package]] name = "windows" version = "0.62.2" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-reference", "windows-time", ] @@ -4644,9 +4645,9 @@ dependencies = [ [[package]] name = "windows-collections" version = "0.3.2" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] @@ -4665,13 +4666,13 @@ dependencies = [ [[package]] name = "windows-core" version = "0.62.2" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-implement 0.60.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-interface 0.59.3 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-result 0.4.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-strings 0.5.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-implement 0.60.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-interface 0.59.3 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-result 0.4.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-strings 0.5.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] @@ -4688,11 +4689,11 @@ dependencies = [ [[package]] name = "windows-future" version = "0.3.2" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] @@ -4709,7 +4710,7 @@ dependencies = [ [[package]] name = "windows-implement" version = "0.60.2" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ "proc-macro2", "quote", @@ -4730,7 +4731,7 @@ dependencies = [ [[package]] name = "windows-interface" version = "0.59.3" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ "proc-macro2", "quote", @@ -4746,7 +4747,7 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-link" version = "0.2.1" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" [[package]] name = "windows-numerics" @@ -4761,33 +4762,38 @@ dependencies = [ [[package]] name = "windows-numerics" version = "0.3.1" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] name = "windows-reactor" version = "0.0.0" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ "rustc-hash", - "windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", - "windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-reference", - "windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-time", ] +[[package]] +name = "windows-reactor-setup" +version = "0.0.0" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" + [[package]] name = "windows-reference" version = "0.1.0" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-time", ] @@ -4803,9 +4809,9 @@ dependencies = [ [[package]] name = "windows-result" version = "0.4.1" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] @@ -4831,9 +4837,9 @@ dependencies = [ [[package]] name = "windows-strings" version = "0.5.1" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] @@ -4941,17 +4947,18 @@ dependencies = [ [[package]] name = "windows-threading" version = "0.2.1" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] name = "windows-time" version = "0.1.0" -source = "git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1#b4129fcc1ae81eec8bf1217539883db821bca3a1" +source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" dependencies = [ - "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", + "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", + "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", ] [[package]] diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml index 6c18bff..2ad60dc 100644 --- a/clients/windows/Cargo.toml +++ b/clients/windows/Cargo.toml @@ -25,11 +25,11 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] } # `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" } +windows-reactor = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" } # 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 = [ +windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f", features = [ "Win32_Foundation", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", @@ -69,5 +69,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from the SDK). +# windows-reactor-setup stages the Windows App SDK runtime bootstrap (framework-dependent) next +# to the exe — the new-model replacement for the old windows-reactor build.rs; same pinned rev. [target.'cfg(windows)'.build-dependencies] winresource = "0.1" +windows-reactor-setup = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" } diff --git a/clients/windows/build.rs b/clients/windows/build.rs index 4c69540..511bd94 100644 --- a/clients/windows/build.rs +++ b/clients/windows/build.rs @@ -7,6 +7,10 @@ fn main() { // is the TARGET (both the x64 and the cross-compiled ARM64 Windows builds pass). #[cfg(windows)] if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { + // Stage the Windows App SDK runtime bootstrap + resources.pri next to the exe + // (framework-dependent deployment; `main` calls `windows_reactor::bootstrap()`). + windows_reactor_setup::as_framework_dependent(); + let icon = "../../packaging/windows/branding/punktfunk.ico"; println!("cargo:rerun-if-changed={icon}"); winresource::WindowsResource::new() diff --git a/clients/windows/packaging/README.md b/clients/windows/packaging/README.md index 5bc0a4b..518b9d1 100644 --- a/clients/windows/packaging/README.md +++ b/clients/windows/packaging/README.md @@ -22,7 +22,7 @@ stamps the manifest `ProcessorArchitecture` and names the output. See | File | Source | |---|---| | `punktfunk-client.exe` | the release build | -| `Microsoft.WindowsAppRuntime.Bootstrap.dll`, `resources.pri` | auto-staged by windows-reactor's `build.rs` | +| `Microsoft.WindowsAppRuntime.Bootstrap.dll`, `resources.pri` | staged by the client's `build.rs` via `windows-reactor-setup::as_framework_dependent()` | | `SDL3.dll` | auto-staged by the `sdl3` crate | | `avcodec/avformat/avutil/swscale/swresample/...-*.dll` | `FFMPEG_DIR\bin` | | `Assets\*.png` | checked-in tile/store logos (rasterized from `packaging/flatpak/io.unom.Punktfunk.svg`) | @@ -30,12 +30,13 @@ stamps the manifest `ProcessorArchitecture` and names the output. See ### Why an "unpackaged" WinUI app packages cleanly -windows-reactor calls `MddBootstrapInitialize2` with `OnPackageIdentity_NOOP` -(`crates/libs/reactor/src/app.rs`), so under MSIX **package identity** the App SDK bootstrapper is -a no-op and the runtime is resolved from the manifest's `` on -`Microsoft.WindowsAppRuntime.2` instead (reactor pins `WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000` -= 2.0). It's a full-trust Win32 app (`EntryPoint="Windows.FullTrustApplication"` + `runFullTrust`) -because it owns raw D3D11, Win32 low-level input hooks, WASAPI and SDL3. +`main` calls `windows_reactor::bootstrap()`, which runs `MddBootstrapInitialize2` with +`OnPackageIdentity_NOOP` (`crates/libs/reactor/src/bootstrap.rs`), so under MSIX **package +identity** the App SDK bootstrapper is a no-op and the runtime is resolved from the manifest's +`` on `Microsoft.WindowsAppRuntime.2` instead (reactor pins +`WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000` = 2.0). It's a full-trust Win32 app +(`EntryPoint="Windows.FullTrustApplication"` + `runFullTrust`) because it owns raw D3D11, Win32 +low-level input hooks, WASAPI and SDL3. ## Versioning diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index e61eb5a..fbe56fc 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -270,7 +270,9 @@ fn connect_with( break; } SessionEvent::Ended(err) => { - st.call(err.unwrap_or_else(|| "Session ended".into())); + // `None` = the user ended the session themselves (the disconnect shortcut) — + // return to the host list silently; an error banner would read as a failure. + st.call(err.unwrap_or_default()); gamepad.detach(); ss.call(Screen::Hosts); break; @@ -343,7 +345,7 @@ pub(crate) fn request_access_page( let cancel_btn = { let (ctx, ss) = (ctx.clone(), set_screen.clone()); button("Cancel") - .icon(SymbolGlyph::Cancel) + .icon(Symbol::Cancel) .on_click(move || { // Return the UI immediately; the parked connect is blocking with no abort, so trip // the flag this request's event loop captured — it then tears down silently when diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index 012cdde..7ae92d1 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -10,7 +10,7 @@ use crate::discovery::DiscoveredHost; use crate::trust::KnownHosts; use windows_reactor::*; -/// Overflow-menu item labels — `on_menu_item_clicked` reports the clicked item by its text. +/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text. const MENU_CONNECT: &str = "Connect"; const MENU_SPEED: &str = "Test network speed\u{2026}"; const MENU_RENAME: &str = "Rename\u{2026}"; @@ -42,9 +42,15 @@ pub(crate) struct HostsProps { /// own `use_state`: a child component's sync `SetState` marks its slot dirty but does not /// re-render when its props are otherwise unchanged, so the toggle wouldn't take. pub(crate) show_add: bool, + /// The modal's entrance-tween progress (0 → 1, root-driven): opacity + slide-up offset. + pub(crate) add_anim: f64, + /// The hovered tile's stable id (saved: fp_hex, discovered: `addr:port`) — root state because + /// the pointer enter/exit handlers bypass the reconciler flush, like the flyout clicks above. + pub(crate) hover: Option, pub(crate) set_forget: AsyncSetState>, pub(crate) set_rename: AsyncSetState>, pub(crate) set_show_add: AsyncSetState, + pub(crate) set_hover: AsyncSetState>, } impl PartialEq for HostsProps { @@ -56,6 +62,8 @@ impl PartialEq for HostsProps { && self.forget == other.forget && self.rename == other.rename && self.show_add == other.show_add + && self.add_anim == other.add_anim + && self.hover == other.hover } } @@ -63,7 +71,12 @@ impl PartialEq for HostsProps { /// optional "…" menu button are SIBLINGS overlaid in one grid cell, never nested: WinUI bubbles /// `Tapped` out of buttons (reactor doesn't mark it handled), so a button inside the tap target /// would fire both its own click and the tile's connect (the old forget-also-connects bug). +/// +/// Hover renders the WinUI card pointer-over look — the card background lifts to the control +/// hover fill while the pointer is inside the tile (tracked via `hover`, see `HostsProps`). fn host_tile( + id: &str, + hover: &Hover, name: &str, sub: &str, status_row: Element, @@ -104,7 +117,27 @@ fn host_tile( .into(), ); } - card_flush(grid(children)).into() + let mut tile = card_flush(grid(children)); + if hover.current.as_deref() == Some(id) { + tile = tile.background(ThemeRef::ControlFillSecondary); + } + let enter = { + let (set, id) = (hover.set.clone(), id.to_string()); + move |_: PointerEventInfo| set.call(Some(id.clone())) + }; + let exit = { + let set = hover.set.clone(); + move || set.call(None) + }; + tile.on_pointer_entered(enter) + .on_pointer_exited(exit) + .into() +} + +/// The hover-tracking pair `host_tile` needs: the currently hovered tile id + its root setter. +pub(crate) struct Hover { + pub(crate) current: Option, + pub(crate) set: AsyncSetState>, } /// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip. @@ -179,12 +212,12 @@ fn rename_editor( card( vstack(( text_box(draft) - .placeholder("Host name") - .on_changed(on_changed), + .placeholder_text("Host name") + .on_text_changed(on_changed), hstack(( button("Save") .accent() - .icon(SymbolGlyph::Accept) + .icon(Symbol::Accept) .on_click(commit), button("Cancel") .subtle() @@ -213,6 +246,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { let rename = props.rename.clone(); let set_forget = &props.set_forget; let set_rename = &props.set_rename; + let hover = Hover { + current: props.hover.clone(), + set: props.set_hover.clone(), + }; let known = KnownHosts::load(); // Responsive column count from the live window width (re-renders on resize): as many @@ -235,14 +272,11 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .grid_column(0) .vertical_alignment(VerticalAlignment::Center), hstack(( - button("Add host") - .accent() - .icon(SymbolGlyph::Add) - .on_click({ - let sa = set_show_add.clone(); - move || sa.call(true) - }), - button("Settings").icon(SymbolGlyph::Setting).on_click({ + button("Add host").accent().icon(Symbol::Add).on_click({ + let sa = set_show_add.clone(); + move || sa.call(true) + }), + button("Settings").icon(Symbol::Setting).on_click({ let ss = set_screen.clone(); move || ss.call(Screen::Settings) }), @@ -293,7 +327,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { let (sf, sr) = (set_forget.clone(), set_rename.clone()); let (fp, name) = (k.fp_hex.clone(), k.name.clone()); button("") - .icon(SymbolGlyph::More) + .icon(Symbol::More) .subtle() .tooltip("More options") .automation_name("More options") @@ -304,7 +338,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { menu_separator(), menu_item(MENU_FORGET), ]) - .on_menu_item_clicked(move |item: String| match item.as_str() { + .on_item_clicked(move |item: String| match item.as_str() { MENU_CONNECT => { initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) } @@ -325,6 +359,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { }; let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); tiles.push(host_tile( + &k.fp_hex, + &hover, &k.name, &format!("{}:{}", k.addr, k.port), status_row( @@ -378,6 +414,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { ("Open", Pill::Neutral) }; tiles.push(host_tile( + &format!("{}:{}", h.addr, h.port), + &hover, &h.name, &format!("{}:{}", h.addr, h.port), status_row(None, badge, kind), @@ -466,13 +504,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .foreground(ThemeRef::SecondaryText), text_box(manual) .header("Address") - .placeholder("192.168.1.20 or my-pc.local") - .on_changed(move |s| set_manual.call(s)) + .placeholder_text("192.168.1.20 or my-pc.local") + .on_text_changed(move |s| set_manual.call(s)) .margin(edges(0.0, 6.0, 0.0, 0.0)), hstack(( button("Connect") .accent() - .icon(SymbolGlyph::Forward) + .icon(Symbol::Forward) .on_click(connect_manual), button("Cancel").on_click({ let sa = set_show_add.clone(); @@ -488,10 +526,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { .max_width(460.0) .horizontal_alignment(HorizontalAlignment::Center) .vertical_alignment(VerticalAlignment::Center) - .margin(uniform(24.0)); + // Entrance: fade + slide up, driven by the root tween (`add_anim` 0 → 1). The card starts + // a bit low and rises to centre — for a centred element, extra top margin shifts it down by + // half the difference, so the offset is doubled. + .opacity(props.add_anim) + .margin(edges( + 24.0, + 24.0 + (1.0 - props.add_anim) * 56.0, + 24.0, + 24.0, + )); + // The scrim fades in with the same tween. let scrim = border(modal).background(Color { - a: 140, + a: (140.0 * props.add_anim) as u8, r: 0, g: 0, b: 0, diff --git a/clients/windows/src/app/licenses.rs b/clients/windows/src/app/licenses.rs index 17d0462..c13ff72 100644 --- a/clients/windows/src/app/licenses.rs +++ b/clients/windows/src/app/licenses.rs @@ -16,7 +16,7 @@ const APP_LICENSE: &str = concat!( const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt"); pub(crate) fn licenses_page(set_screen: &AsyncSetState) -> Element { - let back_btn = button("Back").accent().icon(SymbolGlyph::Back).on_click({ + let back_btn = button("Back").accent().icon(Symbol::Back).on_click({ let ss = set_screen.clone(); move || ss.call(Screen::Settings) }); diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index ced16a6..33acf50 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -200,6 +200,12 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { let (forget, set_forget) = cx.use_async_state(Option::<(String, String)>::None); let (rename, set_rename) = cx.use_async_state(Option::<(String, String)>::None); let (show_add, set_show_add) = cx.use_async_state(false); + // Hovered host tile (its stable id), driving the WinUI-style card hover fill. Root state for + // the same reason as `forget`/`rename`: pointer enter/exit handlers are wired straight in the + // reactor backend, so only a root `AsyncSetState` reliably re-renders the page. + let (hover, set_hover) = cx.use_async_state(Option::::None); + // Which Settings section the NavigationView shows (persists across visits this run). + let (settings_nav, set_settings_nav) = cx.use_async_state("display".to_string()); // Continuous LAN discovery (spawned once). cx.use_effect((), { @@ -279,6 +285,70 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { 0.0 }; + // Settings-section entrance: the same tween again, keyed on the selected section, so + // switching panes slides the CONTENT column up (the sidebar stays put — this must not wrap + // the NavigationView, so it can't ride the screen-level tween above). Entering Settings + // fresh leaves it settled at 1 (only the screen tween plays; no double animation). + let nav_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0))); + let (nav_anim, set_nav_anim) = cx.use_async_state((String::new(), 1.0f64)); + cx.use_effect(settings_nav.clone(), { + let (s, set_nav_anim, gen) = ( + settings_nav.clone(), + set_nav_anim.clone(), + nav_gen.borrow().clone(), + ); + move || { + use std::sync::atomic::Ordering::SeqCst; + let mine = gen.fetch_add(1, SeqCst) + 1; + std::thread::spawn(move || { + const STEPS: u32 = 14; + for i in 0..=STEPS { + if gen.load(SeqCst) != mine { + return; // a newer section switch superseded this tween + } + let p = f64::from(i) / f64::from(STEPS); + let eased = 1.0 - (1.0 - p).powi(3); + set_nav_anim.call((s.clone(), eased)); + std::thread::sleep(std::time::Duration::from_millis(16)); + } + }); + } + }); + let nav_progress = if nav_anim.0 == settings_nav { + nav_anim.1 + } else { + 0.0 + }; + + // "Add host" modal entrance: the same manual tween as the screen navigation (see above for + // why it can't be a composition animation), stepping 0 → 1 when the modal opens. The hosts + // page maps it to the modal's opacity + a downward start offset (the slide-up) and the + // scrim's fade. Closing resets to 0 instantly — the modal unmounts, nothing to animate. + let add_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0))); + let (add_anim, set_add_anim) = cx.use_async_state(0.0f64); + cx.use_effect(show_add, { + let (set_add_anim, gen) = (set_add_anim.clone(), add_gen.borrow().clone()); + move || { + use std::sync::atomic::Ordering::SeqCst; + let mine = gen.fetch_add(1, SeqCst) + 1; + if !show_add { + set_add_anim.call(0.0); + return; + } + std::thread::spawn(move || { + const STEPS: u32 = 12; + for i in 0..=STEPS { + if gen.load(SeqCst) != mine { + return; // reopened/closed mid-tween — a newer run owns the value + } + let p = f64::from(i) / f64::from(STEPS); + set_add_anim.call(1.0 - (1.0 - p).powi(3)); // ease-out cubic + std::thread::sleep(std::time::Duration::from_millis(16)); + } + }); + } + }); + // Each hook-using screen is mounted as its own component so its hooks are isolated from // root's (root's own hooks above stay a stable prefix regardless of which screen renders). let svc = Svc { @@ -297,16 +367,25 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { forget, rename, show_add, + add_anim, + hover, set_forget, set_rename, set_show_add, + set_hover, }, ), // connecting_page / request_access_page / settings_page / licenses_page use no hooks // (they never touch `cx`), so calling them inline is sound. Screen::Connecting => connect::connecting_page(ctx, &status), Screen::RequestAccess => connect::request_access_page(ctx, &set_screen), - Screen::Settings => settings::settings_page(ctx, &set_screen), + Screen::Settings => settings::settings_page( + ctx, + &set_screen, + &settings_nav, + &set_settings_nav, + nav_progress, + ), Screen::Licenses => licenses::licenses_page(&set_screen), Screen::Pair => component(pair::pair_page, svc), Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }), diff --git a/clients/windows/src/app/pair.rs b/clients/windows/src/app/pair.rs index a879b98..52efbb2 100644 --- a/clients/windows/src/app/pair.rs +++ b/clients/windows/src/app/pair.rs @@ -26,7 +26,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { ); button("Pair & Connect") .accent() - .icon(SymbolGlyph::Accept) + .icon(Symbol::Accept) .on_click(move || { let pin = code2.trim().to_string(); let (ctx3, ss, st, target3) = @@ -65,7 +65,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { let cancel_btn = { let ss = set_screen.clone(); button("Cancel") - .icon(SymbolGlyph::Cancel) + .icon(Symbol::Cancel) .on_click(move || ss.call(Screen::Hosts)) }; // The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that @@ -73,7 +73,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { let request_btn = { let (svc, target2) = (props.clone(), target.clone()); button("Request access without a PIN") - .icon(SymbolGlyph::Send) + .icon(Symbol::Send) .on_click(move || request_access(&svc, &target2)) .horizontal_alignment(HorizontalAlignment::Stretch) }; @@ -105,9 +105,9 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { .informational() .is_closable(false), text_box(code) - .placeholder("PIN") + .placeholder_text("PIN") .font_size(28.0) - .on_changed(move |s| set_code.call(s)), + .on_text_changed(move |s| set_code.call(s)), hstack((pair_btn, cancel_btn)).spacing(8.0), text_block( "Don\u{2019}t have a PIN? Request access instead and approve this device on the host \ diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index e77847f..1ea9b10 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -95,27 +95,33 @@ fn setting_toggle( .header(header) .on_content("On") .off_content("Off") - .on_changed(move |v: bool| { + .on_toggled(move |v: bool| { let mut s = ctx.settings.lock().unwrap(); apply(&mut s, v); s.save(); }) } -/// A titled settings card: bold heading, a secondary description, then the controls. -fn settings_card(title: &str, blurb: &str, controls: Vec) -> Element { - let mut children: Vec = vec![ - text_block(title).font_size(15.0).semibold().into(), - text_block(blurb) - .font_size(12.0) - .foreground(ThemeRef::SecondaryText) - .into(), - ]; - children.extend(controls); - card(vstack(children).spacing(10.0)).into() +/// A settings card: just the controls. No heading (the section title is the NavigationView +/// header) and no description paragraph — per-control guidance is a `.tooltip(...)` on the +/// control itself (a paragraph in the card reads as the first control's label). +fn settings_card(controls: Vec) -> Element { + card(vstack(controls).spacing(10.0)).into() } -pub(crate) fn settings_page(ctx: &Arc, set_screen: &AsyncSetState) -> Element { +/// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) — +/// one pane item per section, the section's card as the content, the built-in back arrow +/// returning to the host list. `section`/`set_section` are the selected pane tag, held in ROOT +/// state (this page stays hook-free): `on_selection_changed` is wired in the reactor backend, so +/// only a root `AsyncSetState` reliably re-renders the new section in. `progress` is the +/// section-switch entrance tween (0 → 1), mapped onto the content column's opacity + offset. +pub(crate) fn settings_page( + ctx: &Arc, + set_screen: &AsyncSetState, + section: &str, + set_section: &AsyncSetState, + progress: f64, +) -> Element { let s = ctx.settings.lock().unwrap().clone(); // --- Display --------------------------------------------------------------------------- @@ -138,7 +144,11 @@ pub(crate) fn settings_page(ctx: &Arc, set_screen: &AsyncSetState = REFRESH .iter() @@ -155,17 +165,26 @@ pub(crate) fn settings_page(ctx: &Arc, set_screen: &AsyncSetState, set_screen: &AsyncSetState, set_screen: &AsyncSetState, set_screen: &AsyncSetState ( "Video", - "Hardware decode (D3D11VA) is far lighter than software — keep it on Automatic \ - unless debugging. Run a per-host speed test (host list) before setting a high \ - bitrate.", - { + settings_card({ let mut controls: Vec = vec![decoder_combo.into()]; if let Some(c) = gpu_combo { controls.push(c.into()); } - controls.extend([codec_combo.into(), bitrate_box.into(), hdr_toggle.into()]); + controls.extend([ + codec_combo.into(), + bitrate_box.into(), + hdr_toggle.into(), + hud_toggle.into(), + ]); controls - }, + }), ), - section("INPUT"), - settings_card( + "input" => ( "Input", - "Exactly one controller is forwarded to the host; \u{201C}Automatic\u{201D} picks the \ - most recently connected. The gamepad type is the virtual pad the host creates.", - vec![ + settings_card(vec![ forward_combo.into(), pad_combo.into(), shortcuts_toggle.into(), - ], + ]), ), - section("AUDIO"), - settings_card( + "audio" => ( "Audio", - "Request stereo or surround — the host downmixes if its output has fewer.", - vec![channels_combo.into(), mic_toggle.into()], + settings_card(vec![channels_combo.into(), mic_toggle.into()]), ), - section("ABOUT"), - settings_card( - "About", - "punktfunk is licensed under MIT OR Apache-2.0.", - vec![licenses_button.into()], + "about" => ("About", settings_card(vec![licenses_button.into()])), + _ => ( + "Display", + settings_card(vec![res_combo.into(), hz_combo.into(), comp_combo.into()]), ), - ]) + }; + + // The stock WinUI sidebar (Windows-Settings pattern): pane on the left, the section's card + // as content, the NavigationView's own back arrow returning to the host list. Auto display + // mode collapses the pane on a narrow window, exactly like Windows Settings. + let items = vec![ + NavViewItem::new("Display") + .tag("display") + .icon(Symbol::FullScreen), + NavViewItem::new("Video").tag("video").icon(Symbol::Video), + NavViewItem::new("Input") + .tag("input") + .icon(Symbol::Keyboard), + NavViewItem::new("Audio").tag("audio").icon(Symbol::Volume), + NavViewItem::new("About").tag("about").icon(Symbol::Help), + ]; + // The card is KEYED by section so switching panes REMOUNTS it instead of diffing one + // section's controls into another's: an in-place diff re-sets a reused ComboBox's items + // (which clears WinUI's selection) but skips `selected_index` whenever the two sections' + // values compare equal — the combo then renders with no selected option. A fresh mount + // applies every prop, so the selection always displays. + // + // The content column (not the NavigationView — the sidebar must stay put) carries the + // section-switch entrance: fade + slide-up from the root-driven tween. + let content = page_wide(vec![card.with_key(section)]) + .opacity(progress) + .margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0)); + NavigationView::new(items, content) + .pane_title("Settings") + .header(title) + .selected_tag(section) + .on_selection_changed({ + let ss = set_section.clone(); + move |tag: String| ss.call(tag) + }) + .settings_visible(false) + .back_enabled(true) + .on_back_requested({ + let ss = set_screen.clone(); + move || ss.call(Screen::Hosts) + }) + .into() } diff --git a/clients/windows/src/app/speed.rs b/clients/windows/src/app/speed.rs index 43f39b8..caa7d34 100644 --- a/clients/windows/src/app/speed.rs +++ b/clients/windows/src/app/speed.rs @@ -82,7 +82,7 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element { let back_btn = { let ss = set_screen.clone(); button("Close") - .icon(SymbolGlyph::Back) + .icon(Symbol::Back) .on_click(move || ss.call(Screen::Hosts)) .horizontal_alignment(HorizontalAlignment::Center) }; @@ -126,7 +126,7 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element { let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps); button(format!("Use {recommended_mbps:.0} Mb/s")) .accent() - .icon(SymbolGlyph::Accept) + .icon(Symbol::Accept) .on_click(move || { let mut s = ctx.settings.lock().unwrap(); s.bitrate_kbps = kbps; @@ -154,7 +154,7 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element { hstack((apply_btn, { let ss = set_screen.clone(); button("Close") - .icon(SymbolGlyph::Cancel) + .icon(Symbol::Cancel) .on_click(move || ss.call(Screen::Hosts)) })) .spacing(8.0) diff --git a/clients/windows/src/app/stream.rs b/clients/windows/src/app/stream.rs index 3e56742..b7e18f7 100644 --- a/clients/windows/src/app/stream.rs +++ b/clients/windows/src/app/stream.rs @@ -41,8 +41,8 @@ impl PartialEq for StreamProps { } thread_local! { - /// Frames + host clock offset, stashed by the mount effect for `on_ready` (which fires later, - /// once the native panel exists). + /// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires + /// later, once the native panel exists). static PENDING: RefCell> = const { RefCell::new(None) }; /// The live render thread; stopped + joined by the unmount cleanup (before panel teardown). static RENDER: RefCell> = const { RefCell::new(None) }; @@ -65,7 +65,7 @@ fn window_dpi() -> u32 { pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let ctx = &props.svc.ctx; // Take the connector + frames handoff once on mount; keep the connector alive (and for input) - // in a use_ref, stash frames for `on_ready`, install the input hooks. The cleanup stops the + // in a use_ref, stash frames for `on_mounted`, install the input hooks. The cleanup stops the // render thread FIRST (it must not present into a panel that's tearing down), then removes // the input hooks. let connector_ref = cx.use_ref::>>(None); @@ -95,46 +95,48 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let mode = connector_ref.borrow().as_ref().map(|c| c.mode()); let host = ctx.shared.target.lock().unwrap().name.clone(); - grid(( - swap_chain_panel() - .on_ready(|panel| { - // Placeholder size — the first `on_resize` (fired after the first layout pass) - // resizes to the panel's real pixel size. - let dpi = window_dpi(); - match Presenter::new(1280, 720, dpi) { - Ok(p) => { - if let Err(e) = panel.set_swap_chain(p.swap_chain()) { - tracing::error!(error = %e, "set_swap_chain"); - return; - } - if let Some((frames, clock_offset)) = - PENDING.with(|c| c.borrow_mut().take()) - { - let shared = render::RenderShared::new(1280, 720, dpi); - RENDER.with(|cell| { - *cell.borrow_mut() = - Some(render::spawn(p, frames, shared, clock_offset)); - }); - tracing::info!(dpi, "stream presenter bound — render thread started"); - } + // Read per render: this page re-renders on every HUD sample (~400 ms), so toggling the + // overlay in Settings takes effect mid-stream. + let show_hud = ctx.settings.lock().unwrap().show_hud; + let mut layers: Vec = vec![swap_chain_panel() + .on_mounted(|panel| { + // Placeholder size — the first `on_resize` (fired after the first layout pass) + // resizes to the panel's real pixel size. + let dpi = window_dpi(); + match Presenter::new(1280, 720, dpi) { + Ok(p) => { + if let Err(e) = panel.set_swap_chain(p.swap_chain()) { + tracing::error!(error = %e, "set_swap_chain"); + return; + } + if let Some((frames, clock_offset)) = PENDING.with(|c| c.borrow_mut().take()) { + let shared = render::RenderShared::new(1280, 720, dpi); + RENDER.with(|cell| { + *cell.borrow_mut() = + Some(render::spawn(p, frames, shared, clock_offset)); + }); + tracing::info!(dpi, "stream presenter bound — render thread started"); } - Err(e) => tracing::error!(error = %e, "create presenter"), } - }) - .on_resize(|w, h| { - // DIPs → physical pixels; the presenter maps back via SetMatrixTransform. - let dpi = window_dpi(); - let px = |v: f64| (v * f64::from(dpi) / 96.0).round() as u32; - RENDER.with(|cell| { - if let Some(rt) = cell.borrow().as_ref() { - rt.shared().set_dpi(dpi); - rt.shared().set_size(px(w), px(h)); - } - }); - }), - hud_overlay(&props.hud, mode, &host), - )) - .into() + Err(e) => tracing::error!(error = %e, "create presenter"), + } + }) + .on_resize(|w, h| { + // DIPs → physical pixels; the presenter maps back via SetMatrixTransform. + let dpi = window_dpi(); + let px = |v: f64| (v * f64::from(dpi) / 96.0).round() as u32; + RENDER.with(|cell| { + if let Some(rt) = cell.borrow().as_ref() { + rt.shared().set_dpi(dpi); + rt.shared().set_size(px(w), px(h)); + } + }); + }) + .into()]; + if show_hud { + layers.push(hud_overlay(&props.hud, mode, &host)); + } + grid(layers).into() } /// A small chip for the dark HUD: coloured text on a translucent dark fill. diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index a7ab65b..64bf1a3 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -82,6 +82,12 @@ fn main() { } // Windowed (default): the WinUI 3 app owns host selection, settings, and pairing. + // Framework-dependent deployment: initialize the Windows App SDK runtime before any WinUI + // call (build.rs stages the bootstrap DLL via windows-reactor-setup). + if let Err(e) = windows_reactor::bootstrap() { + tracing::error!(error = %e, "Windows App SDK bootstrap failed"); + std::process::exit(1); + } let gamepad = gamepad::GamepadService::start(); if let Err(e) = app::run(identity, gamepad) { tracing::error!(error = %e, "WinUI app failed"); diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index a23e881..aa32de1 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -149,12 +149,19 @@ pub struct Settings { /// vanished adapter (eGPU unplugged) falls back to automatic. #[serde(default)] pub adapter: String, + /// Show the stats/info overlay (HUD) over the stream. + #[serde(default = "default_true")] + pub show_hud: bool, } fn default_codec() -> String { "auto".into() } +fn default_true() -> bool { + true +} + impl Settings { /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). pub fn preferred_codec(&self) -> u8 { @@ -183,6 +190,7 @@ impl Default for Settings { decoder: "auto".into(), codec: "auto".into(), adapter: String::new(), + show_hud: true, } } }