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:
2026-07-02 11:54:06 +02:00
parent cac5b31535
commit 9074781acd
18 changed files with 2109 additions and 1490 deletions
+20 -4
View File
@@ -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`),
+16 -7
View File
@@ -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
+363
View File
@@ -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()],
)
}
+291
View File
@@ -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)
}
+70
View File
@@ -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(),
])
}
+206
View File
@@ -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 }),
}
}
+126
View File
@@ -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()])
}
+306
View File
@@ -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()],
),
])
}
+179
View File
@@ -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()
}
}
}
+192
View File
@@ -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()
}
+135
View File
@@ -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))
}
+6 -3
View File
@@ -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)) {
+16 -7
View File
@@ -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
View File
@@ -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;
+26
View File
@@ -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']);
+49
View File
@@ -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.
+6 -4
View File
@@ -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)
} }