diff --git a/CLAUDE.md b/CLAUDE.md index 6ebc747..0f1149e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,17 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc **pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`; DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers - (`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). Ships as a **signed + (`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire + convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK; + the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows + injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a + keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the + German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic + (`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux + renders positions under the session compositor's layout (libei) or the virtual keyboard's + uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android + client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't + re-map keycodes semantically. Ships as a **signed Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by @@ -224,23 +234,39 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc **Windows stage 1 done 2026-06-15** (`clients/windows`, binary `punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The - video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain** (WARP fallback for - the GPU-less dev box; runtime-compiled fullscreen-triangle shaders, Contain-fit letterbox), - driven by reactor's per-frame `on_rendering`. **FFmpeg HEVC decode with a D3D11VA - zero-copy hardware path** (`gpu.rs` shares one D3D11 device — hardware+`VIDEO_SUPPORT`, WARP - fallback, multithread-protected — between the decoder and presenter; the decoder outputs - NV12/P010 `ID3D11Texture2D` array slices with `BIND_SHADER_RESOURCE` and the presenter samples - them via per-plane SRVs + YUV→RGB shaders — NV12/BT.709, P010/BT.2020-PQ; **software CPU decode - stays as the robust fallback**, auto-selected with a `DecoderPref` override). **HDR10**: the - client advertises 10-bit/HDR (Settings toggle), detects PQ in-band (`transfer == SMPTE2084`), - and flips the swapchain to `R10G10B10A2` + ST.2084 with HDR10 metadata. **WASAPI** render + mic - capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full trust - surface — all **in-app**: a polished WinUI shell (host cards w/ monogram + status pills, + video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a + **dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is + stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)` + (≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops + backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform` + 96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback. + **FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device + between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's + monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is + **decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11` + and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects + `DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects + non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe** + before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the + presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar + slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped = + the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders + (NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected, + `DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME + shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is + identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR + display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to + `R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render + + mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full + trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills, `InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode + HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/ - mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **(D3D11VA + HDR present + the - GUI polish are written against the windows-rs/reactor APIs but not yet on-glass validated — the - dev VM is headless/WARP; needs the RTX box.)** **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor + mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02 + on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**: + D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA + 0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels, + GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz + virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) + wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy + fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter @@ -268,9 +294,9 @@ 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) — GUI on-glass validation still pending (needs the console session, e.g. PsExec -i 1). - Next: **on-glass validation** of the D3D11VA decode + HDR present + GUI (console session on the - RTX box), then RAWINPUT relative-mouse pointer-lock. + pre-existing; needs the console session, e.g. PsExec -i 1). + 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 `punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl. **HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`), diff --git a/Cargo.lock b/Cargo.lock index 1f5a71b..2977bfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,6 +770,15 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -2760,6 +2769,7 @@ version = "0.4.2" dependencies = [ "anyhow", "async-channel", + "crossbeam-channel", "ffmpeg-next", "mdns-sd", "opus", @@ -2772,6 +2782,7 @@ dependencies = [ "wasapi", "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=b4129fcc1ae81eec8bf1217539883db821bca3a1)", "windows-reactor", + "winresource", ] [[package]] @@ -5106,6 +5117,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml 1.1.2+spec-1.1.0", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml index 355ebbb..6c18bff 100644 --- a/clients/windows/Cargo.toml +++ b/clients/windows/Cargo.toml @@ -39,6 +39,8 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "b4129fcc1ae8 "Win32_Graphics_Gdi", "Win32_System_Console", "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", ] } @@ -57,8 +59,15 @@ sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] } mdns-sd = "0.20" async-channel = "2" +# The decoded-frame channel (session pump → render thread): crossbeam because the render loop +# blocks with `recv_timeout`, which async-channel has no sync analogue of. +crossbeam-channel = "0.5" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" 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). +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" diff --git a/clients/windows/build.rs b/clients/windows/build.rs new file mode 100644 index 0000000..4c69540 --- /dev/null +++ b/clients/windows/build.rs @@ -0,0 +1,18 @@ +//! Embed the Windows version-info + icon resources into `punktfunk-client.exe`. The icon drives +//! Explorer / Alt-Tab / the unpackaged taskbar, and `app::run` stamps it onto the WinUI window's +//! title bar via `WM_SETICON` (the MSIX taskbar/Start icons come from the package assets instead). + +fn main() { + // cfg(windows) is the HOST (skips the Linux/macOS workspace stub build); CARGO_CFG_WINDOWS + // 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() { + let icon = "../../packaging/windows/branding/punktfunk.ico"; + println!("cargo:rerun-if-changed={icon}"); + winresource::WindowsResource::new() + // Ordinal 1 — app/mod.rs loads it by this id for WM_SETICON. + .set_icon_with_id(icon, "1") + .compile() + .expect("embed windows icon resource"); + } +} diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-16.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-16.png new file mode 100644 index 0000000..1f6dfcd Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-16.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-16_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-16_altform-unplated.png new file mode 100644 index 0000000..1f6dfcd Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-16_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-20.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-20.png new file mode 100644 index 0000000..060fe58 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-20.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-20_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-20_altform-unplated.png new file mode 100644 index 0000000..060fe58 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-20_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-24.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-24.png new file mode 100644 index 0000000..867fe7a Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-24.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-24_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..867fe7a Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-256.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-256.png new file mode 100644 index 0000000..1a3c97b Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-256.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-256_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-256_altform-unplated.png new file mode 100644 index 0000000..1a3c97b Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-256_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-30.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-30.png new file mode 100644 index 0000000..6ab279f Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-30.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-30_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-30_altform-unplated.png new file mode 100644 index 0000000..6ab279f Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-30_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-32.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-32.png new file mode 100644 index 0000000..b84fb1d Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-32.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-32_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-32_altform-unplated.png new file mode 100644 index 0000000..b84fb1d Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-32_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-36.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-36.png new file mode 100644 index 0000000..43c6b38 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-36.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-36_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-36_altform-unplated.png new file mode 100644 index 0000000..43c6b38 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-36_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-40.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-40.png new file mode 100644 index 0000000..ac1f9c5 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-40.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-40_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-40_altform-unplated.png new file mode 100644 index 0000000..ac1f9c5 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-40_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-48.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-48.png new file mode 100644 index 0000000..abf387b Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-48.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-48_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-48_altform-unplated.png new file mode 100644 index 0000000..abf387b Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-48_altform-unplated.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-64.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-64.png new file mode 100644 index 0000000..e254fd8 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-64.png differ diff --git a/clients/windows/packaging/assets/Square44x44Logo.targetsize-64_altform-unplated.png b/clients/windows/packaging/assets/Square44x44Logo.targetsize-64_altform-unplated.png new file mode 100644 index 0000000..e254fd8 Binary files /dev/null and b/clients/windows/packaging/assets/Square44x44Logo.targetsize-64_altform-unplated.png differ diff --git a/clients/windows/packaging/pack-msix.ps1 b/clients/windows/packaging/pack-msix.ps1 index dd807d0..7d23197 100644 --- a/clients/windows/packaging/pack-msix.ps1 +++ b/clients/windows/packaging/pack-msix.ps1 @@ -106,6 +106,25 @@ Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force $manifest = (Get-Content -Raw $manifestTemplate).Replace('{VERSION}', $Version).Replace('{PUBLISHER}', $Publisher).Replace('{ARCH}', $Arch) Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 +# --- resource index (resources.pri) --- +# The shell resolves the manifest's logo assets through MRT, so the qualified variants +# (Square44x44Logo.targetsize-*_altform-unplated.png — the alpha-transparent taskbar icons) only +# take effect if a pri indexes them; without one the taskbar falls back to plating the base +# 44x44 onto a solid square (the white-cornered icon). makepri's default config indexes the +# layout's asset files AND merges any existing .pri it finds (reactor's staged WinUI resources) +# via its PRI indexer, yielding one combined resources.pri. Output lands outside the layout +# first — the reactor pri is an input while indexing — then replaces it. +$makepri = Find-SdkTool 'makepri.exe' +$priconfig = Join-Path $OutDir 'priconfig.xml' +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +& $makepri createconfig /cf $priconfig /dq en-US /o +if ($LASTEXITCODE -ne 0) { throw "makepri createconfig failed ($LASTEXITCODE)" } +$priOut = Join-Path $OutDir 'resources.pri' +if (Test-Path $priOut) { Remove-Item $priOut -Force } +& $makepri new /pr $layout /cf $priconfig /mn (Join-Path $layout 'AppxManifest.xml') /of $priOut /o +if ($LASTEXITCODE -ne 0) { throw "makepri new failed ($LASTEXITCODE)" } +Move-Item $priOut (Join-Path $layout 'resources.pri') -Force + Write-Host "layout assembled at $layout :" Get-ChildItem $layout -Recurse -File | ForEach-Object { " $($_.FullName.Substring($layout.Length + 1))" } diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index e907053..012cdde 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -1,5 +1,6 @@ -//! The hosts page: saved (trusted/paired) hosts with per-host actions (speed test, forget), -//! live mDNS discovery, and a manual connect entry. +//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect +//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename / +//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients. use super::connect::initiate; use super::speed::SpeedState; @@ -9,74 +10,190 @@ 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. +const MENU_CONNECT: &str = "Connect"; +const MENU_SPEED: &str = "Test network speed\u{2026}"; +const MENU_RENAME: &str = "Rename\u{2026}"; +const MENU_FORGET: &str = "Forget\u{2026}"; + +/// Tile-grid metrics: minimum tile width before dropping a column, and the gap between tiles. +const TILE_MIN_WIDTH: f64 = 320.0; +const TILE_GAP: f64 = 12.0; + /// Props for the hosts page: the services plus the changing discovery/status data that must /// drive its re-render (compared by value, so a new host list or error refreshes the page). +/// +/// `forget` and `rename` are the per-host action state, and they live in ROOT (not this page's +/// own `use_state`) on purpose: the "…" overflow is a WinUI `MenuFlyout`, whose item clicks are +/// wired directly in the reactor backend (`add_Click`) and so bypass the normal event-dispatch +/// flush — a *sync* child `SetState` from that handler marks state dirty but never pumps the +/// reconciler, so nothing re-renders. Root `AsyncSetState` re-renders the whole tree; because +/// these values are props, the changed value propagates back into this page (a child's own async +/// state would be memoised away when its props are unchanged). `(fp_hex, _)` in each identifies +/// the target saved host; `rename`'s second field is the in-progress draft name. #[derive(Clone)] pub(crate) struct HostsProps { pub(crate) svc: Svc, pub(crate) hosts: Vec, pub(crate) status: String, + pub(crate) forget: Option<(String, String)>, + pub(crate) rename: Option<(String, String)>, + /// Whether the "Add host" modal is open. Root state (like `forget`/`rename`), not the page's + /// 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, + pub(crate) set_forget: AsyncSetState>, + pub(crate) set_rename: AsyncSetState>, + pub(crate) set_show_add: AsyncSetState, } impl PartialEq for HostsProps { fn eq(&self, other: &Self) -> bool { - self.svc == other.svc && self.hosts == other.hosts && self.status == other.status + // Setters are identity-stable; only the value fields drive re-render. + self.svc == other.svc + && self.hosts == other.hosts + && self.status == other.status + && self.forget == other.forget + && self.rename == other.rename + && self.show_add == other.show_add } } -/// A clickable host row: monogram + name/address + optional action buttons + status pill + -/// chevron. `actions` land between the text and the pill (saved hosts: speed test / forget). -fn host_card( +/// A host tile. The tap-to-connect summary (monogram, name, address, status row) and the +/// 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). +fn host_tile( name: &str, sub: &str, - badge: &str, - actions: Vec, - on_tap: impl Fn() + 'static, + status_row: Element, + menu: Option