From 9074781acdfd9c93bdc5fb32e72abaf46d393108 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 2 Jul 2026 11:54:06 +0200 Subject: [PATCH] feat(clients/windows): screen-module restructure + parity features (speed test, native mode, capture UX) Structure: split the 1400-line app.rs into per-screen app/ modules (mod=root/ router, hosts, connect, pair, speed, settings, licenses, stream, style) with shared card/header/busy-page builders and setting_combo/toggle helpers; the re-render rule (thread-driven state lives in root use_async_state, flows down as props) is now documented at the module root. Parity features the other clients already had: - "Native display" resolves the real monitor mode at connect (MonitorFromWindow -> EnumDisplaySettingsW; was a hardcoded 1080p60) - per-host network speed test: saved-host card button + a results screen (probe burst -> goodput/loss -> ~70% recommended bitrate applied in one tap; stale runs invalidated by generation) and `--headless --speed-test`; the bitrate setting becomes a free-form NumberBox so the recommendation round-trips - forget host (ContentDialog confirm -> KnownHosts::remove_by_fp) - settings: forwarded-controller picker (pads/pinned/set_pinned now wired), gamepad type, host compositor, capture-system-shortcuts; the previously dead Settings.compositor / inhibit_shortcuts are honored (shortcuts off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally) - click-to-recapture after a Ctrl+Alt+Shift+Q release; the HUD hint tracks the live capture state Perf: the input hook caches lock geometry (clip rect + contain-fit scale) at engage instead of GetClientRect per WM_MOUSEMOVE; the audio jitter ring trims via drain() and reuses the render scratch buffer. Validated on the bare-metal box: --discover, synthetic-host loopback E2E (TOFU -> clock skew -> HEVC negotiate -> D3D11VA init -> session end), speed-test E2E, and the WinUI shell rendering in the console session via PsExec (SSH/session-0 cannot create windows, pre-existing 0x80070005). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 24 +- clients/windows/README.md | 23 +- clients/windows/src/app.rs | 1429 --------------------------- clients/windows/src/app/connect.rs | 363 +++++++ clients/windows/src/app/hosts.rs | 291 ++++++ clients/windows/src/app/licenses.rs | 70 ++ clients/windows/src/app/mod.rs | 206 ++++ clients/windows/src/app/pair.rs | 126 +++ clients/windows/src/app/settings.rs | 306 ++++++ clients/windows/src/app/speed.rs | 179 ++++ clients/windows/src/app/stream.rs | 192 ++++ clients/windows/src/app/style.rs | 135 +++ clients/windows/src/audio.rs | 9 +- clients/windows/src/gamepad.rs | 23 +- clients/windows/src/input.rs | 138 ++- clients/windows/src/main.rs | 26 + clients/windows/src/session.rs | 49 + clients/windows/src/trust.rs | 10 +- 18 files changed, 2109 insertions(+), 1490 deletions(-) delete mode 100644 clients/windows/src/app.rs create mode 100644 clients/windows/src/app/connect.rs create mode 100644 clients/windows/src/app/hosts.rs create mode 100644 clients/windows/src/app/licenses.rs create mode 100644 clients/windows/src/app/mod.rs create mode 100644 clients/windows/src/app/pair.rs create mode 100644 clients/windows/src/app/settings.rs create mode 100644 clients/windows/src/app/speed.rs create mode 100644 clients/windows/src/app/stream.rs create mode 100644 clients/windows/src/app/style.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4a7a980..a782de2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -241,10 +241,26 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc 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 - — the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. Next: **on-glass - validation** of the D3D11VA decode + HDR present + GUI on the RTX box (the dev VM is - headless/Session-0/WARP → the WinUI window + hardware decode need a real display+GPU: RDP or the - RTX box), then RAWINPUT relative-mouse pointer-lock and a per-host speed test in the UI. + — 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 + `use_async_state` and flows down as props — a child's own async-state write does NOT re-render it); + "Native display" now resolves the real monitor mode at connect (`MonitorFromWindow` → + `EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button + + `--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate + setting is now a free-form NumberBox); **forget host** (ContentDialog confirm → + `KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host + compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/ + `inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally); + **click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state; + input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via + `drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host + 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. **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/clients/windows/README.md b/clients/windows/README.md index 13f4037..88f4913 100644 --- a/clients/windows/README.md +++ b/clients/windows/README.md @@ -18,12 +18,17 @@ the fast **`punktfunk/1`** protocol. - **Your display's native mode** — the host builds a virtual display at exactly your WxH@Hz. - **Audio both ways** — WASAPI render + mic capture. - **Full controller support** — SDL3 gamepads with rumble, lightbar, and DualSense feedback. +- **Your display's native mode, really** — "Native display" resolves the actual size + refresh of + the monitor the window is on at connect time. - **Find hosts automatically** — mDNS discovery lists hosts on your LAN, alongside saved and manual entries. First connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then - reconnects on a pinned identity. -- **Polished shell** — host cards, settings (resolution / refresh / decoder / bitrate / HDR / mic), - a status-chip stream HUD, and the full trust surface. Stream input uses Win32 low-level hooks with - a Ctrl+Alt+Shift+Q capture toggle. + reconnects on a pinned identity. Saved hosts carry per-host actions: a **network speed test** + (probe burst over the real data plane → recommended bitrate, applied in one tap) and **forget**. +- **Polished shell** — host cards, settings (resolution / refresh / host compositor / decoder / + codec / bitrate / HDR / forwarded controller / gamepad type / system shortcuts / audio channels / + mic), a status-chip stream HUD, and the full trust surface. Stream input uses Win32 low-level + hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream + re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host. Builds and ships for both **x64** and **ARM64** as a signed **MSIX**. @@ -45,6 +50,7 @@ cargo build -p punktfunk-client-windows --target x86_64-pc-windows-msvc # CLI paths for testing (no window): punktfunk-client --discover # list hosts on the LAN punktfunk-client --headless --connect host[:port] [--pin HEX] # connect, count frames, print stats +punktfunk-client --headless --speed-test --connect host[:port] # probe burst → recommended bitrate ``` > `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header @@ -54,13 +60,16 @@ punktfunk-client --headless --connect host[:port] [--pin HEX] # connect, count f ``` src/ - main.rs · app.rs entry point + CLI paths; WinUI 3 shell (windows-reactor) + main.rs entry point + CLI paths (--discover · --headless · --speed-test) + app/ WinUI 3 shell (windows-reactor), one module per screen: + mod (root/router) · hosts · connect · pair · speed · settings · + licenses · stream · style (shared cards/pills/monograms) present.rs · gpu.rs SwapChainPanel D3D11 composition swapchain; shared D3D11 device video.rs FFmpeg HEVC decode (D3D11VA zero-copy + software fallback) audio.rs WASAPI render + mic capture gamepad.rs SDL3 controllers + rumble/lightbar/DualSense feedback - input.rs Win32 low-level keyboard/mouse hooks → host input - session.rs session lifecycle over the NativeClient connector + input.rs Win32 low-level hooks → host input (pointer lock · click-to-capture) + session.rs session lifecycle over the NativeClient connector (+ speed probe) trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse packaging/ MSIX manifest, signing, pack script ``` diff --git a/clients/windows/src/app.rs b/clients/windows/src/app.rs deleted file mode 100644 index 71713d8..0000000 --- a/clients/windows/src/app.rs +++ /dev/null @@ -1,1429 +0,0 @@ -//! The WinUI 3 (windows-reactor) application shell — host list, settings, PIN/TOFU pairing, and -//! the stream page (a `SwapChainPanel` bound to the D3D11 composition swapchain in -//! [`crate::present`], driven by reactor's per-frame `on_rendering`). -//! -//! Declarative React-like model: a single root component routes on a `Screen` value held in -//! `use_async_state` so background threads (discovery, the session pump) can drive navigation. -//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel -//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame -//! present must not go through state/rerender. -//! -//! The chrome follows the windows-reactor gallery's look: Mica backdrop, a centred max-width -//! column, theme brushes (`ThemeRef`), and rounded `border` cards. - -use crate::discovery::{self, DiscoveredHost}; -use crate::gamepad::GamepadService; -use crate::present::Presenter; -use crate::session::{self, SessionEvent, SessionParams, Stats}; -use crate::trust::{self, KnownHost, KnownHosts, Settings}; -use crate::video::{DecodedFrame, DecoderPref}; -use punktfunk_core::client::NativeClient; -use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; -use std::cell::RefCell; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use windows_reactor::*; - -const RESOLUTIONS: &[(u32, u32)] = &[ - (0, 0), - (1280, 720), - (1920, 1080), - (2560, 1440), - (3840, 2160), -]; -const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; -/// Decode backend presets: `(stored value, display label)`. -const DECODERS: &[(&str, &str)] = &[ - ("auto", "Automatic (GPU, fall back to CPU)"), - ("hardware", "Hardware (GPU / D3D11VA)"), - ("software", "Software (CPU)"), -]; -/// Bitrate presets in Mb/s; `0` = host default. -const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150]; -/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can -/// capture; the resolved count drives the decoder + WASAPI render layout. -const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")]; -/// Preferred-codec presets: `(stored value, display label)`. Soft — the host falls back if it can't -/// encode the chosen codec. -const CODECS: &[(&str, &str)] = &[ - ("auto", "Automatic"), - ("hevc", "HEVC (H.265)"), - ("h264", "H.264 (AVC)"), - ("av1", "AV1"), -]; - -/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen. -const APP_LICENSE: &str = concat!( - include_str!("../../../LICENSE-MIT"), - "\n\n================================ Apache-2.0 ================================\n\n", - include_str!("../../../LICENSE-APACHE"), -); -/// Third-party software notices for the linked Rust crates (generated by -/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/). -const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt"); - -#[derive(Clone, PartialEq)] -enum Screen { - Hosts, - Connecting, - /// The no-PIN "request access" wait: an identified connect is in flight, parked by the host - /// until the operator approves this device in its console. Cancelable. - RequestAccess, - Stream, - Settings, - /// Open-source / third-party license notices (reached from Settings). - Licenses, - Pair, -} - -/// The host we're about to connect to / pair with (carried into the Pair screen). -#[derive(Clone, Default)] -struct Target { - name: String, - addr: String, - port: u16, - fp_hex: Option, - pair_optional: bool, -} - -/// Stable app services handed to the page components as props. Each routed screen that uses -/// hooks (`hosts_page`/`pair_page`/`stream_page`) is mounted as its own `component(...)`, so -/// its hooks live in an isolated slot list — calling them on the shared parent `cx` would -/// change the hook order whenever the screen changes (reactor's Rules-of-Hooks guard aborts). -/// -/// `Svc` compares equal by `ctx` identity (it never meaningfully changes across renders), so a -/// page whose props are just `Svc` re-renders only via its own state hooks, never spuriously -/// from the parent. -#[derive(Clone)] -struct Svc { - ctx: Arc, - set_screen: AsyncSetState, - set_status: AsyncSetState, -} - -impl PartialEq for Svc { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.ctx, &other.ctx) - } -} - -/// 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). -#[derive(Clone)] -struct HostsProps { - svc: Svc, - hosts: Vec, - status: String, -} - -impl PartialEq for HostsProps { - fn eq(&self, other: &Self) -> bool { - self.svc == other.svc && self.hosts == other.hosts && self.status == other.status - } -} - -/// Props for the stream page: the services plus the live stats that drive the HUD overlay -/// (compared by value, so each new sample re-renders the overlay). -#[derive(Clone)] -struct StreamProps { - svc: Svc, - stats: Stats, -} - -impl PartialEq for StreamProps { - fn eq(&self, other: &Self) -> bool { - self.svc == other.svc && self.stats == other.stats - } -} - -/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver. -struct PresentCtx { - presenter: Presenter, - frames: async_channel::Receiver, -} - -thread_local! { - static PRESENT: RefCell> = const { RefCell::new(None) }; - static PENDING_FRAMES: RefCell>> = - const { RefCell::new(None) }; -} - -/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread). -#[derive(Default)] -struct Shared { - handoff: Mutex, async_channel::Receiver)>>, - target: Mutex, - /// Latest stream stats, written by the session's event loop and mirrored into reactor state - /// by the stream page's HUD poll thread to drive the overlay. - stats: Mutex, - /// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per - /// request: the waiting screen's Cancel button reads it back from here and sets it, and that - /// request's event loop (which captured the same `Arc` at spawn) then tears down silently when - /// the parked connect finally resolves. `None` outside a request-access flow. - cancel: Mutex>>, -} - -pub struct AppCtx { - identity: (String, String), - settings: Mutex, - gamepad: GamepadService, - shared: Arc, -} - -pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> { - let ctx = Arc::new(AppCtx { - identity, - settings: Mutex::new(Settings::load()), - gamepad, - shared: Arc::new(Shared::default()), - }); - App::new() - .title("Punktfunk") - .inner_size(1000.0, 720.0) - .backdrop(Backdrop::Mica) - .render(move |cx| root(cx, &ctx)) -} - -// --- shared styling ----------------------------------------------------------------------- - -fn uniform(v: f64) -> Thickness { - Thickness::uniform(v) -} - -fn edges(left: f64, top: f64, right: f64, bottom: f64) -> Thickness { - Thickness { - left, - top, - right, - bottom, - } -} - -/// A rounded, bordered surface in the theme's card colours. -fn card(child: impl Into) -> Border { - border(child.into()) - .background(ThemeRef::CardBackground) - .border_brush(ThemeRef::CardStroke) - .border_thickness(uniform(1.0)) - .corner_radius(8.0) - .padding(uniform(16.0)) -} - -/// A small all-caps section label above a group of cards. -fn section(label: &str) -> Element { - text_block(label) - .font_size(12.0) - .semibold() - .foreground(ThemeRef::SecondaryText) - .margin(edges(2.0, 10.0, 0.0, 0.0)) - .into() -} - -/// Wrap a screen's children in a scrollable, centred, max-width column. -fn page(children: Vec) -> Element { - let col = vstack(children) - .spacing(10.0) - .max_width(640.0) - .horizontal_alignment(HorizontalAlignment::Center) - .margin(edges(24.0, 24.0, 24.0, 40.0)); - scroll_view(col).into() -} - -/// A rounded square "monogram" for a host, the first letter on an accent fill — a clean leading -/// visual that avoids depending on an icon font being installed. -fn avatar(name: &str) -> Border { - let initial = name - .chars() - .find(|c| c.is_alphanumeric()) - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_else(|| "?".into()); - border( - text_block(initial) - .font_size(17.0) - .semibold() - .foreground(ThemeRef::AccentText) - .horizontal_alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Center), - ) - .background(ThemeRef::Accent) - .corner_radius(10.0) - .width(40.0) - .height(40.0) -} - -/// Pill chip colour intent. -#[derive(Clone, Copy)] -enum Pill { - Accent, - Good, - Neutral, -} - -/// A small rounded status chip (paired/PIN/HDR/etc.). -fn pill(text: &str, kind: Pill) -> Border { - let (bg, fg) = match kind { - Pill::Accent => (ThemeRef::Accent, ThemeRef::AccentText), - Pill::Good => (ThemeRef::SystemSuccessBackground, ThemeRef::SystemSuccess), - Pill::Neutral => (ThemeRef::SubtleFill, ThemeRef::SecondaryText), - }; - border(text_block(text).font_size(11.0).semibold().foreground(fg)) - .background(bg) - .corner_radius(10.0) - .padding(edges(9.0, 3.0, 9.0, 3.0)) -} - -/// A clickable host row: monogram + name/address + status pill + chevron. -fn host_card(name: &str, sub: &str, badge: &str, on_tap: impl Fn() + 'static) -> Element { - let kind = match badge { - "Paired" => Pill::Good, - "Open" => Pill::Neutral, - _ => Pill::Accent, // Trusted / PIN - }; - card( - grid(( - avatar(name) - .grid_column(0) - .vertical_alignment(VerticalAlignment::Center), - vstack(( - text_block(name).font_size(15.0).semibold(), - text_block(sub) - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - )) - .spacing(2.0) - .grid_column(1) - .vertical_alignment(VerticalAlignment::Center) - .margin(edges(12.0, 0.0, 0.0, 0.0)), - pill(badge, kind) - .grid_column(2) - .vertical_alignment(VerticalAlignment::Center) - .margin(edges(0.0, 0.0, 10.0, 0.0)), - text_block("\u{203A}") - .font_size(18.0) - .foreground(ThemeRef::SecondaryText) - .grid_column(3) - .vertical_alignment(VerticalAlignment::Center), - )) - .columns([ - GridLength::Auto, - GridLength::Star(1.0), - GridLength::Auto, - GridLength::Auto, - ]), - ) - .on_tapped(on_tap) - .into() -} - -// --- screens ------------------------------------------------------------------------------ - -fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { - let (screen, set_screen) = cx.use_async_state(Screen::Hosts); - let (hosts, set_hosts) = cx.use_async_state(Vec::::new()); - let (status, set_status) = cx.use_async_state(String::new()); - let (stats, set_stats) = cx.use_async_state(Stats::default()); - - // Continuous LAN discovery (spawned once). - cx.use_effect((), { - let set_hosts = set_hosts.clone(); - move || { - let rx = discovery::browse(); - std::thread::spawn(move || { - let mut acc: Vec = Vec::new(); - while let Ok(h) = rx.recv_blocking() { - if let Some(e) = acc.iter_mut().find(|e| e.key == h.key) { - *e = h; - } else { - acc.push(h); - } - set_hosts.call(acc.clone()); - } - }); - } - }); - - // HUD stats: the session event loop writes `shared.stats`; this poll thread mirrors it into - // root state so the stream page gets it as a *prop*. (A child component's own async-state - // update is pruned when its props are unchanged — only a prop change re-renders it, exactly - // like discovery → hosts above.) - cx.use_effect((), { - let shared = ctx.shared.clone(); - let set_stats = set_stats.clone(); - move || { - std::thread::Builder::new() - .name("pf-hud".into()) - .spawn(move || { - let mut last = Stats::default(); - loop { - std::thread::sleep(std::time::Duration::from_millis(400)); - let s = *shared.stats.lock().unwrap(); - if s != last { - last = s; - set_stats.call(s); - } - } - }) - .ok(); - } - }); - - // 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 { - ctx: ctx.clone(), - set_screen: set_screen.clone(), - set_status: set_status.clone(), - }; - match screen { - Screen::Hosts => component(hosts_page, HostsProps { svc, hosts, status }), - Screen::Connecting => { - let target_name = ctx.shared.target.lock().unwrap().name.clone(); - let headline = if target_name.is_empty() { - "Connecting\u{2026}".to_string() - } else { - format!("Connecting to {target_name}\u{2026}") - }; - vstack(( - ProgressRing::indeterminate() - .width(48.0) - .height(48.0) - .horizontal_alignment(HorizontalAlignment::Center), - text_block(headline) - .font_size(18.0) - .semibold() - .horizontal_alignment(HorizontalAlignment::Center), - text_block(if status.is_empty() { - "Negotiating the session and creating the virtual display\u{2026}".to_string() - } else { - status.clone() - }) - .foreground(ThemeRef::SecondaryText) - .horizontal_alignment(HorizontalAlignment::Center), - )) - .spacing(16.0) - .horizontal_alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Center) - .into() - } - // request_access_page (like settings_page/Connecting) uses no hooks, so calling it inline - // is sound — it only wires a Cancel button to the shared cancel flag + navigation. - Screen::RequestAccess => request_access_page(ctx, &set_screen), - // settings_page uses no hooks (it never touches `cx`), so calling it inline is sound. - Screen::Settings => settings_page(ctx, &set_screen), - // licenses_page is a static text screen (no hooks), so inline is sound. - Screen::Licenses => licenses_page(&set_screen), - Screen::Pair => component(pair_page, svc), - Screen::Stream => component(stream_page, StreamProps { svc, stats }), - } -} - -fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { - let ctx = &props.svc.ctx; - let hosts = props.hosts.as_slice(); - let status = props.status.as_str(); - let set_screen = &props.svc.set_screen; - let set_status = &props.svc.set_status; - let (manual, set_manual) = cx.use_state(String::new()); - let known = KnownHosts::load(); - - let mut body: Vec = Vec::new(); - - // Header: title block + Settings button. - body.push( - grid(( - vstack(( - text_block("Punktfunk").font_size(30.0).bold(), - text_block("Stream from a host on your network.") - .foreground(ThemeRef::SecondaryText), - )) - .spacing(2.0) - .grid_column(0) - .vertical_alignment(VerticalAlignment::Center), - button("Settings") - .icon(SymbolGlyph::Setting) - .on_click({ - let ss = set_screen.clone(); - move || ss.call(Screen::Settings) - }) - .grid_column(1) - .vertical_alignment(VerticalAlignment::Center), - )) - .columns([GridLength::Star(1.0), GridLength::Auto]) - .margin(edges(0.0, 0.0, 0.0, 6.0)) - .into(), - ); - - if !status.is_empty() { - body.push( - InfoBar::new("Couldn't connect") - .message(status.to_string()) - .error() - .is_closable(false) - .into(), - ); - } - - // Saved (trusted/paired) hosts. - if !known.hosts.is_empty() { - body.push(section("SAVED HOSTS")); - for k in &known.hosts { - let target = Target { - name: k.name.clone(), - addr: k.addr.clone(), - port: k.port, - fp_hex: Some(k.fp_hex.clone()), - pair_optional: false, - }; - let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); - body.push(host_card( - &k.name, - &format!("{}:{}", k.addr, k.port), - if k.paired { "Paired" } else { "Trusted" }, - move || initiate(&ctx2, target.clone(), &ss, &st), - )); - } - } - - // Discovered hosts. - body.push(section("ON YOUR NETWORK")); - if hosts.is_empty() { - body.push( - card( - hstack(( - ProgressRing::indeterminate().width(18.0).height(18.0), - text_block("Searching the LAN\u{2026}").foreground(ThemeRef::SecondaryText), - )) - .spacing(12.0), - ) - .into(), - ); - } else { - for h in hosts { - let target = Target { - name: h.name.clone(), - addr: h.addr.clone(), - port: h.port, - fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), - pair_optional: h.pair == "optional", - }; - let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); - let badge = if h.pair == "required" { "PIN" } else { "Open" }; - body.push(host_card( - &h.name, - &format!("{}:{}", h.addr, h.port), - badge, - move || initiate(&ctx2, target.clone(), &ss, &st), - )); - } - } - - // Manual connection. - body.push(section("CONNECT MANUALLY")); - let connect_manual = { - let (ctx2, ss, st, text) = ( - ctx.clone(), - set_screen.clone(), - set_status.clone(), - manual.clone(), - ); - move || { - let text = text.trim(); - if text.is_empty() { - return; - } - let (addr, port) = match text.rsplit_once(':') { - Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)), - None => (text.to_string(), 9777), - }; - initiate( - &ctx2, - Target { - name: addr.clone(), - addr, - port, - fp_hex: None, - pair_optional: false, - }, - &ss, - &st, - ); - } - }; - body.push( - card( - grid(( - text_box(manual) - .placeholder("host or host:port") - .on_changed(move |s| set_manual.call(s)) - .grid_column(0) - .vertical_alignment(VerticalAlignment::Center), - button("Connect") - .accent() - .icon(SymbolGlyph::Forward) - .on_click(connect_manual) - .grid_column(1) - .margin(edges(8.0, 0.0, 0.0, 0.0)), - )) - .columns([GridLength::Star(1.0), GridLength::Auto]), - ) - .into(), - ); - - page(body) -} - -/// The trust gate (mirrors the GTK client's `initiate_connect`): pinned fingerprint → silent -/// connect; known address → stored pin; advertised `pair=optional` → TOFU; otherwise → PIN -/// pairing. -fn initiate( - ctx: &Arc, - target: Target, - set_screen: &AsyncSetState, - set_status: &AsyncSetState, -) { - let known = KnownHosts::load(); - let pin = target - .fp_hex - .as_ref() - .and_then(|fp| known.find_by_fp(fp).map(|_| fp.clone())) - .or_else(|| { - known - .find_by_addr(&target.addr, target.port) - .map(|k| k.fp_hex.clone()) - }) - .and_then(|fp| trust::parse_hex32(&fp)); - - if let Some(pin) = pin { - connect(ctx, &target, Some(pin), set_screen, set_status); - } else if target.pair_optional { - connect(ctx, &target, None, set_screen, set_status); // TOFU - } else { - *ctx.shared.target.lock().unwrap() = target; - set_screen.call(Screen::Pair); - } -} - -/// Tunables that differ between the normal connect and the no-PIN "request access" flow. -/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the -/// plain "Connecting" screen. -struct ConnectOpts { - /// Handshake budget. Request-access uses a long one because the host PARKS the connection - /// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`). - connect_timeout: Duration, - /// Persist the host as *paired* on a successful connect. Set for request-access, where the - /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU - /// persists the host *unpaired* (pinned, but not PIN/approval-verified). - persist_paired: bool, - /// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access). - awaiting_approval: bool, - /// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no - /// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time - /// out; this request's event loop then sees the flag and tears down silently (drops the - /// connector → closes the connection) without touching a screen a new session may already own. - cancel: Option>, -} - -impl Default for ConnectOpts { - fn default() -> Self { - Self { - connect_timeout: Duration::from_secs(15), - persist_paired: false, - awaiting_approval: false, - cancel: None, - } - } -} - -fn connect( - ctx: &Arc, - target: &Target, - pin: Option<[u8; 32]>, - set_screen: &AsyncSetState, - set_status: &AsyncSetState, -) { - connect_with( - ctx, - target, - pin, - set_screen, - set_status, - ConnectOpts::default(), - ); -} - -fn connect_with( - ctx: &Arc, - target: &Target, - pin: Option<[u8; 32]>, - set_screen: &AsyncSetState, - set_status: &AsyncSetState, - opts: ConnectOpts, -) { - let s = ctx.settings.lock().unwrap().clone(); - let mode = if s.width != 0 && s.refresh_hz != 0 { - Mode { - width: s.width, - height: s.height, - refresh_hz: s.refresh_hz, - } - } else { - Mode { - width: 1920, - height: 1080, - refresh_hz: 60, - } - }; - let gamepad_pref = match GamepadPref::from_name(&s.gamepad) { - Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(), - Some(explicit) => explicit, - }; - let handle = session::start(SessionParams { - host: target.addr.clone(), - port: target.port, - mode, - compositor: CompositorPref::Auto, - gamepad: gamepad_pref, - bitrate_kbps: s.bitrate_kbps, - audio_channels: s.audio_channels, - mic_enabled: s.mic_enabled, - hdr_enabled: s.hdr_enabled, - decoder: DecoderPref::from_name(&s.decoder), - preferred_codec: s.preferred_codec(), - pin, - identity: ctx.identity.clone(), - connect_timeout: opts.connect_timeout, - }); - set_status.call(String::new()); - set_screen.call(if opts.awaiting_approval { - Screen::RequestAccess - } else { - Screen::Connecting - }); - - let tofu = pin.is_none(); - let persist_paired = opts.persist_paired; - let cancel = opts.cancel; - let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone()); - let (ss, st) = (set_screen.clone(), set_status.clone()); - let target = target.clone(); - std::thread::spawn(move || loop { - let event = match handle.events.recv_blocking() { - Ok(e) => e, - Err(_) => { - gamepad.detach(); - ss.call(Screen::Hosts); - break; - } - }; - // A cancelled request-access connect that resolved late (the host approved or the park - // timed out after the user walked away): tear down silently. Cancel already returned the - // UI to the host list; dropping `event` (and with it any connector) closes the connection - // without popping a stream or a stray error over the screen a new session may own. - if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { - break; - } - match event { - SessionEvent::Connected { - connector, - fingerprint, - .. - } => { - if persist_paired || tofu { - // Request-access: the operator approved this device, so record the host as a - // trusted PAIRED host — future connects are then silent (rule 1), exactly like - // after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned). - let mut k = KnownHosts::load(); - k.upsert(KnownHost { - name: target.name.clone(), - addr: target.addr.clone(), - port: target.port, - fp_hex: trust::hex(&fingerprint), - paired: persist_paired, - }); - let _ = k.save(); - } - gamepad.attach(connector.clone()); - *shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers - *shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone())); - ss.call(Screen::Stream); - } - SessionEvent::Failed { - msg, - trust_rejected, - } => { - st.call(msg); - gamepad.detach(); - if trust_rejected { - // Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen. - *shared.target.lock().unwrap() = target.clone(); - ss.call(Screen::Pair); - } else { - ss.call(Screen::Hosts); - } - break; - } - SessionEvent::Ended(err) => { - st.call(err.unwrap_or_else(|| "Session ended".into())); - gamepad.detach(); - ss.call(Screen::Hosts); - break; - } - SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s, - } - }); -} - -/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the -/// operator approves this device in its console (or web UI), showing a cancelable "waiting" -/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is -/// saved as paired, so later connects are silent. -fn request_access( - ctx: &Arc, - target: &Target, - set_screen: &AsyncSetState, - set_status: &AsyncSetState, -) { - // Pin the advertised certificate for a discovered host (defence against a host impostor while - // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. - let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32); - // A fresh cancel flag per request, installed where the waiting screen's Cancel button can read - // it back; this request's event loop captures the same `Arc` (via ConnectOpts) below. - let cancel = Arc::new(AtomicBool::new(false)); - *ctx.shared.cancel.lock().unwrap() = Some(cancel.clone()); - connect_with( - ctx, - target, - pin, - set_screen, - set_status, - ConnectOpts { - // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator - // approval still lands on this connection rather than timing the client out first. - connect_timeout: Duration::from_secs(185), - persist_paired: true, - awaiting_approval: true, - cancel: Some(cancel), - }, - ); -} - -/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while -/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and -/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the -/// user has walked away. Mirrors the inline `Connecting` screen; uses no hooks. -fn request_access_page(ctx: &Arc, set_screen: &AsyncSetState) -> Element { - let target_name = ctx.shared.target.lock().unwrap().name.clone(); - let headline = if target_name.is_empty() { - "Waiting for approval\u{2026}".to_string() - } else { - format!("Waiting for {target_name} to approve\u{2026}") - }; - let cancel_btn = { - let (ctx, ss) = (ctx.clone(), set_screen.clone()); - button("Cancel") - .icon(SymbolGlyph::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 - // the connect finally resolves (see ConnectOpts::cancel). - if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() { - c.store(true, Ordering::SeqCst); - } - ss.call(Screen::Hosts); - }) - .horizontal_alignment(HorizontalAlignment::Center) - }; - vstack(( - ProgressRing::indeterminate() - .width(48.0) - .height(48.0) - .horizontal_alignment(HorizontalAlignment::Center), - text_block(headline) - .font_size(18.0) - .semibold() - .horizontal_alignment(HorizontalAlignment::Center), - text_block( - "Approve this device in the host's console or web UI \u{2014} it connects automatically \ - once you approve it. No PIN needed.", - ) - .foreground(ThemeRef::SecondaryText) - .horizontal_alignment(HorizontalAlignment::Center), - cancel_btn, - )) - .spacing(16.0) - .horizontal_alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Center) - .into() -} - -fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { - let ctx = &props.ctx; - let set_screen = &props.set_screen; - let set_status = &props.set_status; - let (code, set_code) = cx.use_state(String::new()); - let target = ctx.shared.target.lock().unwrap().clone(); - - let pair_btn = { - let (ctx2, ss, st, code2, target2) = ( - ctx.clone(), - set_screen.clone(), - set_status.clone(), - code.clone(), - target.clone(), - ); - button("Pair & Connect") - .accent() - .icon(SymbolGlyph::Accept) - .on_click(move || { - let pin = code2.trim().to_string(); - let (ctx3, ss, st, target3) = - (ctx2.clone(), ss.clone(), st.clone(), target2.clone()); - std::thread::spawn(move || { - let name = - std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into()); - match NativeClient::pair( - &target3.addr, - target3.port, - (&ctx3.identity.0, &ctx3.identity.1), - &pin, - &name, - std::time::Duration::from_secs(90), - ) { - Ok(fp) => { - let mut k = KnownHosts::load(); - k.upsert(KnownHost { - name: target3.name.clone(), - addr: target3.addr.clone(), - port: target3.port, - fp_hex: trust::hex(&fp), - paired: true, - }); - let _ = k.save(); - connect(&ctx3, &target3, Some(fp), &ss, &st); - } - Err(e) => { - st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)")); - ss.call(Screen::Hosts); - } - } - }); - }) - }; - let cancel_btn = { - let ss = set_screen.clone(); - button("Cancel") - .icon(SymbolGlyph::Cancel) - .on_click(move || ss.call(Screen::Hosts)) - }; - // The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that - // the host parks until the operator approves this device in its console (delegated approval). - let request_btn = { - let (ctx2, ss, st, target2) = ( - ctx.clone(), - set_screen.clone(), - set_status.clone(), - target.clone(), - ); - button("Request access without a PIN") - .icon(SymbolGlyph::Send) - .on_click(move || request_access(&ctx2, &target2, &ss, &st)) - .horizontal_alignment(HorizontalAlignment::Stretch) - }; - - let content = card(vstack(( - grid(( - avatar(&target.name) - .grid_column(0) - .vertical_alignment(VerticalAlignment::Center), - vstack(( - text_block(format!("Pair with {}", target.name)) - .font_size(20.0) - .semibold(), - text_block(format!("{}:{}", target.addr, target.port)) - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - )) - .spacing(2.0) - .grid_column(1) - .vertical_alignment(VerticalAlignment::Center) - .margin(edges(12.0, 0.0, 0.0, 0.0)), - )) - .columns([GridLength::Auto, GridLength::Star(1.0)]), - InfoBar::new("Arm pairing on the host") - .message( - "On the host's console or web console, start pairing — it shows a 4-digit PIN. \ - Enter it below within 90 seconds.", - ) - .informational() - .is_closable(false), - text_box(code) - .placeholder("PIN") - .font_size(28.0) - .on_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 \ - (its console or web UI) \u{2014} no PIN needed.", - ) - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - request_btn, - )) - .spacing(16.0)) - .max_width(480.0) - .horizontal_alignment(HorizontalAlignment::Center) - .margin(edges(0.0, 60.0, 0.0, 0.0)); - - page(vec![content.into()]) -} - -fn settings_page(ctx: &Arc, set_screen: &AsyncSetState) -> Element { - let s = ctx.settings.lock().unwrap().clone(); - let res_i = RESOLUTIONS - .iter() - .position(|&(w, h)| w == s.width && h == s.height) - .unwrap_or(0) as i32; - let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0) as i32; - - let res_names: Vec = RESOLUTIONS - .iter() - .map(|&(w, h)| { - if w == 0 { - "Native display".into() - } else { - format!("{w} \u{00D7} {h}") - } - }) - .collect(); - let hz_names: Vec = REFRESH - .iter() - .map(|&r| { - if r == 0 { - "Native".into() - } else { - format!("{r} Hz") - } - }) - .collect(); - - let res_combo = { - let ctx = ctx.clone(); - ComboBox::new(res_names) - .header("Resolution") - .selected_index(res_i) - .on_selection_changed(move |i: i32| { - let (w, h) = RESOLUTIONS[(i.max(0) as usize).min(RESOLUTIONS.len() - 1)]; - let mut s = ctx.settings.lock().unwrap(); - (s.width, s.height) = (w, h); - s.save(); - }) - }; - let hz_combo = { - let ctx = ctx.clone(); - ComboBox::new(hz_names) - .header("Refresh rate") - .selected_index(hz_i) - .on_selection_changed(move |i: i32| { - let mut s = ctx.settings.lock().unwrap(); - s.refresh_hz = REFRESH[(i.max(0) as usize).min(REFRESH.len() - 1)]; - s.save(); - }) - }; - let dec_i = DECODERS - .iter() - .position(|&(v, _)| v == s.decoder) - .unwrap_or(0) as i32; - let dec_names: Vec = DECODERS.iter().map(|&(_, l)| l.to_string()).collect(); - let decoder_combo = { - let ctx = ctx.clone(); - ComboBox::new(dec_names) - .header("Video decoder") - .selected_index(dec_i) - .on_selection_changed(move |i: i32| { - let (v, _) = DECODERS[(i.max(0) as usize).min(DECODERS.len() - 1)]; - let mut s = ctx.settings.lock().unwrap(); - s.decoder = v.to_string(); - s.save(); - }) - }; - - let codec_i = CODECS.iter().position(|&(v, _)| v == s.codec).unwrap_or(0) as i32; - let codec_names: Vec = CODECS.iter().map(|&(_, l)| l.to_string()).collect(); - let codec_combo = { - let ctx = ctx.clone(); - ComboBox::new(codec_names) - .header("Video codec") - .selected_index(codec_i) - .on_selection_changed(move |i: i32| { - let (v, _) = CODECS[(i.max(0) as usize).min(CODECS.len() - 1)]; - let mut s = ctx.settings.lock().unwrap(); - s.codec = v.to_string(); - s.save(); - }) - }; - - let br_i = BITRATES_MBPS - .iter() - .position(|&m| m * 1000 == s.bitrate_kbps) - .unwrap_or(0) as i32; - let br_names: Vec = BITRATES_MBPS - .iter() - .map(|&m| { - if m == 0 { - "Automatic".into() - } else { - format!("{m} Mb/s") - } - }) - .collect(); - let bitrate_combo = { - let ctx = ctx.clone(); - ComboBox::new(br_names) - .header("Bitrate") - .selected_index(br_i) - .on_selection_changed(move |i: i32| { - let m = BITRATES_MBPS[(i.max(0) as usize).min(BITRATES_MBPS.len() - 1)]; - let mut s = ctx.settings.lock().unwrap(); - s.bitrate_kbps = m * 1000; - s.save(); - }) - }; - - let hdr_toggle = { - let ctx = ctx.clone(); - ToggleSwitch::new(s.hdr_enabled) - .header("HDR (10-bit, BT.2020 PQ)") - .on_content("On") - .off_content("Off") - .on_changed(move |on: bool| { - let mut s = ctx.settings.lock().unwrap(); - s.hdr_enabled = on; - s.save(); - }) - }; - let mic_toggle = { - let ctx = ctx.clone(); - ToggleSwitch::new(s.mic_enabled) - .header("Stream microphone to the host") - .on_content("On") - .off_content("Off") - .on_changed(move |on: bool| { - let mut s = ctx.settings.lock().unwrap(); - s.mic_enabled = on; - s.save(); - }) - }; - let ac_i = AUDIO_CHANNELS - .iter() - .position(|&(v, _)| v == s.audio_channels) - .unwrap_or(0) as i32; - let ac_names: Vec = AUDIO_CHANNELS.iter().map(|&(_, l)| l.to_string()).collect(); - let channels_combo = { - let ctx = ctx.clone(); - ComboBox::new(ac_names) - .header("Audio channels") - .selected_index(ac_i) - .on_selection_changed(move |i: i32| { - let (v, _) = AUDIO_CHANNELS[(i.max(0) as usize).min(AUDIO_CHANNELS.len() - 1)]; - let mut s = ctx.settings.lock().unwrap(); - s.audio_channels = v; - s.save(); - }) - }; - - let header = grid(( - text_block("Settings") - .font_size(30.0) - .bold() - .grid_column(0) - .vertical_alignment(VerticalAlignment::Center), - button("Back") - .accent() - .icon(SymbolGlyph::Back) - .on_click({ - let ss = set_screen.clone(); - move || ss.call(Screen::Hosts) - }) - .grid_column(1) - .vertical_alignment(VerticalAlignment::Center), - )) - .columns([GridLength::Star(1.0), GridLength::Auto]) - .margin(edges(0.0, 0.0, 0.0, 6.0)); - - let stream_card = card( - vstack(( - text_block("Display").font_size(15.0).semibold(), - text_block("The host creates a virtual display at exactly this mode.") - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - res_combo, - hz_combo, - )) - .spacing(10.0), - ); - - let video_card = card( - vstack(( - text_block("Video").font_size(15.0).semibold(), - text_block( - "Hardware decode (D3D11VA) is zero-copy and far lighter than software — keep it on \ - Automatic unless debugging.", - ) - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - decoder_combo, - codec_combo, - bitrate_combo, - hdr_toggle, - )) - .spacing(10.0), - ); - - let audio_card = card( - vstack(( - text_block("Audio").font_size(15.0).semibold(), - text_block("Request stereo or surround — the host downmixes if its output has fewer.") - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - channels_combo, - mic_toggle, - )) - .spacing(10.0), - ); - - let licenses_button = { - let ss = set_screen.clone(); - button("Third-party licenses").on_click(move || ss.call(Screen::Licenses)) - }; - let about_card = card( - vstack(( - text_block("About").font_size(15.0).semibold(), - text_block("punktfunk is licensed under MIT OR Apache-2.0.") - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - licenses_button, - )) - .spacing(10.0), - ); - - page(vec![ - header.into(), - section("DISPLAY"), - stream_card.into(), - section("VIDEO"), - video_card.into(), - section("AUDIO"), - audio_card.into(), - section("ABOUT"), - about_card.into(), - ]) -} - -/// Static screen: the app's own license + the third-party software notices (reached from Settings). -fn licenses_page(set_screen: &AsyncSetState) -> Element { - let header = grid(( - text_block("Third-party licenses") - .font_size(30.0) - .bold() - .grid_column(0) - .vertical_alignment(VerticalAlignment::Center), - button("Back") - .accent() - .icon(SymbolGlyph::Back) - .on_click({ - let ss = set_screen.clone(); - move || ss.call(Screen::Settings) - }) - .grid_column(1) - .vertical_alignment(VerticalAlignment::Center), - )) - .columns([GridLength::Star(1.0), GridLength::Auto]) - .margin(edges(0.0, 0.0, 0.0, 6.0)); - - let app_card = card( - vstack(( - text_block("punktfunk").font_size(15.0).semibold(), - text_block("Licensed under MIT OR Apache-2.0, at your option.") - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - text_block(APP_LICENSE) - .font_size(11.0) - .foreground(ThemeRef::SecondaryText), - )) - .spacing(8.0), - ); - - let natives_card = card( - vstack(( - text_block("Bundled components").font_size(15.0).semibold(), - text_block( - "FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \ - license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \ - Windows App SDK (Microsoft) are also linked.", - ) - .font_size(12.0) - .foreground(ThemeRef::SecondaryText), - )) - .spacing(8.0), - ); - - let notices_card = card( - vstack(( - text_block("Rust crates").font_size(15.0).semibold(), - text_block(THIRD_PARTY_NOTICES) - .font_size(11.0) - .foreground(ThemeRef::SecondaryText), - )) - .spacing(8.0), - ); - - page(vec![ - header.into(), - section("PUNKTFUNK"), - app_card.into(), - section("BUNDLED"), - natives_card.into(), - section("OPEN SOURCE"), - notices_card.into(), - ]) -} - -// --- stream page -------------------------------------------------------------------------- - -fn present_newest(ctx: &mut PresentCtx) { - // Apply the latest source HDR mastering metadata (from the session pump's 0xCE drain) before - // presenting — a cheap no-op in the presenter when unchanged. - if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() { - ctx.presenter.set_hdr_metadata(meta); - } - // Drain to the newest decoded frame (drop any backlog) and hand it to the presenter by value — - // the GPU zero-copy path retains the decoder surface across re-presents, so ownership matters. - let mut newest = None; - while let Ok(f) = ctx.frames.try_recv() { - newest = Some(f); - } - ctx.presenter.present(newest); -} - -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 (and remove on unmount). - let connector_ref = cx.use_ref::>>(None); - cx.use_effect_with_cleanup((), { - let shared = ctx.shared.clone(); - let connector_ref = connector_ref.clone(); - move || { - if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() { - let mode = connector.mode(); - connector_ref.set(Some(connector.clone())); - PENDING_FRAMES.with(|c| *c.borrow_mut() = Some(frames)); - crate::input::install(connector, mode); - } - Some(crate::input::uninstall) - } - }); - - let rendering = cx.use_ref::>(None); - cx.use_effect((), { - let rendering = rendering.clone(); - move || { - if let Ok(r) = on_rendering(move || { - PRESENT.with(|cell| { - if let Some(ctx) = cell.borrow_mut().as_mut() { - present_newest(ctx); - } - }); - }) { - rendering.set(Some(r)); - } - } - }); - - let mode = connector_ref.borrow().as_ref().map(|c| c.mode()); - grid(( - swap_chain_panel() - .on_ready(|panel| match Presenter::new(1280, 720) { - Ok(p) => { - if let Err(e) = panel.set_swap_chain(p.swap_chain()) { - tracing::error!(error = %e, "set_swap_chain"); - } - if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) { - PRESENT.with(|cell| { - *cell.borrow_mut() = Some(PresentCtx { - presenter: p, - frames, - }); - }); - tracing::info!("stream presenter bound to SwapChainPanel"); - } - } - Err(e) => tracing::error!(error = %e, "create presenter"), - }) - .on_resize(|w, h| { - PRESENT.with(|cell| { - if let Some(ctx) = cell.borrow_mut().as_mut() { - ctx.presenter.resize(w as u32, h as u32); - } - }); - }), - hud_overlay(&props.stats, mode), - )) - .into() -} - -/// A small chip for the dark HUD: coloured text on a translucent dark fill. -fn hud_chip(text: &str, color: Color) -> Border { - border( - text_block(text) - .font_size(11.0) - .semibold() - .foreground(color), - ) - .background(Color::rgb(38, 38, 38)) - .corner_radius(8.0) - .padding(edges(8.0, 2.0, 8.0, 2.0)) -} - -/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode -/// path · HDR), the fps/throughput/latency line, and the release-cursor hint. Layered over the -/// `SwapChainPanel` in the same grid cell. -fn hud_overlay(stats: &Stats, mode: Option) -> Element { - let res = mode - .map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz)) - .unwrap_or_else(|| "\u{2014}".into()); - let mut chips: Vec = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()]; - chips.push(if stats.hardware { - hud_chip("GPU decode", Color::rgb(120, 220, 150)).into() - } else { - hud_chip("CPU decode", Color::rgb(240, 190, 90)).into() - }); - if stats.hdr { - chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into()); - } - let line = format!( - "{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms", - stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms - ); - border( - vstack(( - hstack(chips).spacing(6.0), - text_block(line) - .font_size(11.0) - .foreground(Color::rgb(210, 210, 210)), - text_block("Ctrl+Alt+Shift+Q releases the mouse") - .font_size(11.0) - .foreground(Color::rgb(150, 150, 150)), - )) - .spacing(6.0), - ) - .background(Color::rgb(0, 0, 0)) - .corner_radius(10.0) - .padding(uniform(10.0)) - .opacity(0.82) - .horizontal_alignment(HorizontalAlignment::Right) - .vertical_alignment(VerticalAlignment::Top) - .margin(uniform(12.0)) - .into() -} diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs new file mode 100644 index 0000000..aa32d38 --- /dev/null +++ b/clients/windows/src/app/connect.rs @@ -0,0 +1,363 @@ +//! The trust gate and session lifecycle glue: `initiate` routes a connect through the trust +//! rules (pinned → silent, `pair=optional` → TOFU, otherwise → PIN), `connect_with` starts the +//! session worker and drives navigation from its events, and the "request access" +//! (delegated-approval) flow parks an identified connect until the operator approves it. + +use super::style::*; +use super::{AppCtx, Screen, Svc, Target}; +use crate::session::{self, SessionEvent, SessionParams, Stats}; +use crate::trust::{self, KnownHost, KnownHosts, Settings}; +use crate::video::DecoderPref; +use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use windows_reactor::*; + +/// The trust gate (mirrors the GTK client's `initiate_connect`): pinned fingerprint → silent +/// connect; known address → stored pin; advertised `pair=optional` → TOFU; otherwise → PIN +/// pairing. +pub(crate) fn initiate( + ctx: &Arc, + target: Target, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, +) { + let known = KnownHosts::load(); + let pin = target + .fp_hex + .as_ref() + .and_then(|fp| known.find_by_fp(fp).map(|_| fp.clone())) + .or_else(|| { + known + .find_by_addr(&target.addr, target.port) + .map(|k| k.fp_hex.clone()) + }) + .and_then(|fp| trust::parse_hex32(&fp)); + + if let Some(pin) = pin { + connect(ctx, &target, Some(pin), set_screen, set_status); + } else if target.pair_optional { + connect(ctx, &target, None, set_screen, set_status); // TOFU + } else { + *ctx.shared.target.lock().unwrap() = target; + set_screen.call(Screen::Pair); + } +} + +/// The mode to request: explicit settings, with `0` fields resolved to the native size/refresh +/// of the display our window is on (mirrors the Linux/Swift clients' native-display default). +pub(crate) fn resolve_mode(s: &Settings) -> Mode { + let mut mode = Mode { + width: s.width, + height: s.height, + refresh_hz: s.refresh_hz, + }; + if mode.width == 0 || mode.refresh_hz == 0 { + if let Some((w, h, hz)) = current_display_mode() { + if mode.width == 0 { + (mode.width, mode.height) = (w, h); + } + if mode.refresh_hz == 0 { + mode.refresh_hz = hz; + } + } + } + // No display info (headless session, RDP oddities) — a sane floor. + if mode.width == 0 { + (mode.width, mode.height) = (1920, 1080); + } + if mode.refresh_hz == 0 { + mode.refresh_hz = 60; + } + mode +} + +/// The current mode (physical pixels + refresh) of the display our window occupies: +/// `MonitorFromWindow` on the foreground window — ours, the user just clicked in it — then +/// `EnumDisplaySettingsW(ENUM_CURRENT_SETTINGS)` on that monitor's device. Defaults to the +/// primary display when we're not foreground (e.g. a scripted connect). +fn current_display_mode() -> Option<(u32, u32, u32)> { + use windows::core::PCWSTR; + use windows::Win32::Graphics::Gdi::{ + EnumDisplaySettingsW, GetMonitorInfoW, MonitorFromWindow, DEVMODEW, ENUM_CURRENT_SETTINGS, + MONITORINFO, MONITORINFOEXW, + }; + use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + unsafe { + let monitor = MonitorFromWindow( + GetForegroundWindow(), + windows::Win32::Graphics::Gdi::MONITOR_DEFAULTTOPRIMARY, + ); + let mut info = MONITORINFOEXW::default(); + info.monitorInfo.cbSize = std::mem::size_of::() as u32; + if !GetMonitorInfoW( + monitor, + &mut info as *mut MONITORINFOEXW as *mut MONITORINFO, + ) + .as_bool() + { + return None; + } + let mut dm = DEVMODEW { + dmSize: std::mem::size_of::() as u16, + ..Default::default() + }; + if !EnumDisplaySettingsW( + PCWSTR(info.szDevice.as_ptr()), + ENUM_CURRENT_SETTINGS, + &mut dm, + ) + .as_bool() + { + return None; + } + // dmDisplayFrequency of 0/1 means "hardware default" — unusable as a mode request. + (dm.dmPelsWidth > 0 && dm.dmDisplayFrequency > 1).then_some(( + dm.dmPelsWidth, + dm.dmPelsHeight, + dm.dmDisplayFrequency, + )) + } +} + +/// Tunables that differ between the normal connect and the no-PIN "request access" flow. +/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the +/// plain "Connecting" screen. +pub(crate) struct ConnectOpts { + /// Handshake budget. Request-access uses a long one because the host PARKS the connection + /// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`). + connect_timeout: Duration, + /// Persist the host as *paired* on a successful connect. Set for request-access, where the + /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU + /// persists the host *unpaired* (pinned, but not PIN/approval-verified). + persist_paired: bool, + /// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access). + awaiting_approval: bool, + /// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no + /// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time + /// out; this request's event loop (which captured the same `Arc` at spawn) then tears down + /// silently when the parked connect finally resolves — without touching a screen a new + /// session may already own. + cancel: Option>, +} + +impl Default for ConnectOpts { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(15), + persist_paired: false, + awaiting_approval: false, + cancel: None, + } + } +} + +pub(crate) fn connect( + ctx: &Arc, + target: &Target, + pin: Option<[u8; 32]>, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, +) { + connect_with( + ctx, + target, + pin, + set_screen, + set_status, + ConnectOpts::default(), + ); +} + +fn connect_with( + ctx: &Arc, + target: &Target, + pin: Option<[u8; 32]>, + set_screen: &AsyncSetState, + set_status: &AsyncSetState, + opts: ConnectOpts, +) { + let s = ctx.settings.lock().unwrap().clone(); + let gamepad_pref = match GamepadPref::from_name(&s.gamepad) { + Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(), + Some(explicit) => explicit, + }; + let handle = session::start(SessionParams { + host: target.addr.clone(), + port: target.port, + mode: resolve_mode(&s), + compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto), + gamepad: gamepad_pref, + bitrate_kbps: s.bitrate_kbps, + audio_channels: s.audio_channels, + mic_enabled: s.mic_enabled, + hdr_enabled: s.hdr_enabled, + decoder: DecoderPref::from_name(&s.decoder), + preferred_codec: s.preferred_codec(), + pin, + identity: ctx.identity.clone(), + connect_timeout: opts.connect_timeout, + }); + set_status.call(String::new()); + set_screen.call(if opts.awaiting_approval { + Screen::RequestAccess + } else { + Screen::Connecting + }); + + let tofu = pin.is_none(); + let persist_paired = opts.persist_paired; + let cancel = opts.cancel; + let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone()); + let (ss, st) = (set_screen.clone(), set_status.clone()); + let target = target.clone(); + std::thread::spawn(move || loop { + let event = match handle.events.recv_blocking() { + Ok(e) => e, + Err(_) => { + gamepad.detach(); + ss.call(Screen::Hosts); + break; + } + }; + // A cancelled request-access connect that resolved late (the host approved or the park + // timed out after the user walked away): tear down silently. Cancel already returned the + // UI to the host list; dropping `event` (and with it any connector) closes the connection + // without popping a stream or a stray error over the screen a new session may own. + if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { + break; + } + match event { + SessionEvent::Connected { + connector, + fingerprint, + .. + } => { + if persist_paired || tofu { + // Request-access: the operator approved this device, so record the host as a + // trusted PAIRED host — future connects are then silent (rule 1), exactly like + // after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned). + let mut k = KnownHosts::load(); + k.upsert(KnownHost { + name: target.name.clone(), + addr: target.addr.clone(), + port: target.port, + fp_hex: trust::hex(&fingerprint), + paired: persist_paired, + }); + let _ = k.save(); + } + gamepad.attach(connector.clone()); + *shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers + *shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone())); + ss.call(Screen::Stream); + } + SessionEvent::Failed { + msg, + trust_rejected, + } => { + st.call(msg); + gamepad.detach(); + if trust_rejected { + // Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen. + *shared.target.lock().unwrap() = target.clone(); + ss.call(Screen::Pair); + } else { + ss.call(Screen::Hosts); + } + break; + } + SessionEvent::Ended(err) => { + st.call(err.unwrap_or_else(|| "Session ended".into())); + gamepad.detach(); + ss.call(Screen::Hosts); + break; + } + SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s, + } + }); +} + +/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the +/// operator approves this device in its console (or web UI), showing a cancelable "waiting" +/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is +/// saved as paired, so later connects are silent. +pub(crate) fn request_access(props: &Svc, target: &Target) { + let ctx = &props.ctx; + // Pin the advertised certificate for a discovered host (defence against a host impostor while + // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use. + let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32); + // A fresh cancel flag per request, installed where the waiting screen's Cancel button can read + // it back; this request's event loop captures the same `Arc` (via ConnectOpts) below. + let cancel = Arc::new(AtomicBool::new(false)); + *ctx.shared.cancel.lock().unwrap() = Some(cancel.clone()); + connect_with( + ctx, + target, + pin, + &props.set_screen, + &props.set_status, + ConnectOpts { + // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator + // approval still lands on this connection rather than timing the client out first. + connect_timeout: Duration::from_secs(185), + persist_paired: true, + awaiting_approval: true, + cancel: Some(cancel), + }, + ); +} + +/// The plain "Connecting…" screen shown while the session worker handshakes. No hooks. +pub(crate) fn connecting_page(ctx: &Arc, status: &str) -> Element { + let target_name = ctx.shared.target.lock().unwrap().name.clone(); + let headline = if target_name.is_empty() { + "Connecting\u{2026}".to_string() + } else { + format!("Connecting to {target_name}\u{2026}") + }; + let detail = if status.is_empty() { + "Negotiating the session and creating the virtual display\u{2026}" + } else { + status + }; + busy_page(&headline, detail, Vec::new()) +} + +/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while +/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and +/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the +/// user has walked away. No hooks. +pub(crate) fn request_access_page( + ctx: &Arc, + set_screen: &AsyncSetState, +) -> Element { + let target_name = ctx.shared.target.lock().unwrap().name.clone(); + let headline = if target_name.is_empty() { + "Waiting for approval\u{2026}".to_string() + } else { + format!("Waiting for {target_name} to approve\u{2026}") + }; + let cancel_btn = { + let (ctx, ss) = (ctx.clone(), set_screen.clone()); + button("Cancel") + .icon(SymbolGlyph::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 + // the connect finally resolves (see ConnectOpts::cancel). + if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() { + c.store(true, Ordering::SeqCst); + } + ss.call(Screen::Hosts); + }) + .horizontal_alignment(HorizontalAlignment::Center) + }; + busy_page( + &headline, + "Approve this device in the host's console or web UI \u{2014} it connects automatically \ + once you approve it. No PIN needed.", + vec![cancel_btn.into()], + ) +} diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs new file mode 100644 index 0000000..e907053 --- /dev/null +++ b/clients/windows/src/app/hosts.rs @@ -0,0 +1,291 @@ +//! The hosts page: saved (trusted/paired) hosts with per-host actions (speed test, forget), +//! live mDNS discovery, and a manual connect entry. + +use super::connect::initiate; +use super::speed::SpeedState; +use super::style::*; +use super::{Screen, Svc, Target}; +use crate::discovery::DiscoveredHost; +use crate::trust::KnownHosts; +use windows_reactor::*; + +/// 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). +#[derive(Clone)] +pub(crate) struct HostsProps { + pub(crate) svc: Svc, + pub(crate) hosts: Vec, + pub(crate) status: String, +} + +impl PartialEq for HostsProps { + fn eq(&self, other: &Self) -> bool { + self.svc == other.svc && self.hosts == other.hosts && self.status == other.status + } +} + +/// 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( + name: &str, + sub: &str, + badge: &str, + actions: Vec, + on_tap: impl Fn() + 'static, +) -> Element { + let kind = match badge { + "Paired" => Pill::Good, + "Open" => Pill::Neutral, + _ => Pill::Accent, // Trusted / PIN + }; + card( + grid(( + avatar(name) + .grid_column(0) + .vertical_alignment(VerticalAlignment::Center), + vstack(( + text_block(name).font_size(15.0).semibold(), + text_block(sub) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(2.0) + .grid_column(1) + .vertical_alignment(VerticalAlignment::Center) + .margin(edges(12.0, 0.0, 0.0, 0.0)), + hstack(actions) + .spacing(4.0) + .grid_column(2) + .vertical_alignment(VerticalAlignment::Center) + .margin(edges(0.0, 0.0, 10.0, 0.0)), + pill(badge, kind) + .grid_column(3) + .vertical_alignment(VerticalAlignment::Center) + .margin(edges(0.0, 0.0, 10.0, 0.0)), + text_block("\u{203A}") + .font_size(18.0) + .foreground(ThemeRef::SecondaryText) + .grid_column(4) + .vertical_alignment(VerticalAlignment::Center), + )) + .columns([ + GridLength::Auto, + GridLength::Star(1.0), + GridLength::Auto, + GridLength::Auto, + GridLength::Auto, + ]), + ) + .on_tapped(on_tap) + .into() +} + +pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element { + let ctx = &props.svc.ctx; + let hosts = props.hosts.as_slice(); + let status = props.status.as_str(); + let set_screen = &props.svc.set_screen; + let set_status = &props.svc.set_status; + let (manual, set_manual) = cx.use_state(String::new()); + // Pending "forget host" confirmation: `(fp_hex, name)` of the saved host to drop. Drives the + // ContentDialog below; sync state, so setting it re-renders this page. + let (forget, set_forget) = cx.use_state(Option::<(String, String)>::None); + let known = KnownHosts::load(); + + let mut body: Vec = Vec::new(); + + // Header: title block + Settings button. + body.push( + grid(( + vstack(( + text_block("Punktfunk").font_size(30.0).bold(), + text_block("Stream from a host on your network.") + .foreground(ThemeRef::SecondaryText), + )) + .spacing(2.0) + .grid_column(0) + .vertical_alignment(VerticalAlignment::Center), + button("Settings") + .icon(SymbolGlyph::Setting) + .on_click({ + let ss = set_screen.clone(); + move || ss.call(Screen::Settings) + }) + .grid_column(1) + .vertical_alignment(VerticalAlignment::Center), + )) + .columns([GridLength::Star(1.0), GridLength::Auto]) + .margin(edges(0.0, 0.0, 0.0, 6.0)) + .into(), + ); + + if !status.is_empty() { + body.push( + InfoBar::new("Couldn't connect") + .message(status.to_string()) + .error() + .is_closable(false) + .into(), + ); + } + + // Saved (trusted/paired) hosts — reachable even when mDNS isn't. + if !known.hosts.is_empty() { + body.push(section("SAVED HOSTS")); + for k in &known.hosts { + let target = Target { + name: k.name.clone(), + addr: k.addr.clone(), + port: k.port, + fp_hex: Some(k.fp_hex.clone()), + pair_optional: false, + }; + // Per-host actions: measure the path (probe burst → recommended bitrate) and forget + // (drops the pinned fingerprint — a later connect re-pairs). + let speed_btn = { + let (svc, target) = (props.svc.clone(), target.clone()); + button("Test") + .icon(SymbolGlyph::Sync) + .subtle() + .on_click(move || { + *svc.ctx.shared.target.lock().unwrap() = target.clone(); + // New run: invalidate any still-in-flight probe and reset the screen. + svc.ctx + .shared + .speed_gen + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + svc.set_speed.call(SpeedState::Running); + svc.set_screen.call(Screen::SpeedTest); + }) + }; + let forget_btn = { + let (sf, fp, name) = (set_forget.clone(), k.fp_hex.clone(), k.name.clone()); + button("Forget") + .icon(SymbolGlyph::Delete) + .subtle() + .on_click(move || sf.call(Some((fp.clone(), name.clone())))) + }; + let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); + body.push(host_card( + &k.name, + &format!("{}:{}", k.addr, k.port), + if k.paired { "Paired" } else { "Trusted" }, + vec![speed_btn.into(), forget_btn.into()], + move || initiate(&ctx2, target.clone(), &ss, &st), + )); + } + } + + // Discovered hosts. + body.push(section("ON YOUR NETWORK")); + if hosts.is_empty() { + body.push( + card( + hstack(( + ProgressRing::indeterminate().width(18.0).height(18.0), + text_block("Searching the LAN\u{2026}").foreground(ThemeRef::SecondaryText), + )) + .spacing(12.0), + ) + .into(), + ); + } else { + for h in hosts { + let target = Target { + name: h.name.clone(), + addr: h.addr.clone(), + port: h.port, + fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), + pair_optional: h.pair == "optional", + }; + let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); + let badge = if h.pair == "required" { "PIN" } else { "Open" }; + body.push(host_card( + &h.name, + &format!("{}:{}", h.addr, h.port), + badge, + Vec::new(), + move || initiate(&ctx2, target.clone(), &ss, &st), + )); + } + } + + // Manual connection. + body.push(section("CONNECT MANUALLY")); + let connect_manual = { + let (ctx2, ss, st, text) = ( + ctx.clone(), + set_screen.clone(), + set_status.clone(), + manual.clone(), + ); + move || { + let text = text.trim(); + if text.is_empty() { + return; + } + let (addr, port) = match text.rsplit_once(':') { + Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)), + None => (text.to_string(), 9777), + }; + initiate( + &ctx2, + Target { + name: addr.clone(), + addr, + port, + fp_hex: None, + pair_optional: false, + }, + &ss, + &st, + ); + } + }; + body.push( + card( + grid(( + text_box(manual) + .placeholder("host or host:port") + .on_changed(move |s| set_manual.call(s)) + .grid_column(0) + .vertical_alignment(VerticalAlignment::Center), + button("Connect") + .accent() + .icon(SymbolGlyph::Forward) + .on_click(connect_manual) + .grid_column(1) + .margin(edges(8.0, 0.0, 0.0, 0.0)), + )) + .columns([GridLength::Star(1.0), GridLength::Auto]), + ) + .into(), + ); + + // Forget confirmation (modal; shown while `forget` holds a pending host). Confirmed first, + // since it's destructive and re-establishing trust needs a fresh pairing. + if let Some((fp, name)) = forget { + let sf = set_forget.clone(); + body.push( + ContentDialog::new("Remove saved host?") + .content(format!( + "Forget \u{201C}{name}\u{201D}? You'll need to pair (or trust) it again to \ + reconnect." + )) + .primary_button_text("Remove") + .close_button_text("Cancel") + .is_open(true) + .on_closed(move |r: ContentDialogResult| { + if r == ContentDialogResult::Primary { + let mut known = KnownHosts::load(); + known.remove_by_fp(&fp); + let _ = known.save(); + } + sf.call(None); // re-renders the page; the row is gone on the next load + }) + .into(), + ); + } + + page(body) +} diff --git a/clients/windows/src/app/licenses.rs b/clients/windows/src/app/licenses.rs new file mode 100644 index 0000000..17d0462 --- /dev/null +++ b/clients/windows/src/app/licenses.rs @@ -0,0 +1,70 @@ +//! Static screen: the app's own license + the third-party software notices (reached from +//! Settings). + +use super::style::*; +use super::Screen; +use windows_reactor::*; + +/// punktfunk's own license (MIT OR Apache-2.0). +const APP_LICENSE: &str = concat!( + include_str!("../../../../LICENSE-MIT"), + "\n\n================================ Apache-2.0 ================================\n\n", + include_str!("../../../../LICENSE-APACHE"), +); +/// Third-party software notices for the linked Rust crates (generated by +/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/). +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 ss = set_screen.clone(); + move || ss.call(Screen::Settings) + }); + + let app_card = card( + vstack(( + text_block("punktfunk").font_size(15.0).semibold(), + text_block("Licensed under MIT OR Apache-2.0, at your option.") + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + text_block(APP_LICENSE) + .font_size(11.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(8.0), + ); + + let natives_card = card( + vstack(( + text_block("Bundled components").font_size(15.0).semibold(), + text_block( + "FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \ + license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \ + Windows App SDK (Microsoft) are also linked.", + ) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(8.0), + ); + + let notices_card = card( + vstack(( + text_block("Rust crates").font_size(15.0).semibold(), + text_block(THIRD_PARTY_NOTICES) + .font_size(11.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(8.0), + ); + + page(vec![ + page_header("Third-party licenses", back_btn), + section("PUNKTFUNK"), + app_card.into(), + section("BUNDLED"), + natives_card.into(), + section("OPEN SOURCE"), + notices_card.into(), + ]) +} diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs new file mode 100644 index 0000000..a8160c4 --- /dev/null +++ b/clients/windows/src/app/mod.rs @@ -0,0 +1,206 @@ +//! The WinUI 3 (windows-reactor) application shell. +//! +//! Declarative React-like model: this root component routes on a `Screen` value held in +//! `use_async_state` so background threads (discovery, the session pump) can drive navigation. +//! Each screen lives in its own submodule: +//! +//! * [`hosts`] — saved/discovered/manual host list, plus per-host forget + speed test +//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows) +//! * [`pair`] — the SPAKE2 PIN pairing ceremony +//! * [`speed`] — the per-host network speed test (probe burst over the real data plane) +//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen +//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay +//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor +//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`) +//! +//! **Re-render discipline** (reactor's rules): each hook-using screen is mounted as its own +//! `component(...)` so its hooks live in an isolated slot list. A child's *sync* `use_state` +//! marks it dirty and re-renders it; an `AsyncSetState` written from a background thread does +//! NOT (the child is pruned when its props are unchanged) — so everything thread-driven +//! (discovery, HUD stats, speed-test results) is held as *root* state and passed down as props. +//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel +//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame +//! present must not go through state/rerender. + +mod connect; +mod hosts; +mod licenses; +mod pair; +mod settings; +mod speed; +mod stream; +mod style; + +use crate::discovery::{self, DiscoveredHost}; +use crate::gamepad::GamepadService; +use crate::session::Stats; +use crate::trust::Settings; +use crate::video::DecodedFrame; +use hosts::HostsProps; +use punktfunk_core::client::NativeClient; +use speed::{SpeedProps, SpeedState}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; +use stream::StreamProps; +use windows_reactor::*; + +#[derive(Clone, PartialEq)] +pub(crate) enum Screen { + Hosts, + Connecting, + /// The no-PIN "request access" wait: an identified connect is in flight, parked by the host + /// until the operator approves this device in its console. Cancelable. + RequestAccess, + Stream, + Settings, + /// Open-source / third-party license notices (reached from Settings). + Licenses, + Pair, + /// Per-host network speed test (probe burst + recommended bitrate). + SpeedTest, +} + +/// The host we're about to connect to / pair with / speed-test (carried into those screens +/// via `Shared::target`). +#[derive(Clone, Default)] +pub(crate) struct Target { + pub(crate) name: String, + pub(crate) addr: String, + pub(crate) port: u16, + pub(crate) fp_hex: Option, + pub(crate) pair_optional: bool, +} + +/// Stable app services handed to the page components as props. Each routed screen that uses +/// hooks (`hosts_page`/`pair_page`/`stream_page`/`speed_page`) is mounted as its own +/// `component(...)`, so its hooks live in an isolated slot list — calling them on the shared +/// parent `cx` would change the hook order whenever the screen changes (reactor's +/// Rules-of-Hooks guard aborts). +/// +/// `Svc` compares equal by `ctx` identity (it never meaningfully changes across renders), so a +/// page whose props are just `Svc` re-renders only via its own state hooks, never spuriously +/// from the parent. +#[derive(Clone)] +pub(crate) struct Svc { + pub(crate) ctx: Arc, + pub(crate) set_screen: AsyncSetState, + pub(crate) set_status: AsyncSetState, + /// Speed-test lifecycle lives in root state (thread-driven — see the module docs); the hosts + /// page resets it to `Running` before navigating, the probe worker completes it. + pub(crate) set_speed: AsyncSetState, +} + +impl PartialEq for Svc { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.ctx, &other.ctx) + } +} + +/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread). +#[derive(Default)] +pub(crate) struct Shared { + pub(crate) handoff: Mutex, async_channel::Receiver)>>, + pub(crate) target: Mutex, + /// Latest stream stats, written by the session's event loop and mirrored into reactor state + /// by the HUD poll thread to drive the overlay. + pub(crate) stats: Mutex, + /// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per + /// request: the waiting screen's Cancel button reads it back from here and sets it, and that + /// request's event loop (which captured the same `Arc` at spawn) then tears down silently when + /// the parked connect finally resolves. `None` outside a request-access flow. + pub(crate) cancel: Mutex>>, + /// Speed-test run generation, bumped by the hosts page when it starts a run. A probe worker + /// only publishes its outcome while its generation is still current, so a test abandoned + /// mid-run can't overwrite a newer run's result when it finally resolves. + pub(crate) speed_gen: std::sync::atomic::AtomicU64, +} + +pub struct AppCtx { + pub(crate) identity: (String, String), + pub(crate) settings: Mutex, + pub(crate) gamepad: GamepadService, + pub(crate) shared: Arc, +} + +pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> { + let ctx = Arc::new(AppCtx { + identity, + settings: Mutex::new(Settings::load()), + gamepad, + shared: Arc::new(Shared::default()), + }); + App::new() + .title("Punktfunk") + .inner_size(1000.0, 720.0) + .backdrop(Backdrop::Mica) + .render(move |cx| root(cx, &ctx)) +} + +fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { + let (screen, set_screen) = cx.use_async_state(Screen::Hosts); + let (hosts, set_hosts) = cx.use_async_state(Vec::::new()); + let (status, set_status) = cx.use_async_state(String::new()); + let (hud, set_hud) = cx.use_async_state(stream::HudSample::default()); + let (speed, set_speed) = cx.use_async_state(SpeedState::Running); + + // Continuous LAN discovery (spawned once). + cx.use_effect((), { + let set_hosts = set_hosts.clone(); + move || { + let rx = discovery::browse(); + std::thread::spawn(move || { + let mut acc: Vec = Vec::new(); + while let Ok(h) = rx.recv_blocking() { + if let Some(e) = acc.iter_mut().find(|e| e.key == h.key) { + *e = h; + } else { + acc.push(h); + } + set_hosts.call(acc.clone()); + } + }); + } + }); + + // HUD sample: the session event loop writes `shared.stats` and the input hooks track capture + // state; this poll thread mirrors both into root state so the stream page gets them as a + // *prop* (thread-driven state must be root state — see the module docs). The compare in + // `AsyncSetState::call` makes the idle case free. + cx.use_effect((), { + let shared = ctx.shared.clone(); + let set_hud = set_hud.clone(); + move || { + std::thread::Builder::new() + .name("pf-hud".into()) + .spawn(move || loop { + std::thread::sleep(std::time::Duration::from_millis(400)); + set_hud.call(stream::HudSample { + stats: *shared.stats.lock().unwrap(), + captured: crate::input::is_captured(), + }); + }) + .ok(); + } + }); + + // 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 { + ctx: ctx.clone(), + set_screen: set_screen.clone(), + set_status: set_status.clone(), + set_speed: set_speed.clone(), + }; + match screen { + Screen::Hosts => component(hosts::hosts_page, HostsProps { svc, hosts, status }), + // 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::Licenses => licenses::licenses_page(&set_screen), + Screen::Pair => component(pair::pair_page, svc), + Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }), + Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }), + } +} diff --git a/clients/windows/src/app/pair.rs b/clients/windows/src/app/pair.rs new file mode 100644 index 0000000..a879b98 --- /dev/null +++ b/clients/windows/src/app/pair.rs @@ -0,0 +1,126 @@ +//! The SPAKE2 PIN pairing screen: the host is armed and displays a 4-digit PIN; proving +//! knowledge of it pins the host's certificate (and registers ours) with no offline-guessable +//! transcript. Also offers the no-PIN "request access" (delegated-approval) alternative. + +use super::connect::{connect, request_access}; +use super::style::*; +use super::{Screen, Svc}; +use crate::trust::{self, KnownHost, KnownHosts}; +use punktfunk_core::client::NativeClient; +use windows_reactor::*; + +pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element { + let ctx = &props.ctx; + let set_screen = &props.set_screen; + let set_status = &props.set_status; + let (code, set_code) = cx.use_state(String::new()); + let target = ctx.shared.target.lock().unwrap().clone(); + + let pair_btn = { + let (ctx2, ss, st, code2, target2) = ( + ctx.clone(), + set_screen.clone(), + set_status.clone(), + code.clone(), + target.clone(), + ); + button("Pair & Connect") + .accent() + .icon(SymbolGlyph::Accept) + .on_click(move || { + let pin = code2.trim().to_string(); + let (ctx3, ss, st, target3) = + (ctx2.clone(), ss.clone(), st.clone(), target2.clone()); + std::thread::spawn(move || { + let name = + std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into()); + match NativeClient::pair( + &target3.addr, + target3.port, + (&ctx3.identity.0, &ctx3.identity.1), + &pin, + &name, + std::time::Duration::from_secs(90), + ) { + Ok(fp) => { + let mut k = KnownHosts::load(); + k.upsert(KnownHost { + name: target3.name.clone(), + addr: target3.addr.clone(), + port: target3.port, + fp_hex: trust::hex(&fp), + paired: true, + }); + let _ = k.save(); + connect(&ctx3, &target3, Some(fp), &ss, &st); + } + Err(e) => { + st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)")); + ss.call(Screen::Hosts); + } + } + }); + }) + }; + let cancel_btn = { + let ss = set_screen.clone(); + button("Cancel") + .icon(SymbolGlyph::Cancel) + .on_click(move || ss.call(Screen::Hosts)) + }; + // The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that + // the host parks until the operator approves this device in its console (delegated approval). + let request_btn = { + let (svc, target2) = (props.clone(), target.clone()); + button("Request access without a PIN") + .icon(SymbolGlyph::Send) + .on_click(move || request_access(&svc, &target2)) + .horizontal_alignment(HorizontalAlignment::Stretch) + }; + + let content = card(vstack(( + grid(( + avatar(&target.name) + .grid_column(0) + .vertical_alignment(VerticalAlignment::Center), + vstack(( + text_block(format!("Pair with {}", target.name)) + .font_size(20.0) + .semibold(), + text_block(format!("{}:{}", target.addr, target.port)) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + )) + .spacing(2.0) + .grid_column(1) + .vertical_alignment(VerticalAlignment::Center) + .margin(edges(12.0, 0.0, 0.0, 0.0)), + )) + .columns([GridLength::Auto, GridLength::Star(1.0)]), + InfoBar::new("Arm pairing on the host") + .message( + "On the host's console or web console, start pairing — it shows a 4-digit PIN. \ + Enter it below within 90 seconds.", + ) + .informational() + .is_closable(false), + text_box(code) + .placeholder("PIN") + .font_size(28.0) + .on_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 \ + (its console or web UI) \u{2014} no PIN needed.", + ) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText), + request_btn, + )) + .spacing(16.0)) + .max_width(480.0) + .horizontal_alignment(HorizontalAlignment::Center) + .margin(edges(0.0, 60.0, 0.0, 0.0)); + + page(vec![content.into()]) +} diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs new file mode 100644 index 0000000..7dc2db0 --- /dev/null +++ b/clients/windows/src/app/settings.rs @@ -0,0 +1,306 @@ +//! The settings screen. Every control writes straight back to the persisted [`Settings`] +//! (there is no Apply step), via the small [`setting_combo`]/[`setting_toggle`] builders. + +use super::style::*; +use super::{AppCtx, Screen}; +use crate::trust::Settings; +use punktfunk_core::config::GamepadPref; +use std::sync::Arc; +use windows_reactor::*; + +/// `(0, 0)` = the native size of the display the window is on, resolved at connect. +const RESOLUTIONS: &[(u32, u32)] = &[ + (0, 0), + (1280, 720), + (1920, 1080), + (2560, 1440), + (3840, 2160), +]; +/// `0` = the display's native refresh, resolved at connect. +const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; +/// Decode backend presets: `(stored value, display label)`. +const DECODERS: &[(&str, &str)] = &[ + ("auto", "Automatic (GPU, fall back to CPU)"), + ("hardware", "Hardware (GPU / D3D11VA)"), + ("software", "Software (CPU)"), +]; +/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can +/// capture; the resolved count drives the decoder + WASAPI render layout. +const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")]; +/// Preferred-codec presets: `(stored value, display label)`. Soft — the host falls back if it +/// can't encode the chosen codec. +const CODECS: &[(&str, &str)] = &[ + ("auto", "Automatic"), + ("hevc", "HEVC (H.265)"), + ("h264", "H.264 (AVC)"), + ("av1", "AV1"), +]; +/// Virtual-pad presets: `(stored value, display label)` — the pad the HOST creates. Same set the +/// GTK client offers; "Automatic" resolves from the physical controller at connect. +const GAMEPADS: &[(&str, &str)] = &[ + ("auto", "Automatic (match the controller)"), + ("xbox360", "Xbox 360"), + ("dualsense", "DualSense"), + ("xboxone", "Xbox One"), + ("dualshock4", "DualShock 4"), +]; +/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to +/// auto-detect when the choice is unavailable. Only meaningful against a Linux host. +const COMPOSITORS: &[(&str, &str)] = &[ + ("auto", "Automatic"), + ("kwin", "KWin"), + ("wlroots", "wlroots (Sway/Hyprland)"), + ("mutter", "Mutter (GNOME)"), + ("gamescope", "gamescope"), +]; + +/// A `ComboBox` bound to one settings field: shows `names`, starts at `current`, and runs +/// `apply(settings, picked_index)` under the settings lock, then saves. The index handed to +/// `apply` is already clamped to `names`. +fn setting_combo( + ctx: &Arc, + header: &str, + names: Vec, + current: usize, + apply: impl Fn(&mut Settings, usize) + 'static, +) -> ComboBox { + let ctx = ctx.clone(); + let max = names.len().saturating_sub(1); + ComboBox::new(names) + .header(header) + .selected_index(current as i32) + .on_selection_changed(move |i: i32| { + let mut s = ctx.settings.lock().unwrap(); + apply(&mut s, (i.max(0) as usize).min(max)); + s.save(); + }) +} + +/// The labels of a `(value, label)` preset table, plus the index of `is_current`'s match. +fn presets(table: &[(V, &str)], is_current: impl Fn(&V) -> bool) -> (Vec, usize) { + let names = table.iter().map(|(_, l)| l.to_string()).collect(); + let current = table.iter().position(|(v, _)| is_current(v)).unwrap_or(0); + (names, current) +} + +/// A `ToggleSwitch` bound to one boolean settings field. +fn setting_toggle( + ctx: &Arc, + header: &str, + on: bool, + apply: impl Fn(&mut Settings, bool) + 'static, +) -> ToggleSwitch { + let ctx = ctx.clone(); + ToggleSwitch::new(on) + .header(header) + .on_content("On") + .off_content("Off") + .on_changed(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() +} + +pub(crate) fn settings_page(ctx: &Arc, set_screen: &AsyncSetState) -> Element { + let s = ctx.settings.lock().unwrap().clone(); + + // --- Display --------------------------------------------------------------------------- + let (res_names, res_i) = { + let names: Vec = RESOLUTIONS + .iter() + .map(|&(w, h)| { + if w == 0 { + "Native display".into() + } else { + format!("{w} \u{00D7} {h}") + } + }) + .collect(); + let i = RESOLUTIONS + .iter() + .position(|&(w, h)| w == s.width && h == s.height) + .unwrap_or(0); + (names, i) + }; + let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| { + (s.width, s.height) = RESOLUTIONS[i]; + }); + let (hz_names, hz_i) = { + let names: Vec = REFRESH + .iter() + .map(|&r| { + if r == 0 { + "Native".into() + } else { + format!("{r} Hz") + } + }) + .collect(); + let i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); + (names, i) + }; + let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| { + s.refresh_hz = REFRESH[i]; + }); + let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor); + let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| { + s.compositor = COMPOSITORS[i].0.to_string(); + }); + + // --- Video ----------------------------------------------------------------------------- + let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder); + let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| { + s.decoder = DECODERS[i].0.to_string(); + }); + let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec); + let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| { + s.codec = CODECS[i].0.to_string(); + }); + // Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation + // round-trips exactly. + let bitrate_box = { + let ctx = ctx.clone(); + NumberBox::new(f64::from(s.bitrate_kbps) / 1000.0) + .header("Bitrate (Mb/s, 0 = automatic)") + .range(0.0, 3000.0) + .on_value_changed(move |v: f64| { + let mut s = ctx.settings.lock().unwrap(); + s.bitrate_kbps = (v.clamp(0.0, 3000.0) * 1000.0) as u32; + s.save(); + }) + }; + let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| { + s.hdr_enabled = on + }); + + // --- Input ----------------------------------------------------------------------------- + // Which physical controller forwards as pad 0: automatic = the most recently connected; + // pinning survives until the app exits (Swift/GTK parity). + let pads = ctx.gamepad.pads(); + let (fwd_names, fwd_i) = { + let mut names = vec!["Automatic (most recent)".to_string()]; + names.extend(pads.iter().map(|p| { + let kind = p.kind_label(); + if kind.is_empty() { + p.name.clone() + } else { + format!("{} \u{00B7} {kind}", p.name) + } + })); + let i = ctx + .gamepad + .pinned() + .and_then(|id| pads.iter().position(|p| p.id == id)) + .map_or(0, |i| i + 1); + (names, i) + }; + let forward_combo = { + let svc = ctx.gamepad.clone(); + let ids: Vec = pads.iter().map(|p| p.id).collect(); + ComboBox::new(fwd_names) + .header("Forwarded controller") + .selected_index(fwd_i as i32) + .on_selection_changed(move |i: i32| { + let sel = i.max(0) as usize; + svc.set_pinned(if sel == 0 { + None + } else { + ids.get(sel - 1).copied() + }); + }) + }; + let (pad_names, pad_i) = presets(GAMEPADS, |v| { + GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad) + }); + let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| { + s.gamepad = GAMEPADS[i].0.to_string(); + }); + let shortcuts_toggle = setting_toggle( + ctx, + "Capture system shortcuts (Alt+Tab, Win, \u{2026})", + s.inhibit_shortcuts, + |s, on| s.inhibit_shortcuts = on, + ); + + // --- Audio ----------------------------------------------------------------------------- + let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels); + let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| { + s.audio_channels = AUDIO_CHANNELS[i].0; + }); + let mic_toggle = setting_toggle( + ctx, + "Stream microphone to the host", + s.mic_enabled, + |s, on| s.mic_enabled = on, + ); + + let back_btn = button("Back").accent().icon(SymbolGlyph::Back).on_click({ + let ss = set_screen.clone(); + move || ss.call(Screen::Hosts) + }); + let licenses_button = { + let ss = set_screen.clone(); + button("Third-party licenses").on_click(move || ss.call(Screen::Licenses)) + }; + + page(vec![ + page_header("Settings", back_btn), + section("DISPLAY"), + settings_card( + "Display", + "The host creates a virtual display at exactly this mode. The compositor choice is \ + advisory (Linux hosts only).", + vec![res_combo.into(), hz_combo.into(), comp_combo.into()], + ), + section("VIDEO"), + settings_card( + "Video", + "Hardware decode (D3D11VA) is zero-copy and far lighter than software — keep it on \ + Automatic unless debugging. Run a per-host speed test (host list) before setting a \ + high bitrate.", + vec![ + decoder_combo.into(), + codec_combo.into(), + bitrate_box.into(), + hdr_toggle.into(), + ], + ), + section("INPUT"), + settings_card( + "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![ + forward_combo.into(), + pad_combo.into(), + shortcuts_toggle.into(), + ], + ), + section("AUDIO"), + settings_card( + "Audio", + "Request stereo or surround — the host downmixes if its output has fewer.", + 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()], + ), + ]) +} diff --git a/clients/windows/src/app/speed.rs b/clients/windows/src/app/speed.rs new file mode 100644 index 0000000..43f39b8 --- /dev/null +++ b/clients/windows/src/app/speed.rs @@ -0,0 +1,179 @@ +//! Per-host network speed test (the GTK/Swift clients' "Test Network Speed…"): connect over the +//! real data plane, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, and +//! report goodput · loss · a recommended bitrate (≈70 % of measured), applied in one tap. + +use super::style::*; +use super::{Screen, Svc}; +use crate::session::run_speed_probe; +use windows_reactor::*; + +/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via +/// `Svc::set_speed`, and thread-driven updates only re-render through a prop change — see the +/// app module docs). The hosts page resets it to `Running` before navigating here. +#[derive(Clone, PartialEq)] +pub(crate) enum SpeedState { + Running, + Failed(String), + Done { + mbps: f64, + loss_pct: f32, + recommended_kbps: u32, + }, +} + +/// Props for the speed page: the services plus the probe lifecycle that drives its re-render. +#[derive(Clone)] +pub(crate) struct SpeedProps { + pub(crate) svc: Svc, + pub(crate) state: SpeedState, +} + +impl PartialEq for SpeedProps { + fn eq(&self, other: &Self) -> bool { + self.svc == other.svc && self.state == other.state + } +} + +pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element { + let ctx = &props.svc.ctx; + let set_screen = &props.svc.set_screen; + let target = ctx.shared.target.lock().unwrap().clone(); + + // One probe run per mount (navigating here again re-mounts and re-runs). + cx.use_effect((), { + let set_speed = props.svc.set_speed.clone(); + let shared = ctx.shared.clone(); + let identity = ctx.identity.clone(); + let target = target.clone(); + move || { + use std::sync::atomic::Ordering; + // The generation the hosts page stamped for THIS run; a stale worker (user backed + // out and started another test) must not publish over the newer run. + let generation = shared.speed_gen.load(Ordering::SeqCst); + std::thread::Builder::new() + .name("pf-speedtest".into()) + .spawn(move || { + let outcome = run_speed_probe( + &target.addr, + target.port, + target.fp_hex.as_deref(), + identity, + ); + if shared.speed_gen.load(Ordering::SeqCst) != generation { + return; // superseded + } + set_speed.call(match outcome { + Ok(r) => { + let mbps = f64::from(r.throughput_kbps) / 1000.0; + SpeedState::Done { + mbps, + loss_pct: r.loss_pct, + // ≈70 % of measured: headroom for FEC overhead + real-world loss. + recommended_kbps: r.throughput_kbps / 10 * 7, + } + } + Err(msg) => SpeedState::Failed(msg), + }); + }) + .ok(); + } + }); + + let back_btn = { + let ss = set_screen.clone(); + button("Close") + .icon(SymbolGlyph::Back) + .on_click(move || ss.call(Screen::Hosts)) + .horizontal_alignment(HorizontalAlignment::Center) + }; + let headline = if target.name.is_empty() { + "Network speed test".to_string() + } else { + format!("Network speed test \u{00B7} {}", target.name) + }; + + match &props.state { + SpeedState::Running => busy_page( + &headline, + "Measuring the path over the real data plane \u{2014} a 2 s probe burst\u{2026}", + vec![back_btn.into()], + ), + SpeedState::Failed(msg) => { + let content = vstack(( + text_block(headline) + .font_size(18.0) + .semibold() + .horizontal_alignment(HorizontalAlignment::Center), + InfoBar::new("Speed test failed") + .message(msg.clone()) + .error() + .is_closable(false), + back_btn, + )) + .spacing(16.0) + .max_width(480.0) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center); + content.into() + } + SpeedState::Done { + mbps, + loss_pct, + recommended_kbps, + } => { + let recommended_mbps = f64::from(*recommended_kbps) / 1000.0; + let apply_btn = { + let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps); + button(format!("Use {recommended_mbps:.0} Mb/s")) + .accent() + .icon(SymbolGlyph::Accept) + .on_click(move || { + let mut s = ctx.settings.lock().unwrap(); + s.bitrate_kbps = kbps; + s.save(); + ss.call(Screen::Hosts); + }) + }; + let results = card( + vstack(( + text_block(format!("{mbps:.0} Mbit/s")) + .font_size(34.0) + .bold() + .horizontal_alignment(HorizontalAlignment::Center), + text_block(format!("measured \u{00B7} {loss_pct:.1} % loss")) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText) + .horizontal_alignment(HorizontalAlignment::Center), + text_block(format!( + "Recommended bitrate: {recommended_mbps:.0} Mb/s (\u{2248}70 % of measured, \ + leaving headroom for FEC and loss)" + )) + .font_size(12.0) + .foreground(ThemeRef::SecondaryText) + .horizontal_alignment(HorizontalAlignment::Center), + hstack((apply_btn, { + let ss = set_screen.clone(); + button("Close") + .icon(SymbolGlyph::Cancel) + .on_click(move || ss.call(Screen::Hosts)) + })) + .spacing(8.0) + .horizontal_alignment(HorizontalAlignment::Center), + )) + .spacing(12.0), + ); + vstack(( + text_block(headline) + .font_size(18.0) + .semibold() + .horizontal_alignment(HorizontalAlignment::Center), + results, + )) + .spacing(16.0) + .max_width(480.0) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center) + .into() + } + } +} diff --git a/clients/windows/src/app/stream.rs b/clients/windows/src/app/stream.rs new file mode 100644 index 0000000..111afa9 --- /dev/null +++ b/clients/windows/src/app/stream.rs @@ -0,0 +1,192 @@ +//! The stream page: a `SwapChainPanel` bound to the D3D11 composition swapchain in +//! [`crate::present`], driven by reactor's per-frame `on_rendering`, with a status-chip HUD +//! overlay (mode · decode path · HDR · fps/throughput/latency · capture hint). + +use super::style::{edges, uniform}; +use super::Svc; +use crate::present::Presenter; +use crate::session::Stats; +use crate::video::DecodedFrame; +use punktfunk_core::client::NativeClient; +use punktfunk_core::config::Mode; +use std::cell::RefCell; +use std::sync::Arc; +use windows_reactor::*; + +/// One HUD refresh: the latest session stats plus the input hooks' capture state. Mirrored into +/// root state by the poll thread (`pf-hud`) and passed down as a prop. +#[derive(Clone, Copy, Default, PartialEq)] +pub(crate) struct HudSample { + pub(crate) stats: Stats, + pub(crate) captured: bool, +} + +/// Props for the stream page: the services plus the live HUD sample that drives the overlay +/// (compared by value, so each new sample re-renders the overlay). +#[derive(Clone)] +pub(crate) struct StreamProps { + pub(crate) svc: Svc, + pub(crate) hud: HudSample, +} + +impl PartialEq for StreamProps { + fn eq(&self, other: &Self) -> bool { + self.svc == other.svc && self.hud == other.hud + } +} + +/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver. +struct PresentCtx { + presenter: Presenter, + frames: async_channel::Receiver, +} + +thread_local! { + static PRESENT: RefCell> = const { RefCell::new(None) }; + static PENDING_FRAMES: RefCell>> = + const { RefCell::new(None) }; +} + +fn present_newest(ctx: &mut PresentCtx) { + // Apply the latest source HDR mastering metadata (from the session pump's 0xCE drain) before + // presenting — a cheap no-op in the presenter when unchanged. + if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() { + ctx.presenter.set_hdr_metadata(meta); + } + // Drain to the newest decoded frame (drop any backlog) and hand it to the presenter by value — + // the GPU zero-copy path retains the decoder surface across re-presents, so ownership matters. + let mut newest = None; + while let Ok(f) = ctx.frames.try_recv() { + newest = Some(f); + } + ctx.presenter.present(newest); +} + +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 (and remove on unmount). + let connector_ref = cx.use_ref::>>(None); + cx.use_effect_with_cleanup((), { + let shared = ctx.shared.clone(); + let inhibit = ctx.settings.lock().unwrap().inhibit_shortcuts; + let connector_ref = connector_ref.clone(); + move || { + if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() { + let mode = connector.mode(); + connector_ref.set(Some(connector.clone())); + PENDING_FRAMES.with(|c| *c.borrow_mut() = Some(frames)); + crate::input::install(connector, mode, inhibit); + } + Some(crate::input::uninstall) + } + }); + + let rendering = cx.use_ref::>(None); + cx.use_effect((), { + let rendering = rendering.clone(); + move || { + if let Ok(r) = on_rendering(move || { + PRESENT.with(|cell| { + if let Some(ctx) = cell.borrow_mut().as_mut() { + present_newest(ctx); + } + }); + }) { + rendering.set(Some(r)); + } + } + }); + + let mode = connector_ref.borrow().as_ref().map(|c| c.mode()); + grid(( + swap_chain_panel() + .on_ready(|panel| match Presenter::new(1280, 720) { + Ok(p) => { + if let Err(e) = panel.set_swap_chain(p.swap_chain()) { + tracing::error!(error = %e, "set_swap_chain"); + } + if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) { + PRESENT.with(|cell| { + *cell.borrow_mut() = Some(PresentCtx { + presenter: p, + frames, + }); + }); + tracing::info!("stream presenter bound to SwapChainPanel"); + } + } + Err(e) => tracing::error!(error = %e, "create presenter"), + }) + .on_resize(|w, h| { + PRESENT.with(|cell| { + if let Some(ctx) = cell.borrow_mut().as_mut() { + ctx.presenter.resize(w as u32, h as u32); + } + }); + }), + hud_overlay(&props.hud, mode), + )) + .into() +} + +/// A small chip for the dark HUD: coloured text on a translucent dark fill. +fn hud_chip(text: &str, color: Color) -> Border { + border( + text_block(text) + .font_size(11.0) + .semibold() + .foreground(color), + ) + .background(Color::rgb(38, 38, 38)) + .corner_radius(8.0) + .padding(edges(8.0, 2.0, 8.0, 2.0)) +} + +/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode +/// path · HDR), the fps/throughput/latency line, and the capture-state hint. Layered over the +/// `SwapChainPanel` in the same grid cell. +fn hud_overlay(hud: &HudSample, mode: Option) -> Element { + let stats = &hud.stats; + let res = mode + .map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz)) + .unwrap_or_else(|| "\u{2014}".into()); + let mut chips: Vec = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()]; + chips.push(if stats.hardware { + hud_chip("GPU decode", Color::rgb(120, 220, 150)).into() + } else { + hud_chip("CPU decode", Color::rgb(240, 190, 90)).into() + }); + if stats.hdr { + chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into()); + } + let line = format!( + "{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms", + stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms + ); + let hint = if hud.captured { + "Ctrl+Alt+Shift+Q releases the mouse" + } else { + "Click the stream to capture the mouse" + }; + border( + vstack(( + hstack(chips).spacing(6.0), + text_block(line) + .font_size(11.0) + .foreground(Color::rgb(210, 210, 210)), + text_block(hint) + .font_size(11.0) + .foreground(Color::rgb(150, 150, 150)), + )) + .spacing(6.0), + ) + .background(Color::rgb(0, 0, 0)) + .corner_radius(10.0) + .padding(uniform(10.0)) + .opacity(0.82) + .horizontal_alignment(HorizontalAlignment::Right) + .vertical_alignment(VerticalAlignment::Top) + .margin(uniform(12.0)) + .into() +} diff --git a/clients/windows/src/app/style.rs b/clients/windows/src/app/style.rs new file mode 100644 index 0000000..f9cd3b8 --- /dev/null +++ b/clients/windows/src/app/style.rs @@ -0,0 +1,135 @@ +//! Shared styling primitives for every screen, following the windows-reactor gallery's look: +//! theme brushes (`ThemeRef`), rounded `border` cards, small all-caps section labels, and a +//! centred max-width column per page. + +use windows_reactor::*; + +pub(crate) fn uniform(v: f64) -> Thickness { + Thickness::uniform(v) +} + +pub(crate) fn edges(left: f64, top: f64, right: f64, bottom: f64) -> Thickness { + Thickness { + left, + top, + right, + bottom, + } +} + +/// A rounded, bordered surface in the theme's card colours. +pub(crate) fn card(child: impl Into) -> Border { + border(child.into()) + .background(ThemeRef::CardBackground) + .border_brush(ThemeRef::CardStroke) + .border_thickness(uniform(1.0)) + .corner_radius(8.0) + .padding(uniform(16.0)) +} + +/// A small all-caps section label above a group of cards. +pub(crate) fn section(label: &str) -> Element { + text_block(label) + .font_size(12.0) + .semibold() + .foreground(ThemeRef::SecondaryText) + .margin(edges(2.0, 10.0, 0.0, 0.0)) + .into() +} + +/// Wrap a screen's children in a scrollable, centred, max-width column. +pub(crate) fn page(children: Vec) -> Element { + let col = vstack(children) + .spacing(10.0) + .max_width(640.0) + .horizontal_alignment(HorizontalAlignment::Center) + .margin(edges(24.0, 24.0, 24.0, 40.0)); + scroll_view(col).into() +} + +/// A page header: a large bold title on the left, one action button on the right. +pub(crate) fn page_header(title: &str, action: Button) -> Element { + grid(( + text_block(title) + .font_size(30.0) + .bold() + .grid_column(0) + .vertical_alignment(VerticalAlignment::Center), + action + .grid_column(1) + .vertical_alignment(VerticalAlignment::Center), + )) + .columns([GridLength::Star(1.0), GridLength::Auto]) + .margin(edges(0.0, 0.0, 0.0, 6.0)) + .into() +} + +/// A full-screen centred "busy" scene: spinner, headline, secondary detail line, and optional +/// trailing elements (e.g. a Cancel button). Shared by Connecting / RequestAccess / SpeedTest. +pub(crate) fn busy_page(headline: &str, detail: &str, extra: Vec) -> Element { + let mut children: Vec = vec![ + ProgressRing::indeterminate() + .width(48.0) + .height(48.0) + .horizontal_alignment(HorizontalAlignment::Center) + .into(), + text_block(headline) + .font_size(18.0) + .semibold() + .horizontal_alignment(HorizontalAlignment::Center) + .into(), + text_block(detail) + .foreground(ThemeRef::SecondaryText) + .horizontal_alignment(HorizontalAlignment::Center) + .into(), + ]; + children.extend(extra); + vstack(children) + .spacing(16.0) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center) + .into() +} + +/// A rounded square "monogram" for a host, the first letter on an accent fill — a clean leading +/// visual that avoids depending on an icon font being installed. +pub(crate) fn avatar(name: &str) -> Border { + let initial = name + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".into()); + border( + text_block(initial) + .font_size(17.0) + .semibold() + .foreground(ThemeRef::AccentText) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center), + ) + .background(ThemeRef::Accent) + .corner_radius(10.0) + .width(40.0) + .height(40.0) +} + +/// Pill chip colour intent. +#[derive(Clone, Copy)] +pub(crate) enum Pill { + Accent, + Good, + Neutral, +} + +/// A small rounded status chip (paired/PIN/HDR/etc.). +pub(crate) fn pill(text: &str, kind: Pill) -> Border { + let (bg, fg) = match kind { + Pill::Accent => (ThemeRef::Accent, ThemeRef::AccentText), + Pill::Good => (ThemeRef::SystemSuccessBackground, ThemeRef::SystemSuccess), + Pill::Neutral => (ThemeRef::SubtleFill, ThemeRef::SecondaryText), + }; + border(text_block(text).font_size(11.0).semibold().foreground(fg)) + .background(bg) + .corner_radius(10.0) + .padding(edges(9.0, 3.0, 9.0, 3.0)) +} diff --git a/clients/windows/src/audio.rs b/clients/windows/src/audio.rs index 9eac674..477f209 100644 --- a/clients/windows/src/audio.rs +++ b/clients/windows/src/audio.rs @@ -138,6 +138,7 @@ fn render_thread( // Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic). let mut ring: VecDeque = VecDeque::new(); let mut primed = false; + let mut out = Vec::new(); // per-quantum scratch, reused across iterations while !stop.load(Ordering::Relaxed) { if h_event.wait_for_event(100).is_err() { @@ -159,14 +160,16 @@ fn render_thread( // Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain. let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align); - while ring.len() > target.max(want_bytes) + want_bytes { - ring.pop_front(); + let cap = target.max(want_bytes) + want_bytes; + if ring.len() > cap { + ring.drain(..ring.len() - cap); } if !primed && ring.len() >= target { primed = true; } - let mut out = vec![0u8; want_bytes]; + out.clear(); + out.resize(want_bytes, 0); if primed { let n = ring.len().min(want_bytes); for (dst, b) in out.iter_mut().zip(ring.drain(..n)) { diff --git a/clients/windows/src/gamepad.rs b/clients/windows/src/gamepad.rs index 3c59b49..bb57f02 100644 --- a/clients/windows/src/gamepad.rs +++ b/clients/windows/src/gamepad.rs @@ -31,11 +31,8 @@ const G: f32 = 9.80665; #[derive(Clone, Debug)] pub struct PadInfo { - // `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only - // reads `pref` (via `auto_pref`), so they're unused in reachable code for now. - #[allow(dead_code)] + /// SDL joystick instance id — the settings GUI's pin key. pub id: u32, - #[allow(dead_code)] pub name: String, /// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense, /// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360). @@ -48,6 +45,19 @@ impl PadInfo { fn is_dualsense(&self) -> bool { self.pref == GamepadPref::DualSense } + + /// A short human label for the detected pad family, shown next to the name in the settings + /// GUI's controller list ("" for a generic pad the name already describes). + pub fn kind_label(&self) -> &'static str { + match self.pref { + GamepadPref::DualSense => "DualSense", + GamepadPref::DualShock4 => "DualShock 4", + GamepadPref::XboxOne => "Xbox One", + GamepadPref::SteamDeck => "Steam Deck", + GamepadPref::SteamController => "Steam Controller", + _ => "", + } + } } /// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. @@ -102,7 +112,7 @@ impl GamepadService { } } - #[allow(dead_code)] // consumed by the settings GUI (follow-up) + /// Connected controllers, most recently attached first (the settings GUI's list order). pub fn pads(&self) -> Vec { self.pads.lock().unwrap().clone() } @@ -111,12 +121,11 @@ impl GamepadService { self.active.lock().unwrap().clone() } - #[allow(dead_code)] // consumed by the settings GUI (follow-up) + /// The user-pinned controller (settings GUI), if any — else auto (most recent). pub fn pinned(&self) -> Option { *self.pinned.lock().unwrap() } - #[allow(dead_code)] // consumed by the settings GUI (follow-up) pub fn set_pinned(&self, id: Option) { let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id)); } diff --git a/clients/windows/src/input.rs b/clients/windows/src/input.rs index fccc635..c635391 100644 --- a/clients/windows/src/input.rs +++ b/clients/windows/src/input.rs @@ -15,15 +15,18 @@ //! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate //! snapped to one point. Keys carry the native Windows VK directly (the wire contract). //! -//! **Ctrl+Alt+Shift+Q** toggles capture — releasing the lock hands the cursor back to the local -//! desktop (and re-grabs on the next toggle). Losing foreground also releases the lock so the -//! cursor is never stranded. +//! **Capture state machine** (parity with the GTK/Swift clients): capture engages at stream +//! start, **Ctrl+Alt+Shift+Q** releases it (handing the cursor back to the local desktop), and a +//! **click on the stream** re-engages it. Losing foreground also releases the lock so the cursor +//! is never stranded; regaining it while still captured re-locks. When "capture system +//! shortcuts" is off in Settings, Alt+Tab / Alt+Esc / Ctrl+Esc / the Win keys act on the local +//! desktop instead of being forwarded. use punktfunk_core::client::NativeClient; use punktfunk_core::config::Mode; use punktfunk_core::input::{InputEvent, InputKind}; use std::collections::HashSet; -use std::sync::atomic::{AtomicIsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM}; use windows::Win32::Graphics::Gdi::ClientToScreen; @@ -42,14 +45,21 @@ struct State { mode: Mode, /// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not). hwnd: isize, - /// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q). + /// User intent: forward input to the host (toggled by Ctrl+Alt+Shift+Q / click-to-capture). captured: bool, + /// Forward system shortcuts (Alt+Tab, Win, …) to the host; off = they act locally. + inhibit_shortcuts: bool, /// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real /// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition. locked: bool, - /// Lock centre in screen coordinates (the cursor is warped here after every move). + /// Lock geometry, captured when the lock engages: the confinement rect (screen coordinates, + /// also the click-to-capture hit test), its centre (the cursor is warped here after every + /// move), and the screen→host scale (the Contain-fit display scale's inverse). Stable while + /// locked — the window can't be moved or resized with the cursor confined inside it. + clip: RECT, center_x: i32, center_y: i32, + scale: f32, /// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away. acc_x: f32, acc_y: f32, @@ -66,18 +76,39 @@ struct State { static STATE: Mutex> = Mutex::new(None); static KBD_HOOK: AtomicIsize = AtomicIsize::new(0); static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0); +/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll). +static CAPTURED: AtomicBool = AtomicBool::new(false); + +/// Whether stream input is currently captured (drives the HUD's release/capture hint). +pub fn is_captured() -> bool { + CAPTURED.load(Ordering::Relaxed) +} + +/// Set the capture intent and engage/release the pointer lock to match. +fn set_captured(st: &mut State, on: bool) { + st.captured = on; + CAPTURED.store(on, Ordering::Relaxed); + set_locked(st, on); + if !on { + flush_held(st); // release held keys/buttons so nothing sticks on the host + } +} /// Install the hooks for a streaming session. Call from the UI thread once the window is shown. -pub fn install(connector: Arc, mode: Mode) { +/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local. +pub fn install(connector: Arc, mode: Mode, inhibit_shortcuts: bool) { let hwnd = unsafe { GetForegroundWindow() }; let mut st = State { connector, mode, hwnd: hwnd.0 as isize, - captured: true, + captured: false, + inhibit_shortcuts, locked: false, + clip: RECT::default(), center_x: 0, center_y: 0, + scale: 1.0, acc_x: 0.0, acc_y: 0.0, ctrl: false, @@ -86,8 +117,9 @@ pub fn install(connector: Arc, mode: Mode) { held_keys: HashSet::new(), held_buttons: HashSet::new(), }; - // Lock immediately (the window is foreground at mount, like Moonlight grabbing on stream start). - set_locked(&mut st, true); + // Capture immediately (the window is foreground at mount, like Moonlight grabbing on stream + // start). + set_captured(&mut st, true); *STATE.lock().unwrap() = Some(st); unsafe { let hinst = GetModuleHandleW(None).ok(); @@ -99,6 +131,7 @@ pub fn install(connector: Arc, mode: Mode) { } } tracing::info!( + inhibit_shortcuts, "stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)" ); } @@ -117,8 +150,7 @@ pub fn uninstall() { } } if let Some(mut st) = STATE.lock().unwrap().take() { - set_locked(&mut st, false); // hand the cursor back to the desktop - flush_held(&mut st); + set_captured(&mut st, false); // hand the cursor back + flush held state } } @@ -136,6 +168,7 @@ fn flush_held(st: &mut State) { /// Engage or release the pointer lock: confine + hide + recentre on, free + show on off. /// Guarded so the `ClipCursor`/`ShowCursor` calls stay balanced (one each per transition). +/// Engaging captures the lock geometry (rect, centre, screen→host scale) — see `State::clip`. fn set_locked(st: &mut State, on: bool) { if on == st.locked { return; @@ -155,15 +188,20 @@ fn set_locked(st: &mut State, on: bool) { }; let _ = ClientToScreen(hwnd, &mut tl); let _ = ClientToScreen(hwnd, &mut br); - let clip = RECT { + st.clip = RECT { left: tl.x, top: tl.y, right: br.x, bottom: br.y, }; - let _ = ClipCursor(Some(&clip as *const RECT)); + let _ = ClipCursor(Some(&st.clip as *const RECT)); st.center_x = (tl.x + br.x) / 2; st.center_y = (tl.y + br.y) / 2; + // Screen px → host px: the Contain-fit display scale's inverse, so the host + // cursor tracks the physical mouse 1:1 on screen at any window size. + let (ww, wh) = ((br.x - tl.x).max(1) as f32, (br.y - tl.y).max(1) as f32); + let (vw, vh) = (st.mode.width.max(1) as f32, st.mode.height.max(1) as f32); + st.scale = (ww / vw).min(wh / vh).max(0.01); let _ = SetCursorPos(st.center_x, st.center_y); } let _ = ShowCursor(false); @@ -188,6 +226,17 @@ fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32 }); } +/// System shortcuts that act on the LOCAL desktop when "capture system shortcuts" is off: +/// the Win keys, Alt+Tab, and Alt/Ctrl+Esc. +fn is_system_shortcut(st: &State, vk: u16) -> bool { + match vk { + 0x5B | 0x5C => true, // L/R Win + 0x09 => st.alt, // Alt+Tab + 0x1B => st.alt || st.ctrl, // Alt+Esc / Ctrl+Esc + _ => false, + } +} + unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { if code == HC_ACTION as i32 { let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) }; @@ -210,15 +259,16 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> // Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded). if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift { let on = !st.captured; - st.captured = on; - set_locked(st, on); // grab/release the cursor immediately - if !on { - flush_held(st); // release held keys/buttons so nothing sticks on the host - } + set_captured(st, on); tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)"); return LRESULT(1); } if st.captured { + // With shortcut capture off, hand Alt+Tab & co. to the local desktop — + // neither forwarded nor swallowed. + if !st.inhibit_shortcuts && is_system_shortcut(st, vk) { + return unsafe { CallNextHookEx(None, code, wparam, lparam) }; + } let v = vk as u8; if up { if st.held_keys.remove(&v) { @@ -236,17 +286,27 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> unsafe { CallNextHookEx(None, code, wparam, lparam) } } -/// Client-area size in pixels (for the screen→host relative-motion scale). -fn client_size(hwnd: isize) -> (f32, f32) { +/// Whether a screen point lies inside the window's CURRENT client area (the click-to-capture +/// hit test — computed fresh per click, since the window can move/resize while released). +fn in_client_area(hwnd: isize, pt: POINT) -> bool { + let hwnd = HWND(hwnd as *mut _); let mut rc = RECT::default(); - if unsafe { GetClientRect(HWND(hwnd as *mut _), &mut rc) }.is_ok() { - ( - (rc.right - rc.left).max(1) as f32, - (rc.bottom - rc.top).max(1) as f32, - ) - } else { - (1.0, 1.0) + if unsafe { GetClientRect(hwnd, &mut rc) }.is_err() { + return false; } + let mut tl = POINT { + x: rc.left, + y: rc.top, + }; + let mut br = POINT { + x: rc.right, + y: rc.bottom, + }; + unsafe { + let _ = ClientToScreen(hwnd, &mut tl); + let _ = ClientToScreen(hwnd, &mut br); + } + pt.x >= tl.x && pt.x < br.x && pt.y >= tl.y && pt.y < br.y } unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { @@ -261,6 +321,18 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) if want_lock != st.locked { set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground) } + // Click-to-capture: after a Ctrl+Alt+Shift+Q release, a primary click on the stream + // re-engages capture. The click is consumed — it starts the grab, it isn't gameplay. + if !st.captured + && foreground + && msg == WM_LBUTTONDOWN + && !injected + && in_client_area(st.hwnd, ms.pt) + { + set_captured(st, true); + tracing::info!("capture re-engaged (click on stream)"); + return LRESULT(1); + } if st.locked { // Skip the synthetic move our own SetCursorPos recentre generates. if injected { @@ -272,14 +344,8 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) let dx = (ms.pt.x - st.center_x) as f32; let dy = (ms.pt.y - st.center_y) as f32; if dx != 0.0 || dy != 0.0 { - // screen px → host px: the Contain-fit display scale's inverse, so the - // host cursor tracks the physical mouse 1:1 on screen at any window size. - let (ww, wh) = client_size(st.hwnd); - let (vw, vh) = - (st.mode.width.max(1) as f32, st.mode.height.max(1) as f32); - let s = (ww / vw).min(wh / vh).max(0.01); - st.acc_x += dx / s; - st.acc_y += dy / s; + st.acc_x += dx / st.scale; + st.acc_y += dy / st.scale; let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32); st.acc_x -= hx as f32; st.acc_y -= hy as f32; diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index bb83d79..968fde2 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -12,6 +12,8 @@ //! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz] //! [--bitrate MBPS] [--mic] [--decoder auto|hardware|software] [--no-hdr] //! (no window; count frames + print stats) +//! punktfunk-client --headless --speed-test --connect host[:port] +//! (measure the path: probe burst → goodput / loss / recommended bitrate) // Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click) // does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching @@ -108,6 +110,30 @@ fn run_headless_cli(args: &[String], identity: (String, String)) { Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)), None => (target.clone(), 9777u16), }; + + // Speed test: measure the path over the real data plane, print the outcome, exit. The saved + // fingerprint for this address (if any) pins the connect, like the GUI's per-host test. + if flag("--speed-test") { + let fp = trust::KnownHosts::load() + .find_by_addr(&host, port) + .map(|k| k.fp_hex.clone()); + match session::run_speed_probe(&host, port, fp.as_deref(), identity) { + Ok(r) => { + let mbps = f64::from(r.throughput_kbps) / 1000.0; + let recommended = f64::from(r.throughput_kbps / 10 * 7) / 1000.0; + println!( + "{mbps:.0} Mbit/s measured · {:.1} % loss · recommended bitrate {recommended:.0} Mbit/s (--bitrate {:.0})", + r.loss_pct, + recommended + ); + } + Err(e) => { + eprintln!("speed test failed: {e}"); + std::process::exit(1); + } + } + return; + } let mode = arg("--mode") .and_then(|m| { let mut it = m.split(['x', 'X']); diff --git a/clients/windows/src/session.rs b/clients/windows/src/session.rs index a40cbca..e06ff15 100644 --- a/clients/windows/src/session.rs +++ b/clients/windows/src/session.rs @@ -80,6 +80,55 @@ pub struct SessionHandle { pub stop: Arc, } +/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI): +/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is +/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to +/// completion. Run on a worker thread. +pub fn run_speed_probe( + addr: &str, + port: u16, + fp_hex: Option<&str>, + identity: (String, String), +) -> Result { + // Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU. + let pin = fp_hex.and_then(crate::trust::parse_hex32); + let c = NativeClient::connect( + addr, + port, + Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }, + CompositorPref::Auto, + GamepadPref::Auto, + 0, // bitrate_kbps: host default + 0, // video_caps: probe connect, nothing is decoded + 2, // audio_channels: stereo baseline + crate::video::decodable_codecs(), + 0, // preferred_codec: no preference + None, // launch: no game + pin, + Some(identity), + Duration::from_secs(15), + ) + .map_err(|e| format!("connect: {e:?}"))?; + c.request_probe(3_000_000, 2_000) + .map_err(|e| format!("probe: {e:?}"))?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + std::thread::sleep(Duration::from_millis(250)); + if c.probe_result().done { + // Let the last UDP shards land before tearing down. + std::thread::sleep(Duration::from_millis(400)); + return Ok(c.probe_result()); + } + if Instant::now() > deadline { + return Err("probe timed out".to_string()); + } + } +} + pub fn start(params: SessionParams) -> SessionHandle { let (ev_tx, ev_rx) = async_channel::unbounded(); // Tiny frame queue, newest wins: force_send displaces the oldest when the UI lags. diff --git a/clients/windows/src/trust.rs b/clients/windows/src/trust.rs index a7c21db..6cbc304 100644 --- a/clients/windows/src/trust.rs +++ b/clients/windows/src/trust.rs @@ -84,14 +84,16 @@ 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) } + /// Forget a host (the hosts page's "Forget" action): drops the pinned fingerprint, so a + /// later connect goes back through pairing/TOFU. + pub fn remove_by_fp(&mut self, fp_hex: &str) { + self.hosts.retain(|h| h.fp_hex != fp_hex); + } + pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> { self.hosts.iter().find(|h| h.addr == addr && h.port == port) }