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:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user