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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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`
|
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
|
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
|
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
|
||||||
validation** of the D3D11VA decode + HDR present + GUI on the RTX box (the dev VM is
|
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
|
||||||
headless/Session-0/WARP → the WinUI window + hardware decode need a real display+GPU: RDP or the
|
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
|
||||||
RTX box), then RAWINPUT relative-mouse pointer-lock and a per-host speed test in the UI.
|
`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
|
**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.
|
`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`),
|
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
|
||||||
|
|||||||
@@ -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.
|
- **Your display's native mode** — the host builds a virtual display at exactly your WxH@Hz.
|
||||||
- **Audio both ways** — WASAPI render + mic capture.
|
- **Audio both ways** — WASAPI render + mic capture.
|
||||||
- **Full controller support** — SDL3 gamepads with rumble, lightbar, and DualSense feedback.
|
- **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
|
- **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
|
entries. First connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then
|
||||||
reconnects on a pinned identity.
|
reconnects on a pinned identity. Saved hosts carry per-host actions: a **network speed test**
|
||||||
- **Polished shell** — host cards, settings (resolution / refresh / decoder / bitrate / HDR / mic),
|
(probe burst over the real data plane → recommended bitrate, applied in one tap) and **forget**.
|
||||||
a status-chip stream HUD, and the full trust surface. Stream input uses Win32 low-level hooks with
|
- **Polished shell** — host cards, settings (resolution / refresh / host compositor / decoder /
|
||||||
a Ctrl+Alt+Shift+Q capture toggle.
|
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**.
|
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):
|
# CLI paths for testing (no window):
|
||||||
punktfunk-client --discover # list hosts on the LAN
|
punktfunk-client --discover # list hosts on the LAN
|
||||||
punktfunk-client --headless --connect host[:port] [--pin HEX] # connect, count frames, print stats
|
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
|
> `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/
|
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
|
present.rs · gpu.rs SwapChainPanel D3D11 composition swapchain; shared D3D11 device
|
||||||
video.rs FFmpeg HEVC decode (D3D11VA zero-copy + software fallback)
|
video.rs FFmpeg HEVC decode (D3D11VA zero-copy + software fallback)
|
||||||
audio.rs WASAPI render + mic capture
|
audio.rs WASAPI render + mic capture
|
||||||
gamepad.rs SDL3 controllers + rumble/lightbar/DualSense feedback
|
gamepad.rs SDL3 controllers + rumble/lightbar/DualSense feedback
|
||||||
input.rs Win32 low-level keyboard/mouse hooks → host input
|
input.rs Win32 low-level hooks → host input (pointer lock · click-to-capture)
|
||||||
session.rs session lifecycle over the NativeClient connector
|
session.rs session lifecycle over the NativeClient connector (+ speed probe)
|
||||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
||||||
packaging/ MSIX manifest, signing, pack script
|
packaging/ MSIX manifest, signing, pack script
|
||||||
```
|
```
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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<AppCtx>,
|
||||||
|
target: Target,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
let known = KnownHosts::load();
|
||||||
|
let pin = target
|
||||||
|
.fp_hex
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fp| known.find_by_fp(fp).map(|_| fp.clone()))
|
||||||
|
.or_else(|| {
|
||||||
|
known
|
||||||
|
.find_by_addr(&target.addr, target.port)
|
||||||
|
.map(|k| k.fp_hex.clone())
|
||||||
|
})
|
||||||
|
.and_then(|fp| trust::parse_hex32(&fp));
|
||||||
|
|
||||||
|
if let Some(pin) = pin {
|
||||||
|
connect(ctx, &target, Some(pin), set_screen, set_status);
|
||||||
|
} else if target.pair_optional {
|
||||||
|
connect(ctx, &target, None, set_screen, set_status); // TOFU
|
||||||
|
} else {
|
||||||
|
*ctx.shared.target.lock().unwrap() = target;
|
||||||
|
set_screen.call(Screen::Pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::<MONITORINFOEXW>() 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::<DEVMODEW>() 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<Arc<AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
) {
|
||||||
|
connect_with(
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
pin,
|
||||||
|
set_screen,
|
||||||
|
set_status,
|
||||||
|
ConnectOpts::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_with(
|
||||||
|
ctx: &Arc<AppCtx>,
|
||||||
|
target: &Target,
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
set_status: &AsyncSetState<String>,
|
||||||
|
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<AppCtx>, 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<AppCtx>,
|
||||||
|
set_screen: &AsyncSetState<Screen>,
|
||||||
|
) -> 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()],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<DiscoveredHost>,
|
||||||
|
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<Element>,
|
||||||
|
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<Element> = 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)
|
||||||
|
}
|
||||||
@@ -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<Screen>) -> 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(),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<AppCtx>,
|
||||||
|
pub(crate) set_screen: AsyncSetState<Screen>,
|
||||||
|
pub(crate) set_status: AsyncSetState<String>,
|
||||||
|
/// 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<SpeedState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
|
||||||
|
pub(crate) target: Mutex<Target>,
|
||||||
|
/// 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<Stats>,
|
||||||
|
/// 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<Option<Arc<AtomicBool>>>,
|
||||||
|
/// 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<Settings>,
|
||||||
|
pub(crate) gamepad: GamepadService,
|
||||||
|
pub(crate) shared: Arc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||||
|
let ctx = Arc::new(AppCtx {
|
||||||
|
identity,
|
||||||
|
settings: Mutex::new(Settings::load()),
|
||||||
|
gamepad,
|
||||||
|
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<AppCtx>) -> Element {
|
||||||
|
let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
|
||||||
|
let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
|
||||||
|
let (status, set_status) = cx.use_async_state(String::new());
|
||||||
|
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<DiscoveredHost> = Vec::new();
|
||||||
|
while let Ok(h) = rx.recv_blocking() {
|
||||||
|
if let Some(e) = acc.iter_mut().find(|e| e.key == h.key) {
|
||||||
|
*e = h;
|
||||||
|
} else {
|
||||||
|
acc.push(h);
|
||||||
|
}
|
||||||
|
set_hosts.call(acc.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()])
|
||||||
|
}
|
||||||
@@ -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<AppCtx>,
|
||||||
|
header: &str,
|
||||||
|
names: Vec<String>,
|
||||||
|
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<V>(table: &[(V, &str)], is_current: impl Fn(&V) -> bool) -> (Vec<String>, 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<AppCtx>,
|
||||||
|
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>) -> Element {
|
||||||
|
let mut children: Vec<Element> = 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<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||||
|
let s = ctx.settings.lock().unwrap().clone();
|
||||||
|
|
||||||
|
// --- Display ---------------------------------------------------------------------------
|
||||||
|
let (res_names, res_i) = {
|
||||||
|
let names: Vec<String> = 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<String> = 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<u32> = 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()],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DecodedFrame>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static PRESENT: RefCell<Option<PresentCtx>> = const { RefCell::new(None) };
|
||||||
|
static PENDING_FRAMES: RefCell<Option<async_channel::Receiver<DecodedFrame>>> =
|
||||||
|
const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<Option<Arc<NativeClient>>>(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::<Option<Rendering>>(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<Mode>) -> 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<Element> = 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()
|
||||||
|
}
|
||||||
@@ -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<Element>) -> 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>) -> 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>) -> Element {
|
||||||
|
let mut children: Vec<Element> = 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))
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ fn render_thread(
|
|||||||
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
|
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
|
||||||
let mut ring: VecDeque<u8> = VecDeque::new();
|
let mut ring: VecDeque<u8> = VecDeque::new();
|
||||||
let mut primed = false;
|
let mut primed = false;
|
||||||
|
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
if h_event.wait_for_event(100).is_err() {
|
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.
|
// 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);
|
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
|
||||||
while ring.len() > target.max(want_bytes) + want_bytes {
|
let cap = target.max(want_bytes) + want_bytes;
|
||||||
ring.pop_front();
|
if ring.len() > cap {
|
||||||
|
ring.drain(..ring.len() - cap);
|
||||||
}
|
}
|
||||||
if !primed && ring.len() >= target {
|
if !primed && ring.len() >= target {
|
||||||
primed = true;
|
primed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out = vec![0u8; want_bytes];
|
out.clear();
|
||||||
|
out.resize(want_bytes, 0);
|
||||||
if primed {
|
if primed {
|
||||||
let n = ring.len().min(want_bytes);
|
let n = ring.len().min(want_bytes);
|
||||||
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
|
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
|
||||||
|
|||||||
@@ -31,11 +31,8 @@ const G: f32 = 9.80665;
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PadInfo {
|
pub struct PadInfo {
|
||||||
// `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only
|
/// SDL joystick instance id — the settings GUI's pin key.
|
||||||
// reads `pref` (via `auto_pref`), so they're unused in reachable code for now.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
||||||
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
||||||
@@ -48,6 +45,19 @@ impl PadInfo {
|
|||||||
fn is_dualsense(&self) -> bool {
|
fn is_dualsense(&self) -> bool {
|
||||||
self.pref == GamepadPref::DualSense
|
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.
|
/// 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<PadInfo> {
|
pub fn pads(&self) -> Vec<PadInfo> {
|
||||||
self.pads.lock().unwrap().clone()
|
self.pads.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
@@ -111,12 +121,11 @@ impl GamepadService {
|
|||||||
self.active.lock().unwrap().clone()
|
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<u32> {
|
pub fn pinned(&self) -> Option<u32> {
|
||||||
*self.pinned.lock().unwrap()
|
*self.pinned.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)] // consumed by the settings GUI (follow-up)
|
|
||||||
pub fn set_pinned(&self, id: Option<u32>) {
|
pub fn set_pinned(&self, id: Option<u32>) {
|
||||||
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id));
|
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(id));
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-36
@@ -15,15 +15,18 @@
|
|||||||
//! `WM_MOUSEMOVE` pinned the OS cursor, so `pt` never travelled and the absolute coordinate
|
//! `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).
|
//! 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
|
//! **Capture state machine** (parity with the GTK/Swift clients): capture engages at stream
|
||||||
//! desktop (and re-grabs on the next toggle). Losing foreground also releases the lock so the
|
//! start, **Ctrl+Alt+Shift+Q** releases it (handing the cursor back to the local desktop), and a
|
||||||
//! cursor is never stranded.
|
//! **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::client::NativeClient;
|
||||||
use punktfunk_core::config::Mode;
|
use punktfunk_core::config::Mode;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||||
@@ -42,14 +45,21 @@ struct State {
|
|||||||
mode: Mode,
|
mode: Mode,
|
||||||
/// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not).
|
/// Our window handle, stored as the raw `isize` so `State` is `Send` (`HWND` is not).
|
||||||
hwnd: isize,
|
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,
|
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
|
/// The OS pointer is currently locked (hidden + confined + recentering). Tracks the real
|
||||||
/// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition.
|
/// `ClipCursor`/`ShowCursor` state so we engage/disengage exactly once per transition.
|
||||||
locked: bool,
|
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_x: i32,
|
||||||
center_y: i32,
|
center_y: i32,
|
||||||
|
scale: f32,
|
||||||
/// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away.
|
/// Sub-pixel remainder of the screen→host scale, carried so slow drags aren't truncated away.
|
||||||
acc_x: f32,
|
acc_x: f32,
|
||||||
acc_y: f32,
|
acc_y: f32,
|
||||||
@@ -66,18 +76,39 @@ struct State {
|
|||||||
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
||||||
static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
|
static KBD_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||||
static MOUSE_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.
|
/// Install the hooks for a streaming session. Call from the UI thread once the window is shown.
|
||||||
pub fn install(connector: Arc<NativeClient>, mode: Mode) {
|
/// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local.
|
||||||
|
pub fn install(connector: Arc<NativeClient>, mode: Mode, inhibit_shortcuts: bool) {
|
||||||
let hwnd = unsafe { GetForegroundWindow() };
|
let hwnd = unsafe { GetForegroundWindow() };
|
||||||
let mut st = State {
|
let mut st = State {
|
||||||
connector,
|
connector,
|
||||||
mode,
|
mode,
|
||||||
hwnd: hwnd.0 as isize,
|
hwnd: hwnd.0 as isize,
|
||||||
captured: true,
|
captured: false,
|
||||||
|
inhibit_shortcuts,
|
||||||
locked: false,
|
locked: false,
|
||||||
|
clip: RECT::default(),
|
||||||
center_x: 0,
|
center_x: 0,
|
||||||
center_y: 0,
|
center_y: 0,
|
||||||
|
scale: 1.0,
|
||||||
acc_x: 0.0,
|
acc_x: 0.0,
|
||||||
acc_y: 0.0,
|
acc_y: 0.0,
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
@@ -86,8 +117,9 @@ pub fn install(connector: Arc<NativeClient>, mode: Mode) {
|
|||||||
held_keys: HashSet::new(),
|
held_keys: HashSet::new(),
|
||||||
held_buttons: HashSet::new(),
|
held_buttons: HashSet::new(),
|
||||||
};
|
};
|
||||||
// Lock immediately (the window is foreground at mount, like Moonlight grabbing on stream start).
|
// Capture immediately (the window is foreground at mount, like Moonlight grabbing on stream
|
||||||
set_locked(&mut st, true);
|
// start).
|
||||||
|
set_captured(&mut st, true);
|
||||||
*STATE.lock().unwrap() = Some(st);
|
*STATE.lock().unwrap() = Some(st);
|
||||||
unsafe {
|
unsafe {
|
||||||
let hinst = GetModuleHandleW(None).ok();
|
let hinst = GetModuleHandleW(None).ok();
|
||||||
@@ -99,6 +131,7 @@ pub fn install(connector: Arc<NativeClient>, mode: Mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
inhibit_shortcuts,
|
||||||
"stream input hooks installed — pointer locked (Ctrl+Alt+Shift+Q toggles capture)"
|
"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() {
|
if let Some(mut st) = STATE.lock().unwrap().take() {
|
||||||
set_locked(&mut st, false); // hand the cursor back to the desktop
|
set_captured(&mut st, false); // hand the cursor back + flush held state
|
||||||
flush_held(&mut st);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +168,7 @@ fn flush_held(st: &mut State) {
|
|||||||
|
|
||||||
/// Engage or release the pointer lock: confine + hide + recentre on, free + show on off.
|
/// 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).
|
/// 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) {
|
fn set_locked(st: &mut State, on: bool) {
|
||||||
if on == st.locked {
|
if on == st.locked {
|
||||||
return;
|
return;
|
||||||
@@ -155,15 +188,20 @@ fn set_locked(st: &mut State, on: bool) {
|
|||||||
};
|
};
|
||||||
let _ = ClientToScreen(hwnd, &mut tl);
|
let _ = ClientToScreen(hwnd, &mut tl);
|
||||||
let _ = ClientToScreen(hwnd, &mut br);
|
let _ = ClientToScreen(hwnd, &mut br);
|
||||||
let clip = RECT {
|
st.clip = RECT {
|
||||||
left: tl.x,
|
left: tl.x,
|
||||||
top: tl.y,
|
top: tl.y,
|
||||||
right: br.x,
|
right: br.x,
|
||||||
bottom: br.y,
|
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_x = (tl.x + br.x) / 2;
|
||||||
st.center_y = (tl.y + br.y) / 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 _ = SetCursorPos(st.center_x, st.center_y);
|
||||||
}
|
}
|
||||||
let _ = ShowCursor(false);
|
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 {
|
unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
if code == HC_ACTION as i32 {
|
if code == HC_ACTION as i32 {
|
||||||
let kb = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) };
|
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).
|
// Capture toggle: Ctrl+Alt+Shift+Q (consumed locally, never forwarded).
|
||||||
if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift {
|
if !up && vk == VK_Q.0 && st.ctrl && st.alt && st.shift {
|
||||||
let on = !st.captured;
|
let on = !st.captured;
|
||||||
st.captured = on;
|
set_captured(st, 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
|
|
||||||
}
|
|
||||||
tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)");
|
tracing::info!(captured = on, "capture toggled (Ctrl+Alt+Shift+Q)");
|
||||||
return LRESULT(1);
|
return LRESULT(1);
|
||||||
}
|
}
|
||||||
if st.captured {
|
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;
|
let v = vk as u8;
|
||||||
if up {
|
if up {
|
||||||
if st.held_keys.remove(&v) {
|
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) }
|
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client-area size in pixels (for the screen→host relative-motion scale).
|
/// Whether a screen point lies inside the window's CURRENT client area (the click-to-capture
|
||||||
fn client_size(hwnd: isize) -> (f32, f32) {
|
/// 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();
|
let mut rc = RECT::default();
|
||||||
if unsafe { GetClientRect(HWND(hwnd as *mut _), &mut rc) }.is_ok() {
|
if unsafe { GetClientRect(hwnd, &mut rc) }.is_err() {
|
||||||
(
|
return false;
|
||||||
(rc.right - rc.left).max(1) as f32,
|
|
||||||
(rc.bottom - rc.top).max(1) as f32,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(1.0, 1.0)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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 {
|
if want_lock != st.locked {
|
||||||
set_locked(st, want_lock); // sync to focus changes (e.g. lost foreground)
|
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 {
|
if st.locked {
|
||||||
// Skip the synthetic move our own SetCursorPos recentre generates.
|
// Skip the synthetic move our own SetCursorPos recentre generates.
|
||||||
if injected {
|
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 dx = (ms.pt.x - st.center_x) as f32;
|
||||||
let dy = (ms.pt.y - st.center_y) as f32;
|
let dy = (ms.pt.y - st.center_y) as f32;
|
||||||
if dx != 0.0 || dy != 0.0 {
|
if dx != 0.0 || dy != 0.0 {
|
||||||
// screen px → host px: the Contain-fit display scale's inverse, so the
|
st.acc_x += dx / st.scale;
|
||||||
// host cursor tracks the physical mouse 1:1 on screen at any window size.
|
st.acc_y += dy / st.scale;
|
||||||
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;
|
|
||||||
let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32);
|
let (hx, hy) = (st.acc_x.trunc() as i32, st.acc_y.trunc() as i32);
|
||||||
st.acc_x -= hx as f32;
|
st.acc_x -= hx as f32;
|
||||||
st.acc_y -= hy as f32;
|
st.acc_y -= hy as f32;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
|
//! punktfunk-client --headless --connect host[:port] [--pin HEX] [--pair PIN] [--mode WxHxHz]
|
||||||
//! [--bitrate MBPS] [--mic] [--decoder auto|hardware|software] [--no-hdr]
|
//! [--bitrate MBPS] [--mic] [--decoder auto|hardware|software] [--no-hdr]
|
||||||
//! (no window; count frames + print stats)
|
//! (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)
|
// 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
|
// 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)),
|
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
|
||||||
None => (target.clone(), 9777u16),
|
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")
|
let mode = arg("--mode")
|
||||||
.and_then(|m| {
|
.and_then(|m| {
|
||||||
let mut it = m.split(['x', 'X']);
|
let mut it = m.split(['x', 'X']);
|
||||||
|
|||||||
@@ -80,6 +80,55 @@ pub struct SessionHandle {
|
|||||||
pub stop: Arc<AtomicBool>,
|
pub stop: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<punktfunk_core::client::ProbeOutcome, String> {
|
||||||
|
// 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 {
|
pub fn start(params: SessionParams) -> SessionHandle {
|
||||||
let (ev_tx, ev_rx) = async_channel::unbounded();
|
let (ev_tx, ev_rx) = async_channel::unbounded();
|
||||||
// Tiny frame queue, newest wins: force_send displaces the oldest when the UI lags.
|
// Tiny frame queue, newest wins: force_send displaces the oldest when the UI lags.
|
||||||
|
|||||||
@@ -84,14 +84,16 @@ impl KnownHosts {
|
|||||||
Ok(())
|
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> {
|
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
|
||||||
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
|
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> {
|
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
|
||||||
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user